Files
tg-shuttle-score/src/render.js
T
JianMiauandClaude Fable 5 2e88a7d85a 建立建喵打羽球 Bot:移植 Node-RED 報名流程並新增群組記分板
根本原因:
原本的「今日羽球報名」bot 寫在 Node-RED 的 function 節點裡,不易測試與擴充;
羽球團又需要在 Telegram 群組內直接記分、看發球者,並把戰績寫進 web 版共用的 DB。

影響:
同一個 bot(@JianMiauBadmintonBot)改由本專案服務:報名指令、按鈕、暱稱、
週一 10:00 排程、白名單行為與訊息格式皆與 Node-RED 版相同,舊訊息按鈕仍可用;
另新增 /記分(/score、/new)記分板,並在結束/再一局時寫入共用的 history 表。

修法:
- signup.js:逐句移植 Node-RED bd_sm 狀態機(attendance / tg_poll / tg_members)
- scoreboard.js + match.js + render.js:羽球規則(發球區、換位、Deuce、30 分封頂)、
  上排 1 2/下排 4 3 版面、從今日出席選人開局、⏱ 結算、兩段式結束確認、
  開局者/管理員權限、scoreList(web 版格式)
- history.js:寫入 history 表,欄位與 web 版 ensureHistoryTable 相同
- scripts/nodered-switch.mjs:透過 admin API 暫停/恢復 Node-RED 舊流程
- 單元測試 23 項(規則、報名、選人、結算、權限、歷史寫入),Dockerfile / compose

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 11:10:27 +08:00

