diff --git a/README.md b/README.md index 51d7a5a..d9f9799 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,24 @@ # 羽毛球分組配對器 -這是一個使用 `React + Vite + TypeScript` 製作的網頁應用程式,用來將 A 區與 B 區成員配成羽毛球隊伍。 +這是一個使用 `React + Vite + TypeScript` 製作的網頁應用程式,用來排羽毛球場地賽程與隊伍配對。 -## 功能說明 +- 首頁(`/`)是 V2:2、3 號場地各排 8 局雙打 +- `/v1` 保留舊版:A / B 區三輪配對 + +## V2 功能說明(首頁) + +- 輸入或用 `讀取出席名單` 載入出席成員(來源同 V1 的 attendance API) +- 每局從全部成員中挑 8 人上場(2、3 號場地各 4 人打雙打),其他人休息 +- 成員不固定場地,每局重新分配;系統會讓每個人上場局數盡量平均(落差最多 1 局) +- 搭檔組合在全部組合輪完一遍之前不會重複;對手組合也會盡量錯開 +- 頁面載入時會先透過 `GET /api/skills` 抓 `tg_members` 的實力表,載入完成前不能產生賽程 +- 依實力權重排點:在不重複的前提下,搭檔盡量強弱互補、每局兩隊實力總和盡量接近(查不到的人權重當 1);對戰表平常只顯示名字,滑鼠停在隊伍上會浮出實力泡泡(例如 `阿志 1、蓉蓉 4(合計 5)`) +- 人數 4~7 人時只排 2 號場地 8 局;未滿 4 人無法排賽程 +- `讀取指定日期` 可把之前上傳的 V2 賽程從資料庫回填到畫面;該日還沒賽程時會自動改載入出席名單(V1 格式的舊資料請到 `/v1` 讀取) +- 上傳沿用原本的 `badminton` 表:`personnel` 存全部出席者、`battlecombination` 的 `"0"` / `"1"` 分別存 2、3 號場地的 16 組搭檔(依序每 2 組為一局的對戰雙方) +- `推送到 LINE` 固定推到測試群組(`LINE_TARGET_ID_LOCAL`),不會推到正式羽球群;一樣要按兩次確認 + +## V1 功能說明(`/v1`) - 分別輸入 A 區與 B 區名單 - 可指定要寫入資料庫的目標日期 @@ -14,18 +30,19 @@ - 產生配對後可用按鈕手動上傳資料到資料庫 - 若指定日期已經有資料,上傳前會先詢問是否覆蓋 - 可讀取指定日期的資料庫內容並回填到畫面 -- 若指定日期還沒有配對資料,`讀取指定日期` 會自動改抓 Telegram bot 的出席名單帶入 A 區 -- 也可直接按 `讀取今日出席`,把出席名單帶進 A 區後再自行分配到 B 區 +- 若指定日期還沒有配對資料,`讀取指定日期` 會自動改抓 Telegram bot 的出席名單 +- 也可直接按 `讀取今日出席` 帶入出席名單 +- 載入出席名單時會依 `tg_members.skill` 實力權重自動分區:實力強的一半放 A 區、弱的一半放 B 區(可再手動調整) - 兩邊都沒有資料時,畫面會顯示對應的錯誤訊息 - 組隊結果產生後可一鍵主動推播到指定 LINE 對話(需按兩次確認) - 支援換行、半形逗號、全形逗號與頓號輸入 - 會自動去除空白與重複名稱 -## 操作流程 +## V1 操作流程 1. 選擇 `目標日期` 2. 按 `讀取今日出席` 帶入 Telegram 報名名單(或手動輸入 A 區與 B 區名單) -3. 把 A 區的人自行搬一部分到 B 區 +3. 系統會依實力權重自動分好 A / B 區,需要時再手動微調 4. 按下 `產生三組配對` 5. 按下 `上傳資料` 將資料寫入 DB 6. 若需要,可按 `讀取指定日期` 載回既有資料;該日還沒配對時會自動改載入出席名單 @@ -36,9 +53,10 @@ 出席名單來自 Node-RED 的 Telegram bot「建喵打羽球」:群組成員按「參加」後會寫進同一個資料庫的 `attendance` 表,按「不參加」則直接刪除,所以表裡的就是當天出席名單。 - 使用現有的 `DB_HOST / DB_PORT / DB_USER / DB_PASSWORD / DB_DATABASE` 連線,不需要新的環境變數 -- API:`GET /api/attendance/:time`(`time` 為 `YYYYMMDD`),回傳 `{ ok: true, data: { time, names: [...] } }`;沒資料回 404 -- `attendance` 表沒有 A / B 區資訊,載入後所有人會先放在 A 區,請自行分配到 B 區 -- 載入時會沿用名單去重邏輯,並清空 B 區與既有配對結果 +- API:`GET /api/attendance/:time`(`time` 為 `YYYYMMDD`),回傳 `{ ok: true, data: { time, names: [...], members: [{ name, skill }] } }`;沒資料回 404 +- 每個人的 `skill` 來自 `tg_members.skill`(優先用 `user_id` 對應,對不到再用 `nickname` 對應),查不到就當 1 +- 載入後依 `skill` 由高到低排序,前一半(無條件進位)放 A 區、後一半放 B 區;同分時維持報名順序 +- 載入時會沿用名單去重邏輯,並清空既有配對結果 `attendance` 表結構: @@ -53,7 +71,15 @@ CREATE TABLE attendance ( ) CHARSET utf8mb4; ``` -bot 另外還有 `tg_poll`(報名訊息 id)與 `tg_members`(暱稱記憶)兩張表,這個專案不會讀寫。 +### 實力權重 + +`tg_members` 表加了 `skill` 欄位(`TINYINT UNSIGNED NOT NULL DEFAULT 1`,範圍 1~10,10 最強),這個專案只會讀取、不會寫入: + +```sql +UPDATE tg_members SET skill = 8 WHERE nickname = '柏威'; +``` + +bot 另外還有 `tg_poll`(報名訊息 id)表,這個專案不會讀寫;`tg_members` 也只讀 `skill` 與 `nickname` 對應,不會修改。 ## 使用方式 diff --git a/server/server.mjs b/server/server.mjs index 58f17f8..31ce6a6 100644 --- a/server/server.mjs +++ b/server/server.mjs @@ -9,6 +9,7 @@ const app = express() const port = Number(process.env.PORT ?? process.env.SERVER_PORT ?? 8787) const tableName = process.env.DB_TABLE ?? 'badminton' const attendanceTableName = 'attendance' +const membersTableName = 'tg_members' const currentFilePath = fileURLToPath(import.meta.url) const currentDir = path.dirname(currentFilePath) @@ -55,9 +56,10 @@ app.get('/api/health', (_request, response) => { }) app.post('/api/line/push-match-results', async (request, response) => { - const { time, teams } = request.body ?? {} + const { time, teams, courts, target } = request.body ?? {} + const isV2Payload = Array.isArray(courts) - if (typeof time !== 'string' || !Array.isArray(teams)) { + if (typeof time !== 'string' || (!Array.isArray(teams) && !isV2Payload)) { response.status(400).json({ ok: false, message: '送出的 LINE 訊息資料格式不正確。', @@ -65,7 +67,10 @@ app.post('/api/line/push-match-results', async (request, response) => { return } - if (!lineAccessToken || !lineTargetId) { + const resolvedTargetId = + target === 'local' ? process.env.LINE_TARGET_ID_LOCAL ?? '' : lineTargetId + + if (!lineAccessToken || !resolvedTargetId) { response.status(500).json({ ok: false, message: @@ -75,7 +80,9 @@ app.post('/api/line/push-match-results', async (request, response) => { } try { - const message = buildLineFlexMessage(time, teams) + const message = isV2Payload + ? buildLineFlexMessageV2(time, courts) + : buildLineFlexMessage(time, teams) const lineResponse = await fetch('https://api.line.me/v2/bot/message/push', { method: 'POST', headers: { @@ -83,7 +90,7 @@ app.post('/api/line/push-match-results', async (request, response) => { 'Content-Type': 'application/json', }, body: JSON.stringify({ - to: lineTargetId, + to: resolvedTargetId, messages: [message], }), }) @@ -176,15 +183,30 @@ app.get('/api/attendance/:time', async (request, response) => { try { const [rows] = await pool.execute( - `SELECT nickname FROM \`${attendanceTableName}\` WHERE poll_date = ? ORDER BY joined_at ASC, user_id ASC`, + ` + SELECT + a.nickname AS nickname, + COALESCE( + m.skill, + (SELECT MAX(m2.skill) FROM \`${membersTableName}\` m2 WHERE m2.nickname = a.nickname), + 1 + ) AS skill + FROM \`${attendanceTableName}\` a + LEFT JOIN \`${membersTableName}\` m ON m.user_id = a.user_id + WHERE a.poll_date = ? + ORDER BY a.joined_at ASC, a.user_id ASC + `, [Number(time)], ) - const names = rows - .map((row) => String(row.nickname ?? '').trim()) - .filter(Boolean) + const members = rows + .map((row) => ({ + name: String(row.nickname ?? '').trim(), + skill: clampSkill(row.skill), + })) + .filter((member) => member.name) - if (names.length === 0) { + if (members.length === 0) { response.status(404).json({ ok: false, message: '指定日期沒有出席資料。', @@ -194,7 +216,11 @@ app.get('/api/attendance/:time', async (request, response) => { response.json({ ok: true, - data: { time: Number(time), names }, + data: { + time: Number(time), + names: members.map((member) => member.name), + members, + }, }) } catch (error) { console.error('attendance load error:', error) @@ -205,6 +231,41 @@ app.get('/api/attendance/:time', async (request, response) => { } }) +app.get('/api/skills', async (_request, response) => { + if (!pool) { + response.status(500).json({ + ok: false, + message: `資料庫環境變數缺少:${missingEnv.join(', ')}`, + }) + return + } + + try { + const [rows] = await pool.execute( + `SELECT nickname, skill FROM \`${membersTableName}\` WHERE nickname IS NOT NULL`, + ) + + const skills = {} + for (const row of rows) { + const name = String(row.nickname ?? '').trim() + if (name) { + skills[name] = clampSkill(row.skill) + } + } + + response.json({ + ok: true, + data: { skills }, + }) + } catch (error) { + console.error('skills load error:', error) + response.status(500).json({ + ok: false, + message: error instanceof Error ? error.message : '實力資料讀取失敗。', + }) + } +}) + app.post('/api/match-results', async (request, response) => { if (!pool) { response.status(500).json({ @@ -291,6 +352,16 @@ app.listen(port, () => { } }) +function clampSkill(value) { + const skill = Number(value) + + if (!Number.isFinite(skill)) { + return 1 + } + + return Math.min(10, Math.max(1, Math.round(skill))) +} + let tableReady = false async function ensureTable(poolInstance, currentTableName) { @@ -336,6 +407,117 @@ async function widenLegacyColumns(poolInstance, currentTableName) { } } +function buildLineFlexMessageV2(time, courts) { + const dateText = `${time.slice(0, 4)}-${time.slice(4, 6)}-${time.slice(6, 8)}` + + return { + type: 'flex', + altText: `${dateText} 羽球場地賽程`, + contents: { + type: 'carousel', + contents: courts.map((court) => ({ + type: 'bubble', + size: 'mega', + header: { + type: 'box', + layout: 'vertical', + backgroundColor: '#1D7B4D', + paddingAll: '16px', + contents: [ + { + type: 'text', + text: '勝皇羽球團', + color: '#CDEBDA', + size: 'xs', + weight: 'bold', + }, + { + type: 'text', + text: `${court.court} 號場地`, + color: '#FFFFFF', + size: 'xxl', + weight: 'bold', + margin: 'xs', + }, + { + type: 'text', + text: dateText, + color: '#CDEBDA', + size: 'sm', + margin: 'xs', + }, + ], + }, + body: { + type: 'box', + layout: 'vertical', + spacing: 'xs', + paddingAll: '12px', + contents: court.games.map((game, gameIndex) => ({ + type: 'box', + layout: 'horizontal', + alignItems: 'center', + backgroundColor: gameIndex % 2 === 0 ? '#F2F9F4' : '#FFFFFF', + cornerRadius: '8px', + paddingAll: '8px', + contents: [ + { + type: 'text', + text: String(game.game).padStart(2, '0'), + color: '#1DB446', + weight: 'bold', + size: 'sm', + flex: 1, + }, + { + type: 'text', + text: game.a.join('、'), + size: 'sm', + weight: 'bold', + color: '#8B5A0D', + align: 'end', + flex: 5, + wrap: true, + }, + { + type: 'text', + text: 'vs', + size: 'xs', + color: '#AAAAAA', + align: 'center', + flex: 1, + }, + { + type: 'text', + text: game.b.join('、'), + size: 'sm', + weight: 'bold', + color: '#1D6C46', + flex: 5, + wrap: true, + }, + ], + })), + }, + footer: { + type: 'box', + layout: 'vertical', + paddingAll: '10px', + contents: [ + { + type: 'text', + text: '搭檔不重複・實力平衡排點', + size: 'xxs', + color: '#9AA89E', + align: 'center', + }, + ], + }, + })), + }, + } +} + function buildLineFlexMessage(time, rounds) { const dateText = `${time.slice(0, 4)}-${time.slice(4, 6)}-${time.slice(6, 8)}` diff --git a/src/App.css b/src/App.css index f293b4a..32ebcac 100644 --- a/src/App.css +++ b/src/App.css @@ -4,6 +4,40 @@ padding: 32px 0 56px; } +.app-shell-split { + width: min(1680px, calc(100% - 32px)); + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 24px; + align-items: start; +} + +.split-column { + min-width: 0; +} + +.app-shell-split .results-panel { + margin-bottom: 0; +} + +.app-shell-split .hero-card { + grid-template-columns: 1fr; +} + +.app-shell-split .hero-stats { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +@media (max-width: 1280px) { + .app-shell-split { + grid-template-columns: 1fr; + } + + .app-shell-split .hero-stats { + grid-template-columns: 1fr; + } +} + .hero-card, .panel, .stat-card, @@ -58,6 +92,16 @@ font-weight: 700; } +.version-link { + color: #1d6c46; + text-decoration: underline; + text-underline-offset: 3px; +} + +.version-link:hover { + color: #183926; +} + .hero-copy h1, .panel-heading h2, .tip-card h2, @@ -316,6 +360,57 @@ align-items: start; } +.schedule-list { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.input-grid-single { + grid-template-columns: 1fr; +} + +.rest-line { + margin: 0; + padding: 4px 2px 0; + color: #627265; + font-size: 0.92rem; + line-height: 1.6; +} + +.has-tip { + position: relative; + cursor: help; +} + +.has-tip:hover::after { + content: attr(data-tip); + position: absolute; + bottom: calc(100% + 8px); + left: 50%; + transform: translateX(-50%); + padding: 6px 12px; + border-radius: 10px; + background: #183926; + color: #edf6ef; + font-size: 0.8rem; + font-weight: 600; + white-space: nowrap; + box-shadow: 0 10px 22px rgba(24, 57, 38, 0.28); + pointer-events: none; + z-index: 10; +} + +.has-tip:hover::before { + content: ''; + position: absolute; + bottom: calc(100% + 2px); + left: 50%; + transform: translateX(-50%); + border: 6px solid transparent; + border-top-color: #183926; + pointer-events: none; + z-index: 10; +} + .round-card, .team-card { border-radius: 22px; @@ -383,7 +478,8 @@ grid-template-columns: 1fr; } - .round-list { + .round-list, + .schedule-list { grid-template-columns: 1fr; } diff --git a/src/App.tsx b/src/App.tsx index 71b6118..d23ae5a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,716 +1,14 @@ -import { useEffect, useState } from 'react' -import './App.css' - -type Participant = { - id: string - name: string - isPlaceholder: boolean -} - -type Team = { - id: number - playerA: Participant - playerB: Participant -} - -type RoundResult = { - id: number - teams: Team[] -} - -type ActionState = 'idle' | 'saving' | 'saved' | 'loading' | 'loaded' | 'sharing' | 'error' - -type LoadMatchResultsResponse = { - time: number - personnel: string - battlecombination: string | null -} - -type AttendanceResponse = { - time: number - names: string[] -} - -type HealthResponse = { - lineTargetMode?: string -} - -const STORAGE_KEYS = { - areaA: 'badminton-match-hub::area-a', - areaB: 'badminton-match-hub::area-b', -} as const - -const PLACEHOLDER_NAME = '那個' -const TOTAL_ROUNDS = 3 -const LINE_PUSH_CONFIRM_TIMEOUT_MS = 6000 - -const defaultAreaA = ['建喵', '柏威', '景涵', 'Gary', '昱翔'] -const defaultAreaB = ['小念', '玟瑄', 'RuRu', '肉肉', '蓉蓉'] +import AppV1 from './AppV1' +import AppV2 from './AppV2' function App() { - const [areaAInput, setAreaAInput] = useState(() => - loadRoster(STORAGE_KEYS.areaA, defaultAreaA), - ) - const [areaBInput, setAreaBInput] = useState(() => - loadRoster(STORAGE_KEYS.areaB, defaultAreaB), - ) - const [targetDate, setTargetDate] = useState(() => formatDateInputValue()) - const [results, setResults] = useState([]) - const [error, setError] = useState('') - const [actionState, setActionState] = useState('idle') - const [actionMessage, setActionMessage] = useState('') - const [showTestBadge, setShowTestBadge] = useState(false) - const [linePushArmed, setLinePushArmed] = useState(false) + const path = window.location.pathname - useEffect(() => { - if (!linePushArmed) { - return - } - - const timer = window.setTimeout(() => { - setLinePushArmed(false) - setActionState('idle') - setActionMessage('已逾時取消推送,需要時請再按一次「推送到 LINE」。') - }, LINE_PUSH_CONFIRM_TIMEOUT_MS) - return () => window.clearTimeout(timer) - }, [linePushArmed]) - - useEffect(() => { - window.localStorage.setItem(STORAGE_KEYS.areaA, areaAInput) - }, [areaAInput]) - - useEffect(() => { - window.localStorage.setItem(STORAGE_KEYS.areaB, areaBInput) - }, [areaBInput]) - - useEffect(() => { - void (async () => { - const isLocalMode = await loadEnvironmentMode() - setShowTestBadge(isLocalMode) - })() - }, []) - - const parsedAreaA = parseRoster(areaAInput) - const parsedAreaB = parseRoster(areaBInput) - const zoneGap = Math.abs(parsedAreaA.length - parsedAreaB.length) - const crossCount = Math.floor(zoneGap / 2) - const placeholderCount = zoneGap % 2 - const teamCount = Math.ceil((parsedAreaA.length + parsedAreaB.length) / 2) - - function generateGroups() { - if (parsedAreaA.length === 0 || parsedAreaB.length === 0) { - setResults([]) - setError('A 區與 B 區都至少要輸入 1 位成員。') - setActionState('idle') - setActionMessage('') - return - } - - const shuffledA = shuffleList(parsedAreaA) - const shuffledB = shuffleList(parsedAreaB) - const nextResults = Array.from({ length: TOTAL_ROUNDS }, (_, index) => ({ - id: index + 1, - teams: createRoundTeams(shuffledA, shuffledB, index), - })) - - setResults(nextResults) - setLinePushArmed(false) - setError('') - setActionState('idle') - setActionMessage('已產生配對,請按「上傳資料」。') + if (path === '/v1' || path.startsWith('/v1/')) { + return } - async function uploadResults() { - if (results.length === 0) { - setActionState('error') - setActionMessage('請先產生配對結果,再上傳資料。') - return - } - - if (!targetDate) { - setActionState('error') - setActionMessage('請先選擇目標日期。') - return - } - - try { - const timeKey = convertDateToKey(targetDate) - const existingResult = await findMatchResults(timeKey) - - if (existingResult.found) { - const confirmed = window.confirm(`${timeKey} 已經有資料,確定要覆蓋嗎?`) - - if (!confirmed) { - setActionState('idle') - setActionMessage('已取消上傳。') - return - } - } - - setActionState('saving') - setActionMessage('上傳資料到資料庫中...') - - await saveMatchResults(timeKey, parsedAreaA, parsedAreaB, results) - setActionState('saved') - setActionMessage(`已同步到資料庫:${timeKey}`) - } catch (saveError) { - setActionState('error') - setActionMessage( - saveError instanceof Error ? saveError.message : '資料庫儲存失敗,請稍後再試。', - ) - } - } - - async function loadResults() { - if (!targetDate) { - setActionState('error') - setActionMessage('請先選擇目標日期。') - return - } - - setLinePushArmed(false) - setActionState('loading') - setActionMessage('讀取指定日期資料中...') - - try { - const timeKey = convertDateToKey(targetDate) - const matchResult = await findMatchResults(timeKey) - - if (matchResult.found && matchResult.data) { - const loaded = convertDbRecordToAppState(matchResult.data) - setAreaAInput(loaded.areaA.join('\n')) - setAreaBInput(loaded.areaB.join('\n')) - setResults(loaded.results) - setError('') - setActionState('loaded') - setActionMessage(`已讀取資料:${timeKey}`) - return - } - - const attendance = await findAttendance(timeKey) - - if (!attendance.found) { - throw new Error('指定日期沒有配對資料,也沒有出席資料。') - } - - const attendeeCount = applyAttendance(attendance.names) - setActionState('loaded') - setActionMessage( - `指定日期沒有配對資料,已改載入 ${attendeeCount} 位出席人員到 A 區,請自行分配到 B 區。`, - ) - } catch (loadError) { - setResults([]) - setActionState('error') - setActionMessage( - loadError instanceof Error ? loadError.message : '讀取資料失敗,請稍後再試。', - ) - } - } - - async function loadAttendance() { - if (!targetDate) { - setActionState('error') - setActionMessage('請先選擇目標日期。') - return - } - - setLinePushArmed(false) - setActionState('loading') - setActionMessage('讀取出席人員中...') - - try { - const timeKey = convertDateToKey(targetDate) - const attendance = await findAttendance(timeKey) - - if (!attendance.found) { - throw new Error(attendance.message ?? '指定日期沒有出席資料。') - } - - const attendeeCount = applyAttendance(attendance.names) - setActionState('loaded') - setActionMessage(`已載入 ${attendeeCount} 位出席人員(${timeKey}),全部先放 A 區,請自行分配。`) - } catch (loadError) { - setActionState('error') - setActionMessage( - loadError instanceof Error ? loadError.message : '出席資料讀取失敗,請稍後再試。', - ) - } - } - - function applyAttendance(names: string[]) { - const uniqueNames = parseRoster(names.join('\n')) - setAreaAInput(uniqueNames.join('\n')) - setAreaBInput('') - setResults([]) - setError('') - return uniqueNames.length - } - - async function pushToLine() { - if (results.length === 0) { - setActionState('error') - setActionMessage('請先產生配對結果,再推送到 LINE。') - return - } - - if (!targetDate) { - setActionState('error') - setActionMessage('請先選擇目標日期。') - return - } - - if (!linePushArmed) { - setLinePushArmed(true) - setActionState('idle') - setActionMessage('再按一次「確認推送到 LINE」才會真的送出,6 秒內沒按會自動取消。') - return - } - - setLinePushArmed(false) - - try { - const timeKey = convertDateToKey(targetDate) - setActionState('sharing') - setActionMessage('推送 LINE 訊息中...') - - await pushMatchResultsToLine(timeKey, results) - setActionState('sharing') - setActionMessage(`已推送到指定 LINE 對話:${timeKey}`) - } catch (pushError) { - setActionState('error') - setActionMessage( - pushError instanceof Error ? pushError.message : '推送到 LINE 失敗,請稍後再試。', - ) - } - } - - function resetDemo() { - setAreaAInput(defaultAreaA.join('\n')) - setAreaBInput(defaultAreaB.join('\n')) - setResults([]) - setLinePushArmed(false) - setError('') - setActionState('idle') - setActionMessage('') - } - - return ( -
-
-
- {showTestBadge ?
測試環境
: null} -

羽毛球隊伍配對

-

羽毛球分組配對器

-

- 輸入 A 區與 B 區名單後,系統會產生 3 組完整配對。 - 兩區人數有落差時,每輪會輪流讓人多的一區成員跨區支援; - 補完後仍有缺口才會補入「那個」。 -

-
- - -
- {actionMessage ? ( -

{actionMessage}

- ) : null} -
- -
-
- A 區人數 - {parsedAreaA.length} -
-
- B 區人數 - {parsedAreaB.length} -
-
-

摘要

-

- 每組共 {teamCount} 隊,跨區支援 {crossCount} 人,補位「那個」{placeholderCount} 人。 -

-
-
-
- -
-
-

名單輸入

-

輸入 A 區與 B 區名單

-
- -
- - - - - {results.length > 0 ? ( - - ) : null} -
- -
-