2188 lines
90 KiB
HTML
2188 lines
90 KiB
HTML
|
|
<!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 存於後端(會員 scope),Dev 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 worker(style-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, '&')
|
|||
|
|
.replace(/</g, '<')
|
|||
|
|
.replace(/>/g, '>')
|
|||
|
|
.replace(/"/g, '"')
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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, '"')}">洞察失敗</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>
|