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

2188 lines
90 KiB
HTML
Raw Permalink Normal View History

<!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>