Add LINE push integration and target mode config
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
})),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
12
src/App.css
12
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;
|
||||
|
||||
70
src/App.tsx
70
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() {
|
||||
<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<
|
||||
|
||||
Reference in New Issue
Block a user