1 line
32 KiB
JSON
1 line
32 KiB
JSON
{"repo": "cablate/banini-tracker", "files": {"src/web.ts": {"850297c2003a653023cc36aeb5b63683e49e7c5b": {"t": 1785674595.7844791, "n": 31004, "h": "df73295528fb1b92", "c": "/**\n * Web config panel: Hono server with auth + config CRUD.\n * Single HTML page, no build step.\n */\nimport { Hono } from 'hono';\nimport type { MiddlewareHandler } from 'hono';\nimport { streamSSE } from 'hono/streaming';\nimport { serve } from '@hono/node-server';\nimport { getCookie, setCookie, deleteCookie } from 'hono/cookie';\nimport { isInitialized, setupAdmin, login, logout, validateSession } from './auth.js';\nimport { getAllConfig, setConfigs, getConfigKeys, getConfig, type ConfigKey } from './config-store.js';\nimport { fetchFacebookPosts } from './facebook.js';\nimport { getRecentRuns, getRecentNotifications } from './run-logger.js';\nimport { analyzePosts, DEFAULT_SYSTEM_PROMPT } from './analyze.js';\nimport { createNotifiers, type ReportData, type PostSummary } from './notifiers/index.js';\n\nconst app = new Hono();\n\nconst isProduction = process.env.NODE_ENV === 'production';\n\n// -- Auth middleware --\nconst requireAuth: MiddlewareHandler = async (c, next) => {\n const token = getCookie(c, 'session');\n if (!token || !validateSession(token)) {\n return c.json({ error: 'unauthorized' }, 401);\n }\n await next();\n};\n\n// -- API routes --\n\napp.get('/api/status', (c) => {\n return c.json({ initialized: isInitialized() });\n});\n\napp.post('/api/setup', async (c) => {\n if (isInitialized()) return c.json({ error: 'already initialized' }, 400);\n const { username, password } = await c.req.json();\n try {\n setupAdmin(username, password);\n const token = login(username, password);\n if (token) setCookie(c, 'session', token, { httpOnly: true, sameSite: 'Lax', secure: isProduction, maxAge: 86400, path: '/' });\n return c.json({ ok: true });\n } catch (err: any) {\n return c.json({ error: err.message }, 400);\n }\n});\n\napp.post('/api/login', async (c) => {\n const { username, password } = await c.req.json();\n const token = login(username, password);\n if (!token) return c.json({ error: 'invalid credentials' }, 401);\n setCookie(c, 'session', token, { httpOnly: true, sameSite: 'Lax', secure: isProduction, maxAge: 86400, path: '/' });\n return c.json({ ok: true });\n});\n\napp.post('/api/logout', (c) => {\n const token = getCookie(c, 'session');\n if (token) logout(token);\n deleteCookie(c, 'session', { path: '/' });\n return c.json({ ok: true });\n});\n\napp.get('/api/config', requireAuth, (c) => {\n const config = getAllConfig();\n const masked: Record<string, string> = {};\n for (const [key, value] of Object.entries(config)) {\n if (value && (key.includes('TOKEN') || key.includes('KEY') || key.includes('SECRET'))) {\n masked[key] = value.slice(0, 6) + '***';\n } else {\n masked[key] = value;\n }\n }\n // SYSTEM_PROMPT 空值時回傳預設值,讓前端直接看到\n if (!masked['SYSTEM_PROMPT']) {\n masked['SYSTEM_PROMPT'] = DEFAULT_SYSTEM_PROMPT;\n }\n return c.json({ config: masked, keys: getConfigKeys() });\n});\n\napp.put('/api/config', requireAuth, async (c) => {\n const body = await c.req.json();\n if (typeof body !== 'object' || body === null || Array.isArray(body)) {\n return c.json({ error: 'invalid payload' }, 400);\n }\n const entries: Partial<Record<ConfigKey, string>> = {};\n for (const [key, value] of Object.entries(body)) {\n if (typeof value !== 'string') {\n return c.json({ error: `invalid value for ${key}: expected string` }, 400);\n }\n entries[key as ConfigKey] = value;\n }\n setConfigs(entries);\n return c.json({ ok: true });\n});\n\napp.get('/api/runs', requireAuth, (c) => {\n const runs = getRecentRuns(50);\n return c.json({ runs });\n});\n\napp.get('/api/notifications', requireAuth, (c) => {\n const notifications = getRecentNotifications(100);\n return c.json({ notifications });\n});\n\napp.get('/api/default-prompt', requireAuth, (c) => {\n return c.json({ prompt: DEFAULT_SYSTEM_PROMPT });\n});\n\n// Test pipeline (SSE streaming)\napp.get('/api/test', requireAuth, (c) => {\n const FB_PAGE_URL = 'https://www.facebook.com/DieWithoutBang/';\n\n return streamSSE(c, async (stream) => {\n const send = async (step: string, status: 'ok' | 'fail' | 'info', detail: string) => {\n await stream.writeSSE({ data: JSON.stringify({ step, status, detail }) });\n };\n\n const apifyToken = getConfig('APIFY_TOKEN');\n const llmApiKey = getConfig('LLM_API_KEY');\n if (!apifyToken) { await send('config', 'fail', 'APIFY_TOKEN 未設定'); return; }\n if (!llmApiKey) { await send('config', 'fail', 'LLM_API_KEY 未設定'); return; }\n await send('config', 'ok', '設定檢查通過');\n\n let post: any;\n try {\n await send('fetch', 'info', '正在抓取貼文...');\n const posts = await fetchFacebookPosts(FB_PAGE_URL, apifyToken, 1);\n if (posts.length === 0) { await send('fetch', 'fail', '沒有抓到貼文'); return; }\n post = posts[0];\n const preview = (post.text || '(純圖片)').slice(0, 80);\n await send('fetch', 'ok', preview);\n } catch (err: any) {\n await send('fetch', 'fail', err.message); return;\n }\n\n let analysis: any;\n try {\n await send('analyze', 'info', '正在進行 AI 分析...');\n const localTime = new Date(post.timestamp).toLocaleString('zh-TW', { timeZone: 'Asia/Taipei' });\n let content = `[Facebook] ${post.text}`;\n if (post.ocrText) content += `\\n[圖片 OCR] ${post.ocrText}`;\n if (post.captionText) content += `\\n[影片轉錄] ${post.captionText}`;\n analysis = await analyzePosts(\n [{ text: content, timestamp: localTime, isToday: true }],\n {\n baseUrl: getConfig('LLM_BASE_URL') || 'https://api.deepinfra.com/v1/openai',\n apiKey: llmApiKey,\n model: getConfig('LLM_MODEL') || 'MiniMaxAI/MiniMax-M2.5',\n },\n );\n await send('analyze', 'ok', analysis.summary || '分析完成');\n } catch (err: any) {\n await send('analyze', 'fail', err.message); return;\n }\n\n const notifiers = createNotifiers();\n if (notifiers.length === 0) {\n await send('notify', 'fail', '未設定任何通知管道(Telegram / Discord / LINE)');\n return;\n }\n await send('notify', 'info', `正在推送至 ${notifiers.map(n => n.name).join(', ')}...`);\n const postSummary: PostSummary = {\n source: 'facebook',\n timestamp: new Date(post.timestamp).toLocaleString('zh-TW', { timeZone: 'Asia/Taipei' }),\n isToday: true,\n text: (post.text || '').slice(0, 60),\n url: post.url,\n };\n const reportData: ReportData = {\n analysis, postCount: { fb: 1 }, posts: [postSummary], isFallback: false,\n };\n const results = await Promise.allSettled(notifiers.map((n) => n.send(reportData)));\n const sent: string[] = [];\n const failed: string[] = [];\n for (let i = 0; i < results.length; i++) {\n if (results[i].status === 'fulfilled') {\n sent.push(notifiers[i].name);\n } else {\n const reason = (results[i] as PromiseRejectedResult).reason;\n failed.push(`${notifiers[i].name}: ${reason instanceof Error ? reason.message : reason}`);\n }\n }\n if (failed.length > 0) {\n await send('notify', 'fail', `失敗:${failed.join('; ')}`);\n } else {\n await send('notify', 'ok', `已送出:${sent.join(', ')}`);\n }\n await send('done', 'ok', '測試完成');\n });\n});\n\n// -- Frontend --\napp.get('/', (c) => {\n return c.html(FRONTEND_HTML);\n});\n\n// -- Start server --\nconst WEB_PORT = parseInt(process.env.WEB_PORT || '3000', 10);\n\nexport function startWebServer(): void {\n serve({ fetch: app.fetch, port: WEB_PORT }, () => {\n console.log(`[Web] 設定頁面已啟動: http://localhost:${WEB_PORT}`);\n });\n}\n\n// -- Inline frontend --\nconst FRONTEND_HTML = /* html */ `<!DOCTYPE html>\n<html lang=\"zh-TW\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n<title>banini-tracker</title>\n<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&display=swap\" rel=\"stylesheet\">\n<style>\n :root {\n --bg: #f8fafc;\n --surface: #ffffff;\n --surface-alt: #f1f5f9;\n --border: #e2e8f0;\n --border-hover: #cbd5e1;\n --border-focus: #10b981;\n --text: #18181b;\n --text-secondary: #52525b;\n --text-muted: #a1a1aa;\n --accent: #10b981;\n --accent-hover: #059669;\n --accent-subtle: rgba(16,185,129,0.07);\n --red: #dc2626;\n --red-subtle: rgba(220,38,38,0.06);\n --amber: #d97706;\n --amber-subtle: rgba(217,119,6,0.06);\n --shadow-sm: 0 1px 2px rgba(0,0,0,0.04);\n --shadow-md: 0 4px 12px rgba(0,0,0,0.06);\n --radius: 10px;\n --radius-lg: 14px;\n --font: 'Outfit', system-ui, -apple-system, sans-serif;\n --mono: 'SF Mono', 'Fira Code', 'Cascadia Code', ui-monospace, monospace;\n --transition: 0.2s cubic-bezier(0.16, 1, 0.3, 1);\n }\n * { box-sizing: border-box; margin: 0; padding: 0; }\n html, body { height: 100%; overflow: hidden; }\n body {\n font-family: var(--font); background: var(--bg); color: var(--text);\n line-height: 1.6; -webkit-font-smoothing: antialiased;\n }\n\n /* ── Two-column layout ── */\n .layout {\n display: grid; grid-template-columns: 1fr 1fr;\n height: 100vh; height: 100dvh; gap: 0;\n }\n\n /* ── Left: config ── */\n .col-left {\n display: flex; flex-direction: column; overflow: hidden;\n background: var(--surface);\n border-right: 1px solid var(--border);\n }\n .col-header {\n padding: 1.5rem 2rem 1.25rem;\n display: flex; justify-content: space-between; align-items: center;\n flex-shrink: 0; border-bottom: 1px solid var(--border);\n }\n .col-header h1 {\n font-size: 1.25rem; font-weight: 700; color: var(--text);\n letter-spacing: -0.02em; display: flex; align-items: center; gap: 0.6rem;\n }\n .tag {\n font-family: var(--mono); font-size: 0.7rem; font-weight: 600;\n padding: 0.2rem 0.6rem; background: var(--accent-subtle);\n color: var(--accent); border-radius: 100px; letter-spacing: 0.03em;\n }\n .col-scroll {\n flex: 1; overflow-y: auto; padding: 1.75rem 2rem 2rem;\n }\n .col-scroll::-webkit-scrollbar { width: 5px; }\n .col-scroll::-webkit-scrollbar-track { background: transparent; }\n .col-scroll::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }\n .col-scroll::-webkit-scrollbar-thumb:hover { background: var(--border-hover); }\n .col-footer {\n padding: 1rem 2rem; border-top: 1px solid var(--border);\n flex-shrink: 0;\n }\n\n /* ── Right: log ── */\n .col-right {\n display: flex; flex-direction: column; overflow: hidden;\n background: var(--bg);\n }\n .log-bar {\n display: flex; justify-content: space-between; align-items: center;\n padding: 1.5rem 2rem 1.25rem;\n border-bottom: 1px solid var(--border);\n flex-shrink: 0;\n }\n .log-bar-title {\n font-family: var(--mono); font-size: 0.8rem; font-weight: 600;\n color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.1em;\n }\n .tab-group { display: flex; gap: 0.2rem; }\n .tab-btn {\n font-family: var(--mono); font-size: 0.8rem; font-weight: 600;\n color: var(--text-muted); background: transparent; border: none;\n padding: 0.3rem 0.7rem; border-radius: 6px; cursor: pointer;\n transition: all var(--transition);\n }\n .tab-btn:hover { color: var(--text-secondary); background: var(--surface-alt); }\n .tab-btn.active { color: var(--accent); background: var(--accent-subtle); }\n .run-row {\n padding: 0.5rem 2rem; border-bottom: 1px solid var(--border);\n display: flex; align-items: center; gap: 0.75rem;\n font-size: 0.85rem; transition: background var(--transition);\n }\n .run-row:hover { background: rgba(0,0,0,0.02); }\n .run-status {\n width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;\n }\n .run-status.ok { background: var(--accent); }\n .run-status.fail { background: var(--red); }\n .run-status.skip { background: var(--text-muted); }\n .run-status.running { background: var(--amber); }\n .run-label { font-weight: 600; color: var(--text); min-width: 3.5rem; }\n .run-meta { color: var(--text-muted); font-family: var(--mono); font-size: 0.8rem; }\n .run-summary { color: var(--text-secondary); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n .notif-row {\n padding: 0.5rem 2rem; border-bottom: 1px solid var(--border);\n display: flex; align-items: center; gap: 0.75rem;\n font-size: 0.85rem; transition: background var(--transition);\n }\n .notif-row:hover { background: rgba(0,0,0,0.02); }\n .notif-channel { font-weight: 600; color: var(--text); min-width: 5rem; }\n .log-actions { display: flex; gap: 0.4rem; }\n .log-body {\n flex: 1; overflow-y: auto; padding: 0.75rem 0;\n font-family: var(--mono); font-size: 0.85rem; line-height: 1.9;\n }\n .log-body::-webkit-scrollbar { width: 5px; }\n .log-body::-webkit-scrollbar-track { background: transparent; }\n .log-body::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }\n .log-line { padding: 0.15rem 2rem; transition: background var(--transition); }\n .log-line:hover { background: rgba(0,0,0,0.02); }\n .log-ts { color: var(--text-muted); margin-right: 0.75rem; }\n .log-ok { color: var(--accent); font-weight: 500; }\n .log-fail { color: var(--red); font-weight: 500; }\n .log-info { color: var(--text-secondary); }\n .log-step { color: var(--amber); font-weight: 600; }\n .log-empty {\n color: var(--text-muted); height: 100%; display: flex;\n align-items: center; justify-content: center;\n font-family: var(--font); font-size: 0.95rem; opacity: 0.5;\n }\n\n /* ── Auth overlay ── */\n .auth-overlay {\n position: fixed; inset: 0; background: var(--bg);\n display: flex; align-items: center; justify-content: center; z-index: 10;\n }\n .auth-box {\n width: 380px; background: var(--surface);\n padding: 2.5rem; border-radius: var(--radius-lg);\n box-shadow: var(--shadow-md); border: 1px solid var(--border);\n }\n .auth-box .auth-heading {\n font-size: 1.35rem; font-weight: 700; color: var(--text);\n letter-spacing: -0.02em; margin-bottom: 1.5rem;\n }\n\n /* ── Section labels ── */\n .section {\n font-family: var(--mono); font-size: 0.75rem; font-weight: 600;\n color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.1em;\n margin-top: 2rem; margin-bottom: 0.6rem;\n }\n .section:first-child { margin-top: 0; }\n\n /* ── Fields ── */\n .f { margin-top: 0.75rem; }\n .f:first-child { margin-top: 0; }\n .f label {\n display: block; font-size: 0.875rem; font-weight: 500;\n color: var(--text-secondary); margin-bottom: 0.35rem;\n }\n .f input {\n width: 100%; padding: 0.6rem 0.85rem; font-family: var(--mono);\n font-size: 0.875rem; color: var(--text); background: var(--surface-alt);\n border: 1px solid var(--border); border-radius: var(--radius);\n transition: all var(--transition); outline: none;\n }\n .f input:hover { border-color: var(--border-hover); }\n .f input:focus {\n border-color: var(--border-focus);\n box-shadow: 0 0 0 3px var(--accent-subtle);\n background: var(--surface);\n }\n .f input::placeholder { color: var(--text-muted); font-weight: 400; }\n .f textarea:hover { border-color: var(--border-hover); }\n .f textarea:focus {\n border-color: var(--border-focus);\n box-shadow: 0 0 0 3px var(--accent-subtle);\n background: var(--surface);\n }\n .f textarea::placeholder { color: var(--text-muted); font-weight: 400; }\n\n .sep { border: none; border-top: 1px solid var(--border); margin: 1.5rem 0; }\n\n /* ── Buttons ── */\n button {\n font-family: var(--font); font-size: 0.875rem; font-weight: 600;\n padding: 0.55rem 1.1rem; border: none; border-radius: var(--radius);\n cursor: pointer; transition: all var(--transition); outline: none;\n }\n button:active { transform: translateY(1px) scale(0.98); }\n button:disabled { opacity: 0.4; cursor: not-allowed; transform: none; }\n .btn-primary { background: var(--accent); color: #ffffff; }\n .btn-primary:hover:not(:disabled) { background: var(--accent-hover); }\n .btn-ghost {\n background: transparent; color: var(--text-secondary);\n border: 1px solid var(--border);\n }\n .btn-ghost:hover:not(:disabled) {\n background: var(--surface-alt); color: var(--text);\n border-color: var(--border-hover);\n }\n .btn-test {\n background: var(--amber-subtle); color: var(--amber);\n border: 1px solid rgba(217,119,6,0.15);\n }\n .btn-test:hover:not(:disabled) {\n background: rgba(217,119,6,0.12);\n border-color: rgba(217,119,6,0.3);\n }\n .btn-sm { font-size: 0.8rem; padding: 0.35rem 0.7rem; }\n\n .toast {\n padding: 0.5rem 0.85rem; border-radius: var(--radius);\n margin-top: 0.6rem; font-size: 0.85rem; font-weight: 500;\n }\n .toast.ok {\n background: var(--accent-subtle); color: var(--accent);\n border: 1px solid rgba(16,185,129,0.15);\n }\n .toast.err {\n background: var(--red-subtle); color: var(--red);\n border: 1px solid rgba(220,38,38,0.15);\n }\n .hint {\n font-size: 0.8rem; color: var(--text-muted); margin-top: 0.4rem;\n line-height: 1.5;\n }\n .actions { display: flex; gap: 0.6rem; align-items: center; }\n\n /* ── Mobile: stack ── */\n @media (max-width: 768px) {\n html, body { overflow: auto; }\n .layout { grid-template-columns: 1fr; height: auto; min-height: 100dvh; }\n .col-left { border-right: none; border-bottom: 1px solid var(--border); }\n .col-scroll { overflow: visible; }\n .col-right { min-height: 300px; }\n .col-header, .log-bar { padding: 1.25rem 1.25rem 1rem; }\n .col-scroll { padding: 1.25rem; }\n .col-footer { padding: 1rem 1.25rem; }\n .log-line { padding: 0.15rem 1.25rem; }\n }\n</style>\n</head>\n<body>\n\n<!-- Auth overlay -->\n<div id=\"auth-overlay\" class=\"auth-overlay\" style=\"display:none\">\n <div class=\"auth-box\">\n <div class=\"auth-heading\" id=\"auth-title\">Login</div>\n <div class=\"f\"><label>帳號</label><input id=\"auth-user\" autocomplete=\"username\" placeholder=\"admin\"></div>\n <div class=\"f\"><label>密碼</label><input id=\"auth-pass\" type=\"password\" autocomplete=\"current-password\"></div>\n <div style=\"margin-top:1.25rem\"><button class=\"btn-primary\" onclick=\"doAuth()\">確認</button></div>\n <div id=\"auth-msg\"></div>\n </div>\n</div>\n\n<!-- Main two-column layout -->\n<div class=\"layout\" id=\"main-layout\" style=\"display:none\">\n <div class=\"col-left\">\n <div class=\"col-header\">\n <h1>banini-tracker <span class=\"tag\">config</span></h1>\n <button class=\"btn-ghost btn-sm\" onclick=\"doLogout()\">登出</button>\n </div>\n <div class=\"col-scroll\">\n <div class=\"section\">必填</div>\n <div class=\"f\"><label>Apify Token</label><input id=\"cfg-APIFY_TOKEN\" placeholder=\"apify_api_...\"></div>\n <div class=\"f\"><label>LLM API Key</label><input id=\"cfg-LLM_API_KEY\" placeholder=\"sk-...\"></div>\n <hr class=\"sep\">\n <div class=\"section\">Telegram</div>\n <div class=\"f\"><label>Bot Token</label><input id=\"cfg-TG_BOT_TOKEN\" placeholder=\"110201543:AAHdqT...\"></div>\n <div class=\"f\"><label>Channel ID</label><input id=\"cfg-TG_CHANNEL_ID\" placeholder=\"-100...\"></div>\n <hr class=\"sep\">\n <div class=\"section\">Discord</div>\n <div class=\"f\"><label>Bot Token</label><input id=\"cfg-DISCORD_BOT_TOKEN\"></div>\n <div class=\"f\"><label>Channel ID</label><input id=\"cfg-DISCORD_CHANNEL_ID\"></div>\n <hr class=\"sep\">\n <div class=\"section\">LINE</div>\n <div class=\"f\"><label>Channel Access Token</label><input id=\"cfg-LINE_CHANNEL_ACCESS_TOKEN\"></div>\n <div class=\"f\"><label>To</label><input id=\"cfg-LINE_TO\" placeholder=\"userId / groupId\"></div>\n <hr class=\"sep\">\n <div class=\"section\">進階</div>\n <div class=\"f\"><label>LLM Base URL</label><input id=\"cfg-LLM_BASE_URL\"></div>\n <div class=\"f\"><label>LLM Model</label><input id=\"cfg-LLM_MODEL\"></div>\n <div class=\"f\"><label>Transcriber</label><input id=\"cfg-TRANSCRIBER\" placeholder=\"noop / groq\"></div>\n <div class=\"f\"><label>Groq API Key</label><input id=\"cfg-GROQ_API_KEY\"></div>\n <div class=\"f\"><label>FinMind Token</label><input id=\"cfg-FINMIND_TOKEN\"></div>\n <p class=\"hint\">Base URL 預設 DeepInfra / Model 預設 MiniMax-M2.5</p>\n <hr class=\"sep\">\n <div class=\"section\">分析提示詞</div>\n <div class=\"f\">\n <div style=\"display:flex;justify-content:space-between;align-items:center;margin-bottom:0.35rem\">\n <label style=\"margin-bottom:0\">System Prompt</label>\n <button class=\"btn-ghost btn-sm\" onclick=\"resetPrompt()\" type=\"button\">重置為預設</button>\n </div>\n <textarea id=\"cfg-SYSTEM_PROMPT\" rows=\"10\" style=\"\n width:100%;padding:0.6rem 0.85rem;font-family:var(--mono);\n font-size:0.8rem;color:var(--text);background:var(--surface-alt);\n border:1px solid var(--border);border-radius:var(--radius);\n transition:all var(--transition);outline:none;resize:vertical;\n line-height:1.6;min-height:120px;\n \" placeholder=\"留空則使用預設提示詞\"></textarea>\n <p class=\"hint\">自訂 LLM 分析提示詞。留空時使用內建預設值。</p>\n </div>\n </div>\n <div class=\"col-footer\">\n <div class=\"actions\">\n <button class=\"btn-primary\" onclick=\"saveConfig()\">儲存</button>\n <button class=\"btn-test\" id=\"test-btn\" onclick=\"runTest()\">測試連線</button>\n </div>\n <div id=\"config-msg\"></div>\n </div>\n </div>\n\n <div class=\"col-right\">\n <div class=\"log-bar\">\n <div class=\"tab-group\">\n <button class=\"tab-btn active\" data-tab=\"log\" onclick=\"switchTab('log')\">Log</button>\n <button class=\"tab-btn\" data-tab=\"runs\" onclick=\"switchTab('runs')\">排程</button>\n <button class=\"tab-btn\" data-tab=\"notifs\" onclick=\"switchTab('notifs')\">推送</button>\n </div>\n <div class=\"log-actions\">\n <button class=\"btn-ghost btn-sm\" id=\"tab-action-1\" onclick=\"clearLog()\">清除</button>\n <button class=\"btn-ghost btn-sm\" id=\"tab-action-2\" onclick=\"copyLog()\">複製</button>\n </div>\n </div>\n <div class=\"log-body\" id=\"log-body\">\n <div class=\"log-empty\">等待操作</div>\n </div>\n <div class=\"log-body\" id=\"runs-body\" style=\"display:none\">\n <div class=\"log-empty\">載入中...</div>\n </div>\n <div class=\"log-body\" id=\"notifs-body\" style=\"display:none\">\n <div class=\"log-empty\">載入中...</div>\n </div>\n </div>\n</div>\n\n<script>\nlet configKeys = [];\nlet originalValues = {};\nlet isSetup = false;\nlet logLines = [];\nconst $ = id => document.getElementById(id);\nfunction ts() { return new Date().toLocaleTimeString('zh-TW', { hour12: false }); }\nfunction esc(s) { return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }\n\nfunction appendLog(text, type) {\n const body = $('log-body');\n const empty = body.querySelector('.log-empty');\n if (empty) empty.remove();\n const el = document.createElement('div');\n el.className = 'log-line';\n const t = ts();\n const cls = { ok:'log-ok', fail:'log-fail', step:'log-step', info:'log-info' }[type] || 'log-info';\n el.innerHTML = '<span class=\"log-ts\">' + t + '</span><span class=\"' + cls + '\">' + esc(text) + '</span>';\n body.appendChild(el);\n body.scrollTop = body.scrollHeight;\n logLines.push('[' + t + '] ' + text);\n}\n\nfunction clearLog() {\n $('log-body').innerHTML = '<div class=\"log-empty\">等待操作</div>';\n logLines = [];\n}\n\nasync function copyLog() {\n try {\n await navigator.clipboard.writeText(logLines.join('\\\\n'));\n const b = $('copy-btn'); b.textContent = '已複製';\n setTimeout(() => { b.textContent = '複製'; }, 1200);\n } catch {}\n}\n\nasync function init() {\n const res = await fetch('/api/status');\n const { initialized } = await res.json();\n if (!initialized) {\n isSetup = true;\n $('auth-title').textContent = '建立管理員帳號';\n $('auth-overlay').style.display = 'flex';\n return;\n }\n const cfgRes = await fetch('/api/config');\n if (cfgRes.status === 401) {\n $('auth-title').textContent = '登入';\n $('auth-overlay').style.display = 'flex';\n return;\n }\n $('main-layout').style.display = 'grid';\n showConfig(await cfgRes.json());\n}\n\nasync function doAuth() {\n const user = $('auth-user').value, pass = $('auth-pass').value;\n const url = isSetup ? '/api/setup' : '/api/login';\n const res = await fetch(url, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({username:user,password:pass}) });\n const data = await res.json();\n if (!res.ok) { toast('auth-msg', data.error, true); return; }\n $('auth-overlay').style.display = 'none';\n $('main-layout').style.display = 'grid';\n appendLog(isSetup ? '管理員帳號已建立' : '登入成功', 'ok');\n const cfgRes = await fetch('/api/config');\n showConfig(await cfgRes.json());\n}\n\nlet defaultPrompt = '';\n\nfunction showConfig(data) {\n configKeys = data.keys || [];\n originalValues = {};\n for (const key of configKeys) {\n const el = $('cfg-' + key);\n const val = data.config[key] || '';\n if (el) el.value = val;\n originalValues[key] = val;\n }\n // 載入預設 prompt(用於重置按鈕 + 空值時自動填入)\n if (!defaultPrompt) {\n fetch('/api/default-prompt').then(r => r.json()).then(d => {\n defaultPrompt = d.prompt;\n const el = $('cfg-SYSTEM_PROMPT');\n if (el && !el.value) {\n el.value = defaultPrompt;\n originalValues['SYSTEM_PROMPT'] = '';\n }\n }).catch(() => {});\n }\n}\n\nasync function resetPrompt() {\n if (!defaultPrompt) {\n try {\n const r = await fetch('/api/default-prompt');\n const d = await r.json();\n defaultPrompt = d.prompt;\n } catch { appendLog('無法取得預設提示詞', 'fail'); return; }\n }\n $('cfg-SYSTEM_PROMPT').value = defaultPrompt;\n appendLog('已重置為預設提示詞', 'info');\n}\n\nasync function saveConfig() {\n const body = {}; let n = 0;\n for (const key of configKeys) {\n const el = $('cfg-' + key);\n const val = el ? (key === 'SYSTEM_PROMPT' ? el.value : el.value.trim()) : '';\n if (val !== originalValues[key]) { body[key] = val; n++; }\n }\n if (!n) { appendLog('沒有變更', 'info'); toast('config-msg','沒有變更',false); return; }\n appendLog('儲存 ' + n + ' 項變更...', 'step');\n const res = await fetch('/api/config', { method:'PUT', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body) });\n const data = await res.json();\n if (!res.ok) { appendLog('儲存失敗:' + data.error, 'fail'); toast('config-msg', data.error, true); return; }\n appendLog('設定已儲存', 'ok');\n toast('config-msg', '已儲存,下次排程生效', false);\n const cfgRes = await fetch('/api/config');\n if (cfgRes.ok) showConfig(await cfgRes.json());\n}\n\nasync function runTest() {\n const btn = $('test-btn');\n btn.disabled = true; btn.textContent = '執行中...';\n const labels = { config:'設定檢查', fetch:'抓取貼文', analyze:'AI 分析', notify:'通知推送', done:'完成' };\n appendLog('-- 連線測試 --', 'step');\n try {\n const res = await fetch('/api/test');\n const reader = res.body.getReader();\n const dec = new TextDecoder();\n let buf = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buf += dec.decode(value, { stream: true });\n const parts = buf.split('\\\\n');\n buf = parts.pop() || '';\n for (const ln of parts) {\n if (!ln.startsWith('data: ')) continue;\n try {\n const ev = JSON.parse(ln.slice(6));\n const lbl = labels[ev.step] || ev.step;\n if (ev.status === 'info') appendLog(lbl + ' ' + ev.detail, 'info');\n else if (ev.status === 'ok') appendLog('[OK] ' + lbl + ' ' + ev.detail, 'ok');\n else appendLog('[FAIL] ' + lbl + ' ' + ev.detail, 'fail');\n } catch {}\n }\n }\n } catch (err) { appendLog('請求失敗:' + err.message, 'fail'); }\n appendLog('-- 測試結束 --', 'step');\n btn.disabled = false; btn.textContent = '測試連線';\n}\n\nasync function doLogout() {\n await fetch('/api/logout', { method:'POST' });\n $('main-layout').style.display = 'none';\n $('auth-title').textContent = '登入';\n $('auth-overlay').style.display = 'flex';\n appendLog('已登出', 'info');\n}\n\nfunction toast(id, text, isErr) {\n const el = $(id);\n el.className = 'toast ' + (isErr ? 'err' : 'ok');\n el.textContent = text;\n setTimeout(() => { el.textContent = ''; el.className = ''; }, 4000);\n}\n\nlet currentTab = 'log';\nfunction switchTab(tab) {\n currentTab = tab;\n document.querySelectorAll('.tab-btn').forEach(b => b.classList.toggle('active', b.dataset.tab === tab));\n $('log-body').style.display = tab === 'log' ? '' : 'none';\n $('runs-body').style.display = tab === 'runs' ? '' : 'none';\n $('notifs-body').style.display = tab === 'notifs' ? '' : 'none';\n // Update action buttons\n const a1 = $('tab-action-1'), a2 = $('tab-action-2');\n if (tab === 'log') {\n a1.textContent = '清除'; a1.onclick = clearLog;\n a2.textContent = '複製'; a2.onclick = copyLog;\n a1.style.display = ''; a2.style.display = '';\n } else if (tab === 'runs') {\n a1.textContent = '重新整理'; a1.onclick = loadRuns;\n a2.style.display = 'none';\n loadRuns();\n } else {\n a1.textContent = '重新整理'; a1.onclick = loadNotifs;\n a2.style.display = 'none';\n loadNotifs();\n }\n}\n\nfunction fmtTime(iso) {\n if (!iso) return '-';\n return new Date(iso).toLocaleString('zh-TW', { timeZone: 'Asia/Taipei', hour12: false, month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit', second:'2-digit' });\n}\n\nasync function loadRuns() {\n const body = $('runs-body');\n body.innerHTML = '<div class=\"log-empty\">載入中...</div>';\n try {\n const res = await fetch('/api/runs');\n const { runs } = await res.json();\n if (runs.length === 0) { body.innerHTML = '<div class=\"log-empty\">尚無排程紀錄</div>'; return; }\n body.innerHTML = '';\n for (const r of runs) {\n const el = document.createElement('div');\n el.className = 'run-row';\n const dur = r.ended_at ? ((new Date(r.ended_at) - new Date(r.started_at)) / 1000).toFixed(0) + 's' : '...';\n el.innerHTML = '<div class=\"run-status ' + r.status + '\"></div>'\n + '<span class=\"run-label\">' + esc(r.label) + '</span>'\n + '<span class=\"run-meta\">' + fmtTime(r.started_at) + ' (' + dur + ')</span>'\n + '<span class=\"run-summary\">' + esc(r.summary || r.error_message || (r.posts_new > 0 ? r.posts_new + ' 篇新貼文' : '')) + '</span>'\n + '<span class=\"run-meta\">' + r.posts_found + '/' + r.posts_new + '</span>';\n body.appendChild(el);\n }\n } catch { body.innerHTML = '<div class=\"log-empty\">載入失敗</div>'; }\n}\n\nasync function loadNotifs() {\n const body = $('notifs-body');\n body.innerHTML = '<div class=\"log-empty\">載入中...</div>';\n try {\n const res = await fetch('/api/notifications');\n const { notifications } = await res.json();\n if (notifications.length === 0) { body.innerHTML = '<div class=\"log-empty\">尚無推送紀錄</div>'; return; }\n body.innerHTML = '';\n for (const n of notifications) {\n const el = document.createElement('div');\n el.className = 'notif-row';\n const statusCls = n.status === 'sent' ? 'log-ok' : 'log-fail';\n el.innerHTML = '<div class=\"run-status ' + (n.status === 'sent' ? 'ok' : 'fail') + '\"></div>'\n + '<span class=\"notif-channel\">' + esc(n.channel) + '</span>'\n + '<span class=\"run-meta\">' + fmtTime(n.sent_at) + '</span>'\n + '<span class=\"' + statusCls + '\">' + esc(n.status === 'sent' ? '成功' : n.error_message || '失敗') + '</span>';\n body.appendChild(el);\n }\n } catch { body.innerHTML = '<div class=\"log-empty\">載入失敗</div>'; }\n}\n\ninit();\n</script>\n</body>\n</html>`;\n"}}}} |