新增 V2 場地排程頁面與實力權重系統
根本原因: V1 三輪配對制不符合實際打球流程:實際是 2、3 號場地各打 8 局雙打、 每局換搭檔輪休;且配對完全沒考慮成員實力,會出現強強同隊的失衡對戰。 影響: - 首頁改為 V2 場地排程:每局從全員挑 8 人分兩場地打雙打,其餘自動輪休, 搭檔組合在全部輪過一遍之前不重複,每人上場局數落差最多 1 局 - tg_members 新增 skill 欄位(1~10,預設 1),attendance API 一併帶出實力, 新增 GET /api/skills 供頁面開啟時載入全體實力表(載完才能產生賽程) - V2 依實力排點:搭檔強弱互補、每局兩隊實力總和盡量接近, 滑鼠停在隊伍上會浮出實力泡泡;讀取指定日期可回填已上傳的 V2 賽程 - V1 完整保留於 /v1,讀取出席名單改為依實力自動分 A / B 區 - V2 推送 LINE 固定走測試目標(LINE_TARGET_ID_LOCAL),不碰正式群組, 訊息改用綠底標題+斑馬紋對戰列的新版 Flex 卡片 - 上傳沿用 badminton 表,battlecombination 改以場地為 key 存 16 組搭檔 修法: App.tsx 拆為路由(/ 進 AppV2、/v1 進 AppV1);AppV2 排程演算法以 105 種 完美配對窮舉搭配重試 40 次,取「重複最少、總實力差最小」的一份賽程; server 新增 /api/skills、LINE V2 Flex 訊息與 target 覆寫,attendance 查詢 LEFT JOIN tg_members 取得 skill(user_id 優先、nickname 備援、預設 1)。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+193
-11
@@ -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)}`
|
||||
|
||||
|
||||
Reference in New Issue
Block a user