152 lines
5.8 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 把比賽狀態轉成 Telegram 訊息文字(HTML)與 inline 鍵盤
import { getServing, getWinner, isDoubles } from './match.js'
import { escapeHtml } from './util.js'
const TEAM_ICON = ['🅰️', '🅱️']
const COURT_LABEL = { right: '右區', left: '左區' }
// 隊員名單,依「畫面位置」排列(同 web 版:上排 1 2、下排 4 3),發球者前面加 🏸
// 上排 🅰️ 為鏡像:畫面左 = 自己的右發球區;下排 🅱️:畫面右 = 自己的右發球區
function renderPlayers(match, teamIndex, serving) {
const team = match.teams[teamIndex]
let ordered = team.players
if (team.players.length === 2) {
const rightPlayer = team.players[team.right]
const leftPlayer = team.players[1 - team.right]
ordered = teamIndex === 0 ? [rightPlayer, leftPlayer] : [leftPlayer, rightPlayer]
}
return ordered
.map((name) => {
const isServer = serving && serving.team === teamIndex && serving.server === name
return `${isServer ? '🏸' : ''}${escapeHtml(name)}`
})
.join(' / ')
}
export function renderText(match, { ended = false } = {}) {
const serving = getServing(match)
const winner = getWinner(match)
const lines = []
lines.push(`🏸 <b>羽球記分板</b> ${isDoubles(match) ? '雙打' : '單打'}${match.target} 分制`)
lines.push('')
for (const teamIndex of [0, 1]) {
const marker = winner === teamIndex ? ' 🏆' : ''
lines.push(
`${TEAM_ICON[teamIndex]} ${renderPlayers(match, teamIndex, serving)} <b>${match.score[teamIndex]}</b>${marker}`,
)
}
lines.push('')
const hasPoints = (match.scoreList ?? []).length > 0
if (ended) {
if (match.historyId) lines.push(`⏹ 已結束 📝 已存入歷史戰績 #${match.historyId}`)
else if (hasPoints && !match.finished) lines.push('⏹ 已結束(未結算,未存入戰績)')
else lines.push('⏹ 已結束')
} else if (match.confirmEnd) {
if (match.finished) lines.push('❓ <b>確定要結束嗎?</b>會把這場寫入歷史戰績')
else if (hasPoints) lines.push('❓ <b>確定要結束嗎?</b>比賽未結算,不會寫入戰績(要存請先按 ⏱ 結算)')
else lines.push('❓ <b>確定要結束嗎?</b>')
} else if (match.settled) {
const result = winner !== null ? `${TEAM_ICON[winner]} 獲勝` : '平手'
lines.push(`⏱ 時間到結算 ${match.score[0]} : ${match.score[1]} ${result}`)
} else if (winner !== null) {
lines.push(`🎉 ${TEAM_ICON[winner]} 獲勝! ${match.score[0]} : ${match.score[1]}`)
} else if (!serving) {
lines.push('👉 請先選擇由哪一隊先發球')
} else {
lines.push(
`發球:${TEAM_ICON[serving.team]} <b>${escapeHtml(serving.server)}</b>${COURT_LABEL[serving.court]}` +
` 接發:${escapeHtml(serving.receiver)}`,
)
}
return lines.join('\n')
}
// callback_data 格式:sc:<action>
const btn = (text, action) => ({ text, callback_data: `sc:${action}` })
export function renderKeyboard(match, { ended = false } = {}) {
if (ended) return { inline_keyboard: [] }
const rows = buildKeyboardRows(match)
if (match.confirmEnd) {
// 兩段式確認:把「結束」換成「確定結束 / 取消」
return {
inline_keyboard: rows.map((row) =>
row.some((b) => b.callback_data === 'sc:end')
? [btn('✅ 確定結束', 'end'), btn('↩️ 取消', 'endcancel')]
: row,
),
}
}
return { inline_keyboard: rows }
}
function buildKeyboardRows(match) {
if (match.finished) {
return [
[btn('↩️ 上一步', 'undo'), btn('🔄 再一局', 'reset')],
[btn('🏁 結束', 'end')],
]
}
if (match.server === null) {
const rows = [[btn('🅰️ 先發球', 'srv0'), btn('🅱️ 先發球', 'srv1')]]
if (isDoubles(match)) {
rows.push([btn('🔀 🅰️ 換位', 'swap0'), btn('🔀 🅱️ 換位', 'swap1')])
}
rows.push([btn('🏁 結束', 'end')])
return rows
}
return [
[btn('🅰️ +1', 'pt0'), btn('🅱️ +1', 'pt1')],
[btn('↩️ 上一步', 'undo'), btn('🔄 重新開始', 'reset')],
[btn('⏱ 結算', 'settle'), btn('🏁 結束', 'end')],
]
}
// ---------- 選人(/記分 無參數:從今日出席名單點選 1→4) ----------
const PICK_NUM = ['1️⃣', '2️⃣', '3️⃣', '4️⃣']
export function renderPickerText(picker) {
const slot = (i) => (picker.picked[i] != null ? escapeHtml(picker.names[picker.picked[i]]) : '—')
const lines = [
`🏸 <b>選擇上場人員</b> 依 1 → 4 順序點選(今日出席 ${picker.names.length} 人)`,
'',
`🅰️ 1️⃣ ${slot(0)} 2️⃣ ${slot(1)}`,
`🅱️ 4️⃣ ${slot(3)} 3️⃣ ${slot(2)}`,
'',
]
if (picker.picked.length === 0) lines.push('👉 點名字加入;1、2 一隊,3、4 一隊(位置同 web 版:上 1 2/下 4 3);點滿 4 人自動開局')
else if (picker.picked.length === 2) lines.push('👉 繼續點第 3、4 位,或按「單打開始」')
else if (picker.picked.length < 4) lines.push(`👉 繼續點第 ${picker.picked.length + 1} 位`)
return lines.join('\n')
}
export function renderPickerKeyboard(picker) {
const rows = []
const perRow = picker.names.length > 8 ? 3 : 2
let row = []
picker.names.forEach((name, i) => {
const order = picker.picked.indexOf(i)
const label = order >= 0 ? `${PICK_NUM[order]} ${name}` : name
row.push({ text: label, callback_data: `sc:pick${i}` })
if (row.length === perRow) {
rows.push(row)
row = []
}
})
if (row.length) rows.push(row)
const controls = []
if (picker.picked.length > 0) controls.push({ text: '↩️ 取消上一位', callback_data: 'sc:unpick' })
if (picker.picked.length === 2) controls.push({ text: '▶️ 單打開始', callback_data: 'sc:pickgo' })
controls.push({ text: '✖️ 取消', callback_data: 'sc:end' })
rows.push(controls)
return { inline_keyboard: rows }
}