From 2bed4f6df0f0955bf60e1f75d98be5369eaa4b4f Mon Sep 17 00:00:00 2001 From: JianMiau Date: Wed, 15 Apr 2026 16:22:19 +0800 Subject: [PATCH] Add LINE push integration and target mode config --- .env.example | 4 ++ README.md | 9 +++ server/server.mjs | 162 ++++++++++++++++++++++++++++++++++++++++++++++ src/App.css | 12 ++++ src/App.tsx | 70 +++++++++++++++++++- 5 files changed, 256 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index fb723b1..59ce9f4 100644 --- a/.env.example +++ b/.env.example @@ -5,3 +5,7 @@ DB_PASSWORD=your-password DB_DATABASE=badminton DB_TABLE=badminton SERVER_PORT=8787 +LINE_CHANNEL_ACCESS_TOKEN=your-line-channel-access-token +LINE_TARGET_MODE=local +LINE_TARGET_ID_LOCAL=your-line-local-target-id +LINE_TARGET_ID_PROD=your-line-production-target-id diff --git a/README.md b/README.md index 28a8161..95d76b5 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ - 若指定日期已經有資料,上傳前會先詢問是否覆蓋 - 可讀取指定日期的資料庫內容並回填到畫面 - 若指定日期沒有資料,畫面會顯示「指定日期沒有資料」 +- 組隊結果產生後可一鍵主動推播到指定 LINE 對話 - 支援換行、半形逗號、全形逗號與頓號輸入 - 會自動去除空白與重複名稱 @@ -30,6 +31,14 @@ npm run dev http://localhost:3500 ``` +推播到 LINE 時,會直接使用 Flex Message 主動送到指定對話,格式參考既有 `line-bot-ts` 羽球查詢結果樣式。 + +LINE 推播目標支援分成兩組: + +- `LINE_TARGET_ID_LOCAL`: 本地測試用對話 +- `LINE_TARGET_ID_PROD`: 正式環境用對話 +- `LINE_TARGET_MODE`: `local` 或 `prod` + ## 資料庫欄位 - `time`: 目標日期,格式為 `YYYYMMDD` diff --git a/server/server.mjs b/server/server.mjs index 619ab68..6d1646a 100644 --- a/server/server.mjs +++ b/server/server.mjs @@ -17,6 +17,14 @@ const distReady = existsSync(path.join(distDir, 'index.html')) const requiredEnv = ['DB_HOST', 'DB_PORT', 'DB_USER', 'DB_PASSWORD', 'DB_DATABASE'] const missingEnv = requiredEnv.filter((key) => !process.env[key]) +const lineAccessToken = + process.env.LINE_CHANNEL_ACCESS_TOKEN ?? process.env.channelAccessToken ?? '' +const lineTargetMode = (process.env.LINE_TARGET_MODE ?? 'local').toLowerCase() +const lineTargetId = + process.env.LINE_TARGET_ID ?? + (lineTargetMode === 'prod' + ? process.env.LINE_TARGET_ID_PROD ?? '' + : process.env.LINE_TARGET_ID_LOCAL ?? '') const pool = missingEnv.length === 0 @@ -39,10 +47,64 @@ app.get('/api/health', (_request, response) => { ok: true, dbReady: Boolean(pool), distReady, + lineReady: Boolean(lineAccessToken && lineTargetId), + lineTargetMode, missingEnv, }) }) +app.post('/api/line/push-match-results', async (request, response) => { + const { time, teams } = request.body ?? {} + + if (typeof time !== 'string' || !Array.isArray(teams)) { + response.status(400).json({ + ok: false, + message: '送出的 LINE 訊息資料格式不正確。', + }) + return + } + + if (!lineAccessToken || !lineTargetId) { + response.status(500).json({ + ok: false, + message: + 'LINE 推播環境變數缺少,請檢查 LINE_CHANNEL_ACCESS_TOKEN 與 LINE_TARGET_ID_LOCAL / LINE_TARGET_ID_PROD。', + }) + return + } + + try { + const message = buildLineFlexMessage(time, teams) + const lineResponse = await fetch('https://api.line.me/v2/bot/message/push', { + method: 'POST', + headers: { + Authorization: `Bearer ${lineAccessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + to: lineTargetId, + messages: [message], + }), + }) + + if (!lineResponse.ok) { + const errorText = await lineResponse.text() + throw new Error(`LINE 推播失敗:${errorText}`) + } + + response.json({ + ok: true, + message: '已推送到 LINE。', + }) + } catch (error) { + console.error('line push error:', error) + response.status(500).json({ + ok: false, + message: error instanceof Error ? error.message : 'LINE 推播失敗。', + }) + } +}) + app.get('/api/match-results/:time', async (request, response) => { if (!pool) { response.status(500).json({ @@ -172,6 +234,7 @@ if (distReady) { app.listen(port, () => { console.log(`Server ready on http://localhost:${port}`) console.log(`Static files: ${distReady ? 'loaded' : 'missing'}`) + console.log(`LINE target mode: ${lineTargetMode}`) if (missingEnv.length > 0) { console.log(`Missing env: ${missingEnv.join(', ')}`) } @@ -187,3 +250,102 @@ async function ensureTable(poolInstance, currentTableName) { ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci `) } + +function buildLineFlexMessage(time, rounds) { + const dateText = `${time.slice(0, 4)}-${time.slice(4, 6)}-${time.slice(6, 8)}` + + return { + type: 'flex', + altText: `${dateText} 羽球隊伍配對`, + contents: { + type: 'carousel', + contents: rounds.map((round, roundIndex) => ({ + type: 'bubble', + size: 'micro', + body: { + type: 'box', + layout: 'vertical', + contents: [ + { + type: 'text', + text: '勝皇羽球團', + weight: 'bold', + color: '#1DB446', + size: 'sm', + }, + { + type: 'text', + text: dateText, + weight: 'bold', + size: 'xl', + margin: 'sm', + }, + { + type: 'text', + text: `第${roundIndex + 1}輪`, + weight: 'bold', + size: 'xxl', + margin: 'sm', + }, + { + type: 'separator', + margin: 'md', + }, + { + type: 'box', + layout: 'horizontal', + contents: [ + { + type: 'text', + text: '一號隊友', + size: 'sm', + flex: 0, + }, + { + type: 'text', + text: '二號隊友', + size: 'sm', + align: 'center', + }, + ], + margin: 'md', + }, + { + type: 'separator', + margin: 'sm', + }, + { + type: 'box', + layout: 'vertical', + spacing: 'sm', + margin: 'md', + contents: round.teams.map((team) => ({ + type: 'box', + layout: 'horizontal', + contents: [ + { + type: 'text', + text: team.a, + size: 'sm', + flex: 0, + }, + { + type: 'text', + text: team.b, + size: 'sm', + align: 'center', + }, + ], + })), + }, + ], + }, + styles: { + footer: { + separator: true, + }, + }, + })), + }, + } +} diff --git a/src/App.css b/src/App.css index 8f18496..df373b3 100644 --- a/src/App.css +++ b/src/App.css @@ -257,6 +257,10 @@ line-height: 1.65; } +.save-status-sharing { + color: #1d6c46; +} + .error-banner { margin: 18px 0 0; padding: 14px 16px; @@ -272,6 +276,14 @@ gap: 14px; } +.share-row { + margin-bottom: 18px; +} + +.share-button { + min-width: 160px; +} + .round-card, .team-card { border-radius: 22px; diff --git a/src/App.tsx b/src/App.tsx index bab8fef..34ad51b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,7 +18,7 @@ type RoundResult = { teams: Team[] } -type ActionState = 'idle' | 'saving' | 'saved' | 'loading' | 'loaded' | 'error' +type ActionState = 'idle' | 'saving' | 'saved' | 'loading' | 'loaded' | 'sharing' | 'error' type LoadMatchResultsResponse = { time: number @@ -154,6 +154,35 @@ function App() { } } + async function pushToLine() { + if (results.length === 0) { + setActionState('error') + setActionMessage('請先產生配對結果,再推送到 LINE。') + return + } + + if (!targetDate) { + setActionState('error') + setActionMessage('請先選擇目標日期。') + return + } + + 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')) @@ -270,6 +299,19 @@ function App() {

三組名單

+ {results.length > 0 ? ( +
+ +
+ ) : null} + {results.length > 0 ? (
{results.map((round) => ( @@ -409,6 +451,32 @@ async function findMatchResults(time: string) { } } +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 失敗。') + } +} + function convertDbRecordToAppState(record: LoadMatchResultsResponse) { const personnel = JSON.parse(record.personnel) as [number, string][] const battlecombination = JSON.parse(record.battlecombination ?? '{}') as Record<