thread-master/old/backend/dev-console/index.html

2188 lines
90 KiB
HTML
Raw 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.

<!doctype html>
<html lang="zh-Hant">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>巡樓 Dev Console</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<style>
:root {
--bg: #e8eef5;
--bg-soft: #f2f6fa;
--card: #fffdf9;
--ink: #1a2332;
--muted: #5a6578;
--line: #d8dee8;
--brand: #2a9d8f;
--brand-soft: #e6f5f2;
--brand-hover: #238b7e;
--danger: #c0392b;
--danger-soft: #fdecea;
--ok: #1e7a4c;
--ok-soft: #edf9f2;
--warn: #8a6d1d;
--warn-soft: #fff8e6;
--shadow: 0 4px 24px rgba(26, 35, 50, 0.06);
--radius-lg: 1.25rem;
--radius-md: 0.85rem;
--mono: ui-monospace, 'SF Mono', Menlo, monospace;
--sans: 'Inter', 'PingFang TC', 'Helvetica Neue', system-ui, sans-serif;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: var(--sans);
background: linear-gradient(165deg, #dfe8f2 0%, #eef3f8 38%, #e8f0e8 100%);
color: var(--ink);
line-height: 1.65;
min-height: 100vh;
}
header {
padding: 1rem 1.5rem;
background: rgba(255, 253, 249, 0.92);
border-bottom: 1px solid var(--line);
backdrop-filter: blur(10px);
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
position: sticky;
top: 0;
z-index: 10;
box-shadow: 0 1px 0 rgba(255,255,255,0.6);
}
header h1 { margin: 0; font-size: 1.15rem; font-weight: 700; letter-spacing: -0.02em; }
header p { margin: 0.15rem 0 0; color: var(--muted); font-size: 0.84rem; }
main {
max-width: 1080px;
margin: 0 auto;
padding: 1.25rem 1.5rem 3.5rem;
display: grid;
gap: 1.1rem;
}
section {
background: var(--card);
border: 2px solid rgba(255,255,255,0.8);
border-radius: var(--radius-lg);
padding: 1.15rem 1.25rem;
box-shadow: var(--shadow);
}
section h2 {
margin: 0 0 0.35rem;
font-size: 1rem;
font-weight: 700;
letter-spacing: -0.01em;
}
.section-desc {
margin: 0 0 1rem;
font-size: 0.84rem;
color: var(--muted);
line-height: 1.6;
}
.row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
margin-bottom: 0.5rem;
}
label { font-size: 0.82rem; color: var(--muted); min-width: 5.5rem; }
input, select, textarea {
font: inherit;
padding: 0.45rem 0.6rem;
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
min-width: 0;
}
input { flex: 1; }
button {
font: inherit;
padding: 0.45rem 0.85rem;
border: 1px solid var(--line);
border-radius: 999px;
background: #fff;
cursor: pointer;
}
button:hover { border-color: var(--brand); color: var(--brand); background: var(--brand-soft); }
button.primary {
background: var(--brand);
border-color: var(--brand);
color: #fff;
box-shadow: 0 2px 8px rgba(42, 157, 143, 0.28);
}
button.primary:hover { background: var(--brand-hover); color: #fff; filter: none; }
button.danger { color: var(--danger); }
.status { font-size: 0.82rem; color: var(--muted); }
.status.ok { color: var(--ok); }
.status.err { color: var(--danger); }
.list {
margin: 0.5rem 0 0;
padding: 0;
list-style: none;
border: 1px solid var(--line);
border-radius: 8px;
overflow: hidden;
}
.list li {
padding: 0.55rem 0.75rem;
border-bottom: 1px solid var(--line);
cursor: pointer;
display: flex;
justify-content: space-between;
gap: 0.75rem;
font-size: 0.9rem;
}
.list li:last-child { border-bottom: none; }
.list li:hover { background: #f0faf8; }
.list li.active { background: #e6f5f2; font-weight: 600; }
.list li .meta { color: var(--muted); font-size: 0.78rem; }
.playground-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
gap: 0.75rem;
margin-top: 0.75rem;
}
.play-card {
border: 1px solid var(--line);
border-radius: 10px;
padding: 0.75rem;
background: #fafcfb;
}
.play-card h3 {
margin: 0 0 0.5rem;
font-size: 0.88rem;
color: var(--ink);
}
.play-card .scope {
font-size: 0.72rem;
color: var(--muted);
font-family: ui-monospace, monospace;
}
.play-card .row { margin-top: 0.45rem; }
.play-card input, .play-card textarea {
width: 100%;
font-size: 0.85rem;
}
.play-card textarea { min-height: 3.5rem; resize: vertical; }
pre, .log {
margin: 0.5rem 0 0;
padding: 0.75rem;
background: #0f1419;
color: #d6e2f0;
border-radius: 8px;
font-family: var(--mono);
font-size: 0.78rem;
overflow: auto;
max-height: 280px;
white-space: pre-wrap;
word-break: break-word;
}
.steps { margin: 0.5rem 0 0; font-size: 0.85rem; }
.steps div { padding: 0.2rem 0; }
.badge {
display: inline-block;
padding: 0.1rem 0.45rem;
border-radius: 999px;
font-size: 0.72rem;
background: #eef2f7;
}
.badge.running { background: #fff3cd; }
.badge.succeeded, .badge.completed { background: #d4edda; }
.badge.failed { background: #f8d7da; color: var(--danger); }
/* —— 貼文成效儀表板 —— */
.section-perf { padding: 0; overflow: hidden; }
.perf-hero {
padding: 1.25rem 1.35rem 1rem;
background: linear-gradient(135deg, #e6f5f2 0%, #f8fbfd 55%, #fffdf9 100%);
border-bottom: 1px solid var(--line);
}
.perf-hero-top {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.perf-hero h2 { margin: 0; font-size: 1.12rem; }
.perf-hero .section-desc { margin: 0.35rem 0 0; max-width: 36rem; }
.perf-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
background: rgba(255,255,255,0.75);
border: 1px solid var(--line);
border-radius: 999px;
padding: 0.35rem 0.5rem 0.35rem 0.85rem;
}
.perf-toolbar label { min-width: auto; margin: 0; font-weight: 500; }
.perf-toolbar input[type="number"] {
width: 3.5rem;
flex: 0;
text-align: center;
border-radius: 999px;
padding: 0.35rem 0.5rem;
}
.perf-body { padding: 1rem 1.25rem 1.25rem; }
.perf-summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(7.5rem, 1fr));
gap: 0.65rem;
margin-bottom: 1rem;
}
.perf-stat {
background: linear-gradient(180deg, #fff 0%, #f8fbfc 100%);
border: 1px solid var(--line);
border-radius: var(--radius-md);
padding: 0.75rem 0.85rem;
text-align: center;
}
.perf-stat.highlight {
background: linear-gradient(180deg, var(--brand-soft) 0%, #fff 100%);
border-color: #b8e0d8;
}
.perf-stat .val {
font-size: 1.35rem;
font-weight: 700;
letter-spacing: -0.03em;
color: var(--ink);
line-height: 1.2;
}
.perf-stat.highlight .val { color: var(--brand-hover); }
.perf-stat .lbl {
font-size: 0.72rem;
color: var(--muted);
margin-top: 0.2rem;
font-weight: 500;
}
.perf-insights-block {
margin-bottom: 1rem;
padding: 0.85rem 1rem;
border-radius: var(--radius-md);
background: var(--bg-soft);
border: 1px solid var(--line);
}
.perf-insights-head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.65rem;
}
.perf-insights-head strong { font-size: 0.88rem; }
.perf-pill-grid {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
}
.perf-pill {
display: inline-flex;
align-items: baseline;
gap: 0.35rem;
padding: 0.35rem 0.7rem;
border-radius: 999px;
background: #fff;
border: 1px solid var(--line);
font-size: 0.8rem;
}
.perf-pill .name { color: var(--muted); }
.perf-pill .num { font-weight: 700; color: var(--ink); }
.perf-scopes {
margin-bottom: 1rem;
border: 1px dashed var(--line);
border-radius: var(--radius-md);
padding: 0.5rem 0.75rem;
background: #fafcfb;
}
.perf-scopes summary {
cursor: pointer;
font-size: 0.82rem;
font-weight: 600;
color: var(--muted);
user-select: none;
list-style: none;
}
.perf-scopes summary::-webkit-details-marker { display: none; }
.perf-scopes summary::before { content: '▸ '; }
.perf-scopes[open] summary::before { content: '▾ '; }
.cap-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(13.5rem, 1fr));
gap: 0.55rem;
margin-top: 0.65rem;
}
.cap-card {
border: 1px solid var(--line);
border-radius: 10px;
padding: 0.6rem 0.7rem;
font-size: 0.76rem;
background: #fff;
line-height: 1.5;
}
.cap-card.trackable {
border-color: #9fd4cc;
background: linear-gradient(180deg, #f4fbf9 0%, #fff 100%);
}
.cap-card:not(.trackable) { opacity: 0.82; }
.cap-card .cap-title { font-weight: 600; margin-bottom: 0.15rem; font-size: 0.8rem; }
.cap-card .cap-scope {
display: inline-block;
font-family: var(--mono);
font-size: 0.68rem;
color: var(--brand-hover);
background: var(--brand-soft);
padding: 0.1rem 0.4rem;
border-radius: 4px;
margin-bottom: 0.25rem;
}
.cap-card .cap-metrics { color: var(--muted); }
.cap-card .cap-note { margin-top: 0.25rem; color: var(--muted); font-size: 0.72rem; }
.cap-badge {
float: right;
font-size: 0.65rem;
font-weight: 600;
padding: 0.12rem 0.45rem;
border-radius: 999px;
}
.cap-badge.yes { background: var(--ok-soft); color: var(--ok); }
.cap-badge.no { background: #f0f2f5; color: var(--muted); }
.perf-posts { display: grid; gap: 0.65rem; }
.perf-post {
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: #fff;
overflow: hidden;
transition: box-shadow 0.15s, border-color 0.15s;
}
.perf-post:hover {
border-color: #b8e0d8;
box-shadow: 0 6px 20px rgba(42, 157, 143, 0.08);
}
.perf-post-main { padding: 0.85rem 1rem 0.65rem; }
.perf-post-text {
font-size: 0.92rem;
line-height: 1.65;
margin: 0 0 0.5rem;
word-break: break-word;
}
.perf-post-meta {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1rem;
font-size: 0.74rem;
color: var(--muted);
}
.perf-post-meta code {
font-size: 0.68rem;
background: #f0f4f8;
padding: 0.1rem 0.35rem;
border-radius: 4px;
}
.perf-metrics {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 1px;
background: var(--line);
border-top: 1px solid var(--line);
}
@media (max-width: 640px) {
.perf-metrics { grid-template-columns: repeat(3, 1fr); }
}
.perf-metric {
background: #fafcfb;
padding: 0.55rem 0.4rem;
text-align: center;
}
.perf-metric.primary { background: var(--brand-soft); }
.perf-metric .m-val {
font-size: 1rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--ink);
}
.perf-metric.primary .m-val { color: var(--brand-hover); }
.perf-metric .m-lbl { font-size: 0.68rem; color: var(--muted); margin-top: 0.1rem; }
.perf-post-foot {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.45rem 1rem 0.55rem;
background: #f8fafb;
border-top: 1px solid var(--line);
font-size: 0.76rem;
}
.perf-post-foot a {
color: var(--brand);
font-weight: 600;
text-decoration: none;
}
.perf-post-foot a:hover { text-decoration: underline; }
.insight-tag {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.15rem 0.55rem;
border-radius: 999px;
font-size: 0.72rem;
font-weight: 600;
}
.insight-tag.ok { background: var(--ok-soft); color: var(--ok); }
.insight-tag.err { background: var(--danger-soft); color: var(--danger); max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.perf-empty {
text-align: center;
padding: 2.5rem 1rem;
color: var(--muted);
border: 2px dashed var(--line);
border-radius: var(--radius-md);
background: var(--bg-soft);
}
.perf-empty .icon {
width: 3rem;
height: 3rem;
margin: 0 auto 0.65rem;
border-radius: 50%;
background: linear-gradient(135deg, var(--brand-soft), #fff);
border: 2px solid #b8e0d8;
position: relative;
}
.perf-empty .icon::after {
content: '';
position: absolute;
inset: 0.65rem 0.55rem 0.55rem 0.65rem;
border: 2px solid var(--brand);
border-radius: 3px;
opacity: 0.45;
}
.perf-loading .perf-post {
pointer-events: none;
opacity: 0.55;
animation: perf-pulse 1.2s ease-in-out infinite;
}
@keyframes perf-pulse {
0%, 100% { opacity: 0.45; }
50% { opacity: 0.75; }
}
.chip-status {
display: inline-flex;
align-items: center;
padding: 0.2rem 0.55rem;
border-radius: 999px;
font-size: 0.72rem;
font-weight: 600;
}
.chip-status.ok { background: var(--ok-soft); color: var(--ok); }
.chip-status.err { background: var(--danger-soft); color: var(--danger); }
.chip-status.skip { background: var(--warn-soft); color: var(--warn); }
.badge.cancelled { background: #fde8e8; color: var(--danger); }
.badge.queued, .badge.pending { background: #e8eef5; }
.job-banner {
margin: 0.5rem 0 0;
padding: 0.65rem 0.85rem;
border-radius: 8px;
font-size: 0.88rem;
border: 1px solid var(--line);
background: #f8fafc;
}
.job-banner.ok { border-color: #b8e0cf; background: #edf9f2; color: var(--ok); }
.job-banner.err {
border-color: #f1b0b7;
background: #fff5f5;
color: var(--danger);
font-weight: 600;
}
.job-banner.warn { border-color: #f5d88e; background: #fffbeb; color: #8a6d1d; }
.steps div.step-failed { color: var(--danger); font-weight: 600; }
.steps div.step-cancelled { color: #8a6d1d; }
#jobDetail.fail { outline: 2px solid #f1b0b7; }
.persona-panel { display: grid; gap: 0.75rem; margin-top: 0.5rem; }
.persona-hero {
padding: 0.85rem 1rem;
border-radius: 10px;
background: linear-gradient(135deg, #edf9f2 0%, #f8fafc 100%);
border: 1px solid #cfe9df;
}
.persona-hero h3 { margin: 0 0 0.35rem; font-size: 1.05rem; }
.persona-hero .sub { color: var(--muted); font-size: 0.82rem; }
.persona-grid { display: grid; gap: 0.65rem; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }
.persona-card {
border: 1px solid var(--line);
border-radius: 10px;
padding: 0.7rem 0.8rem;
background: #fbfcfe;
}
.persona-card h4 {
margin: 0 0 0.45rem;
font-size: 0.78rem;
color: var(--muted);
letter-spacing: 0.04em;
text-transform: uppercase;
}
.persona-card .body {
font-size: 0.9rem;
white-space: pre-wrap;
word-break: break-word;
line-height: 1.65;
}
.dim-grid { display: grid; gap: 0.55rem; }
.dim-card {
border: 1px solid var(--line);
border-left: 3px solid var(--brand);
border-radius: 8px;
padding: 0.6rem 0.75rem;
background: #fff;
}
.dim-card h5 { margin: 0 0 0.25rem; font-size: 0.84rem; }
.dim-card p { margin: 0; font-size: 0.86rem; color: var(--ink); line-height: 1.6; }
.dim-card ul { margin: 0.35rem 0 0; padding-left: 1.1rem; font-size: 0.8rem; color: var(--muted); }
.sample-post {
border: 1px solid var(--line);
border-radius: 8px;
padding: 0.55rem 0.7rem;
margin-top: 0.4rem;
background: #fff;
font-size: 0.84rem;
line-height: 1.6;
}
.sample-post .meta { font-size: 0.75rem; color: var(--muted); margin-bottom: 0.25rem; }
.sample-post a { color: var(--brand); }
.persona-empty { color: var(--muted); font-size: 0.88rem; padding: 0.5rem 0; }
details.persona-raw summary {
cursor: pointer;
font-size: 0.82rem;
color: var(--muted);
user-select: none;
}
details.persona-raw pre {
margin: 0.5rem 0 0;
padding: 0.75rem;
background: #0f1419;
color: #d6e2f0;
border-radius: 8px;
font-family: var(--mono);
font-size: 0.76rem;
overflow: auto;
max-height: 360px;
white-space: pre-wrap;
word-break: break-word;
}
textarea { width: 100%; min-height: 120px; font-family: var(--mono); font-size: 0.78rem; }
.hint { font-size: 0.8rem; color: var(--muted); margin-top: 0.35rem; }
</style>
</head>
<body>
<header>
<div>
<h1>巡樓 Dev Console</h1>
<p>暫用開發面板 · 人設 + 8D 公開爬蟲 · 重做前端前夠用就好</p>
</div>
<div class="status" id="connStatus">尚未連線</div>
</header>
<main>
<section>
<h2>1. 登入</h2>
<div class="row">
<label for="tenantId">Tenant</label>
<input id="tenantId" value="default" />
</div>
<div class="row">
<label for="email">Email</label>
<input id="email" type="email" placeholder="you@example.com" />
</div>
<div class="row">
<label for="password">密碼</label>
<input id="password" type="password" placeholder="至少 8 碼" />
</div>
<div class="row">
<button class="primary" id="btnLogin">登入</button>
<button id="btnRegister">註冊</button>
<button id="btnLogout" class="danger">登出</button>
<span class="status" id="authStatus"></span>
</div>
</section>
<section>
<h2>2. Threads API 連線(多帳號)</h2>
<p class="hint" id="threadsApiMeta">每位會員可綁定多個 Threads 帳號token 存 Mongo系統會在到期前自動 refresh</p>
<p class="hint" id="threadsSelectedMeta">選中帳號:—</p>
<div class="row">
<button class="primary" id="btnConnectThreads">新增並連線 Threads</button>
<button id="btnConnectExisting">連線選中帳號</button>
<button id="btnActivateThreads">設為使用中</button>
<button id="btnDisconnectThreads" class="danger">中斷選中帳號 API</button>
<button id="btnReloadThreads">重新載入</button>
<span class="status" id="threadsApiStatus"></span>
</div>
<ul class="list" id="threadsAccountList"></ul>
<div class="row" style="margin-top:0.75rem">
<strong>OAuth 除錯 log</strong>
<button id="btnReloadOAuthLogs">重新整理 log</button>
<span class="hint">授權後若仍顯示未連線,先看這裡的 <code>stage</code></span>
</div>
<pre class="log" id="threadsOAuthLogs" style="max-height:14rem;overflow:auto">(載入中…)</pre>
<p class="hint" id="threadsOAuthConfigHint">Meta Redirect URI載入中…</p>
<div class="row" style="margin-top:1rem;flex-wrap:wrap;gap:0.5rem 1rem">
<strong>產品連線設定</strong>
<label><input type="checkbox" id="prefSearchViaApi" /> API 關鍵字搜尋</label>
<label><input type="checkbox" id="prefPublishViaApi" /> API 發文</label>
<label><input type="checkbox" id="prefScrapeReplies" /> API 抓留言</label>
<button id="btnSaveConnectionPrefs">儲存連線設定</button>
</div>
<div class="row" style="margin-top:0.75rem">
<strong>Threads 權限煙霧測試</strong>
<button class="primary" id="btnThreadsSmokeTest">測試選中帳號全部 API</button>
<span class="hint" id="threadsSmokeSummary">讀取類 API 自動測;發文/刪文/隱藏留言需手動</span>
</div>
<ul class="list" id="threadsSmokeResults"></ul>
</section>
<section class="section-perf">
<div class="perf-hero">
<div class="perf-hero-top">
<div>
<h2>貼文成效追蹤</h2>
<p class="section-desc">瀏覽、互動與帳號洞察一次看完。數字來自 Threads Graph API需帳號已 OAuth 連線。</p>
</div>
<div class="perf-toolbar">
<label for="perfLimit">最近</label>
<input type="number" id="perfLimit" min="1" max="30" value="15" />
<span style="font-size:0.82rem;color:var(--muted)"></span>
<button class="primary" id="btnLoadPostPerformance">載入成效</button>
</div>
</div>
<span class="status" id="perfStatus"></span>
</div>
<div class="perf-body">
<div id="perfSummary" class="perf-summary" hidden></div>
<div id="perfAccountInsights" class="perf-insights-block" hidden></div>
<details class="perf-scopes">
<summary>各 API scope 能追蹤什麼(展開)</summary>
<div id="perfCapabilities" class="cap-grid"></div>
</details>
<div id="perfPostsList" class="perf-posts">
<div class="perf-empty">
<div class="icon" aria-hidden="true"></div>
<div>選中已連線帳號,按「載入成效」</div>
</div>
</div>
</div>
</section>
<section class="section-perf">
<h2>3. 發文佇列</h2>
<p class="section-desc">排程或立即發文;到期由後端 sweeper 透過 Threads API 發佈,並自動排 +1h / +24h / +7d 成效檢查。</p>
<div class="row" style="flex-wrap:wrap;gap:0.5rem 1rem;align-items:flex-end">
<div style="flex:1;min-width:14rem">
<label for="pqText">貼文內容</label>
<textarea id="pqText" rows="3" placeholder="要發佈的 Threads 文字" style="width:100%"></textarea>
</div>
<div>
<label for="pqScheduledAt">排程時間(留空=立即)</label>
<input type="datetime-local" id="pqScheduledAt" />
</div>
<button class="primary" id="btnCreatePublishQueue">加入佇列</button>
<button id="btnReloadPublishQueue">重新載入</button>
</div>
<span class="status" id="pqStatus"></span>
<ul class="list" id="pqList"><li class="hint">選中帳號後載入佇列</li></ul>
</section>
<section class="section-perf">
<h2>4. 發文健康度</h2>
<p class="section-desc">已發佈貼文與成效 checkpoint 彙總(來自 publish_queue + publish_analytics</p>
<div class="row">
<button class="primary" id="btnLoadPublishHealth">載入健康度</button>
<span class="status" id="phStatus"></span>
</div>
<div id="phSummary" class="perf-summary" hidden></div>
<ul class="list" id="phList"><li class="hint">按「載入健康度」</li></ul>
</section>
<section>
<h2>2b. Threads API 手動測試台</h2>
<p class="hint">針對<strong>選中帳號</strong>逐項呼叫 Graph API寫入類發文/刪文)會真的改 Threads 上的內容。</p>
<div class="playground-grid">
<div class="play-card">
<h3>個人資料 /me</h3>
<div class="scope">threads_basic</div>
<div class="row"><button type="button" data-pg="me">執行</button></div>
</div>
<div class="play-card">
<h3>我的貼文列表</h3>
<div class="scope">threads_basic</div>
<div class="row"><button type="button" data-pg="user_threads">執行</button></div>
</div>
<div class="play-card">
<h3>關鍵字搜尋</h3>
<div class="scope">threads_keyword_search</div>
<div class="row"><input id="pgQuery" placeholder="關鍵字,例如 threads" value="threads" /></div>
<div class="row"><button type="button" data-pg="keyword_search">搜尋</button></div>
</div>
<div class="play-card">
<h3>帳號查詢</h3>
<div class="scope">threads_profile_discovery</div>
<div class="row"><input id="pgUsername" placeholder="@username" /></div>
<div class="row"><button type="button" data-pg="profile_lookup">查詢</button></div>
</div>
<div class="play-card">
<h3>提及 mentions</h3>
<div class="scope">threads_manage_mentions</div>
<div class="row"><button type="button" data-pg="mentions">執行</button></div>
</div>
<div class="play-card">
<h3>帳號洞察 insights</h3>
<div class="scope">threads_manage_insights</div>
<div class="row"><button type="button" data-pg="insights">執行</button></div>
</div>
<div class="play-card">
<h3>地點搜尋</h3>
<div class="scope">threads_location_tagging</div>
<div class="row"><input id="pgLocation" placeholder="地點,例如 Taipei" value="Taipei" /></div>
<div class="row"><button type="button" data-pg="location_search">搜尋</button></div>
</div>
<div class="play-card">
<h3>貼文統計</h3>
<div class="scope">threads_manage_insights · GET /{media-id}/insights</div>
<div class="row"><input id="pgMediaId" placeholder="media_id" /></div>
<div class="row"><button type="button" data-pg="media_stats">查詢</button></div>
</div>
<div class="play-card">
<h3>貼文洞察 insights</h3>
<div class="scope">threads_manage_insights</div>
<div class="row"><input id="pgMediaIdInsights" placeholder="media_id可與上方相同" /></div>
<div class="row"><button type="button" data-pg="media_insights">查詢</button></div>
</div>
<div class="play-card">
<h3>貼文留言</h3>
<div class="scope">threads_read_replies</div>
<div class="row"><input id="pgMediaIdReplies" placeholder="media_id可與上方相同" /></div>
<div class="row"><button type="button" data-pg="media_replies">抓留言</button></div>
</div>
<div class="play-card">
<h3>發文(真的會發)</h3>
<div class="scope">threads_content_publish</div>
<div class="row"><textarea id="pgPublishText" placeholder="測試文內容"></textarea></div>
<div class="row"><button type="button" class="primary" data-pg="publish_text" data-confirm="確定要發文到 Threads">發文</button></div>
</div>
<div class="play-card">
<h3>回覆留言(真的會發)</h3>
<div class="scope">threads_content_publish</div>
<div class="row"><input id="pgReplyToId" placeholder="reply_to_id / media_id" /></div>
<div class="row"><textarea id="pgReplyText" placeholder="回覆內容"></textarea></div>
<div class="row"><button type="button" class="primary" data-pg="publish_reply" data-confirm="確定要發回覆?">發回覆</button></div>
</div>
<div class="play-card">
<h3>刪除貼文</h3>
<div class="scope">threads_delete</div>
<div class="row"><input id="pgDeleteMediaId" placeholder="media_id" /></div>
<div class="row"><button type="button" class="danger" data-pg="delete_media" data-confirm="確定刪除這則 Threads 貼文?">刪除</button></div>
</div>
<div class="play-card">
<h3>隱藏 / 顯示留言</h3>
<div class="scope">threads_manage_replies</div>
<div class="row"><input id="pgHideReplyId" placeholder="reply_id" /></div>
<div class="row"><label><input type="checkbox" id="pgHideReply" checked /> 隱藏</label></div>
<div class="row"><button type="button" data-pg="hide_reply" data-confirm="確定變更留言隱藏狀態?">套用</button></div>
</div>
<div class="play-card">
<h3>同步到 Instagram</h3>
<div class="scope">threads_share_to_instagram</div>
<div class="row"><input id="pgCrosspostMediaId" placeholder="media_id" /></div>
<div class="row"><button type="button" data-pg="share_to_instagram" data-confirm="確定 crosspost 到 IG需已連結 IG 帳號。">同步</button></div>
</div>
<div class="play-card">
<h3>刷新 long-lived token</h3>
<div class="scope">token refresh</div>
<div class="row"><button type="button" data-pg="refresh_token">刷新並存回 DB</button></div>
</div>
</div>
<p class="hint" id="threadsPlaygroundStatus">結果會顯示在下方;成功後可把 media_id 複製到其他卡片繼續測。</p>
<pre class="log" id="threadsPlaygroundResult" style="max-height:18rem;overflow:auto">(尚無結果)</pre>
</section>
<section>
<h2>3. AI 設定8D 研究用)</h2>
<p class="hint" id="aiAccountMeta">登入後自動載入經營帳號與 AI 設定</p>
<div class="row">
<label for="researchProvider">研究 Provider</label>
<select id="researchProvider">
<option value="opencode-go">opencode-go</option>
<option value="xai">xai (Grok)</option>
</select>
</div>
<div class="row">
<label for="apiKey">API Key</label>
<input id="apiKey" type="password" placeholder="貼上 provider API key" autocomplete="off" />
</div>
<div class="row">
<label for="researchModel">研究 Model</label>
<select id="researchModel" style="min-width: 12rem"></select>
<input id="researchModelCustom" placeholder="或手動輸入 model id" style="max-width: 14rem" />
<button id="btnFetchModels">讀取模型列表</button>
</div>
<div class="row">
<button class="primary" id="btnSaveAi">儲存 AI 設定</button>
<button id="btnReloadAi">重新載入</button>
<span class="status" id="aiStatus"></span>
</div>
<p class="hint">8D 分析使用<strong>研究用</strong> provider / model。Key 存於後端(會員 scopeDev Console 不會把完整 key 留在 localStorage。</p>
</section>
<section>
<h2>4. 人設</h2>
<div class="row">
<label for="displayName">名稱</label>
<input id="displayName" placeholder="新人設" />
<button class="primary" id="btnCreatePersona">建立</button>
<button id="btnRefreshPersonas">重新載入</button>
</div>
<ul class="list" id="personaList"></ul>
<p class="hint">點選一筆人設後,下方可跑 8D 分析。</p>
</section>
<section>
<h2>5. 8D 風格分析(公開爬蟲,不需 session</h2>
<div class="row">
<label for="benchmark">對標帳號</label>
<input id="benchmark" placeholder="@username" />
<button class="primary" id="btnStart8d" disabled>開始 8D</button>
</div>
<p class="hint">需先完成上方 AI 設定,並啟動 Go API + Node workerstyle-8d</p>
<div class="job-banner" id="jobBanner" hidden>(尚無任務狀態)</div>
<div class="status" id="jobHint"></div>
<div class="row" style="margin-top: 0.5rem">
<label for="jobId">Job ID</label>
<input id="jobId" placeholder="自動填入或手動貼上" />
<button id="btnPollJob">查詢一次</button>
<button id="btnAutoPoll">自動輪詢</button>
</div>
<div class="steps" id="jobSteps"></div>
<pre id="jobDetail">(尚無任務)</pre>
</section>
<section>
<h2>6. 人設詳情</h2>
<div class="row">
<button id="btnReloadPersona" disabled>重新載入選中人設</button>
<span class="status" id="personaMeta"></span>
</div>
<div id="personaPanel" class="persona-panel">
<p class="persona-empty">選中人設後顯示內容</p>
</div>
</section>
<section>
<h2>Log</h2>
<pre class="log" id="log"></pre>
</section>
</main>
<script type="module">
const SUCCESS = 102000
const STORAGE_KEY = 'haixun.dev-console'
const $ = (id) => document.getElementById(id)
const state = loadState()
function loadState() {
try {
return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}')
} catch {
return {}
}
}
function saveState() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
}
function log(msg, data) {
const el = $('log')
const line = data === undefined ? String(msg) : `${msg} ${JSON.stringify(data, null, 2)}`
el.textContent = `${new Date().toLocaleTimeString()} ${line}\n\n` + el.textContent
}
function escapeHtml(text) {
return String(text ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
async function api(path, { method = 'GET', body, auth = false } = {}) {
const headers = { 'Content-Type': 'application/json' }
if (auth && state.accessToken) {
headers.Authorization = `Bearer ${state.accessToken}`
}
const res = await fetch(path, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
})
const raw = await res.text()
let json
try {
json = JSON.parse(raw)
} catch {
throw new Error(`非 JSON 回應 ${path}: HTTP ${res.status}`)
}
if (json.code !== SUCCESS) {
throw new Error(json.message || `API 錯誤 ${json.code}`)
}
return json.data
}
function setAuthStatus(text, ok) {
const el = $('authStatus')
el.textContent = text
el.className = 'status' + (ok ? ' ok' : ok === false ? ' err' : '')
}
function badge(status) {
const s = (status || 'unknown').toLowerCase()
return `<span class="badge ${s}">${status || '—'}</span>`
}
function setAiStatus(text, ok) {
const el = $('aiStatus')
el.textContent = text
el.className = 'status' + (ok ? ' ok' : ok === false ? ' err' : '')
}
function setThreadsApiStatus(text, ok) {
const el = $('threadsApiStatus')
el.textContent = text
el.className = 'status' + (ok ? ' ok' : ok === false ? ' err' : '')
}
function formatTokenExpiry(ns) {
const n = Number(ns)
if (!n) return '(無到期資訊)'
const d = new Date(n / 1e6)
return d.toLocaleString('zh-TW', { hour12: false })
}
function renderThreadsAccounts(data) {
const ul = $('threadsAccountList')
ul.innerHTML = ''
const list = data?.list || []
const activeId = data?.active_account_id || ''
if (!list.length) {
ul.innerHTML = '<li style="cursor:default;color:var(--muted)">尚無經營帳號</li>'
return
}
for (const a of list) {
const li = document.createElement('li')
const selected = a.id === (state.threadsAccountId || activeId)
li.className = selected ? 'active' : ''
const apiLabel = a.api_connected
? `API ✓ ${a.username ? '@' + a.username : ''}`
: 'API 未連線'
const expiry = a.api_connected ? formatTokenExpiry(a.api_token_expires_at) : ''
li.innerHTML = `<span>${a.display_name || a.id}${a.id === activeId ? ' · 使用中' : ''}</span><span class="meta">${apiLabel}${expiry ? ' · 到期 ' + expiry : ''}</span>`
li.onclick = () => {
selectThreadsAccount(a, activeId)
}
ul.appendChild(li)
}
}
function selectThreadsAccount(account, activeId) {
if (!account?.id) return
state.threadsAccountId = account.id
saveState()
const label = account.display_name || account.username || account.id
$('threadsSelectedMeta').textContent = `選中帳號:${label}${account.id}${account.api_connected ? ' · 已連 API' : ' · 未連線'}`
if (activeId !== undefined) {
renderThreadsAccounts({ list: state.threadsAccounts || [], active_account_id: activeId })
}
setThreadsApiStatus(`已選 ${label}`, null)
$('btnDisconnectThreads').disabled = !account.api_connected
}
let oauthLogTimer = null
function formatOAuthLogLine(item) {
const t = new Date(Math.floor(item.at / 1e6)).toLocaleString('zh-TW', { hour12: false })
const acct = item.account_id ? ` account=${item.account_id}` : ''
return `[${t}] ${item.level} ${item.stage}${acct}${item.message}`
}
async function loadOAuthConfig() {
try {
const cfg = await api('/api/v1/threads-accounts/oauth/config', { auth: true })
const el = $('threadsOAuthConfigHint')
const uri = cfg.redirect_uri || '(未設定)'
el.innerHTML = `Meta Redirect URI須與 Dashboard 完全相同):<code>${uri}</code>`
if (cfg.requires_https) {
el.innerHTML += ' · <strong class="status err">目前不是 https://Meta 會回 1349187</strong>'
setThreadsApiStatus('THREADS_REDIRECT_URI 必須改成 https://(可用 ./infra/start-ngrok.sh', false)
} else if (!cfg.enabled) {
el.innerHTML += ' · <span class="status err">OAuth 未完整設定</span>'
} else {
el.innerHTML += ' · ngrok 導回時請點「Visit Site」讓 callback 打到 API'
}
} catch (e) {
$('threadsOAuthConfigHint').textContent = '無法載入 OAuth 設定:' + e.message
}
}
async function loadOAuthLogs() {
if (!state.accessToken) {
$('threadsOAuthLogs').textContent = '(請先登入)'
return
}
try {
const data = await api('/api/v1/threads-accounts/oauth/logs', { auth: true })
const lines = (data.list || []).map(formatOAuthLogLine)
$('threadsOAuthLogs').textContent = lines.length ? lines.join('\n') : '(尚無 OAuth log — 按連線後這裡會出現 start / callback / exchange_code 等步驟)'
} catch (e) {
$('threadsOAuthLogs').textContent = '載入 OAuth log 失敗:' + e.message
}
}
function startOAuthLogPoll() {
if (oauthLogTimer) clearInterval(oauthLogTimer)
loadOAuthLogs().catch(() => {})
oauthLogTimer = setInterval(() => loadOAuthLogs().catch(() => {}), 5000)
}
function renderSmokeResults(data) {
const el = $('threadsSmokeResults')
const items = data?.list || []
if (!items.length) {
el.innerHTML = '<li class="hint">尚無結果</li>'
return
}
el.innerHTML = items.map((item) => {
const chipCls = item.status === 'ok' ? 'ok' : item.status === 'error' ? 'err' : 'skip'
const chipLabel = item.status === 'ok' ? '通過' : item.status === 'error' ? '失敗' : item.status === 'manual' ? '手動' : '略過'
const detail = item.message ? `${escapeHtml(item.message)}` : (item.count ? ` (${item.count})` : '')
return `<li><span class="chip-status ${chipCls}">${chipLabel}</span> <code>${escapeHtml(item.scope)}</code> ${escapeHtml(item.name)}${detail}</li>`
}).join('')
$('threadsSmokeSummary').textContent =
`通過 ${data.passed} · 失敗 ${data.failed} · 略過/手動 ${data.skipped} · 共 ${data.total}`
}
async function loadConnectionPrefs() {
const accountId = state.threadsAccountId
if (!accountId || !state.accessToken) return
const data = await api(`/api/v1/threads-accounts/${encodeURIComponent(accountId)}/connection`, { auth: true })
const p = data.prefs || {}
$('prefSearchViaApi').checked = !!p.search_via_api
$('prefPublishViaApi').checked = !!p.publish_via_api
$('prefScrapeReplies').checked = !!p.scrape_replies
}
async function saveConnectionPrefs() {
const accountId = state.threadsAccountId || (await ensureThreadsAccount())
await api(`/api/v1/threads-accounts/${encodeURIComponent(accountId)}/connection`, {
method: 'PATCH',
auth: true,
body: {
search_via_api: $('prefSearchViaApi').checked,
publish_via_api: $('prefPublishViaApi').checked,
scrape_replies: $('prefScrapeReplies').checked,
},
})
setThreadsApiStatus('已儲存連線設定(掃描/發文會走 API', true)
log('連線設定已儲存', { account_id: accountId })
}
function playgroundBody(action) {
const mid = $('pgMediaId')?.value?.trim()
const body = { action, limit: 10 }
switch (action) {
case 'keyword_search':
body.query = $('pgQuery').value.trim()
body.search_type = 'TOP'
break
case 'profile_lookup':
body.username = $('pgUsername').value.trim()
break
case 'location_search':
body.location_query = $('pgLocation').value.trim()
break
case 'media_stats':
body.media_id = mid
break
case 'media_insights':
body.media_id = ($('pgMediaIdInsights')?.value?.trim() || mid)
break
case 'media_replies':
body.media_id = ($('pgMediaIdReplies').value.trim() || mid)
break
case 'publish_text':
body.text = $('pgPublishText').value.trim()
break
case 'publish_reply':
body.reply_to_id = $('pgReplyToId').value.trim()
body.text = $('pgReplyText').value.trim()
break
case 'delete_media':
body.media_id = $('pgDeleteMediaId').value.trim()
break
case 'hide_reply':
body.reply_id = $('pgHideReplyId').value.trim()
body.hide = $('pgHideReply').checked
break
case 'share_to_instagram':
body.media_id = $('pgCrosspostMediaId').value.trim()
break
default:
break
}
return body
}
async function runPlaygroundAction(action, { confirmMsg } = {}) {
const accountId = state.threadsAccountId
if (!accountId) throw new Error('請先在上方選中要測試的經營帳號')
if (confirmMsg && !window.confirm(confirmMsg)) return
$('threadsPlaygroundStatus').textContent = `執行 ${action}`
$('threadsPlaygroundResult').textContent = '請求中…'
const data = await api(`/api/v1/threads-accounts/${encodeURIComponent(accountId)}/api/playground`, {
method: 'POST',
auth: true,
body: playgroundBody(action),
})
const out = { action: data.action, ok: data.ok, message: data.message, data: data.data }
$('threadsPlaygroundResult').textContent = JSON.stringify(out, null, 2)
if (data.ok) {
$('threadsPlaygroundStatus').textContent = `${action} 成功`
$('threadsPlaygroundStatus').className = 'hint status ok'
const mediaId = data.data?.media_id || data.data?.MediaID
if (mediaId && !$('pgMediaId').value) $('pgMediaId').value = mediaId
} else {
$('threadsPlaygroundStatus').textContent = `${action} 失敗:${data.message}`
$('threadsPlaygroundStatus').className = 'hint status err'
}
log('Playground ' + action, out)
}
function bindPlaygroundButtons() {
document.querySelectorAll('[data-pg]').forEach((btn) => {
btn.onclick = () => runPlaygroundAction(btn.dataset.pg, {
confirmMsg: btn.dataset.confirm || '',
}).catch((e) => {
$('threadsPlaygroundStatus').textContent = e.message
$('threadsPlaygroundStatus').className = 'hint status err'
$('threadsPlaygroundResult').textContent = e.message
log('Playground 失敗', e.message)
})
})
}
function fmtNum(n) {
if (n === undefined || n === null || n === '') return '—'
return Number(n).toLocaleString('zh-TW')
}
function shortText(text, max = 48) {
const t = escapeHtml(text).replace(/\s+/g, ' ').trim()
if (!t) return '(無文字)'
return t.length > max ? t.slice(0, max) + '…' : t
}
function metricLabel(name) {
const map = {
views: '瀏覽', likes: '讚', like_count: '讚', replies: '回覆', reply_count: '回覆',
reposts: '轉發', repost_count: '轉發', quotes: '引用', quote_count: '引用',
shares: '分享', followers_count: '粉絲', clicks: '點擊',
}
return map[name] || name
}
function sumPosts(posts, key) {
return (posts || []).reduce((n, p) => n + (Number(p[key]) || 0), 0)
}
function renderPostPerformance(data) {
const capsEl = $('perfCapabilities')
const caps = data.capabilities || []
capsEl.innerHTML = caps.map((c) => {
const cls = c.trackable ? 'cap-card trackable' : 'cap-card'
const badge = c.trackable
? '<span class="cap-badge yes">可追蹤</span>'
: '<span class="cap-badge no">非成效</span>'
const metrics = (c.metrics || []).map(metricLabel).join(' · ')
const note = c.note ? `<div class="cap-note">${c.note}</div>` : ''
return `<div class="${cls}">${badge}<div class="cap-title">${c.name}</div><span class="cap-scope">${c.scope}</span>${metrics ? `<div class="cap-metrics">${metrics}</div>` : ''}${note}</div>`
}).join('')
const ai = data.account_insights || {}
const aiEl = $('perfAccountInsights')
const statusCls = ai.status === 'ok' ? 'ok' : ai.status === 'error' ? 'err' : 'skip'
const pills = (ai.metrics || []).map((m) =>
`<span class="perf-pill"><span class="name">${metricLabel(m.name)}</span><span class="num">${fmtNum(m.value)}</span></span>`
).join('')
aiEl.hidden = false
aiEl.innerHTML =
`<div class="perf-insights-head">` +
`<strong>帳號洞察</strong>` +
`<span class="chip-status ${statusCls}">${ai.status || '—'}</span>` +
(ai.message ? `<span class="hint" style="margin:0">${ai.message}</span>` : '') +
`</div>` +
(pills ? `<div class="perf-pill-grid">${pills}</div>` : `<span class="hint">尚無帳號層級指標</span>`)
const posts = data.posts || []
const summaryEl = $('perfSummary')
if (posts.length) {
summaryEl.hidden = false
const user = data.username ? `@${data.username}` : (data.threads_user_id || '帳號')
summaryEl.innerHTML = [
{ val: posts.length, lbl: '貼文數', hi: false },
{ val: sumPosts(posts, 'views'), lbl: '總瀏覽', hi: true },
{ val: sumPosts(posts, 'like_count'), lbl: '總讚', hi: false },
{ val: sumPosts(posts, 'reply_count'), lbl: '總回覆', hi: false },
{ val: sumPosts(posts, 'repost_count'), lbl: '總轉發', hi: false },
{ val: sumPosts(posts, 'quote_count'), lbl: '總引用', hi: false },
].map((s) =>
`<div class="perf-stat${s.hi ? ' highlight' : ''}"><div class="val">${fmtNum(s.val)}</div><div class="lbl">${s.lbl}</div></div>`
).join('')
$('perfStatus').textContent = `${user} · ${posts.length}`
} else {
summaryEl.hidden = true
summaryEl.innerHTML = ''
}
const listEl = $('perfPostsList')
listEl.classList.remove('perf-loading')
if (!posts.length) {
listEl.innerHTML =
`<div class="perf-empty"><div class="icon" aria-hidden="true"></div><div>尚無貼文,或無法讀取貼文列表</div></div>`
return
}
listEl.innerHTML = posts.map((p) => {
const ts = p.timestamp
? new Date(p.timestamp).toLocaleString('zh-TW', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false })
: '—'
const insightTag = p.insights_status === 'ok'
? '<span class="insight-tag ok">洞察 OK</span>'
: (p.insights_message
? `<span class="insight-tag err" title="${p.insights_message.replace(/"/g, '&quot;')}">洞察失敗</span>`
: '<span class="insight-tag skip">—</span>')
const link = p.permalink
? `<a href="${p.permalink}" target="_blank" rel="noopener">在 Threads 開啟</a>`
: '<span class="hint">無連結</span>'
const metrics = [
{ key: 'views', lbl: '瀏覽', primary: true },
{ key: 'like_count', lbl: '讚' },
{ key: 'reply_count', lbl: '回覆' },
{ key: 'repost_count', lbl: '轉發' },
{ key: 'quote_count', lbl: '引用' },
{ key: 'shares', lbl: '分享' },
].map((m) =>
`<div class="perf-metric${m.primary ? ' primary' : ''}"><div class="m-val">${fmtNum(p[m.key])}</div><div class="m-lbl">${m.lbl}</div></div>`
).join('')
return `<article class="perf-post">
<div class="perf-post-main">
<p class="perf-post-text">${shortText(p.text, 120)}</p>
<div class="perf-post-meta"><span>${ts}</span><code>${p.media_id}</code></div>
</div>
<div class="perf-metrics">${metrics}</div>
<div class="perf-post-foot">${link}${insightTag}</div>
</article>`
}).join('')
}
function showPerfLoading() {
$('perfSummary').hidden = true
$('perfAccountInsights').hidden = true
const listEl = $('perfPostsList')
listEl.classList.add('perf-loading')
listEl.innerHTML = Array.from({ length: 3 }, () =>
`<article class="perf-post"><div class="perf-post-main"><p class="perf-post-text" style="color:var(--muted)">載入中…</p></div><div class="perf-metrics">${Array(6).fill('<div class="perf-metric"><div class="m-val">—</div><div class="m-lbl">…</div></div>').join('')}</div></article>`
).join('')
}
function pqScheduledNs() {
const raw = $('pqScheduledAt').value
if (!raw) return 0
const ms = Date.parse(raw)
if (!Number.isFinite(ms)) return 0
return ms * 1e6
}
function renderPublishQueue(data) {
const list = data.list || []
const el = $('pqList')
if (!list.length) {
el.innerHTML = '<li class="hint">佇列是空的</li>'
return
}
el.innerHTML = list.map((item) => {
const when = item.scheduled_at
? new Date(Math.floor(item.scheduled_at / 1e6)).toLocaleString('zh-TW', { hour12: false })
: '—'
const err = item.error_message ? `<div class="hint" style="color:var(--danger)">${escapeHtml(item.error_message)}</div>` : ''
const cancelBtn = item.status === 'scheduled'
? `<button type="button" data-pq-cancel="${escapeHtml(item.id)}">取消</button>`
: ''
const link = item.permalink ? ` · <a href="${escapeHtml(item.permalink)}" target="_blank" rel="noreferrer">連結</a>` : ''
return `<li>
<span class="chip-status">${escapeHtml(item.status)}</span>
<code>${escapeHtml(item.id.slice(0, 8))}</code>
${escapeHtml(when)}${link}
<div style="margin-top:0.35rem">${escapeHtml((item.text || '').slice(0, 120))}</div>
${err}
<div class="row" style="margin-top:0.35rem">${cancelBtn}</div>
</li>`
}).join('')
el.querySelectorAll('[data-pq-cancel]').forEach((btn) => {
btn.onclick = () => cancelPublishQueueItem(btn.getAttribute('data-pq-cancel')).catch((e) => {
$('pqStatus').textContent = e.message
$('pqStatus').className = 'status err'
})
})
}
async function loadPublishQueue() {
const accountId = state.threadsAccountId
if (!accountId) throw new Error('請先選中經營帳號')
$('pqStatus').textContent = '載入中…'
$('pqStatus').className = 'status'
const data = await api(`/api/v1/threads-accounts/${encodeURIComponent(accountId)}/publish-queue?page=1&pageSize=20`, { auth: true })
renderPublishQueue(data)
$('pqStatus').textContent = `${data.pagination?.total ?? (data.list || []).length}`
$('pqStatus').className = 'status ok'
log('發文佇列', { total: data.pagination?.total })
}
async function createPublishQueueItem() {
const accountId = state.threadsAccountId
if (!accountId) throw new Error('請先選中經營帳號')
const text = $('pqText').value.trim()
if (!text) throw new Error('請輸入貼文內容')
const scheduledAt = pqScheduledNs()
$('pqStatus').textContent = scheduledAt > 0 ? '建立排程中…' : '建立並嘗試立即發文…'
const body = { text }
if (scheduledAt > 0) body.scheduled_at = scheduledAt
const data = await api(`/api/v1/threads-accounts/${encodeURIComponent(accountId)}/publish-queue`, {
method: 'POST',
auth: true,
body,
})
log('已加入佇列', { id: data.id, status: data.status })
$('pqText').value = ''
await loadPublishQueue()
}
async function cancelPublishQueueItem(queueId) {
const accountId = state.threadsAccountId
if (!accountId || !queueId) return
await api(`/api/v1/threads-accounts/${encodeURIComponent(accountId)}/publish-queue/${encodeURIComponent(queueId)}/cancel`, {
method: 'POST',
auth: true,
})
await loadPublishQueue()
}
function renderPublishHealth(data) {
const s = data.summary || {}
const sumEl = $('phSummary')
sumEl.hidden = false
sumEl.innerHTML = [
{ val: s.pending_scheduled ?? 0, lbl: '待發佈' },
{ val: s.failed_count ?? 0, lbl: '失敗' },
{ val: s.published_7d ?? 0, lbl: '7日已發' },
{ val: s.total_likes_7d ?? 0, lbl: '7日總讚' },
{ val: s.total_replies_7d ?? 0, lbl: '7日總回覆' },
].map((m) => `<div class="perf-stat"><div class="s-val">${m.val}</div><div class="s-lbl">${m.lbl}</div></div>`).join('')
const list = data.list || []
const el = $('phList')
if (!list.length) {
el.innerHTML = '<li class="hint">尚無已發佈紀錄</li>'
return
}
el.innerHTML = list.map((item) => {
const when = item.published_at
? new Date(Math.floor(item.published_at / 1e6)).toLocaleString('zh-TW', { hour12: false })
: '—'
const cps = (item.checkpoints || []).map((cp) =>
`<span class="hint">${escapeHtml(cp.checkpoint)}: ${cp.views || 0}瀏覽 ${cp.like_count}${cp.reply_count}回</span>`
).join(' · ')
const link = item.permalink ? `<a href="${escapeHtml(item.permalink)}" target="_blank" rel="noreferrer">連結</a> · ` : ''
return `<li>${link}<strong>${escapeHtml(when)}</strong> · ${item.views || 0}瀏覽 ${item.like_count}${item.reply_count}回覆
<div class="hint">${cps || '尚無 checkpoint'}</div>
<div style="margin-top:0.25rem">${escapeHtml((item.text || '').slice(0, 100))}</div></li>`
}).join('')
}
async function loadPublishHealth() {
const accountId = state.threadsAccountId
if (!accountId) throw new Error('請先選中經營帳號')
$('phStatus').textContent = '載入中…'
const data = await api(`/api/v1/threads-accounts/${encodeURIComponent(accountId)}/publish-health?page=1&pageSize=15`, { auth: true })
renderPublishHealth(data)
$('phStatus').textContent = `${data.pagination?.total ?? 0} 篇已發佈`
$('phStatus').className = 'status ok'
log('發文健康度', data.summary)
}
async function loadPostPerformance() {
const accountId = state.threadsAccountId
if (!accountId) throw new Error('請先選中要追蹤的經營帳號')
const limit = Math.min(30, Math.max(1, parseInt($('perfLimit').value, 10) || 15))
$('perfStatus').textContent = '正在向 Threads API 拉資料…'
$('perfStatus').className = 'status'
showPerfLoading()
const data = await api(`/api/v1/threads-accounts/${encodeURIComponent(accountId)}/post-performance?limit=${limit}`, { auth: true })
renderPostPerformance(data)
const fetched = data.fetched_at ? new Date(Math.floor(data.fetched_at / 1e6)).toLocaleString('zh-TW', { hour12: false }) : ''
const base = $('perfStatus').textContent
$('perfStatus').textContent = `${base} · 更新 ${fetched}`
$('perfStatus').className = 'status ok'
log('貼文成效', { posts: data.posts?.length, account_id: accountId })
}
async function runThreadsSmokeTest() {
const accountId = state.threadsAccountId
if (!accountId) throw new Error('請先選中要測試的經營帳號')
setThreadsApiStatus('正在測試 Threads API…', null)
$('threadsSmokeResults').innerHTML = '<li class="hint">執行中…</li>'
const data = await api(`/api/v1/threads-accounts/${encodeURIComponent(accountId)}/api/smoke-test`, {
method: 'POST',
auth: true,
})
renderSmokeResults(data)
if (data.failed > 0) {
setThreadsApiStatus(`煙霧測試完成:${data.failed} 項失敗`, false)
} else {
setThreadsApiStatus(`煙霧測試完成:${data.passed} 項通過`, true)
}
log('Threads 煙霧測試', { passed: data.passed, failed: data.failed, skipped: data.skipped })
}
async function loadThreadsApiStatus() {
const data = await api('/api/v1/threads-accounts', { auth: true })
state.threadsAccounts = data.list || []
const ids = new Set((data.list || []).map((a) => a.id))
if (!state.threadsAccountId || !ids.has(state.threadsAccountId)) {
state.threadsAccountId = data.active_account_id || data.list?.[0]?.id || ''
saveState()
}
renderThreadsAccounts(data)
const connected = (data.list || []).filter((a) => a.api_connected).length
$('threadsApiMeta').textContent = `${data.list?.length || 0} 個經營帳號 · ${connected} 個已連 API · 使用中 ${data.active_account_id || '—'}`
const current = (data.list || []).find((a) => a.id === state.threadsAccountId)
if (current?.username && !$('pgUsername').value) {
$('pgUsername').value = current.username
}
if (current) {
selectThreadsAccount(current, data.active_account_id)
if (current.api_connected) {
setThreadsApiStatus(`選中帳號已連線 ${current.username ? '@' + current.username : ''}`, true)
} else {
setThreadsApiStatus('選中帳號尚未連線 API — 按「連線選中帳號」授權', false)
}
} else {
$('threadsSelectedMeta').textContent = '選中帳號:—'
}
log('Threads 帳號列表', { count: data.list?.length, connected, active: data.active_account_id })
loadOAuthLogs().catch(() => {})
loadConnectionPrefs().catch(() => {})
}
async function connectThreads(accountId, { newShell = false } = {}) {
let q = ''
if (newShell) {
q = ''
} else {
const targetId = accountId || state.threadsAccountId
if (!targetId) {
throw new Error('請先在列表點選要連線的經營帳號')
}
q = `?account_id=${encodeURIComponent(targetId)}`
}
const data = await api(`/api/v1/threads-accounts/oauth/start${q}`, { auth: true })
state.threadsAccountId = data.account_id
saveState()
sessionStorage.setItem('threads_oauth_pending', JSON.stringify({
at: Date.now(),
account_id: data.account_id,
}))
log('導向 Threads OAuth', { account_id: data.account_id, new_shell: newShell })
window.location.href = data.authorize_url
}
async function activateThreadsAccount() {
const accountId = state.threadsAccountId || (await ensureThreadsAccount())
await api(`/api/v1/threads-accounts/${encodeURIComponent(accountId)}/activate`, {
method: 'POST',
auth: true,
})
await loadThreadsApiStatus()
setThreadsApiStatus('已切換使用中帳號', true)
}
async function disconnectThreads() {
const accountId = state.threadsAccountId || (await ensureThreadsAccount())
await api(`/api/v1/threads-accounts/${encodeURIComponent(accountId)}/api/disconnect`, {
method: 'POST',
auth: true,
})
await loadThreadsApiStatus()
setThreadsApiStatus('已中斷選中帳號 API 連線', null)
}
function clearOAuthPending() {
sessionStorage.removeItem('threads_oauth_pending')
}
function checkPendingOAuth() {
const raw = sessionStorage.getItem('threads_oauth_pending')
if (!raw) return
let pending
try { pending = JSON.parse(raw) } catch { clearOAuthPending(); return }
if (!pending?.at || Date.now() - pending.at > 15 * 60 * 1000) {
clearOAuthPending()
return
}
setThreadsApiStatus(
'授權流程可能尚未完成:請等 Meta 自動導回 API網址會短暫跳到 127.0.0.1:8890再跳回本頁。若用 ngrok 請在警告頁按「Visit Site」不要手動關分頁回來。',
false,
)
log('OAuth 待完成', pending)
}
async function handleOAuthReturn() {
const params = new URLSearchParams(window.location.search)
const oauth = params.get('threads_oauth')
if (!oauth) {
checkPendingOAuth()
return
}
clearOAuthPending()
if (oauth === 'ok') {
const aid = params.get('account_id')
if (aid) { state.threadsAccountId = aid; saveState() }
setThreadsApiStatus('Threads OAuth 回傳成功,正在確認連線狀態…', true)
log('OAuth 回傳 ok', { account_id: aid })
try {
await loadThreadsApiStatus()
const current = (state.threadsAccounts || []).find((a) => a.id === (aid || state.threadsAccountId))
if (current?.api_connected) {
setThreadsApiStatus(`Threads API 已連線 ${current.username ? '@' + current.username : ''}`, true)
} else {
setThreadsApiStatus('OAuth 已回傳成功但 API 仍顯示未連線:請重啟 API 後再授權一次,或查看 API 終端機日誌', false)
}
} catch (e) {
setThreadsApiStatus('OAuth 回傳成功但載入帳號失敗:' + e.message, false)
log('OAuth 後載入失敗', e.message)
}
} else if (oauth === 'error') {
const msg = decodeURIComponent((params.get('message') || '未知錯誤').replace(/\+/g, ' '))
setThreadsApiStatus('OAuth 失敗:' + msg, false)
log('OAuth 失敗', msg)
}
params.delete('threads_oauth')
params.delete('account_id')
params.delete('message')
const next = window.location.pathname + (params.toString() ? `?${params}` : '')
window.history.replaceState({}, '', next)
}
function setJobHint(text, ok) {
const el = $('jobHint')
el.textContent = text || ''
el.className = 'status' + (ok ? ' ok' : ok === false ? ' err' : '')
}
function jobStatusLabel(status) {
const map = {
queued: '排隊中',
running: '執行中',
succeeded: '成功',
completed: '完成',
failed: '失敗',
cancelled: '已取消',
expired: '已過期',
}
return map[status] || status || '未知'
}
const DIM_LABELS = {
d1Tone: 'D1 語氣口吻',
d2Structure: 'D2 結構排版',
d3Interaction: 'D3 互動策略',
d4Topics: 'D4 主題選材',
d5Rhythm: 'D5 發文節律',
d6Visual: 'D6 視覺符號',
d7Conversion: 'D7 轉換誘因',
d8Risk: 'D8 風險紅線',
}
function formatTs(ns) {
const n = Number(ns)
if (!n) return '—'
const ms = n > 1e15 ? Math.floor(n / 1_000_000) : n
try {
return new Date(ms).toLocaleString('zh-TW', { hour12: false })
} catch {
return String(ns)
}
}
function parseJsonString(raw) {
if (!raw || typeof raw !== 'string') return null
const trimmed = raw.trim()
if (!trimmed) return null
try {
return JSON.parse(trimmed)
} catch {
return null
}
}
function prettyJson(value) {
try {
return JSON.stringify(value, null, 2)
} catch {
return String(value)
}
}
function buildExportPersona(persona, style) {
const out = { ...persona }
if (style) out.style_profile = style
else if (typeof out.style_profile === 'string') {
const parsed = parseJsonString(out.style_profile)
if (parsed) out.style_profile = parsed
}
return out
}
function renderTextCard(title, text) {
const body = String(text || '').trim()
if (!body) return ''
return `<div class="persona-card"><h4>${escapeHtml(title)}</h4><div class="body">${escapeHtml(body)}</div></div>`
}
function renderDimensionCards(analysis) {
if (!analysis || typeof analysis !== 'object') return ''
const keys = Object.keys(DIM_LABELS)
const cards = keys
.map((key) => {
const dim = analysis[key]
if (!dim) return ''
const summary = dim.summary || dim.Summary || ''
const evidence = dim.evidence || dim.Evidence || []
const evHtml = Array.isArray(evidence) && evidence.length
? `<ul>${evidence.map((e) => `<li>${escapeHtml(e)}</li>`).join('')}</ul>`
: ''
if (!summary && !evHtml) return ''
return `<div class="dim-card"><h5>${escapeHtml(DIM_LABELS[key] || key)}</h5><p>${escapeHtml(summary || '—')}</p>${evHtml}</div>`
})
.filter(Boolean)
.join('')
if (!cards) return ''
return `<div class="persona-card" style="grid-column:1/-1"><h4>8D 風格分析</h4><div class="dim-grid">${cards}</div></div>`
}
function renderSamplePosts(posts) {
if (!Array.isArray(posts) || !posts.length) return ''
const items = posts
.map((post, i) => {
const text = post.text || post.Text || ''
const likes = post.likeCount ?? post.like_count ?? post.LikeCount ?? 0
const replies = post.replyCount ?? post.reply_count ?? post.ReplyCount ?? 0
const link = post.permalink || post.Permalink || ''
const linkHtml = link ? ` · <a href="${escapeHtml(link)}" target="_blank" rel="noreferrer">連結</a>` : ''
return `<div class="sample-post"><div class="meta">#${i + 1} · ${likes} 讚 · ${replies} 回覆${linkHtml}</div>${escapeHtml(text)}</div>`
})
.join('')
return `<div class="persona-card" style="grid-column:1/-1"><h4>樣本貼文</h4>${items}</div>`
}
function renderPersonaPanel(persona) {
const panel = $('personaPanel')
if (!persona) {
panel.innerHTML = '<p class="persona-empty">選中人設後顯示內容</p>'
return
}
const style = parseJsonString(persona.style_profile)
const benchmark = persona.style_benchmark ? `@${persona.style_benchmark}` : '尚未設定'
const engagement = style?.engagement
const engagementText = engagement
? `樣本 ${engagement.measuredPosts ?? '—'} 篇 · 中位互動 ${engagement.medianInteractions ?? '—'} · 判定 ${engagement.verdict ?? '—'}`
: ''
let personaDraftHtml = ''
if (style?.personaDraft) {
personaDraftHtml = renderTextCard('8D 人設草稿', style.personaDraft)
} else if (persona.persona) {
personaDraftHtml = renderTextCard('人設敘述', persona.persona)
}
const research = persona.copy_research_map
let researchHtml = ''
if (research && typeof research === 'object') {
const parts = []
if (research.audience_summary) parts.push(renderTextCard('受眾摘要', research.audience_summary))
if (research.content_goal) parts.push(renderTextCard('內容目標', research.content_goal))
if (parts.length) researchHtml = `<div class="persona-grid">${parts.join('')}</div>`
}
panel.innerHTML = `
<div class="persona-hero">
<h3>${escapeHtml(persona.display_name || persona.id)}</h3>
<div class="sub">ID ${escapeHtml(persona.id)} · 對標 ${escapeHtml(benchmark)}${style?.analyzedAt ? ` · 分析於 ${escapeHtml(style.analyzedAt)}` : ''}</div>
<div class="sub">建立 ${formatTs(persona.create_at)} · 更新 ${formatTs(persona.update_at)}</div>
${engagementText ? `<div class="sub">${escapeHtml(engagementText)}</div>` : ''}
</div>
<div class="persona-grid">
${renderTextCard('簡介 Brief', persona.brief)}
${personaDraftHtml}
</div>
${researchHtml}
${renderDimensionCards(style?.analysis)}
${renderSamplePosts(style?.samplePosts)}
<details class="persona-raw">
<summary>原始 JSON格式化</summary>
<pre>${escapeHtml(prettyJson(buildExportPersona(persona, style)))}</pre>
</details>
`
}
function pickJobError(job) {
if (!job) return ''
if (job.error?.trim()) return job.error.trim()
const failedStep = (job.progress?.steps || []).find((s) => s.status === 'failed')
if (failedStep?.message?.trim()) return failedStep.message.trim()
if (job.progress?.summary?.trim() && job.status === 'failed') return job.progress.summary.trim()
return ''
}
function isMaskedKey(value) {
return String(value || '').startsWith('••••')
}
function selectedResearchModel() {
const custom = $('researchModelCustom').value.trim()
if (custom) return custom
return $('researchModel').value.trim()
}
function fillModelSelect(models, current) {
const sel = $('researchModel')
sel.innerHTML = ''
const list = Array.isArray(models) ? models : []
if (!list.length) {
const opt = document.createElement('option')
opt.value = current || ''
opt.textContent = current || '(請先讀取模型或手動輸入)'
sel.appendChild(opt)
return
}
for (const m of list) {
const opt = document.createElement('option')
opt.value = m
opt.textContent = m
sel.appendChild(opt)
}
if (current && list.includes(current)) sel.value = current
else if (current) $('researchModelCustom').value = current
}
async function ensureThreadsAccount() {
if (state.threadsAccountId) {
$('aiAccountMeta').textContent = `AI 用帳號殼 ${state.threadsAccountId}key 存會員 scope與 Threads 選中帳號可不同)`
return state.threadsAccountId
}
const data = await api('/api/v1/threads-accounts', { auth: true })
let accountId = data.active_account_id || data.list?.[0]?.id
if (!accountId) {
const created = await api('/api/v1/threads-accounts', {
method: 'POST',
auth: true,
body: { display_name: 'Dev Console', activate: true },
})
accountId = created.id
log('已建立預設經營帳號', { id: accountId })
}
state.threadsAccountId = accountId
saveState()
$('aiAccountMeta').textContent = `經營帳號 ID${accountId}AI key 存會員 scope任一帳號讀寫相同設定`
return accountId
}
async function loadAiSettings() {
const accountId = state.threadsAccountId || (await ensureThreadsAccount())
const s = await api(`/api/v1/threads-accounts/${encodeURIComponent(accountId)}/ai-settings`, { auth: true })
const provider = s.research_provider || s.provider || 'opencode-go'
const model = s.research_model || s.model || ''
$('researchProvider').value = provider
const masked = s.api_keys?.[provider] || ''
$('apiKey').value = masked
$('apiKey').placeholder = s.api_keys_configured?.[provider]
? '已設定(留空不變;要換 key 請直接貼新的)'
: '貼上 provider API key'
fillModelSelect([], model)
if (model) $('researchModelCustom').value = model
setAiStatus(s.api_keys_configured?.[provider] ? `已設定 ${provider} key` : '尚未設定 API key', !!s.api_keys_configured?.[provider])
log('AI 設定已載入', { provider, model, configured: s.api_keys_configured })
}
async function fetchModels() {
const accountId = state.threadsAccountId || (await ensureThreadsAccount())
const provider = $('researchProvider').value.trim()
const body = {}
const key = $('apiKey').value.trim()
if (key && !isMaskedKey(key)) body.api_key = key
const data = await api(
`/api/v1/threads-accounts/${encodeURIComponent(accountId)}/ai-settings/providers/${encodeURIComponent(provider)}/models`,
{ method: 'POST', auth: true, body },
)
if (data.error) throw new Error(data.error)
fillModelSelect(data.models || [], selectedResearchModel() || data.models?.[0])
setAiStatus(`已載入 ${data.models?.length || 0} 個模型`, true)
log('模型列表', data)
}
async function saveAiSettings() {
const accountId = state.threadsAccountId || (await ensureThreadsAccount())
const provider = $('researchProvider').value.trim()
const model = selectedResearchModel()
if (!provider || !model) throw new Error('請選 provider 與 model')
const body = {
research_provider: provider,
research_model: model,
}
const key = $('apiKey').value.trim()
if (key && !isMaskedKey(key)) {
body.api_keys = { [provider]: key }
}
const s = await api(`/api/v1/threads-accounts/${encodeURIComponent(accountId)}/ai-settings`, {
method: 'PUT',
auth: true,
body,
})
await loadAiSettings()
setAiStatus('AI 設定已儲存', true)
log('AI 設定已儲存', { provider: s.research_provider, model: s.research_model })
}
async function afterAuth() {
await loadOAuthConfig()
await loadThreadsApiStatus()
startOAuthLogPoll()
await ensureThreadsAccount()
await loadAiSettings()
await refreshPersonas()
if (state.selectedPersonaId) selectPersona(state.selectedPersonaId)
}
async function checkHealth() {
try {
await api('/api/v1/health')
$('connStatus').textContent = 'API 連線正常'
$('connStatus').className = 'status ok'
} catch (e) {
$('connStatus').textContent = 'API 離線:' + e.message
$('connStatus').className = 'status err'
}
}
function renderPersonas(list) {
const ul = $('personaList')
ul.innerHTML = ''
if (!list?.length) {
ul.innerHTML = '<li style="cursor:default;color:var(--muted)">尚無人設</li>'
return
}
for (const p of list) {
const li = document.createElement('li')
li.className = p.id === state.selectedPersonaId ? 'active' : ''
li.innerHTML = `<span>${p.display_name || p.id}</span><span class="meta">${p.style_benchmark ? '@' + p.style_benchmark : '未分析'}</span>`
li.onclick = () => selectPersona(p.id)
ul.appendChild(li)
}
}
async function refreshPersonas() {
const data = await api('/api/v1/personas', { auth: true })
state.personas = data.list || []
renderPersonas(state.personas)
saveState()
log('人設列表已更新', { count: state.personas.length })
}
function selectPersona(id) {
state.selectedPersonaId = id
saveState()
renderPersonas(state.personas || [])
$('btnStart8d').disabled = !id
$('btnReloadPersona').disabled = !id
loadPersonaDetail()
}
async function loadPersonaDetail() {
const id = state.selectedPersonaId
if (!id) return
const p = await api(`/api/v1/personas/${encodeURIComponent(id)}`, { auth: true })
$('personaMeta').textContent = p.style_profile
? `8D 已分析 · @${p.style_benchmark || '—'}`
: `尚未 8D 分析`
renderPersonaPanel(p)
log('人設詳情', { id, style_benchmark: p.style_benchmark, has_style: !!p.style_profile })
}
async function login() {
const data = await api('/api/v1/auth/login', {
method: 'POST',
body: {
tenant_id: $('tenantId').value.trim(),
email: $('email').value.trim(),
password: $('password').value,
},
})
state.accessToken = data.access_token
state.refreshToken = data.refresh_token
state.uid = data.uid
saveState()
setAuthStatus(`已登入 uid=${data.uid}`, true)
log('登入成功', { uid: data.uid })
await afterAuth()
}
async function register() {
const data = await api('/api/v1/auth/register', {
method: 'POST',
body: {
tenant_id: $('tenantId').value.trim(),
email: $('email').value.trim(),
password: $('password').value,
display_name: $('email').value.split('@')[0],
},
})
state.accessToken = data.access_token
state.refreshToken = data.refresh_token
state.uid = data.uid
saveState()
setAuthStatus(`註冊並登入 uid=${data.uid}`, true)
log('註冊成功', { uid: data.uid })
await afterAuth()
}
function logout() {
state.accessToken = ''
state.refreshToken = ''
state.uid = ''
saveState()
setAuthStatus('已登出', false)
$('personaList').innerHTML = ''
renderPersonaPanel(null)
$('btnStart8d').disabled = true
$('btnReloadPersona').disabled = true
state.threadsAccountId = ''
$('aiAccountMeta').textContent = '登入後自動載入經營帳號與 AI 設定'
setAiStatus('')
log('已登出')
}
async function createPersona() {
const name = $('displayName').value.trim() || '新人設'
const p = await api('/api/v1/personas', {
method: 'POST',
auth: true,
body: { display_name: name },
})
$('displayName').value = ''
await refreshPersonas()
selectPersona(p.id)
log('建立人設', { id: p.id })
}
async function start8d() {
const id = state.selectedPersonaId
const username = $('benchmark').value.trim().replace(/^@/, '')
if (!id || !username) throw new Error('請選人設並填對標帳號')
const data = await api(`/api/v1/personas/${encodeURIComponent(id)}/style-analysis`, {
method: 'POST',
auth: true,
body: { benchmark_username: username },
})
$('jobId').value = data.job_id
state.lastJobId = data.job_id
saveState()
setJobHint(data.message || '任務已建立', true)
$('jobBanner').hidden = true
$('jobDetail').classList.remove('fail')
log('8D 任務已建立', data)
await pollJob()
startAutoPoll()
}
function renderJob(job) {
if (!job) return
const status = (job.status || '').toLowerCase()
const steps = job.progress?.steps || []
const errText = pickJobError(job)
const banner = $('jobBanner')
$('jobSteps').innerHTML = steps
.map((s) => {
const st = (s.status || '').toLowerCase()
const cls = st === 'failed' ? 'step-failed' : st === 'cancelled' ? 'step-cancelled' : ''
return `<div class="${cls}">${badge(s.status)} <strong>${s.id}</strong> — ${s.message || ''}</div>`
})
.join('')
$('jobDetail').textContent = JSON.stringify(
{
id: job.id,
status: job.status,
phase: job.phase,
percentage: job.progress?.percentage,
summary: job.progress?.summary,
error: job.error,
result: job.result,
steps: job.progress?.steps,
},
null,
2,
)
banner.hidden = false
banner.className = 'job-banner'
$('jobDetail').classList.remove('fail')
if (status === 'failed') {
banner.classList.add('err')
banner.textContent = `任務失敗:${errText || '請查看下方步驟與詳情'}`
setJobHint(errText || job.progress?.summary || '任務失敗', false)
$('jobDetail').classList.add('fail')
log('任務失敗', { id: job.id, error: errText, phase: job.phase })
} else if (status === 'cancelled') {
banner.classList.add('warn')
banner.textContent = `任務已取消${job.cancel_reason ? `${job.cancel_reason}` : ''}`
setJobHint('任務已取消', false)
log('任務已取消', { id: job.id, reason: job.cancel_reason })
} else if (status === 'succeeded' || status === 'completed') {
banner.classList.add('ok')
banner.textContent = `任務成功(${jobStatusLabel(status)}`
setJobHint(job.progress?.summary || '任務完成', true)
} else if (status === 'running' || status === 'queued') {
banner.classList.add('warn')
banner.textContent = `${jobStatusLabel(status)} · ${job.progress?.summary || job.phase || ''}${job.progress?.percentage ?? 0}%`
setJobHint(job.progress?.summary || jobStatusLabel(status), null)
} else {
banner.textContent = `${jobStatusLabel(status)} · ${job.phase || ''}`
setJobHint(job.progress?.summary || jobStatusLabel(status), null)
}
if (['succeeded', 'completed', 'failed', 'cancelled', 'expired'].includes(status)) {
stopAutoPoll()
if (status === 'succeeded' || status === 'completed') {
loadPersonaDetail().catch(() => {})
}
}
}
async function pollJob() {
const id = $('jobId').value.trim()
if (!id) return
const job = await api(`/api/v1/jobs/${encodeURIComponent(id)}`, { auth: true })
renderJob(job)
if (job.status !== 'failed') {
log('Job 狀態', { id: job.id, status: job.status, phase: job.phase })
}
return job
}
let pollTimer = null
function startAutoPoll() {
stopAutoPoll()
pollTimer = setInterval(() => pollJob().catch((e) => log('輪詢失敗', e.message)), 3000)
$('btnAutoPoll').textContent = '停止輪詢'
}
function stopAutoPoll() {
if (pollTimer) clearInterval(pollTimer)
pollTimer = null
$('btnAutoPoll').textContent = '自動輪詢'
}
$('btnConnectThreads').onclick = () => connectThreads(null, { newShell: true }).catch((e) => { setThreadsApiStatus(e.message, false); log('OAuth 啟動失敗', e.message) })
$('btnConnectExisting').onclick = () => connectThreads(state.threadsAccountId).catch((e) => {
setThreadsApiStatus(e.message, false)
log('OAuth 啟動失敗', e.message)
})
$('btnActivateThreads').onclick = () => activateThreadsAccount().catch((e) => { setThreadsApiStatus(e.message, false); log('切換帳號失敗', e.message) })
$('btnDisconnectThreads').onclick = () => disconnectThreads().catch((e) => { setThreadsApiStatus(e.message, false); log('中斷連線失敗', e.message) })
$('btnReloadThreads').onclick = () => loadThreadsApiStatus().catch((e) => { setThreadsApiStatus(e.message, false); log('載入 Threads 狀態失敗', e.message) })
$('btnReloadOAuthLogs').onclick = () => loadOAuthLogs().catch((e) => log('載入 OAuth log 失敗', e.message))
$('btnThreadsSmokeTest').onclick = () => runThreadsSmokeTest().catch((e) => {
setThreadsApiStatus(e.message, false)
log('煙霧測試失敗', e.message)
})
$('btnLoadPostPerformance').onclick = () => loadPostPerformance().catch((e) => {
$('perfStatus').textContent = e.message
$('perfStatus').className = 'status err'
log('貼文成效失敗', e.message)
})
$('btnCreatePublishQueue').onclick = () => createPublishQueueItem().catch((e) => {
$('pqStatus').textContent = e.message
$('pqStatus').className = 'status err'
log('加入佇列失敗', e.message)
})
$('btnReloadPublishQueue').onclick = () => loadPublishQueue().catch((e) => {
$('pqStatus').textContent = e.message
$('pqStatus').className = 'status err'
})
$('btnLoadPublishHealth').onclick = () => loadPublishHealth().catch((e) => {
$('phStatus').textContent = e.message
$('phStatus').className = 'status err'
log('健康度失敗', e.message)
})
$('btnSaveConnectionPrefs').onclick = () => saveConnectionPrefs().catch((e) => {
setThreadsApiStatus(e.message, false)
log('儲存連線設定失敗', e.message)
})
$('btnSaveAi').onclick = () => saveAiSettings().catch((e) => { setAiStatus(e.message, false); log('儲存 AI 失敗', e.message) })
$('btnReloadAi').onclick = () => loadAiSettings().catch((e) => { setAiStatus(e.message, false); log('載入 AI 失敗', e.message) })
$('btnFetchModels').onclick = () => fetchModels().catch((e) => { setAiStatus(e.message, false); log('讀取模型失敗', e.message) })
$('researchProvider').onchange = () => loadAiSettings().catch(() => {})
$('btnLogin').onclick = () => login().catch((e) => { setAuthStatus(e.message, false); log('登入失敗', e.message) })
$('btnRegister').onclick = () => register().catch((e) => { setAuthStatus(e.message, false); log('註冊失敗', e.message) })
$('btnLogout').onclick = logout
$('btnCreatePersona').onclick = () => createPersona().catch((e) => log('建立失敗', e.message))
$('btnRefreshPersonas').onclick = () => refreshPersonas().catch((e) => log('載入失敗', e.message))
$('btnReloadPersona').onclick = () => loadPersonaDetail().catch((e) => log('載入失敗', e.message))
$('btnStart8d').onclick = () => start8d().catch((e) => {
setJobHint(e.message, false)
$('jobBanner').hidden = false
$('jobBanner').className = 'job-banner err'
$('jobBanner').textContent = `無法建立任務:${e.message}`
log('8D 失敗', e.message)
})
$('btnPollJob').onclick = () => pollJob().catch((e) => log('查詢失敗', e.message))
$('btnAutoPoll').onclick = () => (pollTimer ? stopAutoPoll() : startAutoPoll())
// restore form
if (state.tenantId) $('tenantId').value = state.tenantId
$('tenantId').onchange = () => { state.tenantId = $('tenantId').value; saveState() }
if (state.accessToken) {
setAuthStatus(`token 已還原 uid=${state.uid || '?'}`, true)
afterAuth().catch(() => setAuthStatus('token 可能過期,請重新登入', false))
}
if (state.lastJobId) {
$('jobId').value = state.lastJobId
pollJob().catch(() => {})
}
handleOAuthReturn()
bindPlaygroundButtons()
checkHealth()
</script>
</body>
</html>