feat: add douyin workbench results ui

This commit is contained in:
kris
2026-03-21 00:52:23 +08:00
parent 741fe4f983
commit f6462dbccc
3 changed files with 855 additions and 2 deletions

View File

@@ -34,6 +34,11 @@ npm run control-panel
http://127.0.0.1:3618
```
这个本地页面现在包含两部分:
- 上半部分是浏览器辅助采集控制台
- 下半部分是 `Douyin Workbench`可直接查看账号列表、Agent 分析结论、快照详情、相似账号和对标关系
或者继续用命令行:
```bash

View File

@@ -176,6 +176,7 @@ http://127.0.0.1:3618
控制台步骤:
1. 填写抖音主页链接和 StoryForge 账号
2. 如需查看采集结果,不用离开这个页面;下半部分 `Douyin Workbench` 会展示账号列表、Agent 结论、快照详情和对标结果
2. 点击 `开始采集`
3. 在弹出的 Chromium 里登录或通过挑战页
4. 回到控制台点击 `已完成登录,继续采集`

View File

@@ -509,8 +509,149 @@ function renderPage() {
font-size: 13px;
line-height: 1.55;
}
.subpanel {
border: 1px solid rgba(22, 49, 61, 0.1);
border-radius: 18px;
padding: 16px;
background: rgba(255, 255, 255, 0.72);
}
.workbench-layout {
display: grid;
grid-template-columns: 320px 1fr;
gap: 18px;
margin-top: 18px;
}
.account-list,
.snapshot-list,
.similar-list {
display: grid;
gap: 10px;
}
.account-item,
.snapshot-item,
.similar-item,
.link-item,
.video-item,
.report-item {
border: 1px solid rgba(22, 49, 61, 0.1);
border-radius: 16px;
padding: 14px;
background: rgba(255, 255, 255, 0.8);
}
.account-item {
cursor: pointer;
transition: transform 0.15s ease, border-color 0.15s ease;
width: 100%;
text-align: left;
}
.account-item:hover {
transform: translateY(-1px);
border-color: rgba(31, 110, 95, 0.28);
}
.account-item.active,
.snapshot-item.active,
.similar-item.active {
border-color: rgba(31, 110, 95, 0.55);
background: rgba(31, 110, 95, 0.08);
}
.profile-hero {
display: grid;
grid-template-columns: 86px 1fr;
gap: 16px;
align-items: center;
}
.avatar {
width: 86px;
height: 86px;
border-radius: 22px;
object-fit: cover;
background: rgba(22, 49, 61, 0.08);
border: 1px solid rgba(22, 49, 61, 0.12);
}
.metric-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.metric-card {
border: 1px solid rgba(22, 49, 61, 0.1);
border-radius: 16px;
padding: 14px;
background: rgba(255, 255, 255, 0.84);
}
.metric-label {
color: var(--muted);
font-size: 12px;
margin-bottom: 6px;
}
.metric-value {
font-size: 18px;
font-weight: 700;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.chip {
display: inline-flex;
align-items: center;
padding: 6px 10px;
border-radius: 999px;
background: rgba(22, 49, 61, 0.08);
color: var(--ink);
font-size: 12px;
}
.two-col {
display: grid;
grid-template-columns: 0.9fr 1.1fr;
gap: 16px;
}
.detail-box {
border: 1px solid rgba(22, 49, 61, 0.1);
border-radius: 16px;
padding: 14px;
background: rgba(255, 255, 255, 0.78);
min-height: 180px;
}
.inline-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.mono {
font-family: "SF Mono", ui-monospace, monospace;
font-size: 12px;
word-break: break-all;
}
select {
width: 100%;
border-radius: 14px;
border: 1px solid rgba(22, 49, 61, 0.12);
padding: 12px 14px;
font: inherit;
background: rgba(255, 255, 255, 0.96);
color: var(--ink);
}
.report-suggestion {
border-left: 3px solid rgba(31, 110, 95, 0.55);
padding-left: 12px;
margin-top: 12px;
white-space: pre-wrap;
line-height: 1.6;
}
.empty-state {
color: var(--muted);
font-size: 14px;
line-height: 1.6;
}
.section-divider {
height: 1px;
background: rgba(22, 49, 61, 0.1);
margin: 4px 0;
}
@media (max-width: 900px) {
.grid, .row, .checks { grid-template-columns: 1fr; }
.grid, .row, .checks, .workbench-layout, .metric-grid, .two-col { grid-template-columns: 1fr; }
}
</style>
</head>
@@ -602,6 +743,117 @@ function renderPage() {
</div>
<div id="recent-runs" class="recent-list" style="margin-top: 14px;"></div>
</section>
<section class="card" style="margin-top: 18px;">
<div style="display: flex; justify-content: space-between; gap: 12px; align-items: center; flex-wrap: wrap;">
<div>
<h2>Douyin Workbench</h2>
<p class="hint">采集完成后的结构化数据、Agent 结论、快照与对标结果,都在这块工作台里查看。</p>
</div>
<div class="actions">
<button class="secondary" id="workbench-login-button" type="button">登录并加载</button>
<button class="secondary" id="workbench-refresh-button" type="button">刷新工作台</button>
<button class="secondary" id="workbench-logout-button" type="button">清除会话</button>
</div>
</div>
<div id="workbench-session" class="status-box" style="margin-top: 14px;">
<p class="hint">登录 StoryForge 后,这里会展示抖音账号列表和分析工作台。</p>
</div>
<div class="workbench-layout">
<section class="subpanel stack">
<div style="display:flex;justify-content:space-between;gap:12px;align-items:center;">
<h3>账号列表</h3>
<span class="pill" id="accounts-count-pill">0 个账号</span>
</div>
<input id="accounts-filter" placeholder="按昵称、抖音号、标签搜索" autocomplete="off" />
<div id="accounts-list" class="account-list">
<p class="empty-state">登录后将显示可用账号。</p>
</div>
</section>
<section class="stack">
<div id="workspace-empty" class="subpanel">
<p class="empty-state">先登录并在左侧选择一个账号,或者从“最近运行”里直接打开同步成功的账号。</p>
</div>
<div id="workspace-content" class="stack" hidden>
<section class="subpanel stack">
<div style="display:flex;justify-content:space-between;gap:12px;align-items:center;flex-wrap:wrap;">
<h3>账号总览</h3>
<div class="inline-actions">
<button class="secondary" id="reload-selected-account-button" type="button">刷新当前账号</button>
</div>
</div>
<div id="account-overview"></div>
</section>
<section class="subpanel stack">
<div style="display:flex;justify-content:space-between;gap:12px;align-items:center;flex-wrap:wrap;">
<h3>Agent 结论</h3>
<div class="inline-actions">
<button class="primary" id="run-analysis-button" type="button">运行分析</button>
</div>
</div>
<div class="row">
<label>
分析重点
<textarea id="analysis-focus" placeholder="例如:请重点总结选题结构、钩子设计、镜头节奏和二创模板。"></textarea>
</label>
<div class="stack">
<label>
分析模型
<select id="analysis-model-select"></select>
</label>
<label>
最大视频数
<input id="analysis-max-videos" type="number" min="1" max="20" value="12" />
</label>
</div>
</div>
<div id="analysis-feedback" class="hint">这里会显示分析执行状态和最近报告。</div>
<div id="analysis-reports" class="stack"></div>
</section>
<section class="subpanel stack">
<h3>快照与原始采集</h3>
<div id="snapshot-summary"></div>
<div class="two-col">
<div class="stack">
<h4 style="margin:0;">快照列表</h4>
<div id="snapshot-list" class="snapshot-list"></div>
</div>
<div class="stack">
<h4 style="margin:0;">快照详情</h4>
<div id="snapshot-detail" class="detail-box">
<p class="empty-state">选择左侧快照后,这里会展示字段摘要和原始内容。</p>
</div>
</div>
</div>
</section>
<section class="subpanel stack">
<h3>对标与相似账号</h3>
<div id="linked-accounts" class="stack"></div>
<div class="section-divider"></div>
<div class="two-col">
<div class="stack">
<h4 style="margin:0;">最近相似搜索</h4>
<div id="similar-search-list" class="similar-list"></div>
</div>
<div class="stack">
<h4 style="margin:0;">相似搜索详情</h4>
<div id="similar-search-detail" class="detail-box">
<p class="empty-state">选择一次相似搜索后,这里会展示候选账号和推荐理由。</p>
</div>
</div>
</div>
</section>
</div>
</section>
</div>
</section>
</main>
<script>
@@ -612,7 +864,43 @@ function renderPage() {
const refreshButton = document.getElementById("refresh-button");
const form = document.getElementById("capture-form");
const storageKey = "storyforge-douyin-control-panel";
const sessionStorageKey = "storyforge-douyin-workbench-session";
const workbenchSessionEl = document.getElementById("workbench-session");
const accountsCountPillEl = document.getElementById("accounts-count-pill");
const accountsListEl = document.getElementById("accounts-list");
const accountsFilterEl = document.getElementById("accounts-filter");
const workspaceEmptyEl = document.getElementById("workspace-empty");
const workspaceContentEl = document.getElementById("workspace-content");
const accountOverviewEl = document.getElementById("account-overview");
const analysisFeedbackEl = document.getElementById("analysis-feedback");
const analysisReportsEl = document.getElementById("analysis-reports");
const analysisFocusEl = document.getElementById("analysis-focus");
const analysisModelSelectEl = document.getElementById("analysis-model-select");
const analysisMaxVideosEl = document.getElementById("analysis-max-videos");
const snapshotSummaryEl = document.getElementById("snapshot-summary");
const snapshotListEl = document.getElementById("snapshot-list");
const snapshotDetailEl = document.getElementById("snapshot-detail");
const linkedAccountsEl = document.getElementById("linked-accounts");
const similarSearchListEl = document.getElementById("similar-search-list");
const similarSearchDetailEl = document.getElementById("similar-search-detail");
const workbenchLoginButton = document.getElementById("workbench-login-button");
const workbenchRefreshButton = document.getElementById("workbench-refresh-button");
const workbenchLogoutButton = document.getElementById("workbench-logout-button");
const reloadSelectedAccountButton = document.getElementById("reload-selected-account-button");
const runAnalysisButton = document.getElementById("run-analysis-button");
let activeRunId = "";
const workbenchState = {
session: null,
accounts: [],
selectedAccountId: "",
selectedWorkspace: null,
snapshots: [],
selectedSnapshotId: "",
selectedSnapshotDetail: null,
similarSearchDetail: null,
loadingAccountId: "",
lastAnalysisMessage: ""
};
function escapeHtml(value) {
return String(value || "")
@@ -621,6 +909,458 @@ function renderPage() {
.replaceAll(">", "&gt;");
}
function escapeHtmlWithBreaks(value) {
return escapeHtml(value).replaceAll("\\n", "<br>");
}
function safeArray(value) {
return Array.isArray(value) ? value : [];
}
function formatNumber(value) {
const num = Number(value || 0);
if (!Number.isFinite(num)) {
return "-";
}
if (Math.abs(num) >= 10000) {
return (num / 10000).toFixed(1) + "w";
}
return num.toLocaleString("zh-CN", { maximumFractionDigits: 2 });
}
function formatDateTime(value) {
if (!value) {
return "-";
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return String(value);
}
return date.toLocaleString("zh-CN", { hour12: false });
}
function normalizeBackendUrl(value) {
let normalized = String(value || "").trim();
while (normalized.endsWith("/")) {
normalized = normalized.slice(0, -1);
}
return normalized;
}
function getFormAuthPayload() {
const backendUrl = normalizeBackendUrl(document.getElementById("backend-url").value);
const username = document.getElementById("username").value.trim();
const password = document.getElementById("password").value;
const token = document.getElementById("token").value.trim();
return {
backendUrl,
username,
password,
token
};
}
function persistWorkbenchSession(session) {
workbenchState.session = session;
localStorage.setItem(sessionStorageKey, JSON.stringify(session));
renderWorkbenchSession();
}
function clearWorkbenchSession() {
workbenchState.session = null;
workbenchState.accounts = [];
workbenchState.selectedAccountId = "";
workbenchState.selectedWorkspace = null;
workbenchState.snapshots = [];
workbenchState.selectedSnapshotId = "";
workbenchState.selectedSnapshotDetail = null;
workbenchState.similarSearchDetail = null;
workbenchState.lastAnalysisMessage = "";
localStorage.removeItem(sessionStorageKey);
renderWorkbenchSession();
renderAccountList();
renderWorkspace();
}
function loadWorkbenchSession() {
try {
const saved = JSON.parse(localStorage.getItem(sessionStorageKey) || "null");
if (saved && saved.token && saved.backendUrl) {
workbenchState.session = saved;
}
} catch {}
}
async function storyforgeFetch(pathname, options = {}) {
const session = workbenchState.session;
const backendUrl = normalizeBackendUrl(options.backendUrl || session?.backendUrl || document.getElementById("backend-url").value);
const token = String(options.token || session?.token || "").trim();
const headers = {
"content-type": "application/json"
};
if (token) {
headers.Authorization = "Bearer " + token;
}
const response = await fetch(backendUrl + pathname, {
method: options.method || "GET",
headers,
body: options.body ? JSON.stringify(options.body) : undefined
});
const raw = await response.text();
let payload = null;
try {
payload = raw ? JSON.parse(raw) : null;
} catch {
payload = { raw };
}
if (!response.ok) {
const detail = payload?.detail;
const message =
(typeof detail === "string" && detail) ||
detail?.message ||
payload?.message ||
payload?.raw ||
("HTTP " + response.status);
throw new Error(message);
}
return payload;
}
function renderWorkbenchSession() {
const session = workbenchState.session;
if (!session) {
workbenchSessionEl.innerHTML = '<p class="hint">还没有 StoryForge 会话。你可以直接用上面的账号密码登录,然后加载抖音工作台。</p>';
return;
}
workbenchSessionEl.innerHTML = [
'<div class="status-line"><strong>当前账号</strong><span>' + escapeHtml(session.account?.display_name || session.account?.username || "未知账号") + '</span></div>',
'<div class="status-line"><strong>角色</strong><span>' + escapeHtml(session.account?.role || "-") + '</span></div>',
'<div class="status-line"><strong>后端地址</strong><span class="path">' + escapeHtml(session.backendUrl) + '</span></div>',
'<div class="status-line"><strong>Token</strong><span class="mono">' + escapeHtml((session.token || "").slice(0, 12)) + "..." + '</span></div>'
].join("");
}
function renderAccountList() {
const filterText = accountsFilterEl.value.trim().toLowerCase();
const accounts = safeArray(workbenchState.accounts).filter((account) => {
if (!filterText) {
return true;
}
const haystack = [
account.nickname,
account.douyin_id,
account.signature,
...safeArray(account.tags),
...safeArray(account.keywords)
].join(" ").toLowerCase();
return haystack.includes(filterText);
});
accountsCountPillEl.textContent = accounts.length + " 个账号";
if (!accounts.length) {
accountsListEl.innerHTML = '<p class="empty-state">当前没有符合条件的账号。</p>';
return;
}
accountsListEl.innerHTML = accounts.map((account) => {
const selected = workbenchState.selectedAccountId === account.id;
return [
'<button type="button" class="account-item ' + (selected ? "active" : "") + '" data-account-id="' + escapeHtml(account.id) + '">',
'<div style="display:flex;justify-content:space-between;gap:12px;align-items:flex-start;">',
'<strong>' + escapeHtml(account.nickname || "未命名账号") + '</strong>',
'<span class="pill">' + escapeHtml(account.sync_status || "unknown") + '</span>',
'</div>',
'<p class="meta" style="margin:10px 0 0;">抖音号:' + escapeHtml(account.douyin_id || "-") + '</p>',
'<p class="meta" style="margin:6px 0 0;">最近视频:' + escapeHtml(account.video_summary?.count ?? "-") + ' 条</p>',
safeArray(account.tags).length ? '<div class="chips" style="margin-top:10px;">' + safeArray(account.tags).slice(0, 6).map((tag) => '<span class="chip">' + escapeHtml(tag) + '</span>').join("") + '</div>' : '',
'</button>'
].join("");
}).join("");
}
function renderSnapshotDetail() {
const detail = workbenchState.selectedSnapshotDetail;
if (!detail) {
snapshotDetailEl.innerHTML = '<p class="empty-state">选择左侧快照后,这里会展示字段摘要和原始内容。</p>';
return;
}
const fields = safeArray(detail.fields).slice(0, 40).map((field) => {
return '<div class="link-item"><div><strong>' + escapeHtml(field.field_path || "-") + '</strong></div><div class="meta" style="margin-top:6px;">类型:' + escapeHtml(field.field_type || "-") + '</div><div class="meta" style="margin-top:6px;">值:' + escapeHtml(String(field.field_value || "")).slice(0, 220) + '</div></div>';
}).join("");
snapshotDetailEl.innerHTML = [
'<div class="stack">',
'<div class="meta">快照类型:' + escapeHtml(detail.snapshot_type || "-") + '</div>',
'<div class="meta">来源:' + escapeHtml(detail.source_url || "-") + '</div>',
detail.summary ? '<pre>' + escapeHtml(JSON.stringify(detail.summary, null, 2)) + '</pre>' : '',
fields || '<p class="empty-state">这个快照当前没有可展示的字段。</p>',
'</div>'
].join("");
}
function renderSimilarSearchDetail() {
const detail = workbenchState.similarSearchDetail;
if (!detail) {
similarSearchDetailEl.innerHTML = '<p class="empty-state">选择一次相似搜索后,这里会展示候选账号和推荐理由。</p>';
return;
}
const candidates = safeArray(detail.candidates);
similarSearchDetailEl.innerHTML = [
'<div class="stack">',
'<div class="meta">关键词:' + escapeHtml(safeArray(detail.keywords).join(" / ")) + '</div>',
candidates.length ? candidates.map((candidate) => {
return [
'<div class="link-item">',
'<div style="display:flex;justify-content:space-between;gap:12px;align-items:flex-start;">',
'<strong>' + escapeHtml(candidate.candidate_nickname || candidate.candidate_profile_url || "候选账号") + '</strong>',
'<span class="pill">综合 ' + escapeHtml(formatNumber(candidate.agent_score || candidate.heuristic_score)) + '</span>',
'</div>',
'<div class="meta" style="margin-top:8px;">' + escapeHtml(candidate.candidate_profile_url || "-") + '</div>',
'<div class="meta" style="margin-top:8px;">' + escapeHtml(candidate.rationale_text || "暂无理由说明") + '</div>',
'</div>'
].join("");
}).join("") : '<p class="empty-state">这次搜索还没有候选账号。</p>',
'</div>'
].join("");
}
function renderWorkspace() {
const workspace = workbenchState.selectedWorkspace;
if (!workspace || !workspace.account) {
workspaceEmptyEl.hidden = false;
workspaceContentEl.hidden = true;
accountOverviewEl.innerHTML = "";
analysisReportsEl.innerHTML = "";
snapshotSummaryEl.innerHTML = "";
snapshotListEl.innerHTML = "";
linkedAccountsEl.innerHTML = "";
similarSearchListEl.innerHTML = "";
renderSnapshotDetail();
renderSimilarSearchDetail();
return;
}
const account = workspace.account;
const videos = safeArray(account.video_summary?.videos);
const reports = safeArray(workspace.recent_reports);
const linkedAccounts = safeArray(workspace.linked_accounts);
const similarSearches = safeArray(workspace.recent_similarity_searches);
const models = safeArray(workspace.available_model_profiles);
const syncErrors = safeArray(workspace.sync_errors);
workspaceEmptyEl.hidden = true;
workspaceContentEl.hidden = false;
if (!analysisFocusEl.value.trim() && reports[0]?.focus_text) {
analysisFocusEl.value = reports[0].focus_text;
}
const selectedModelId = analysisModelSelectEl.value;
analysisModelSelectEl.innerHTML = models.length ? models.map((model) => {
const selected = selectedModelId ? selectedModelId === model.id : Boolean(model.is_default);
return '<option value="' + escapeHtml(model.id) + '"' + (selected ? " selected" : "") + '>' + escapeHtml(model.name + " / " + model.model_name) + '</option>';
}).join("") : '<option value="">暂无可用模型</option>';
accountOverviewEl.innerHTML = [
'<div class="profile-hero">',
account.avatar_url ? '<img class="avatar" src="' + escapeHtml(account.avatar_url) + '" alt="avatar" />' : '<div class="avatar"></div>',
'<div class="stack">',
'<div style="display:flex;justify-content:space-between;gap:12px;align-items:flex-start;flex-wrap:wrap;">',
'<div><h3 style="margin:0;">' + escapeHtml(account.nickname || "未命名账号") + '</h3><div class="meta" style="margin-top:8px;">抖音号:' + escapeHtml(account.douyin_id || "-") + '</div></div>',
'<span class="pill">' + escapeHtml(account.sync_status || "unknown") + '</span>',
'</div>',
'<div class="meta">' + escapeHtmlWithBreaks(account.signature || "暂无签名") + '</div>',
'<div class="path">' + escapeHtml(account.profile_url || "-") + '</div>',
'</div>',
'</div>',
'<div class="metric-grid" style="margin-top: 16px;">',
'<div class="metric-card"><div class="metric-label">作品数</div><div class="metric-value">' + escapeHtml(formatNumber(account.video_summary?.count)) + '</div></div>',
'<div class="metric-card"><div class="metric-label">平均播放</div><div class="metric-value">' + escapeHtml(formatNumber(account.video_summary?.avg_play)) + '</div></div>',
'<div class="metric-card"><div class="metric-label">平均点赞</div><div class="metric-value">' + escapeHtml(formatNumber(account.video_summary?.avg_like)) + '</div></div>',
'<div class="metric-card"><div class="metric-label">平均分享</div><div class="metric-value">' + escapeHtml(formatNumber(account.video_summary?.avg_share)) + '</div></div>',
'</div>',
safeArray(account.tags).length ? '<div class="chips" style="margin-top:16px;">' + safeArray(account.tags).slice(0, 18).map((tag) => '<span class="chip">' + escapeHtml(tag) + '</span>').join("") + '</div>' : '',
syncErrors.length ? '<div class="link-item" style="margin-top:16px;"><strong>同步提示</strong><div class="meta" style="margin-top:8px;">' + escapeHtml(syncErrors.join(" / ")) + '</div></div>' : '',
videos.length ? '<div class="stack" style="margin-top:16px;"><h4 style="margin:0;">最近作品</h4>' + videos.slice(0, 8).map((video) => '<div class="video-item"><strong>' + escapeHtml(video.title || video.description || video.aweme_id) + '</strong><div class="meta" style="margin-top:8px;">发布时间:' + escapeHtml(formatDateTime(video.published_at)) + '</div><div class="meta" style="margin-top:6px;">播放 ' + escapeHtml(formatNumber(video.stats?.play)) + ' / 点赞 ' + escapeHtml(formatNumber(video.stats?.like)) + ' / 评论 ' + escapeHtml(formatNumber(video.stats?.comment)) + '</div></div>').join("") + '</div>' : ''
].join("");
analysisReportsEl.innerHTML = reports.length ? reports.map((report) => {
return [
'<div class="report-item">',
'<div style="display:flex;justify-content:space-between;gap:12px;align-items:flex-start;flex-wrap:wrap;">',
'<strong>' + escapeHtml(report.focus_text || "默认分析") + '</strong>',
'<span class="pill">' + escapeHtml(formatDateTime(report.created_at)) + '</span>',
'</div>',
safeArray(report.suggestions).length ? safeArray(report.suggestions).map((suggestion) => '<div class="report-suggestion"><div class="meta">' + escapeHtml(suggestion.model_label || "模型") + ' / ' + escapeHtml(suggestion.status || "-") + '</div><div style="margin-top:8px;">' + escapeHtml(suggestion.suggestion_text || "暂无结论") + '</div></div>').join("") : '<p class="empty-state" style="margin-top:10px;">这份报告还没有 suggestion。</p>',
'</div>'
].join("");
}).join("") : '<p class="empty-state">这个账号还没有分析报告。你可以直接点上面的“运行分析”。</p>';
snapshotSummaryEl.innerHTML = [
workspace.latest_public_snapshot ? '<div class="link-item"><strong>最新 public 快照</strong><div class="meta" style="margin-top:8px;">' + escapeHtml(workspace.latest_public_snapshot.source_url || "-") + '</div><div class="meta" style="margin-top:6px;">采集时间:' + escapeHtml(formatDateTime(workspace.latest_public_snapshot.collected_at)) + ',字段数 ' + escapeHtml(workspace.latest_public_snapshot.field_count) + '</div></div>' : '',
workspace.latest_creator_snapshot ? '<div class="link-item"><strong>最新 creator 快照</strong><div class="meta" style="margin-top:8px;">' + escapeHtml(workspace.latest_creator_snapshot.source_url || "-") + '</div><div class="meta" style="margin-top:6px;">采集时间:' + escapeHtml(formatDateTime(workspace.latest_creator_snapshot.collected_at)) + ',字段数 ' + escapeHtml(workspace.latest_creator_snapshot.field_count) + '</div></div>' : '',
(!workspace.latest_public_snapshot && !workspace.latest_creator_snapshot) ? '<p class="empty-state">当前没有可展示的最新快照。</p>' : ''
].join("");
snapshotListEl.innerHTML = workbenchState.snapshots.length ? workbenchState.snapshots.map((snapshot) => {
const selected = workbenchState.selectedSnapshotId === snapshot.id;
return '<button type="button" class="snapshot-item ' + (selected ? "active" : "") + '" data-snapshot-id="' + escapeHtml(snapshot.id) + '"><strong>' + escapeHtml(snapshot.snapshot_type || "snapshot") + '</strong><div class="meta" style="margin-top:8px;">' + escapeHtml(snapshot.source_url || "-") + '</div><div class="meta" style="margin-top:6px;">字段 ' + escapeHtml(snapshot.field_count) + ' / ' + escapeHtml(formatDateTime(snapshot.collected_at)) + '</div></button>';
}).join("") : '<p class="empty-state">这个账号当前没有快照记录。</p>';
renderSnapshotDetail();
linkedAccountsEl.innerHTML = linkedAccounts.length ? [
'<h4 style="margin:0;">已保存对标账号</h4>',
linkedAccounts.map((link) => '<div class="link-item"><div style="display:flex;justify-content:space-between;gap:12px;align-items:flex-start;"><strong>' + escapeHtml(link.target_nickname || link.target_profile_url || "未命名对标") + '</strong><span class="pill">' + escapeHtml(link.relation_type || "benchmark") + '</span></div><div class="meta" style="margin-top:8px;">' + escapeHtml(link.target_profile_url || "-") + '</div><div class="meta" style="margin-top:6px;">' + escapeHtml(link.note || "无备注") + '</div></div>').join("")
].join("") : '<p class="empty-state">这个账号还没有保存对标关系。</p>';
similarSearchListEl.innerHTML = similarSearches.length ? similarSearches.map((search) => {
const selected = workbenchState.similarSearchDetail?.id === search.id;
return '<button type="button" class="similar-item ' + (selected ? "active" : "") + '" data-search-id="' + escapeHtml(search.id) + '"><strong>' + escapeHtml(safeArray(search.keywords).slice(0, 4).join(" / ") || search.id) + '</strong><div class="meta" style="margin-top:8px;">' + escapeHtml(formatDateTime(search.created_at)) + '</div></button>';
}).join("") : '<p class="empty-state">这个账号还没有相似搜索记录。</p>';
renderSimilarSearchDetail();
}
async function loadSnapshotDetail(snapshotId) {
if (!snapshotId || !workbenchState.selectedAccountId) {
workbenchState.selectedSnapshotId = "";
workbenchState.selectedSnapshotDetail = null;
renderSnapshotDetail();
return;
}
workbenchState.selectedSnapshotId = snapshotId;
renderWorkspace();
try {
workbenchState.selectedSnapshotDetail = await storyforgeFetch("/v2/douyin/accounts/" + encodeURIComponent(workbenchState.selectedAccountId) + "/snapshots/" + encodeURIComponent(snapshotId));
} catch (error) {
workbenchState.selectedSnapshotDetail = {
snapshot_type: "error",
source_url: "",
summary: { error: error.message },
fields: []
};
}
renderSnapshotDetail();
renderAccountList();
}
async function loadSimilarSearchDetail(searchId) {
if (!searchId) {
workbenchState.similarSearchDetail = null;
renderSimilarSearchDetail();
renderWorkspace();
return;
}
try {
workbenchState.similarSearchDetail = await storyforgeFetch("/v2/douyin/similar-searches/" + encodeURIComponent(searchId));
} catch (error) {
workbenchState.similarSearchDetail = {
id: searchId,
keywords: [],
candidates: [],
context: { error: error.message }
};
}
renderWorkspace();
}
async function selectAccount(accountId, options = {}) {
if (!accountId) {
return;
}
const preserveFeedback = options.preserveFeedback === true;
workbenchState.selectedAccountId = accountId;
workbenchState.loadingAccountId = accountId;
if (!preserveFeedback) {
workbenchState.lastAnalysisMessage = "";
}
analysisFeedbackEl.textContent = "正在加载账号工作台...";
renderAccountList();
try {
const results = await Promise.all([
storyforgeFetch("/v2/douyin/accounts/" + encodeURIComponent(accountId) + "/workspace"),
storyforgeFetch("/v2/douyin/accounts/" + encodeURIComponent(accountId) + "/snapshots").catch(() => [])
]);
workbenchState.selectedWorkspace = results[0];
workbenchState.snapshots = safeArray(results[1]);
workbenchState.selectedSnapshotId = options.snapshotId || workbenchState.snapshots[0]?.id || "";
workbenchState.selectedSnapshotDetail = null;
workbenchState.similarSearchDetail = null;
renderWorkspace();
if (workbenchState.selectedSnapshotId) {
await loadSnapshotDetail(workbenchState.selectedSnapshotId);
}
const firstSearchId = safeArray(workbenchState.selectedWorkspace?.recent_similarity_searches)[0]?.id;
if (firstSearchId) {
await loadSimilarSearchDetail(firstSearchId);
}
analysisFeedbackEl.textContent = workbenchState.lastAnalysisMessage || "工作台已加载。";
} catch (error) {
analysisFeedbackEl.textContent = "加载工作台失败: " + error.message;
} finally {
workbenchState.loadingAccountId = "";
renderAccountList();
}
}
async function loadWorkbenchAccounts() {
if (!workbenchState.session) {
renderWorkbenchSession();
return;
}
accountsCountPillEl.textContent = "加载中...";
try {
workbenchState.accounts = safeArray(await storyforgeFetch("/v2/douyin/accounts"));
renderAccountList();
const selectedExists = workbenchState.accounts.some((account) => account.id === workbenchState.selectedAccountId);
const nextId = selectedExists ? workbenchState.selectedAccountId : workbenchState.accounts[0]?.id;
if (nextId) {
await selectAccount(nextId);
} else {
renderWorkspace();
analysisFeedbackEl.textContent = "当前还没有抖音账号数据。";
}
} catch (error) {
accountsCountPillEl.textContent = "加载失败";
analysisFeedbackEl.textContent = "加载账号列表失败: " + error.message;
}
}
async function loginWorkbench() {
const auth = getFormAuthPayload();
if (!auth.backendUrl) {
alert("请先填写 StoryForge 地址。");
return;
}
try {
let session = null;
if (auth.token) {
const account = await storyforgeFetch("/v2/me", {
backendUrl: auth.backendUrl,
token: auth.token
});
session = { backendUrl: auth.backendUrl, token: auth.token, account };
} else {
if (!auth.username || !auth.password) {
alert("登录工作台需要账号密码,或者直接提供 Token。");
return;
}
const loginPayload = await storyforgeFetch("/v2/auth/login", {
backendUrl: auth.backendUrl,
method: "POST",
body: {
username: auth.username,
password: auth.password
}
});
session = {
backendUrl: auth.backendUrl,
token: loginPayload.token,
account: loginPayload.account
};
}
persistWorkbenchSession(session);
await loadWorkbenchAccounts();
} catch (error) {
alert("工作台登录失败: " + error.message);
}
}
function renderActiveRun(run) {
activeRunId = run?.id || "";
continueButton.disabled = !run || run.status !== "awaiting_continue";
@@ -651,7 +1391,8 @@ function renderPage() {
}
recentRunsEl.innerHTML = items.map((item) => {
const summary = item.summary || {};
const syncResult = summary.sync_result || {};
const syncResult = item.syncResponse?.account || summary.sync_result || {};
const accountId = item.syncResponse?.account?.id || summary.sync_result?.account_id || "";
return [
'<article class="recent-item">',
'<div style="display:flex;justify-content:space-between;gap:12px;align-items:center;">',
@@ -661,6 +1402,7 @@ function renderPage() {
'<p class="meta" style="margin:10px 0 0;">作品链接 ' + escapeHtml(summary.video_link_count ?? "-") + 'creator 页面 ' + escapeHtml(summary.captured_creator_pages ?? "-") + '</p>',
syncResult.nickname ? '<p class="meta" style="margin:8px 0 0;">同步账号:' + escapeHtml(syncResult.nickname) + '</p>' : '',
item.outputDir ? '<div class="path" style="margin-top:8px;">' + escapeHtml(item.outputDir) + '</div>' : '',
accountId ? '<div class="inline-actions" style="margin-top:10px;"><button class="secondary" type="button" data-open-account-id="' + escapeHtml(accountId) + '">打开工作台</button></div>' : '',
'</article>'
].join("");
}).join("");
@@ -679,6 +1421,7 @@ function renderPage() {
if (saved.profileUrl) document.getElementById("profile-url").value = saved.profileUrl;
if (saved.backendUrl) document.getElementById("backend-url").value = saved.backendUrl;
if (saved.username) document.getElementById("username").value = saved.username;
if (saved.token) document.getElementById("token").value = saved.token;
if (saved.note) document.getElementById("note").value = saved.note;
if (saved.maxVideos !== undefined) document.getElementById("max-videos").value = saved.maxVideos;
if (saved.syncEnabled !== undefined) document.getElementById("sync-enabled").checked = Boolean(saved.syncEnabled);
@@ -693,6 +1436,7 @@ function renderPage() {
profileUrl: payload.profileUrl,
backendUrl: payload.backendUrl,
username: payload.username,
token: payload.token,
note: payload.note,
maxVideos: payload.maxVideos,
syncEnabled: payload.syncEnabled,
@@ -721,6 +1465,10 @@ function renderPage() {
skipCreatorCenter: document.getElementById("skip-creator-center").checked,
allowCreatorCenterFallback: document.getElementById("allow-fallback").checked
};
if (payload.syncEnabled && !payload.token && workbenchState.session?.token && workbenchState.session.backendUrl === payload.backendUrl) {
payload.token = workbenchState.session.token;
payload.storyforgeToken = workbenchState.session.token;
}
if (payload.syncEnabled && !payload.token && (!payload.username || !payload.password)) {
alert("当前页面没有读到 StoryForge 账号或密码。请重新输入,或直接填 Token。");
return;
@@ -739,6 +1487,98 @@ function renderPage() {
await refreshStatus();
});
workbenchLoginButton.addEventListener("click", loginWorkbench);
workbenchRefreshButton.addEventListener("click", async () => {
if (!workbenchState.session) {
await loginWorkbench();
return;
}
await loadWorkbenchAccounts();
});
workbenchLogoutButton.addEventListener("click", () => {
clearWorkbenchSession();
analysisFeedbackEl.textContent = "会话已清除。";
});
reloadSelectedAccountButton.addEventListener("click", async () => {
if (!workbenchState.selectedAccountId) {
return;
}
await selectAccount(workbenchState.selectedAccountId);
});
runAnalysisButton.addEventListener("click", async () => {
if (!workbenchState.selectedAccountId) {
alert("请先选择一个账号。");
return;
}
const modelId = analysisModelSelectEl.value;
analysisFeedbackEl.textContent = "正在调用 Agent 生成分析结论...";
runAnalysisButton.disabled = true;
try {
const workspace = workbenchState.selectedWorkspace || {};
const linkedIds = safeArray(workspace.linked_accounts)
.map((item) => item.target_account_id)
.filter(Boolean);
const result = await storyforgeFetch("/v2/douyin/accounts/" + encodeURIComponent(workbenchState.selectedAccountId) + "/analysis", {
method: "POST",
body: {
model_profile_ids: modelId ? [modelId] : [],
linked_account_ids: linkedIds,
include_linked_accounts: true,
include_recent_similar_candidates: true,
max_videos: Math.max(1, Math.min(20, Number.parseInt(analysisMaxVideosEl.value || "12", 10) || 12)),
extra_focus: analysisFocusEl.value.trim(),
temperature: 0.35
}
});
workbenchState.lastAnalysisMessage = "分析完成,已生成 " + safeArray(result.suggestions).length + " 条建议。";
analysisFeedbackEl.textContent = workbenchState.lastAnalysisMessage;
await selectAccount(workbenchState.selectedAccountId, { preserveFeedback: true });
} catch (error) {
workbenchState.lastAnalysisMessage = "分析失败: " + error.message;
analysisFeedbackEl.textContent = workbenchState.lastAnalysisMessage;
} finally {
runAnalysisButton.disabled = false;
}
});
accountsFilterEl.addEventListener("input", renderAccountList);
document.addEventListener("click", async (event) => {
const accountButton = event.target.closest("[data-account-id]");
if (accountButton) {
await selectAccount(accountButton.getAttribute("data-account-id"));
return;
}
const snapshotButton = event.target.closest("[data-snapshot-id]");
if (snapshotButton) {
await loadSnapshotDetail(snapshotButton.getAttribute("data-snapshot-id"));
return;
}
const searchButton = event.target.closest("[data-search-id]");
if (searchButton) {
await loadSimilarSearchDetail(searchButton.getAttribute("data-search-id"));
return;
}
const openAccountButton = event.target.closest("[data-open-account-id]");
if (openAccountButton) {
if (!workbenchState.session) {
await loginWorkbench();
}
const targetId = openAccountButton.getAttribute("data-open-account-id");
if (targetId) {
if (!workbenchState.accounts.length) {
await loadWorkbenchAccounts();
}
await selectAccount(targetId);
window.scrollTo({ top: document.body.scrollHeight, behavior: "smooth" });
}
}
});
continueButton.addEventListener("click", async () => {
if (!activeRunId) {
return;
@@ -756,6 +1596,13 @@ function renderPage() {
refreshButton.addEventListener("click", refreshStatus);
loadSavedValues();
loadWorkbenchSession();
renderWorkbenchSession();
renderAccountList();
renderWorkspace();
if (workbenchState.session) {
loadWorkbenchAccounts();
}
refreshStatus();
setInterval(refreshStatus, 1500);
</script>