Files

1 line
32 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }\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"}}}}