diff --git a/web/storyforge-web-v4/README.md b/web/storyforge-web-v4/README.md
index 0920324..3a2b93c 100644
--- a/web/storyforge-web-v4/README.md
+++ b/web/storyforge-web-v4/README.md
@@ -45,6 +45,8 @@
- 创建 Agent
- 对当前 Douyin 对标账号重跑分析
- 批量分析高分作品
+- 查找相似对标账号
+- 查看任务详情、事件和 artifacts/result
- 使用 Agent 生成文案
- 创建 AI 视频任务
- 创建实拍剪辑任务
diff --git a/web/storyforge-web-v4/assets/app.js b/web/storyforge-web-v4/assets/app.js
index 69c5e2c..106fecd 100644
--- a/web/storyforge-web-v4/assets/app.js
+++ b/web/storyforge-web-v4/assets/app.js
@@ -23,7 +23,9 @@ const appState = {
busy: false,
message: "",
lastAction: null,
- lastGeneratedCopy: null
+ lastGeneratedCopy: null,
+ lastSimilaritySearch: null,
+ lastJobDetail: null
};
function safeArray(value) {
@@ -274,6 +276,14 @@ function ensureActionUi() {
function renderActionFields(fields) {
return fields.map((field) => {
const common = `data-action-field="${escapeHtml(field.name)}"`;
+ if (field.type === "html") {
+ return `
+
+
+
${field.html || ""}
+
+ `;
+ }
if (field.type === "textarea") {
return `
@@ -342,6 +352,7 @@ function openActionModal(config) {
message.textContent = "";
submit.textContent = config.submitLabel || "执行";
submit.disabled = false;
+ submit.hidden = Boolean(config.hideSubmit);
modal.classList.remove("hidden");
}
@@ -468,6 +479,8 @@ async function logoutSession() {
appState.documents = [];
appState.lastAction = null;
appState.lastGeneratedCopy = null;
+ appState.lastSimilaritySearch = null;
+ appState.lastJobDetail = null;
renderAll();
}
@@ -656,6 +669,15 @@ function getVideoLink(video) {
return video.share_url || video.play_url || "";
}
+async function loadJobDetail(jobId) {
+ const [job, events] = await Promise.all([
+ storyforgeFetch(`/v2/explore/jobs/${encodeURIComponent(jobId)}`),
+ storyforgeFetch(`/v2/explore/jobs/${encodeURIComponent(jobId)}/events`).catch(() => [])
+ ]);
+ appState.lastJobDetail = { job, events: safeArray(events) };
+ return appState.lastJobDetail;
+}
+
function screenShell(title, subtitle, actionsHtml, bodyHtml) {
return `
@@ -888,10 +910,11 @@ function renderDiscoveryScreen() {
const videos = safeArray(appState.selectedVideos?.items);
const topVideos = getHighScoreVideos(3);
const latestVideos = getLatestVideos(2);
+ const similarCandidates = safeArray(appState.lastSimilaritySearch?.candidates).slice(0, 5);
return screenShell(
"找对标",
"这里已经接入真实抖音账号列表和单账号详情。",
- `${button("导入主页", "open-import-homepage")} ${button("账号分析", "analyze-selected-account")} ${button("高分分析", "analyze-top-videos")} ${button("切到生产", "goto-production", "primary")}`,
+ `${button("导入主页", "open-import-homepage")} ${button("账号分析", "analyze-selected-account")} ${button("高分分析", "analyze-top-videos")} ${button("查相似", "open-similar-search")} ${button("存对标", "open-benchmark-link", "primary")}`,
`
+
+
${escapeHtml(formatNumber(similarCandidates.length))} 个
+
+ ${similarCandidates.map((candidate) => `
+
+
${escapeHtml(candidate.candidate_nickname || candidate.candidate_profile_url || "候选账号")}
+
${escapeHtml(brief(candidate.rationale_text || "暂无理由", 96))}
+
+
+ `).join("") || `
还没有相似候选
先点“查相似”,这里会展示最近一轮结果。
`}
+
+
@@ -1251,6 +1289,7 @@ function renderProductionScreen() {
${escapeHtml(job.status)}
${escapeHtml(job.line_type || "analysis")}
+ 看详情
`).join("") || ``}
@@ -1304,7 +1343,7 @@ function renderReviewScreen() {
${escapeHtml(job.title)}
${escapeHtml(brief(job.style_summary || job.transcript_text || "已完成,待补复盘。", 84))}
-
已完成${escapeHtml(job.line_type || "analysis")}
+
已完成${escapeHtml(job.line_type || "analysis")}看详情
`).join("") || ``}
@@ -1717,6 +1756,131 @@ function openAnalyzeTopVideosAction() {
});
}
+function openSimilaritySearchAction() {
+ const account = requireSelectedAccountRow();
+ openActionModal({
+ title: "查相似账号",
+ description: "让 Agent 基于当前账号画像找更多可借鉴对象。",
+ submitLabel: "开始查找",
+ fields: [
+ { name: "maxCandidates", label: "最多候选数", type: "number", value: 8, min: 3, max: 20 },
+ { name: "extraRequirements", label: "额外要求", type: "textarea", rows: 4, placeholder: "例如:优先找创业成交类、口播结构强的账号" }
+ ],
+ onSubmit: async (values) => {
+ const created = await storyforgeFetch("/v2/douyin/similar-searches", {
+ method: "POST",
+ body: {
+ source_account_id: account.id,
+ candidate_urls: [],
+ seed_linked_accounts: true,
+ search_public_pages: true,
+ model_profile_id: "",
+ max_candidates: Number(values.maxCandidates || 8),
+ extra_requirements: values.extraRequirements || ""
+ }
+ });
+ const searchId = created.id || created.search_id;
+ const detail = searchId
+ ? await storyforgeFetch(`/v2/douyin/similar-searches/${encodeURIComponent(searchId)}`)
+ : created;
+ appState.lastSimilaritySearch = detail;
+ rememberAction("相似账号已生成", `已生成 ${formatNumber(safeArray(detail.candidates).length)} 个候选账号。`, "green", detail);
+ await loadDouyinAccount(account.id);
+ renderAll();
+ }
+ });
+}
+
+function openBenchmarkLinkAction() {
+ const account = requireSelectedAccountRow();
+ const options = safeArray(appState.accounts)
+ .filter((item) => item.id !== account.id)
+ .map((item) => ({ value: item.id, label: item.nickname || item.douyin_id || item.id }));
+ openActionModal({
+ title: "保存对标关系",
+ description: "把当前账号和另一个账号关联成对标关系,便于后续持续跟踪。",
+ submitLabel: "保存关系",
+ fields: [
+ { name: "targetAccountId", label: "目标账号", type: "select", value: options[0]?.value || "", options },
+ { name: "relationType", label: "关系类型", type: "select", value: "benchmark", options: [
+ { value: "benchmark", label: "对标" },
+ { value: "learn", label: "学习" },
+ { value: "watch", label: "跟踪" }
+ ] },
+ { name: "note", label: "备注", placeholder: "例如:开场结构很强,适合持续跟踪" }
+ ],
+ onSubmit: async (values) => {
+ if (!values.targetAccountId) throw new Error("请先选择一个目标账号");
+ await storyforgeFetch(`/v2/douyin/accounts/${encodeURIComponent(account.id)}/benchmark-links`, {
+ method: "POST",
+ body: {
+ target_account_ids: [values.targetAccountId],
+ target_profile_urls: [],
+ relation_type: values.relationType || "benchmark",
+ note: values.note || "",
+ search_id: appState.lastSimilaritySearch?.id || ""
+ }
+ });
+ rememberAction("对标关系已保存", "当前账号的对标关系已更新。", "green");
+ await loadDouyinAccount(account.id);
+ renderAll();
+ }
+ });
+}
+
+function openJobDetailAction(jobId) {
+ if (!jobId) return;
+ setBusy(true, "正在加载任务详情...");
+ loadJobDetail(jobId)
+ .then(({ job, events }) => {
+ const artifacts = JSON.stringify(job.artifacts || {}, null, 2);
+ const result = JSON.stringify(job.result || {}, null, 2);
+ openActionModal({
+ title: job.title || "任务详情",
+ description: `状态:${job.status || "-"} · 类型:${job.line_type || job.source_type || "-"}`,
+ hideSubmit: true,
+ fields: [
+ {
+ type: "html",
+ label: "任务摘要",
+ html: `
+
+
任务 ID${escapeHtml(job.id)}
+
状态${escapeHtml(job.status || "-")}
+
链路${escapeHtml(job.line_type || "-")}
+
创建时间${escapeHtml(formatDateTime(job.created_at))}
+
+ `
+ },
+ {
+ type: "html",
+ label: "事件时间线",
+ html: `
+
+ ${safeArray(events).slice(-6).map((event) => `
+
+
${escapeHtml(event.event_type || "event")}
+
${escapeHtml(brief(event.message || JSON.stringify(event.payload || {}), 120))}
+
+ `).join("") || `
`}
+
+ `
+ },
+ { type: "textarea", name: "artifactsReadonly", label: "Artifacts", value: artifacts, rows: 8 },
+ { type: "textarea", name: "resultReadonly", label: "Result", value: result, rows: 8 }
+ ]
+ });
+ document.querySelector('[data-action-field="artifactsReadonly"]')?.setAttribute("readonly", "readonly");
+ document.querySelector('[data-action-field="resultReadonly"]')?.setAttribute("readonly", "readonly");
+ })
+ .catch((error) => {
+ alert("加载任务详情失败: " + error.message);
+ })
+ .finally(() => {
+ setBusy(false, "");
+ });
+}
+
function openGenerateCopyAction() {
const assistant = requireSelectedAssistant();
openActionModal({
@@ -1929,6 +2093,18 @@ document.addEventListener("click", async (event) => {
openCreateRealCutAction();
return;
}
+ if (name === "open-similar-search") {
+ openSimilaritySearchAction();
+ return;
+ }
+ if (name === "open-benchmark-link") {
+ openBenchmarkLinkAction();
+ return;
+ }
+ if (name === "open-job-detail") {
+ openJobDetailAction(action.dataset.jobId || "");
+ return;
+ }
if (name === "create-project") {
await createProject();
return;
diff --git a/web/storyforge-web-v4/assets/styles.css b/web/storyforge-web-v4/assets/styles.css
index ff35ee9..46004d1 100644
--- a/web/storyforge-web-v4/assets/styles.css
+++ b/web/storyforge-web-v4/assets/styles.css
@@ -649,6 +649,27 @@ select {
color: #b24c4c;
}
+.clickable-tag {
+ cursor: pointer;
+}
+
+.sheet-html {
+ border: 1px solid var(--line);
+ border-radius: 16px;
+ background: linear-gradient(180deg, #fbfdff 0%, #f5f9ff 100%);
+ padding: 14px;
+}
+
+.detail-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 12px;
+}
+
+.task-item.compact {
+ padding: 12px 14px;
+}
+
.two-col {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);