新增讀取 Telegram 出席名單功能,讀取指定日期改為自動 fallback
根本原因: Node-RED 的 Telegram bot「建喵打羽球」已把報名名單寫進同庫的 attendance 表,但配對器沒有任何入口讀取,每次仍需手動把出席者一個個打進名單。 影響: - 新增 GET /api/attendance/:time,用既有 pool 讀 attendance 表,沒資料回 404 - 「讀取指定日期」改為兩段:先讀 badminton 配對資料,該日還沒配對時自動改載入出席名單 - 新增「讀取今日出席」按鈕可直接載入出席名單 - attendance 表沒有分區資訊,出席者一律先放 A 區、清空 B 區與既有配對,由使用者自行分配 - badminton 表結構與上傳流程不變 修法: server.mjs 新增 attendance 端點;App.tsx 新增 findAttendance / loadAttendance / applyAttendance,loadResults 改用 findMatchResults 判斷後 fallback,移除不再使用的 loadMatchResults;README 補上讀取今日出席章節與 attendance 表結構。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -14,19 +14,46 @@
|
|||||||
- 產生配對後可用按鈕手動上傳資料到資料庫
|
- 產生配對後可用按鈕手動上傳資料到資料庫
|
||||||
- 若指定日期已經有資料,上傳前會先詢問是否覆蓋
|
- 若指定日期已經有資料,上傳前會先詢問是否覆蓋
|
||||||
- 可讀取指定日期的資料庫內容並回填到畫面
|
- 可讀取指定日期的資料庫內容並回填到畫面
|
||||||
- 若指定日期沒有資料,畫面會顯示「指定日期沒有資料」
|
- 若指定日期還沒有配對資料,`讀取指定日期` 會自動改抓 Telegram bot 的出席名單帶入 A 區
|
||||||
- 組隊結果產生後可一鍵主動推播到指定 LINE 對話
|
- 也可直接按 `讀取今日出席`,把出席名單帶進 A 區後再自行分配到 B 區
|
||||||
|
- 兩邊都沒有資料時,畫面會顯示對應的錯誤訊息
|
||||||
|
- 組隊結果產生後可一鍵主動推播到指定 LINE 對話(需按兩次確認)
|
||||||
- 支援換行、半形逗號、全形逗號與頓號輸入
|
- 支援換行、半形逗號、全形逗號與頓號輸入
|
||||||
- 會自動去除空白與重複名稱
|
- 會自動去除空白與重複名稱
|
||||||
|
|
||||||
## 操作流程
|
## 操作流程
|
||||||
|
|
||||||
1. 輸入 A 區與 B 區名單
|
1. 選擇 `目標日期`
|
||||||
2. 按下 `產生三組配對`
|
2. 按 `讀取今日出席` 帶入 Telegram 報名名單(或手動輸入 A 區與 B 區名單)
|
||||||
3. 選擇 `目標日期`
|
3. 把 A 區的人自行搬一部分到 B 區
|
||||||
4. 按下 `上傳資料` 將資料寫入 DB
|
4. 按下 `產生三組配對`
|
||||||
5. 若需要,可按 `讀取指定日期` 載回既有資料
|
5. 按下 `上傳資料` 將資料寫入 DB
|
||||||
6. 組隊結果顯示後,可按 `推送到 LINE`
|
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`(暱稱記憶)兩張表,這個專案不會讀寫。
|
||||||
|
|
||||||
## 使用方式
|
## 使用方式
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url'
|
|||||||
const app = express()
|
const app = express()
|
||||||
const port = Number(process.env.PORT ?? process.env.SERVER_PORT ?? 8787)
|
const port = Number(process.env.PORT ?? process.env.SERVER_PORT ?? 8787)
|
||||||
const tableName = process.env.DB_TABLE ?? 'badminton'
|
const tableName = process.env.DB_TABLE ?? 'badminton'
|
||||||
|
const attendanceTableName = 'attendance'
|
||||||
|
|
||||||
const currentFilePath = fileURLToPath(import.meta.url)
|
const currentFilePath = fileURLToPath(import.meta.url)
|
||||||
const currentDir = path.dirname(currentFilePath)
|
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) => {
|
app.post('/api/match-results', async (request, response) => {
|
||||||
if (!pool) {
|
if (!pool) {
|
||||||
response.status(500).json({
|
response.status(500).json({
|
||||||
|
|||||||
+95
-9
@@ -26,6 +26,11 @@ type LoadMatchResultsResponse = {
|
|||||||
battlecombination: string | null
|
battlecombination: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AttendanceResponse = {
|
||||||
|
time: number
|
||||||
|
names: string[]
|
||||||
|
}
|
||||||
|
|
||||||
type HealthResponse = {
|
type HealthResponse = {
|
||||||
lineTargetMode?: string
|
lineTargetMode?: string
|
||||||
}
|
}
|
||||||
@@ -168,14 +173,31 @@ function App() {
|
|||||||
setActionMessage('讀取指定日期資料中...')
|
setActionMessage('讀取指定日期資料中...')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = await loadMatchResults(convertDateToKey(targetDate))
|
const timeKey = convertDateToKey(targetDate)
|
||||||
const loaded = convertDbRecordToAppState(payload)
|
const matchResult = await findMatchResults(timeKey)
|
||||||
|
|
||||||
|
if (matchResult.found && matchResult.data) {
|
||||||
|
const loaded = convertDbRecordToAppState(matchResult.data)
|
||||||
setAreaAInput(loaded.areaA.join('\n'))
|
setAreaAInput(loaded.areaA.join('\n'))
|
||||||
setAreaBInput(loaded.areaB.join('\n'))
|
setAreaBInput(loaded.areaB.join('\n'))
|
||||||
setResults(loaded.results)
|
setResults(loaded.results)
|
||||||
setError('')
|
setError('')
|
||||||
setActionState('loaded')
|
setActionState('loaded')
|
||||||
setActionMessage(`已讀取資料:${convertDateToKey(targetDate)}`)
|
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) {
|
} catch (loadError) {
|
||||||
setResults([])
|
setResults([])
|
||||||
setActionState('error')
|
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() {
|
async function pushToLine() {
|
||||||
if (results.length === 0) {
|
if (results.length === 0) {
|
||||||
setActionState('error')
|
setActionState('error')
|
||||||
@@ -310,6 +371,14 @@ function App() {
|
|||||||
>
|
>
|
||||||
讀取指定日期
|
讀取指定日期
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className="ghost-button upload-button"
|
||||||
|
type="button"
|
||||||
|
onClick={() => void loadAttendance()}
|
||||||
|
disabled={actionState === 'saving' || actionState === 'loading'}
|
||||||
|
>
|
||||||
|
讀取今日出席
|
||||||
|
</button>
|
||||||
{results.length > 0 ? (
|
{results.length > 0 ? (
|
||||||
<button
|
<button
|
||||||
className={`primary-button upload-button${linePushArmed ? ' is-armed' : ''}`}
|
className={`primary-button upload-button${linePushArmed ? ' is-armed' : ''}`}
|
||||||
@@ -479,14 +548,31 @@ async function saveMatchResults(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadMatchResults(time: string) {
|
async function findAttendance(time: string) {
|
||||||
const result = await findMatchResults(time)
|
const response = await fetch(`/api/attendance/${time}`)
|
||||||
|
const payload = (await response.json()) as {
|
||||||
if (!result.found || !result.data) {
|
ok?: boolean
|
||||||
throw new Error('指定日期沒有資料。')
|
message?: string
|
||||||
|
data?: AttendanceResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.data
|
if (response.status === 404) {
|
||||||
|
return {
|
||||||
|
found: false as const,
|
||||||
|
names: [] as string[],
|
||||||
|
message: payload.message ?? '指定日期沒有出席資料。',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok || !payload.ok) {
|
||||||
|
throw new Error(payload.message ?? '出席資料讀取失敗。')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
found: true as const,
|
||||||
|
names: payload.data?.names ?? [],
|
||||||
|
message: undefined,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function findMatchResults(time: string) {
|
async function findMatchResults(time: string) {
|
||||||
|
|||||||
Reference in New Issue
Block a user