Add LINE push integration and target mode config

This commit is contained in:
2026-04-15 16:22:19 +08:00
parent 1687223631
commit 2bed4f6df0
5 changed files with 256 additions and 1 deletions

View File

@@ -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

View File

@@ -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`

View File

@@ -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,
},
},
})),
},
}
}

View File

@@ -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;

View File

@@ -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() {
<h2></h2>
</div>
{results.length > 0 ? (
<div className="share-row">
<button
className="primary-button share-button"
type="button"
onClick={() => void pushToLine()}
disabled={actionState === 'saving' || actionState === 'loading'}
>
LINE
</button>
</div>
) : null}
{results.length > 0 ? (
<div className="round-list">
{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<