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 ? (