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_DATABASE=badminton
|
||||||
DB_TABLE=badminton
|
DB_TABLE=badminton
|
||||||
SERVER_PORT=8787
|
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
|
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`
|
- `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 requiredEnv = ['DB_HOST', 'DB_PORT', 'DB_USER', 'DB_PASSWORD', 'DB_DATABASE']
|
||||||
const missingEnv = requiredEnv.filter((key) => !process.env[key])
|
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 =
|
const pool =
|
||||||
missingEnv.length === 0
|
missingEnv.length === 0
|
||||||
@@ -39,10 +47,64 @@ app.get('/api/health', (_request, response) => {
|
|||||||
ok: true,
|
ok: true,
|
||||||
dbReady: Boolean(pool),
|
dbReady: Boolean(pool),
|
||||||
distReady,
|
distReady,
|
||||||
|
lineReady: Boolean(lineAccessToken && lineTargetId),
|
||||||
|
lineTargetMode,
|
||||||
missingEnv,
|
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) => {
|
app.get('/api/match-results/:time', async (request, response) => {
|
||||||
if (!pool) {
|
if (!pool) {
|
||||||
response.status(500).json({
|
response.status(500).json({
|
||||||
@@ -172,6 +234,7 @@ if (distReady) {
|
|||||||
app.listen(port, () => {
|
app.listen(port, () => {
|
||||||
console.log(`Server ready on http://localhost:${port}`)
|
console.log(`Server ready on http://localhost:${port}`)
|
||||||
console.log(`Static files: ${distReady ? 'loaded' : 'missing'}`)
|
console.log(`Static files: ${distReady ? 'loaded' : 'missing'}`)
|
||||||
|
console.log(`LINE target mode: ${lineTargetMode}`)
|
||||||
if (missingEnv.length > 0) {
|
if (missingEnv.length > 0) {
|
||||||
console.log(`Missing env: ${missingEnv.join(', ')}`)
|
console.log(`Missing env: ${missingEnv.join(', ')}`)
|
||||||
}
|
}
|
||||||
@@ -187,3 +250,102 @@ async function ensureTable(poolInstance, currentTableName) {
|
|||||||
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
|
) 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;
|
line-height: 1.65;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.save-status-sharing {
|
||||||
|
color: #1d6c46;
|
||||||
|
}
|
||||||
|
|
||||||
.error-banner {
|
.error-banner {
|
||||||
margin: 18px 0 0;
|
margin: 18px 0 0;
|
||||||
padding: 14px 16px;
|
padding: 14px 16px;
|
||||||
@@ -272,6 +276,14 @@
|
|||||||
gap: 14px;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.share-row {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-button {
|
||||||
|
min-width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
.round-card,
|
.round-card,
|
||||||
.team-card {
|
.team-card {
|
||||||
border-radius: 22px;
|
border-radius: 22px;
|
||||||
|
|||||||
70
src/App.tsx
70
src/App.tsx
@@ -18,7 +18,7 @@ type RoundResult = {
|
|||||||
teams: Team[]
|
teams: Team[]
|
||||||
}
|
}
|
||||||
|
|
||||||
type ActionState = 'idle' | 'saving' | 'saved' | 'loading' | 'loaded' | 'error'
|
type ActionState = 'idle' | 'saving' | 'saved' | 'loading' | 'loaded' | 'sharing' | 'error'
|
||||||
|
|
||||||
type LoadMatchResultsResponse = {
|
type LoadMatchResultsResponse = {
|
||||||
time: number
|
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() {
|
function resetDemo() {
|
||||||
setAreaAInput(defaultAreaA.join('\n'))
|
setAreaAInput(defaultAreaA.join('\n'))
|
||||||
setAreaBInput(defaultAreaB.join('\n'))
|
setAreaBInput(defaultAreaB.join('\n'))
|
||||||
@@ -270,6 +299,19 @@ function App() {
|
|||||||
<h2>三組名單</h2>
|
<h2>三組名單</h2>
|
||||||
</div>
|
</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 ? (
|
{results.length > 0 ? (
|
||||||
<div className="round-list">
|
<div className="round-list">
|
||||||
{results.map((round) => (
|
{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) {
|
function convertDbRecordToAppState(record: LoadMatchResultsResponse) {
|
||||||
const personnel = JSON.parse(record.personnel) as [number, string][]
|
const personnel = JSON.parse(record.personnel) as [number, string][]
|
||||||
const battlecombination = JSON.parse(record.battlecombination ?? '{}') as Record<
|
const battlecombination = JSON.parse(record.battlecombination ?? '{}') as Record<
|
||||||
|
|||||||
Reference in New Issue
Block a user