diff --git a/README.md b/README.md index 881eabd..51d7a5a 100644 --- a/README.md +++ b/README.md @@ -14,19 +14,46 @@ - 產生配對後可用按鈕手動上傳資料到資料庫 - 若指定日期已經有資料,上傳前會先詢問是否覆蓋 - 可讀取指定日期的資料庫內容並回填到畫面 -- 若指定日期沒有資料,畫面會顯示「指定日期沒有資料」 -- 組隊結果產生後可一鍵主動推播到指定 LINE 對話 +- 若指定日期還沒有配對資料,`讀取指定日期` 會自動改抓 Telegram bot 的出席名單帶入 A 區 +- 也可直接按 `讀取今日出席`,把出席名單帶進 A 區後再自行分配到 B 區 +- 兩邊都沒有資料時,畫面會顯示對應的錯誤訊息 +- 組隊結果產生後可一鍵主動推播到指定 LINE 對話(需按兩次確認) - 支援換行、半形逗號、全形逗號與頓號輸入 - 會自動去除空白與重複名稱 ## 操作流程 -1. 輸入 A 區與 B 區名單 -2. 按下 `產生三組配對` -3. 選擇 `目標日期` -4. 按下 `上傳資料` 將資料寫入 DB -5. 若需要,可按 `讀取指定日期` 載回既有資料 -6. 組隊結果顯示後,可按 `推送到 LINE` +1. 選擇 `目標日期` +2. 按 `讀取今日出席` 帶入 Telegram 報名名單(或手動輸入 A 區與 B 區名單) +3. 把 A 區的人自行搬一部分到 B 區 +4. 按下 `產生三組配對` +5. 按下 `上傳資料` 將資料寫入 DB +6. 若需要,可按 `讀取指定日期` 載回既有資料;該日還沒配對時會自動改載入出席名單 +7. 組隊結果顯示後,可按 `推送到 LINE`(按兩次確認才會送出) + +## 讀取今日出席 + +出席名單來自 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 區與既有配對結果 + +`attendance` 表結構: + +```sql +CREATE TABLE attendance ( + poll_date INT(11) NOT NULL COMMENT '場次日期 YYYYMMDD', + user_id BIGINT NOT NULL COMMENT 'Telegram user id', + nickname VARCHAR(64) NOT NULL COMMENT '顯示名稱(就是要放進 personnel 的名字)', + tg_name VARCHAR(128) NULL COMMENT 'Telegram 顯示名', + joined_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '報名時間', + PRIMARY KEY (poll_date, user_id) +) CHARSET utf8mb4; +``` + +bot 另外還有 `tg_poll`(報名訊息 id)與 `tg_members`(暱稱記憶)兩張表,這個專案不會讀寫。 ## 使用方式 diff --git a/server/server.mjs b/server/server.mjs index 2ee7c30..58f17f8 100644 --- a/server/server.mjs +++ b/server/server.mjs @@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url' 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 currentFilePath = fileURLToPath(import.meta.url) const currentDir = path.dirname(currentFilePath) @@ -154,6 +155,56 @@ app.get('/api/match-results/:time', async (request, response) => { } }) +app.get('/api/attendance/:time', async (request, response) => { + if (!pool) { + response.status(500).json({ + ok: false, + message: `資料庫環境變數缺少:${missingEnv.join(', ')}`, + }) + return + } + + const time = String(request.params.time ?? '') + + if (!/^\d{8}$/.test(time)) { + response.status(400).json({ + ok: false, + message: '日期格式不正確,請使用 YYYYMMDD。', + }) + return + } + + try { + const [rows] = await pool.execute( + `SELECT nickname FROM \`${attendanceTableName}\` WHERE poll_date = ? ORDER BY joined_at ASC, user_id ASC`, + [Number(time)], + ) + + const names = rows + .map((row) => String(row.nickname ?? '').trim()) + .filter(Boolean) + + if (names.length === 0) { + response.status(404).json({ + ok: false, + message: '指定日期沒有出席資料。', + }) + return + } + + response.json({ + ok: true, + data: { time: Number(time), names }, + }) + } catch (error) { + console.error('attendance 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({ diff --git a/src/App.tsx b/src/App.tsx index 9b17dd8..71b6118 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -26,6 +26,11 @@ type LoadMatchResultsResponse = { battlecombination: string | null } +type AttendanceResponse = { + time: number + names: string[] +} + type HealthResponse = { lineTargetMode?: string } @@ -168,14 +173,31 @@ function App() { setActionMessage('讀取指定日期資料中...') try { - const payload = await loadMatchResults(convertDateToKey(targetDate)) - const loaded = convertDbRecordToAppState(payload) - setAreaAInput(loaded.areaA.join('\n')) - setAreaBInput(loaded.areaB.join('\n')) - setResults(loaded.results) - setError('') + 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(`已讀取資料:${convertDateToKey(targetDate)}`) + setActionMessage( + `指定日期沒有配對資料,已改載入 ${attendeeCount} 位出席人員到 A 區,請自行分配到 B 區。`, + ) } catch (loadError) { setResults([]) setActionState('error') @@ -185,6 +207,45 @@ function App() { } } + 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') @@ -310,6 +371,14 @@ function App() { > 讀取指定日期 + {results.length > 0 ? (