新增 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:
@@ -1,8 +1,24 @@
|
|||||||
# 羽毛球分組配對器
|
# 羽毛球分組配對器
|
||||||
|
|
||||||
這是一個使用 `React + Vite + TypeScript` 製作的網頁應用程式,用來將 A 區與 B 區成員配成羽毛球隊伍。
|
這是一個使用 `React + Vite + TypeScript` 製作的網頁應用程式,用來排羽毛球場地賽程與隊伍配對。
|
||||||
|
|
||||||
## 功能說明
|
- 首頁(`/`)是 V2:2、3 號場地各排 8 局雙打
|
||||||
|
- `/v1` 保留舊版:A / B 區三輪配對
|
||||||
|
|
||||||
|
## V2 功能說明(首頁)
|
||||||
|
|
||||||
|
- 輸入或用 `讀取出席名單` 載入出席成員(來源同 V1 的 attendance API)
|
||||||
|
- 每局從全部成員中挑 8 人上場(2、3 號場地各 4 人打雙打),其他人休息
|
||||||
|
- 成員不固定場地,每局重新分配;系統會讓每個人上場局數盡量平均(落差最多 1 局)
|
||||||
|
- 搭檔組合在全部組合輪完一遍之前不會重複;對手組合也會盡量錯開
|
||||||
|
- 頁面載入時會先透過 `GET /api/skills` 抓 `tg_members` 的實力表,載入完成前不能產生賽程
|
||||||
|
- 依實力權重排點:在不重複的前提下,搭檔盡量強弱互補、每局兩隊實力總和盡量接近(查不到的人權重當 1);對戰表平常只顯示名字,滑鼠停在隊伍上會浮出實力泡泡(例如 `阿志 1、蓉蓉 4(合計 5)`)
|
||||||
|
- 人數 4~7 人時只排 2 號場地 8 局;未滿 4 人無法排賽程
|
||||||
|
- `讀取指定日期` 可把之前上傳的 V2 賽程從資料庫回填到畫面;該日還沒賽程時會自動改載入出席名單(V1 格式的舊資料請到 `/v1` 讀取)
|
||||||
|
- 上傳沿用原本的 `badminton` 表:`personnel` 存全部出席者、`battlecombination` 的 `"0"` / `"1"` 分別存 2、3 號場地的 16 組搭檔(依序每 2 組為一局的對戰雙方)
|
||||||
|
- `推送到 LINE` 固定推到測試群組(`LINE_TARGET_ID_LOCAL`),不會推到正式羽球群;一樣要按兩次確認
|
||||||
|
|
||||||
|
## V1 功能說明(`/v1`)
|
||||||
|
|
||||||
- 分別輸入 A 區與 B 區名單
|
- 分別輸入 A 區與 B 區名單
|
||||||
- 可指定要寫入資料庫的目標日期
|
- 可指定要寫入資料庫的目標日期
|
||||||
@@ -14,18 +30,19 @@
|
|||||||
- 產生配對後可用按鈕手動上傳資料到資料庫
|
- 產生配對後可用按鈕手動上傳資料到資料庫
|
||||||
- 若指定日期已經有資料,上傳前會先詢問是否覆蓋
|
- 若指定日期已經有資料,上傳前會先詢問是否覆蓋
|
||||||
- 可讀取指定日期的資料庫內容並回填到畫面
|
- 可讀取指定日期的資料庫內容並回填到畫面
|
||||||
- 若指定日期還沒有配對資料,`讀取指定日期` 會自動改抓 Telegram bot 的出席名單帶入 A 區
|
- 若指定日期還沒有配對資料,`讀取指定日期` 會自動改抓 Telegram bot 的出席名單
|
||||||
- 也可直接按 `讀取今日出席`,把出席名單帶進 A 區後再自行分配到 B 區
|
- 也可直接按 `讀取今日出席` 帶入出席名單
|
||||||
|
- 載入出席名單時會依 `tg_members.skill` 實力權重自動分區:實力強的一半放 A 區、弱的一半放 B 區(可再手動調整)
|
||||||
- 兩邊都沒有資料時,畫面會顯示對應的錯誤訊息
|
- 兩邊都沒有資料時,畫面會顯示對應的錯誤訊息
|
||||||
- 組隊結果產生後可一鍵主動推播到指定 LINE 對話(需按兩次確認)
|
- 組隊結果產生後可一鍵主動推播到指定 LINE 對話(需按兩次確認)
|
||||||
- 支援換行、半形逗號、全形逗號與頓號輸入
|
- 支援換行、半形逗號、全形逗號與頓號輸入
|
||||||
- 會自動去除空白與重複名稱
|
- 會自動去除空白與重複名稱
|
||||||
|
|
||||||
## 操作流程
|
## V1 操作流程
|
||||||
|
|
||||||
1. 選擇 `目標日期`
|
1. 選擇 `目標日期`
|
||||||
2. 按 `讀取今日出席` 帶入 Telegram 報名名單(或手動輸入 A 區與 B 區名單)
|
2. 按 `讀取今日出席` 帶入 Telegram 報名名單(或手動輸入 A 區與 B 區名單)
|
||||||
3. 把 A 區的人自行搬一部分到 B 區
|
3. 系統會依實力權重自動分好 A / B 區,需要時再手動微調
|
||||||
4. 按下 `產生三組配對`
|
4. 按下 `產生三組配對`
|
||||||
5. 按下 `上傳資料` 將資料寫入 DB
|
5. 按下 `上傳資料` 將資料寫入 DB
|
||||||
6. 若需要,可按 `讀取指定日期` 載回既有資料;該日還沒配對時會自動改載入出席名單
|
6. 若需要,可按 `讀取指定日期` 載回既有資料;該日還沒配對時會自動改載入出席名單
|
||||||
@@ -36,9 +53,10 @@
|
|||||||
出席名單來自 Node-RED 的 Telegram bot「建喵打羽球」:群組成員按「參加」後會寫進同一個資料庫的 `attendance` 表,按「不參加」則直接刪除,所以表裡的就是當天出席名單。
|
出席名單來自 Node-RED 的 Telegram bot「建喵打羽球」:群組成員按「參加」後會寫進同一個資料庫的 `attendance` 表,按「不參加」則直接刪除,所以表裡的就是當天出席名單。
|
||||||
|
|
||||||
- 使用現有的 `DB_HOST / DB_PORT / DB_USER / DB_PASSWORD / DB_DATABASE` 連線,不需要新的環境變數
|
- 使用現有的 `DB_HOST / DB_PORT / DB_USER / DB_PASSWORD / DB_DATABASE` 連線,不需要新的環境變數
|
||||||
- API:`GET /api/attendance/:time`(`time` 為 `YYYYMMDD`),回傳 `{ ok: true, data: { time, names: [...] } }`;沒資料回 404
|
- API:`GET /api/attendance/:time`(`time` 為 `YYYYMMDD`),回傳 `{ ok: true, data: { time, names: [...], members: [{ name, skill }] } }`;沒資料回 404
|
||||||
- `attendance` 表沒有 A / B 區資訊,載入後所有人會先放在 A 區,請自行分配到 B 區
|
- 每個人的 `skill` 來自 `tg_members.skill`(優先用 `user_id` 對應,對不到再用 `nickname` 對應),查不到就當 1
|
||||||
- 載入時會沿用名單去重邏輯,並清空 B 區與既有配對結果
|
- 載入後依 `skill` 由高到低排序,前一半(無條件進位)放 A 區、後一半放 B 區;同分時維持報名順序
|
||||||
|
- 載入時會沿用名單去重邏輯,並清空既有配對結果
|
||||||
|
|
||||||
`attendance` 表結構:
|
`attendance` 表結構:
|
||||||
|
|
||||||
@@ -53,7 +71,15 @@ CREATE TABLE attendance (
|
|||||||
) CHARSET utf8mb4;
|
) CHARSET utf8mb4;
|
||||||
```
|
```
|
||||||
|
|
||||||
bot 另外還有 `tg_poll`(報名訊息 id)與 `tg_members`(暱稱記憶)兩張表,這個專案不會讀寫。
|
### 實力權重
|
||||||
|
|
||||||
|
`tg_members` 表加了 `skill` 欄位(`TINYINT UNSIGNED NOT NULL DEFAULT 1`,範圍 1~10,10 最強),這個專案只會讀取、不會寫入:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
UPDATE tg_members SET skill = 8 WHERE nickname = '柏威';
|
||||||
|
```
|
||||||
|
|
||||||
|
bot 另外還有 `tg_poll`(報名訊息 id)表,這個專案不會讀寫;`tg_members` 也只讀 `skill` 與 `nickname` 對應,不會修改。
|
||||||
|
|
||||||
## 使用方式
|
## 使用方式
|
||||||
|
|
||||||
|
|||||||
+193
-11
@@ -9,6 +9,7 @@ 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 attendanceTableName = 'attendance'
|
||||||
|
const membersTableName = 'tg_members'
|
||||||
|
|
||||||
const currentFilePath = fileURLToPath(import.meta.url)
|
const currentFilePath = fileURLToPath(import.meta.url)
|
||||||
const currentDir = path.dirname(currentFilePath)
|
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) => {
|
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({
|
response.status(400).json({
|
||||||
ok: false,
|
ok: false,
|
||||||
message: '送出的 LINE 訊息資料格式不正確。',
|
message: '送出的 LINE 訊息資料格式不正確。',
|
||||||
@@ -65,7 +67,10 @@ app.post('/api/line/push-match-results', async (request, response) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!lineAccessToken || !lineTargetId) {
|
const resolvedTargetId =
|
||||||
|
target === 'local' ? process.env.LINE_TARGET_ID_LOCAL ?? '' : lineTargetId
|
||||||
|
|
||||||
|
if (!lineAccessToken || !resolvedTargetId) {
|
||||||
response.status(500).json({
|
response.status(500).json({
|
||||||
ok: false,
|
ok: false,
|
||||||
message:
|
message:
|
||||||
@@ -75,7 +80,9 @@ app.post('/api/line/push-match-results', async (request, response) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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', {
|
const lineResponse = await fetch('https://api.line.me/v2/bot/message/push', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -83,7 +90,7 @@ app.post('/api/line/push-match-results', async (request, response) => {
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
to: lineTargetId,
|
to: resolvedTargetId,
|
||||||
messages: [message],
|
messages: [message],
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
@@ -176,15 +183,30 @@ app.get('/api/attendance/:time', async (request, response) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const [rows] = await pool.execute(
|
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)],
|
[Number(time)],
|
||||||
)
|
)
|
||||||
|
|
||||||
const names = rows
|
const members = rows
|
||||||
.map((row) => String(row.nickname ?? '').trim())
|
.map((row) => ({
|
||||||
.filter(Boolean)
|
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({
|
response.status(404).json({
|
||||||
ok: false,
|
ok: false,
|
||||||
message: '指定日期沒有出席資料。',
|
message: '指定日期沒有出席資料。',
|
||||||
@@ -194,7 +216,11 @@ app.get('/api/attendance/:time', async (request, response) => {
|
|||||||
|
|
||||||
response.json({
|
response.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
data: { time: Number(time), names },
|
data: {
|
||||||
|
time: Number(time),
|
||||||
|
names: members.map((member) => member.name),
|
||||||
|
members,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('attendance load error:', 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) => {
|
app.post('/api/match-results', async (request, response) => {
|
||||||
if (!pool) {
|
if (!pool) {
|
||||||
response.status(500).json({
|
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
|
let tableReady = false
|
||||||
|
|
||||||
async function ensureTable(poolInstance, currentTableName) {
|
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) {
|
function buildLineFlexMessage(time, rounds) {
|
||||||
const dateText = `${time.slice(0, 4)}-${time.slice(4, 6)}-${time.slice(6, 8)}`
|
const dateText = `${time.slice(0, 4)}-${time.slice(4, 6)}-${time.slice(6, 8)}`
|
||||||
|
|
||||||
|
|||||||
+97
-1
@@ -4,6 +4,40 @@
|
|||||||
padding: 32px 0 56px;
|
padding: 32px 0 56px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-shell-split {
|
||||||
|
width: min(1680px, calc(100% - 32px));
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
gap: 24px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-column {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell-split .results-panel {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell-split .hero-card {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell-split .hero-stats {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1280px) {
|
||||||
|
.app-shell-split {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell-split .hero-stats {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.hero-card,
|
.hero-card,
|
||||||
.panel,
|
.panel,
|
||||||
.stat-card,
|
.stat-card,
|
||||||
@@ -58,6 +92,16 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.version-link {
|
||||||
|
color: #1d6c46;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-link:hover {
|
||||||
|
color: #183926;
|
||||||
|
}
|
||||||
|
|
||||||
.hero-copy h1,
|
.hero-copy h1,
|
||||||
.panel-heading h2,
|
.panel-heading h2,
|
||||||
.tip-card h2,
|
.tip-card h2,
|
||||||
@@ -316,6 +360,57 @@
|
|||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.schedule-list {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-grid-single {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rest-line {
|
||||||
|
margin: 0;
|
||||||
|
padding: 4px 2px 0;
|
||||||
|
color: #627265;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.has-tip {
|
||||||
|
position: relative;
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
|
||||||
|
.has-tip:hover::after {
|
||||||
|
content: attr(data-tip);
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 8px);
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #183926;
|
||||||
|
color: #edf6ef;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
box-shadow: 0 10px 22px rgba(24, 57, 38, 0.28);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.has-tip:hover::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 2px);
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
border: 6px solid transparent;
|
||||||
|
border-top-color: #183926;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
.round-card,
|
.round-card,
|
||||||
.team-card {
|
.team-card {
|
||||||
border-radius: 22px;
|
border-radius: 22px;
|
||||||
@@ -383,7 +478,8 @@
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.round-list {
|
.round-list,
|
||||||
|
.schedule-list {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+6
-708
@@ -1,716 +1,14 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import AppV1 from './AppV1'
|
||||||
import './App.css'
|
import AppV2 from './AppV2'
|
||||||
|
|
||||||
type Participant = {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
isPlaceholder: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
type Team = {
|
|
||||||
id: number
|
|
||||||
playerA: Participant
|
|
||||||
playerB: Participant
|
|
||||||
}
|
|
||||||
|
|
||||||
type RoundResult = {
|
|
||||||
id: number
|
|
||||||
teams: Team[]
|
|
||||||
}
|
|
||||||
|
|
||||||
type ActionState = 'idle' | 'saving' | 'saved' | 'loading' | 'loaded' | 'sharing' | 'error'
|
|
||||||
|
|
||||||
type LoadMatchResultsResponse = {
|
|
||||||
time: number
|
|
||||||
personnel: string
|
|
||||||
battlecombination: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
type AttendanceResponse = {
|
|
||||||
time: number
|
|
||||||
names: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
type HealthResponse = {
|
|
||||||
lineTargetMode?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const STORAGE_KEYS = {
|
|
||||||
areaA: 'badminton-match-hub::area-a',
|
|
||||||
areaB: 'badminton-match-hub::area-b',
|
|
||||||
} as const
|
|
||||||
|
|
||||||
const PLACEHOLDER_NAME = '那個'
|
|
||||||
const TOTAL_ROUNDS = 3
|
|
||||||
const LINE_PUSH_CONFIRM_TIMEOUT_MS = 6000
|
|
||||||
|
|
||||||
const defaultAreaA = ['建喵', '柏威', '景涵', 'Gary', '昱翔']
|
|
||||||
const defaultAreaB = ['小念', '玟瑄', 'RuRu', '肉肉', '蓉蓉']
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [areaAInput, setAreaAInput] = useState(() =>
|
const path = window.location.pathname
|
||||||
loadRoster(STORAGE_KEYS.areaA, defaultAreaA),
|
|
||||||
)
|
|
||||||
const [areaBInput, setAreaBInput] = useState(() =>
|
|
||||||
loadRoster(STORAGE_KEYS.areaB, defaultAreaB),
|
|
||||||
)
|
|
||||||
const [targetDate, setTargetDate] = useState(() => formatDateInputValue())
|
|
||||||
const [results, setResults] = useState<RoundResult[]>([])
|
|
||||||
const [error, setError] = useState('')
|
|
||||||
const [actionState, setActionState] = useState<ActionState>('idle')
|
|
||||||
const [actionMessage, setActionMessage] = useState('')
|
|
||||||
const [showTestBadge, setShowTestBadge] = useState(false)
|
|
||||||
const [linePushArmed, setLinePushArmed] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
if (path === '/v1' || path.startsWith('/v1/')) {
|
||||||
if (!linePushArmed) {
|
return <AppV1 />
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const timer = window.setTimeout(() => {
|
|
||||||
setLinePushArmed(false)
|
|
||||||
setActionState('idle')
|
|
||||||
setActionMessage('已逾時取消推送,需要時請再按一次「推送到 LINE」。')
|
|
||||||
}, LINE_PUSH_CONFIRM_TIMEOUT_MS)
|
|
||||||
return () => window.clearTimeout(timer)
|
|
||||||
}, [linePushArmed])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
window.localStorage.setItem(STORAGE_KEYS.areaA, areaAInput)
|
|
||||||
}, [areaAInput])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
window.localStorage.setItem(STORAGE_KEYS.areaB, areaBInput)
|
|
||||||
}, [areaBInput])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void (async () => {
|
|
||||||
const isLocalMode = await loadEnvironmentMode()
|
|
||||||
setShowTestBadge(isLocalMode)
|
|
||||||
})()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const parsedAreaA = parseRoster(areaAInput)
|
|
||||||
const parsedAreaB = parseRoster(areaBInput)
|
|
||||||
const zoneGap = Math.abs(parsedAreaA.length - parsedAreaB.length)
|
|
||||||
const crossCount = Math.floor(zoneGap / 2)
|
|
||||||
const placeholderCount = zoneGap % 2
|
|
||||||
const teamCount = Math.ceil((parsedAreaA.length + parsedAreaB.length) / 2)
|
|
||||||
|
|
||||||
function generateGroups() {
|
|
||||||
if (parsedAreaA.length === 0 || parsedAreaB.length === 0) {
|
|
||||||
setResults([])
|
|
||||||
setError('A 區與 B 區都至少要輸入 1 位成員。')
|
|
||||||
setActionState('idle')
|
|
||||||
setActionMessage('')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const shuffledA = shuffleList(parsedAreaA)
|
|
||||||
const shuffledB = shuffleList(parsedAreaB)
|
|
||||||
const nextResults = Array.from({ length: TOTAL_ROUNDS }, (_, index) => ({
|
|
||||||
id: index + 1,
|
|
||||||
teams: createRoundTeams(shuffledA, shuffledB, index),
|
|
||||||
}))
|
|
||||||
|
|
||||||
setResults(nextResults)
|
|
||||||
setLinePushArmed(false)
|
|
||||||
setError('')
|
|
||||||
setActionState('idle')
|
|
||||||
setActionMessage('已產生配對,請按「上傳資料」。')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function uploadResults() {
|
return <AppV2 />
|
||||||
if (results.length === 0) {
|
|
||||||
setActionState('error')
|
|
||||||
setActionMessage('請先產生配對結果,再上傳資料。')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!targetDate) {
|
|
||||||
setActionState('error')
|
|
||||||
setActionMessage('請先選擇目標日期。')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const timeKey = convertDateToKey(targetDate)
|
|
||||||
const existingResult = await findMatchResults(timeKey)
|
|
||||||
|
|
||||||
if (existingResult.found) {
|
|
||||||
const confirmed = window.confirm(`${timeKey} 已經有資料,確定要覆蓋嗎?`)
|
|
||||||
|
|
||||||
if (!confirmed) {
|
|
||||||
setActionState('idle')
|
|
||||||
setActionMessage('已取消上傳。')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setActionState('saving')
|
|
||||||
setActionMessage('上傳資料到資料庫中...')
|
|
||||||
|
|
||||||
await saveMatchResults(timeKey, parsedAreaA, parsedAreaB, results)
|
|
||||||
setActionState('saved')
|
|
||||||
setActionMessage(`已同步到資料庫:${timeKey}`)
|
|
||||||
} catch (saveError) {
|
|
||||||
setActionState('error')
|
|
||||||
setActionMessage(
|
|
||||||
saveError instanceof Error ? saveError.message : '資料庫儲存失敗,請稍後再試。',
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadResults() {
|
|
||||||
if (!targetDate) {
|
|
||||||
setActionState('error')
|
|
||||||
setActionMessage('請先選擇目標日期。')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setLinePushArmed(false)
|
|
||||||
setActionState('loading')
|
|
||||||
setActionMessage('讀取指定日期資料中...')
|
|
||||||
|
|
||||||
try {
|
|
||||||
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(
|
|
||||||
`指定日期沒有配對資料,已改載入 ${attendeeCount} 位出席人員到 A 區,請自行分配到 B 區。`,
|
|
||||||
)
|
|
||||||
} catch (loadError) {
|
|
||||||
setResults([])
|
|
||||||
setActionState('error')
|
|
||||||
setActionMessage(
|
|
||||||
loadError instanceof Error ? loadError.message : '讀取資料失敗,請稍後再試。',
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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')
|
|
||||||
setActionMessage('請先產生配對結果,再推送到 LINE。')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!targetDate) {
|
|
||||||
setActionState('error')
|
|
||||||
setActionMessage('請先選擇目標日期。')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!linePushArmed) {
|
|
||||||
setLinePushArmed(true)
|
|
||||||
setActionState('idle')
|
|
||||||
setActionMessage('再按一次「確認推送到 LINE」才會真的送出,6 秒內沒按會自動取消。')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setLinePushArmed(false)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const timeKey = convertDateToKey(targetDate)
|
|
||||||
setActionState('sharing')
|
|
||||||
setActionMessage('推送 LINE 訊息中...')
|
|
||||||
|
|
||||||
await pushMatchResultsToLine(timeKey, results)
|
|
||||||
setActionState('sharing')
|
|
||||||
setActionMessage(`已推送到指定 LINE 對話:${timeKey}`)
|
|
||||||
} catch (pushError) {
|
|
||||||
setActionState('error')
|
|
||||||
setActionMessage(
|
|
||||||
pushError instanceof Error ? pushError.message : '推送到 LINE 失敗,請稍後再試。',
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetDemo() {
|
|
||||||
setAreaAInput(defaultAreaA.join('\n'))
|
|
||||||
setAreaBInput(defaultAreaB.join('\n'))
|
|
||||||
setResults([])
|
|
||||||
setLinePushArmed(false)
|
|
||||||
setError('')
|
|
||||||
setActionState('idle')
|
|
||||||
setActionMessage('')
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="app-shell">
|
|
||||||
<section className="hero-card">
|
|
||||||
<div className="hero-copy">
|
|
||||||
{showTestBadge ? <div className="env-badge">測試環境</div> : null}
|
|
||||||
<p className="eyebrow">羽毛球隊伍配對</p>
|
|
||||||
<h1>羽毛球分組配對器</h1>
|
|
||||||
<p className="hero-text">
|
|
||||||
輸入 A 區與 B 區名單後,系統會產生 3 組完整配對。
|
|
||||||
兩區人數有落差時,每輪會輪流讓人多的一區成員跨區支援;
|
|
||||||
補完後仍有缺口才會補入「那個」。
|
|
||||||
</p>
|
|
||||||
<div className="hero-actions">
|
|
||||||
<button className="primary-button" type="button" onClick={generateGroups}>
|
|
||||||
產生三組配對
|
|
||||||
</button>
|
|
||||||
<button className="ghost-button" type="button" onClick={resetDemo}>
|
|
||||||
載入範例名單
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{actionMessage ? (
|
|
||||||
<p className={`save-status save-status-${actionState}`}>{actionMessage}</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="hero-stats">
|
|
||||||
<article className="stat-card">
|
|
||||||
<span>A 區人數</span>
|
|
||||||
<strong>{parsedAreaA.length}</strong>
|
|
||||||
</article>
|
|
||||||
<article className="stat-card">
|
|
||||||
<span>B 區人數</span>
|
|
||||||
<strong>{parsedAreaB.length}</strong>
|
|
||||||
</article>
|
|
||||||
<article className="tip-card">
|
|
||||||
<h2>摘要</h2>
|
|
||||||
<p>
|
|
||||||
每組共 {teamCount} 隊,跨區支援 {crossCount} 人,補位「那個」{placeholderCount} 人。
|
|
||||||
</p>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="panel input-panel">
|
|
||||||
<div className="panel-heading">
|
|
||||||
<p className="eyebrow">名單輸入</p>
|
|
||||||
<h2>輸入 A 區與 B 區名單</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="upload-row">
|
|
||||||
<label className="date-field">
|
|
||||||
<span>目標日期</span>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={targetDate}
|
|
||||||
onChange={(event) => {
|
|
||||||
setTargetDate(event.target.value)
|
|
||||||
setLinePushArmed(false)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<button
|
|
||||||
className="primary-button upload-button"
|
|
||||||
type="button"
|
|
||||||
onClick={() => void uploadResults()}
|
|
||||||
disabled={results.length === 0 || actionState === 'saving' || actionState === 'loading'}
|
|
||||||
>
|
|
||||||
上傳資料
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="ghost-button upload-button"
|
|
||||||
type="button"
|
|
||||||
onClick={() => void loadResults()}
|
|
||||||
disabled={actionState === 'saving' || actionState === 'loading'}
|
|
||||||
>
|
|
||||||
讀取指定日期
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="ghost-button upload-button"
|
|
||||||
type="button"
|
|
||||||
onClick={() => void loadAttendance()}
|
|
||||||
disabled={actionState === 'saving' || actionState === 'loading'}
|
|
||||||
>
|
|
||||||
讀取今日出席
|
|
||||||
</button>
|
|
||||||
{results.length > 0 ? (
|
|
||||||
<button
|
|
||||||
className={`primary-button upload-button${linePushArmed ? ' is-armed' : ''}`}
|
|
||||||
type="button"
|
|
||||||
onClick={() => void pushToLine()}
|
|
||||||
disabled={actionState === 'saving' || actionState === 'loading'}
|
|
||||||
>
|
|
||||||
{linePushArmed ? '確認推送到 LINE' : '推送到 LINE'}
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="input-grid">
|
|
||||||
<label className="roster-field">
|
|
||||||
<span>A 區成員</span>
|
|
||||||
<textarea
|
|
||||||
value={areaAInput}
|
|
||||||
onChange={(event) => setAreaAInput(event.target.value)}
|
|
||||||
placeholder={'每行一位\n例如:建喵'}
|
|
||||||
rows={10}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="roster-field">
|
|
||||||
<span>B 區成員</span>
|
|
||||||
<textarea
|
|
||||||
value={areaBInput}
|
|
||||||
onChange={(event) => setAreaBInput(event.target.value)}
|
|
||||||
placeholder={'每行一位\n例如:小念'}
|
|
||||||
rows={10}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="helper-text">支援換行、逗號、頓號分隔,會自動去除重複名稱。</p>
|
|
||||||
{error ? <p className="error-banner">{error}</p> : null}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="panel results-panel">
|
|
||||||
<div className="panel-heading">
|
|
||||||
<p className="eyebrow">配對結果</p>
|
|
||||||
<h2>三組名單</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{results.length > 0 ? (
|
|
||||||
<div className="round-list">
|
|
||||||
{results.map((round) => (
|
|
||||||
<article className="round-card" key={round.id}>
|
|
||||||
<h3 className="round-title">第 {round.id} 組</h3>
|
|
||||||
<div className="team-list">
|
|
||||||
{round.teams.map((team) => (
|
|
||||||
<article className="team-card" key={`${round.id}-${team.id}`}>
|
|
||||||
<span className="team-index">第 {team.id} 隊</span>
|
|
||||||
<div className="team-line">
|
|
||||||
<span className={team.playerA.isPlaceholder ? 'name-placeholder' : 'name-a'}>
|
|
||||||
{team.playerA.name}
|
|
||||||
</span>
|
|
||||||
<span className="team-divider">×</span>
|
|
||||||
<span className={team.playerB.isPlaceholder ? 'name-placeholder' : 'name-b'}>
|
|
||||||
{team.playerB.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="empty-state">
|
|
||||||
<p>按下「產生三組配對」後,這裡會直接顯示誰跟誰一隊。</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function balanceZones(areaA: string[], areaB: string[], roundIndex: number) {
|
|
||||||
const moveCount = Math.floor(Math.abs(areaA.length - areaB.length) / 2)
|
|
||||||
|
|
||||||
if (moveCount === 0) {
|
|
||||||
return { areaA, areaB }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (areaA.length > areaB.length) {
|
|
||||||
const { moved, remaining } = pickCrossMembers(areaA, moveCount, roundIndex)
|
|
||||||
return { areaA: remaining, areaB: [...areaB, ...moved] }
|
|
||||||
}
|
|
||||||
|
|
||||||
const { moved, remaining } = pickCrossMembers(areaB, moveCount, roundIndex)
|
|
||||||
return { areaA: [...areaA, ...moved], areaB: remaining }
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickCrossMembers(list: string[], moveCount: number, roundIndex: number) {
|
|
||||||
const movedIndexes = new Set(
|
|
||||||
Array.from({ length: moveCount }, (_, offset) => (roundIndex * moveCount + offset) % list.length),
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
moved: list.filter((_, index) => movedIndexes.has(index)),
|
|
||||||
remaining: list.filter((_, index) => !movedIndexes.has(index)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createParticipants(players: string[], targetCount: number, zone: 'A' | 'B') {
|
|
||||||
const participants: Participant[] = players.map((name, index) => ({
|
|
||||||
id: `${zone}-${index}-${name}`,
|
|
||||||
name,
|
|
||||||
isPlaceholder: false,
|
|
||||||
}))
|
|
||||||
|
|
||||||
for (let index = players.length; index < targetCount; index += 1) {
|
|
||||||
participants.push({
|
|
||||||
id: `${zone}-placeholder-${index}`,
|
|
||||||
name: PLACEHOLDER_NAME,
|
|
||||||
isPlaceholder: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return participants
|
|
||||||
}
|
|
||||||
|
|
||||||
function createRoundTeams(areaA: string[], areaB: string[], roundIndex: number) {
|
|
||||||
const balanced = balanceZones(areaA, areaB, roundIndex)
|
|
||||||
const targetCount = Math.max(balanced.areaA.length, balanced.areaB.length)
|
|
||||||
const playersA = createParticipants(balanced.areaA, targetCount, 'A')
|
|
||||||
const playersB = createParticipants(balanced.areaB, targetCount, 'B')
|
|
||||||
|
|
||||||
return playersA.map((playerA, index) => ({
|
|
||||||
id: index + 1,
|
|
||||||
playerA,
|
|
||||||
playerB: playersB[(index + roundIndex) % playersB.length],
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveMatchResults(
|
|
||||||
time: string,
|
|
||||||
areaA: string[],
|
|
||||||
areaB: string[],
|
|
||||||
rounds: RoundResult[],
|
|
||||||
) {
|
|
||||||
const response = await fetch('/api/match-results', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
time,
|
|
||||||
areaA,
|
|
||||||
areaB,
|
|
||||||
teams: rounds.map((round) => ({
|
|
||||||
round: round.id,
|
|
||||||
teams: round.teams.map((team) => ({
|
|
||||||
team: team.id,
|
|
||||||
a: team.playerA.name,
|
|
||||||
b: team.playerB.name,
|
|
||||||
})),
|
|
||||||
})),
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
const payload = (await response.json()) as { ok?: boolean; message?: string }
|
|
||||||
|
|
||||||
if (!response.ok || !payload.ok) {
|
|
||||||
throw new Error(payload.message ?? '資料庫儲存失敗。')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function findAttendance(time: string) {
|
|
||||||
const response = await fetch(`/api/attendance/${time}`)
|
|
||||||
const payload = (await response.json()) as {
|
|
||||||
ok?: boolean
|
|
||||||
message?: string
|
|
||||||
data?: AttendanceResponse
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
|
||||||
const response = await fetch(`/api/match-results/${time}`)
|
|
||||||
const payload = (await response.json()) as {
|
|
||||||
ok?: boolean
|
|
||||||
message?: string
|
|
||||||
data?: LoadMatchResultsResponse
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.status === 404) {
|
|
||||||
return {
|
|
||||||
found: false,
|
|
||||||
data: null,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.ok || !payload.ok) {
|
|
||||||
throw new Error(payload.message ?? '讀取資料失敗。')
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
found: true,
|
|
||||||
data: payload.data ?? null,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pushMatchResultsToLine(time: string, rounds: RoundResult[]) {
|
|
||||||
const response = await fetch('/api/line/push-match-results', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
time,
|
|
||||||
teams: rounds.map((round) => ({
|
|
||||||
round: round.id,
|
|
||||||
teams: round.teams.map((team) => ({
|
|
||||||
team: team.id,
|
|
||||||
a: team.playerA.name,
|
|
||||||
b: team.playerB.name,
|
|
||||||
})),
|
|
||||||
})),
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
const payload = (await response.json()) as { ok?: boolean; message?: string }
|
|
||||||
|
|
||||||
if (!response.ok || !payload.ok) {
|
|
||||||
throw new Error(payload.message ?? '推送到 LINE 失敗。')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadEnvironmentMode() {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/health')
|
|
||||||
if (!response.ok) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = (await response.json()) as HealthResponse
|
|
||||||
return payload.lineTargetMode === 'local'
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function convertDbRecordToAppState(record: LoadMatchResultsResponse) {
|
|
||||||
const personnel = JSON.parse(record.personnel) as [number, string][]
|
|
||||||
const battlecombination = JSON.parse(record.battlecombination ?? '{}') as Record<
|
|
||||||
string,
|
|
||||||
[string, string][]
|
|
||||||
>
|
|
||||||
|
|
||||||
const areaA = personnel.filter(([group]) => group === 1).map(([, name]) => name)
|
|
||||||
const areaB = personnel.filter(([group]) => group === 0).map(([, name]) => name)
|
|
||||||
|
|
||||||
const results = Object.keys(battlecombination)
|
|
||||||
.sort((left, right) => Number(left) - Number(right))
|
|
||||||
.map((key, roundIndex) => ({
|
|
||||||
id: roundIndex + 1,
|
|
||||||
teams: battlecombination[key].map((team, teamIndex) => ({
|
|
||||||
id: teamIndex + 1,
|
|
||||||
playerA: createLoadedParticipant(team[0], 'A', roundIndex, teamIndex),
|
|
||||||
playerB: createLoadedParticipant(team[1], 'B', roundIndex, teamIndex),
|
|
||||||
})),
|
|
||||||
}))
|
|
||||||
|
|
||||||
return { areaA, areaB, results }
|
|
||||||
}
|
|
||||||
|
|
||||||
function createLoadedParticipant(name: string, zone: 'A' | 'B', roundIndex: number, teamIndex: number) {
|
|
||||||
return {
|
|
||||||
id: `${zone}-loaded-${roundIndex}-${teamIndex}-${name}`,
|
|
||||||
name,
|
|
||||||
isPlaceholder: name === PLACEHOLDER_NAME,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDateInputValue() {
|
|
||||||
const now = new Date()
|
|
||||||
const year = now.getFullYear()
|
|
||||||
const month = String(now.getMonth() + 1).padStart(2, '0')
|
|
||||||
const day = String(now.getDate()).padStart(2, '0')
|
|
||||||
return `${year}-${month}-${day}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function convertDateToKey(dateValue: string) {
|
|
||||||
const [year = '', month = '', day = ''] = dateValue.split('-')
|
|
||||||
return `${year}${month}${day}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadRoster(storageKey: string, fallbackList: string[]) {
|
|
||||||
const storedValue = window.localStorage.getItem(storageKey)
|
|
||||||
return storedValue && storedValue.trim() ? storedValue : fallbackList.join('\n')
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseRoster(input: string) {
|
|
||||||
const uniqueNames = new Set<string>()
|
|
||||||
|
|
||||||
input
|
|
||||||
.split(/[\n,,、]+/g)
|
|
||||||
.map((name) => name.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
.forEach((name) => uniqueNames.add(name))
|
|
||||||
|
|
||||||
return Array.from(uniqueNames)
|
|
||||||
}
|
|
||||||
|
|
||||||
function shuffleList<T>(list: T[]) {
|
|
||||||
const next = [...list]
|
|
||||||
|
|
||||||
for (let index = next.length - 1; index > 0; index -= 1) {
|
|
||||||
const swapIndex = Math.floor(Math.random() * (index + 1))
|
|
||||||
;[next[index], next[swapIndex]] = [next[swapIndex], next[index]]
|
|
||||||
}
|
|
||||||
|
|
||||||
return next
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App
|
export default App
|
||||||
|
|||||||
+746
@@ -0,0 +1,746 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import './App.css'
|
||||||
|
|
||||||
|
type Participant = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
isPlaceholder: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type Team = {
|
||||||
|
id: number
|
||||||
|
playerA: Participant
|
||||||
|
playerB: Participant
|
||||||
|
}
|
||||||
|
|
||||||
|
type RoundResult = {
|
||||||
|
id: number
|
||||||
|
teams: Team[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type ActionState = 'idle' | 'saving' | 'saved' | 'loading' | 'loaded' | 'sharing' | 'error'
|
||||||
|
|
||||||
|
type LoadMatchResultsResponse = {
|
||||||
|
time: number
|
||||||
|
personnel: string
|
||||||
|
battlecombination: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type AttendanceMember = {
|
||||||
|
name: string
|
||||||
|
skill: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type AttendanceResponse = {
|
||||||
|
time: number
|
||||||
|
names: string[]
|
||||||
|
members?: AttendanceMember[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type HealthResponse = {
|
||||||
|
lineTargetMode?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEYS = {
|
||||||
|
areaA: 'badminton-match-hub::area-a',
|
||||||
|
areaB: 'badminton-match-hub::area-b',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const PLACEHOLDER_NAME = '那個'
|
||||||
|
const TOTAL_ROUNDS = 3
|
||||||
|
const LINE_PUSH_CONFIRM_TIMEOUT_MS = 6000
|
||||||
|
|
||||||
|
const defaultAreaA = ['景涵', '建喵', '柏威', 'Gary', '阿志', '瑞奇', '小念', '肉肉']
|
||||||
|
const defaultAreaB = ['蘿莉', 'RuRu', '蓉蓉', '昱翔', '楨楨', '詣翔', 'Yang', '小馬']
|
||||||
|
|
||||||
|
function AppV1() {
|
||||||
|
const [areaAInput, setAreaAInput] = useState(() =>
|
||||||
|
loadRoster(STORAGE_KEYS.areaA, defaultAreaA),
|
||||||
|
)
|
||||||
|
const [areaBInput, setAreaBInput] = useState(() =>
|
||||||
|
loadRoster(STORAGE_KEYS.areaB, defaultAreaB),
|
||||||
|
)
|
||||||
|
const [targetDate, setTargetDate] = useState(() => formatDateInputValue())
|
||||||
|
const [results, setResults] = useState<RoundResult[]>([])
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [actionState, setActionState] = useState<ActionState>('idle')
|
||||||
|
const [actionMessage, setActionMessage] = useState('')
|
||||||
|
const [showTestBadge, setShowTestBadge] = useState(false)
|
||||||
|
const [linePushArmed, setLinePushArmed] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!linePushArmed) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
setLinePushArmed(false)
|
||||||
|
setActionState('idle')
|
||||||
|
setActionMessage('已逾時取消推送,需要時請再按一次「推送到 LINE」。')
|
||||||
|
}, LINE_PUSH_CONFIRM_TIMEOUT_MS)
|
||||||
|
return () => window.clearTimeout(timer)
|
||||||
|
}, [linePushArmed])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.localStorage.setItem(STORAGE_KEYS.areaA, areaAInput)
|
||||||
|
}, [areaAInput])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.localStorage.setItem(STORAGE_KEYS.areaB, areaBInput)
|
||||||
|
}, [areaBInput])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void (async () => {
|
||||||
|
const isLocalMode = await loadEnvironmentMode()
|
||||||
|
setShowTestBadge(isLocalMode)
|
||||||
|
})()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const parsedAreaA = parseRoster(areaAInput)
|
||||||
|
const parsedAreaB = parseRoster(areaBInput)
|
||||||
|
const zoneGap = Math.abs(parsedAreaA.length - parsedAreaB.length)
|
||||||
|
const crossCount = Math.floor(zoneGap / 2)
|
||||||
|
const placeholderCount = zoneGap % 2
|
||||||
|
const teamCount = Math.ceil((parsedAreaA.length + parsedAreaB.length) / 2)
|
||||||
|
|
||||||
|
function generateGroups() {
|
||||||
|
if (parsedAreaA.length === 0 || parsedAreaB.length === 0) {
|
||||||
|
setResults([])
|
||||||
|
setError('A 區與 B 區都至少要輸入 1 位成員。')
|
||||||
|
setActionState('idle')
|
||||||
|
setActionMessage('')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const shuffledA = shuffleList(parsedAreaA)
|
||||||
|
const shuffledB = shuffleList(parsedAreaB)
|
||||||
|
const nextResults = Array.from({ length: TOTAL_ROUNDS }, (_, index) => ({
|
||||||
|
id: index + 1,
|
||||||
|
teams: createRoundTeams(shuffledA, shuffledB, index),
|
||||||
|
}))
|
||||||
|
|
||||||
|
setResults(nextResults)
|
||||||
|
setLinePushArmed(false)
|
||||||
|
setError('')
|
||||||
|
setActionState('idle')
|
||||||
|
setActionMessage('已產生配對,請按「上傳資料」。')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadResults() {
|
||||||
|
if (results.length === 0) {
|
||||||
|
setActionState('error')
|
||||||
|
setActionMessage('請先產生配對結果,再上傳資料。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetDate) {
|
||||||
|
setActionState('error')
|
||||||
|
setActionMessage('請先選擇目標日期。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const timeKey = convertDateToKey(targetDate)
|
||||||
|
const existingResult = await findMatchResults(timeKey)
|
||||||
|
|
||||||
|
if (existingResult.found) {
|
||||||
|
const confirmed = window.confirm(`${timeKey} 已經有資料,確定要覆蓋嗎?`)
|
||||||
|
|
||||||
|
if (!confirmed) {
|
||||||
|
setActionState('idle')
|
||||||
|
setActionMessage('已取消上傳。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setActionState('saving')
|
||||||
|
setActionMessage('上傳資料到資料庫中...')
|
||||||
|
|
||||||
|
await saveMatchResults(timeKey, parsedAreaA, parsedAreaB, results)
|
||||||
|
setActionState('saved')
|
||||||
|
setActionMessage(`已同步到資料庫:${timeKey}`)
|
||||||
|
} catch (saveError) {
|
||||||
|
setActionState('error')
|
||||||
|
setActionMessage(
|
||||||
|
saveError instanceof Error ? saveError.message : '資料庫儲存失敗,請稍後再試。',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadResults() {
|
||||||
|
if (!targetDate) {
|
||||||
|
setActionState('error')
|
||||||
|
setActionMessage('請先選擇目標日期。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLinePushArmed(false)
|
||||||
|
setActionState('loading')
|
||||||
|
setActionMessage('讀取指定日期資料中...')
|
||||||
|
|
||||||
|
try {
|
||||||
|
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 summary = applyAttendance(attendance.members)
|
||||||
|
setActionState('loaded')
|
||||||
|
setActionMessage(
|
||||||
|
`指定日期沒有配對資料,已改載入 ${summary.total} 位出席人員,依實力分為 A 區 ${summary.areaACount} 人、B 區 ${summary.areaBCount} 人。`,
|
||||||
|
)
|
||||||
|
} catch (loadError) {
|
||||||
|
setResults([])
|
||||||
|
setActionState('error')
|
||||||
|
setActionMessage(
|
||||||
|
loadError instanceof Error ? loadError.message : '讀取資料失敗,請稍後再試。',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 summary = applyAttendance(attendance.members)
|
||||||
|
setActionState('loaded')
|
||||||
|
setActionMessage(
|
||||||
|
`已載入 ${summary.total} 位出席人員(${timeKey}),依實力分為 A 區 ${summary.areaACount} 人、B 區 ${summary.areaBCount} 人。`,
|
||||||
|
)
|
||||||
|
} catch (loadError) {
|
||||||
|
setActionState('error')
|
||||||
|
setActionMessage(
|
||||||
|
loadError instanceof Error ? loadError.message : '出席資料讀取失敗,請稍後再試。',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyAttendance(members: AttendanceMember[]) {
|
||||||
|
const uniqueMembers = new Map<string, number>()
|
||||||
|
|
||||||
|
members.forEach((member) => {
|
||||||
|
const name = member.name.trim()
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
uniqueMembers.set(name, Math.max(uniqueMembers.get(name) ?? 1, member.skill))
|
||||||
|
})
|
||||||
|
|
||||||
|
const ranked = Array.from(uniqueMembers, ([name, skill]) => ({ name, skill })).sort(
|
||||||
|
(left, right) => right.skill - left.skill,
|
||||||
|
)
|
||||||
|
const areaACount = Math.ceil(ranked.length / 2)
|
||||||
|
const areaA = ranked.slice(0, areaACount).map((member) => member.name)
|
||||||
|
const areaB = ranked.slice(areaACount).map((member) => member.name)
|
||||||
|
|
||||||
|
setAreaAInput(areaA.join('\n'))
|
||||||
|
setAreaBInput(areaB.join('\n'))
|
||||||
|
setResults([])
|
||||||
|
setError('')
|
||||||
|
return { total: ranked.length, areaACount: areaA.length, areaBCount: areaB.length }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pushToLine() {
|
||||||
|
if (results.length === 0) {
|
||||||
|
setActionState('error')
|
||||||
|
setActionMessage('請先產生配對結果,再推送到 LINE。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetDate) {
|
||||||
|
setActionState('error')
|
||||||
|
setActionMessage('請先選擇目標日期。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!linePushArmed) {
|
||||||
|
setLinePushArmed(true)
|
||||||
|
setActionState('idle')
|
||||||
|
setActionMessage('再按一次「確認推送到 LINE」才會真的送出,6 秒內沒按會自動取消。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLinePushArmed(false)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const timeKey = convertDateToKey(targetDate)
|
||||||
|
setActionState('sharing')
|
||||||
|
setActionMessage('推送 LINE 訊息中...')
|
||||||
|
|
||||||
|
await pushMatchResultsToLine(timeKey, results)
|
||||||
|
setActionState('sharing')
|
||||||
|
setActionMessage(`已推送到指定 LINE 對話:${timeKey}`)
|
||||||
|
} catch (pushError) {
|
||||||
|
setActionState('error')
|
||||||
|
setActionMessage(
|
||||||
|
pushError instanceof Error ? pushError.message : '推送到 LINE 失敗,請稍後再試。',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetDemo() {
|
||||||
|
setAreaAInput(defaultAreaA.join('\n'))
|
||||||
|
setAreaBInput(defaultAreaB.join('\n'))
|
||||||
|
setResults([])
|
||||||
|
setLinePushArmed(false)
|
||||||
|
setError('')
|
||||||
|
setActionState('idle')
|
||||||
|
setActionMessage('')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="app-shell">
|
||||||
|
<section className="hero-card">
|
||||||
|
<div className="hero-copy">
|
||||||
|
{showTestBadge ? <div className="env-badge">測試環境</div> : null}
|
||||||
|
<p className="eyebrow">羽毛球隊伍配對(舊版)・<a className="version-link" href="/">切換到 V2</a></p>
|
||||||
|
<h1>羽毛球分組配對器 V1</h1>
|
||||||
|
<p className="hero-text">
|
||||||
|
輸入 A 區與 B 區名單後,系統會產生 3 組完整配對。
|
||||||
|
兩區人數有落差時,每輪會輪流讓人多的一區成員跨區支援;
|
||||||
|
補完後仍有缺口才會補入「那個」。
|
||||||
|
</p>
|
||||||
|
<div className="hero-actions">
|
||||||
|
<button className="primary-button" type="button" onClick={generateGroups}>
|
||||||
|
產生三組配對
|
||||||
|
</button>
|
||||||
|
<button className="ghost-button" type="button" onClick={resetDemo}>
|
||||||
|
載入範例名單
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{actionMessage ? (
|
||||||
|
<p className={`save-status save-status-${actionState}`}>{actionMessage}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="hero-stats">
|
||||||
|
<article className="stat-card">
|
||||||
|
<span>A 區人數</span>
|
||||||
|
<strong>{parsedAreaA.length}</strong>
|
||||||
|
</article>
|
||||||
|
<article className="stat-card">
|
||||||
|
<span>B 區人數</span>
|
||||||
|
<strong>{parsedAreaB.length}</strong>
|
||||||
|
</article>
|
||||||
|
<article className="tip-card">
|
||||||
|
<h2>摘要</h2>
|
||||||
|
<p>
|
||||||
|
每組共 {teamCount} 隊,跨區支援 {crossCount} 人,補位「那個」{placeholderCount} 人。
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="panel input-panel">
|
||||||
|
<div className="panel-heading">
|
||||||
|
<p className="eyebrow">名單輸入</p>
|
||||||
|
<h2>輸入 A 區與 B 區名單</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="upload-row">
|
||||||
|
<label className="date-field">
|
||||||
|
<span>目標日期</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={targetDate}
|
||||||
|
onChange={(event) => {
|
||||||
|
setTargetDate(event.target.value)
|
||||||
|
setLinePushArmed(false)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
className="primary-button upload-button"
|
||||||
|
type="button"
|
||||||
|
onClick={() => void uploadResults()}
|
||||||
|
disabled={results.length === 0 || actionState === 'saving' || actionState === 'loading'}
|
||||||
|
>
|
||||||
|
上傳資料
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="ghost-button upload-button"
|
||||||
|
type="button"
|
||||||
|
onClick={() => void loadResults()}
|
||||||
|
disabled={actionState === 'saving' || actionState === 'loading'}
|
||||||
|
>
|
||||||
|
讀取指定日期
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="ghost-button upload-button"
|
||||||
|
type="button"
|
||||||
|
onClick={() => void loadAttendance()}
|
||||||
|
disabled={actionState === 'saving' || actionState === 'loading'}
|
||||||
|
>
|
||||||
|
讀取今日出席
|
||||||
|
</button>
|
||||||
|
{results.length > 0 ? (
|
||||||
|
<button
|
||||||
|
className={`primary-button upload-button${linePushArmed ? ' is-armed' : ''}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => void pushToLine()}
|
||||||
|
disabled={actionState === 'saving' || actionState === 'loading'}
|
||||||
|
>
|
||||||
|
{linePushArmed ? '確認推送到 LINE' : '推送到 LINE'}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="input-grid">
|
||||||
|
<label className="roster-field">
|
||||||
|
<span>A 區成員</span>
|
||||||
|
<textarea
|
||||||
|
value={areaAInput}
|
||||||
|
onChange={(event) => setAreaAInput(event.target.value)}
|
||||||
|
placeholder={'每行一位\n例如:建喵'}
|
||||||
|
rows={10}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="roster-field">
|
||||||
|
<span>B 區成員</span>
|
||||||
|
<textarea
|
||||||
|
value={areaBInput}
|
||||||
|
onChange={(event) => setAreaBInput(event.target.value)}
|
||||||
|
placeholder={'每行一位\n例如:小念'}
|
||||||
|
rows={10}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="helper-text">支援換行、逗號、頓號分隔,會自動去除重複名稱。</p>
|
||||||
|
{error ? <p className="error-banner">{error}</p> : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="panel results-panel">
|
||||||
|
<div className="panel-heading">
|
||||||
|
<p className="eyebrow">配對結果</p>
|
||||||
|
<h2>三組名單</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{results.length > 0 ? (
|
||||||
|
<div className="round-list">
|
||||||
|
{results.map((round) => (
|
||||||
|
<article className="round-card" key={round.id}>
|
||||||
|
<h3 className="round-title">第 {round.id} 組</h3>
|
||||||
|
<div className="team-list">
|
||||||
|
{round.teams.map((team) => (
|
||||||
|
<article className="team-card" key={`${round.id}-${team.id}`}>
|
||||||
|
<span className="team-index">第 {team.id} 隊</span>
|
||||||
|
<div className="team-line">
|
||||||
|
<span className={team.playerA.isPlaceholder ? 'name-placeholder' : 'name-a'}>
|
||||||
|
{team.playerA.name}
|
||||||
|
</span>
|
||||||
|
<span className="team-divider">×</span>
|
||||||
|
<span className={team.playerB.isPlaceholder ? 'name-placeholder' : 'name-b'}>
|
||||||
|
{team.playerB.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="empty-state">
|
||||||
|
<p>按下「產生三組配對」後,這裡會直接顯示誰跟誰一隊。</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function balanceZones(areaA: string[], areaB: string[], roundIndex: number) {
|
||||||
|
const moveCount = Math.floor(Math.abs(areaA.length - areaB.length) / 2)
|
||||||
|
|
||||||
|
if (moveCount === 0) {
|
||||||
|
return { areaA, areaB }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (areaA.length > areaB.length) {
|
||||||
|
const { moved, remaining } = pickCrossMembers(areaA, moveCount, roundIndex)
|
||||||
|
return { areaA: remaining, areaB: [...areaB, ...moved] }
|
||||||
|
}
|
||||||
|
|
||||||
|
const { moved, remaining } = pickCrossMembers(areaB, moveCount, roundIndex)
|
||||||
|
return { areaA: [...areaA, ...moved], areaB: remaining }
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickCrossMembers(list: string[], moveCount: number, roundIndex: number) {
|
||||||
|
const movedIndexes = new Set(
|
||||||
|
Array.from({ length: moveCount }, (_, offset) => (roundIndex * moveCount + offset) % list.length),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
moved: list.filter((_, index) => movedIndexes.has(index)),
|
||||||
|
remaining: list.filter((_, index) => !movedIndexes.has(index)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createParticipants(players: string[], targetCount: number, zone: 'A' | 'B') {
|
||||||
|
const participants: Participant[] = players.map((name, index) => ({
|
||||||
|
id: `${zone}-${index}-${name}`,
|
||||||
|
name,
|
||||||
|
isPlaceholder: false,
|
||||||
|
}))
|
||||||
|
|
||||||
|
for (let index = players.length; index < targetCount; index += 1) {
|
||||||
|
participants.push({
|
||||||
|
id: `${zone}-placeholder-${index}`,
|
||||||
|
name: PLACEHOLDER_NAME,
|
||||||
|
isPlaceholder: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return participants
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRoundTeams(areaA: string[], areaB: string[], roundIndex: number) {
|
||||||
|
const balanced = balanceZones(areaA, areaB, roundIndex)
|
||||||
|
const targetCount = Math.max(balanced.areaA.length, balanced.areaB.length)
|
||||||
|
const playersA = createParticipants(balanced.areaA, targetCount, 'A')
|
||||||
|
const playersB = createParticipants(balanced.areaB, targetCount, 'B')
|
||||||
|
|
||||||
|
return playersA.map((playerA, index) => ({
|
||||||
|
id: index + 1,
|
||||||
|
playerA,
|
||||||
|
playerB: playersB[(index + roundIndex) % playersB.length],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveMatchResults(
|
||||||
|
time: string,
|
||||||
|
areaA: string[],
|
||||||
|
areaB: string[],
|
||||||
|
rounds: RoundResult[],
|
||||||
|
) {
|
||||||
|
const response = await fetch('/api/match-results', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
time,
|
||||||
|
areaA,
|
||||||
|
areaB,
|
||||||
|
teams: rounds.map((round) => ({
|
||||||
|
round: round.id,
|
||||||
|
teams: round.teams.map((team) => ({
|
||||||
|
team: team.id,
|
||||||
|
a: team.playerA.name,
|
||||||
|
b: team.playerB.name,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const payload = (await response.json()) as { ok?: boolean; message?: string }
|
||||||
|
|
||||||
|
if (!response.ok || !payload.ok) {
|
||||||
|
throw new Error(payload.message ?? '資料庫儲存失敗。')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findAttendance(time: string) {
|
||||||
|
const response = await fetch(`/api/attendance/${time}`)
|
||||||
|
const payload = (await response.json()) as {
|
||||||
|
ok?: boolean
|
||||||
|
message?: string
|
||||||
|
data?: AttendanceResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 404) {
|
||||||
|
return {
|
||||||
|
found: false as const,
|
||||||
|
members: [] as AttendanceMember[],
|
||||||
|
message: payload.message ?? '指定日期沒有出席資料。',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok || !payload.ok) {
|
||||||
|
throw new Error(payload.message ?? '出席資料讀取失敗。')
|
||||||
|
}
|
||||||
|
|
||||||
|
const members =
|
||||||
|
payload.data?.members ??
|
||||||
|
(payload.data?.names ?? []).map((name) => ({ name, skill: 1 }))
|
||||||
|
|
||||||
|
return {
|
||||||
|
found: true as const,
|
||||||
|
members,
|
||||||
|
message: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findMatchResults(time: string) {
|
||||||
|
const response = await fetch(`/api/match-results/${time}`)
|
||||||
|
const payload = (await response.json()) as {
|
||||||
|
ok?: boolean
|
||||||
|
message?: string
|
||||||
|
data?: LoadMatchResultsResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 404) {
|
||||||
|
return {
|
||||||
|
found: false,
|
||||||
|
data: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok || !payload.ok) {
|
||||||
|
throw new Error(payload.message ?? '讀取資料失敗。')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
found: true,
|
||||||
|
data: payload.data ?? null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pushMatchResultsToLine(time: string, rounds: RoundResult[]) {
|
||||||
|
const response = await fetch('/api/line/push-match-results', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
time,
|
||||||
|
teams: rounds.map((round) => ({
|
||||||
|
round: round.id,
|
||||||
|
teams: round.teams.map((team) => ({
|
||||||
|
team: team.id,
|
||||||
|
a: team.playerA.name,
|
||||||
|
b: team.playerB.name,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const payload = (await response.json()) as { ok?: boolean; message?: string }
|
||||||
|
|
||||||
|
if (!response.ok || !payload.ok) {
|
||||||
|
throw new Error(payload.message ?? '推送到 LINE 失敗。')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadEnvironmentMode() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/health')
|
||||||
|
if (!response.ok) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = (await response.json()) as HealthResponse
|
||||||
|
return payload.lineTargetMode === 'local'
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertDbRecordToAppState(record: LoadMatchResultsResponse) {
|
||||||
|
const personnel = JSON.parse(record.personnel) as [number, string][]
|
||||||
|
const battlecombination = JSON.parse(record.battlecombination ?? '{}') as Record<
|
||||||
|
string,
|
||||||
|
[string, string][]
|
||||||
|
>
|
||||||
|
|
||||||
|
const areaA = personnel.filter(([group]) => group === 1).map(([, name]) => name)
|
||||||
|
const areaB = personnel.filter(([group]) => group === 0).map(([, name]) => name)
|
||||||
|
|
||||||
|
const results = Object.keys(battlecombination)
|
||||||
|
.sort((left, right) => Number(left) - Number(right))
|
||||||
|
.map((key, roundIndex) => ({
|
||||||
|
id: roundIndex + 1,
|
||||||
|
teams: battlecombination[key].map((team, teamIndex) => ({
|
||||||
|
id: teamIndex + 1,
|
||||||
|
playerA: createLoadedParticipant(team[0], 'A', roundIndex, teamIndex),
|
||||||
|
playerB: createLoadedParticipant(team[1], 'B', roundIndex, teamIndex),
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
|
||||||
|
return { areaA, areaB, results }
|
||||||
|
}
|
||||||
|
|
||||||
|
function createLoadedParticipant(name: string, zone: 'A' | 'B', roundIndex: number, teamIndex: number) {
|
||||||
|
return {
|
||||||
|
id: `${zone}-loaded-${roundIndex}-${teamIndex}-${name}`,
|
||||||
|
name,
|
||||||
|
isPlaceholder: name === PLACEHOLDER_NAME,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateInputValue() {
|
||||||
|
const now = new Date()
|
||||||
|
const year = now.getFullYear()
|
||||||
|
const month = String(now.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(now.getDate()).padStart(2, '0')
|
||||||
|
return `${year}-${month}-${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertDateToKey(dateValue: string) {
|
||||||
|
const [year = '', month = '', day = ''] = dateValue.split('-')
|
||||||
|
return `${year}${month}${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadRoster(storageKey: string, fallbackList: string[]) {
|
||||||
|
const storedValue = window.localStorage.getItem(storageKey)
|
||||||
|
return storedValue && storedValue.trim() ? storedValue : fallbackList.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRoster(input: string) {
|
||||||
|
const uniqueNames = new Set<string>()
|
||||||
|
|
||||||
|
input
|
||||||
|
.split(/[\n,,、]+/g)
|
||||||
|
.map((name) => name.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.forEach((name) => uniqueNames.add(name))
|
||||||
|
|
||||||
|
return Array.from(uniqueNames)
|
||||||
|
}
|
||||||
|
|
||||||
|
function shuffleList<T>(list: T[]) {
|
||||||
|
const next = [...list]
|
||||||
|
|
||||||
|
for (let index = next.length - 1; index > 0; index -= 1) {
|
||||||
|
const swapIndex = Math.floor(Math.random() * (index + 1))
|
||||||
|
;[next[index], next[swapIndex]] = [next[swapIndex], next[index]]
|
||||||
|
}
|
||||||
|
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AppV1
|
||||||
+1041
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user