feat: add reviews and integration health controls
This commit is contained in:
@@ -22,6 +22,8 @@ const appState = {
|
||||
lastSeenAt: Number(localStorage.getItem(STORAGE_KEY + ":lastSeenAt") || Date.now()),
|
||||
trackingAccounts: [],
|
||||
trackingDigest: null,
|
||||
reviews: [],
|
||||
integrationHealth: null,
|
||||
busy: false,
|
||||
message: "",
|
||||
lastAction: null,
|
||||
@@ -98,6 +100,9 @@ function statusTone(status) {
|
||||
const normalized = String(status || "").toLowerCase();
|
||||
if (["completed", "ready", "approved", "ok"].includes(normalized)) return "green";
|
||||
if (["failed", "error", "rejected"].includes(normalized)) return "red";
|
||||
if (["worth_scaling", "good_reference"].includes(normalized)) return "green";
|
||||
if (["needs_rework"].includes(normalized)) return "red";
|
||||
if (["hold"].includes(normalized)) return "orange";
|
||||
if (["running", "processing", "pending", "queued"].includes(normalized)) return "orange";
|
||||
return "blue";
|
||||
}
|
||||
@@ -489,6 +494,8 @@ async function logoutSession() {
|
||||
appState.documents = [];
|
||||
appState.trackingAccounts = [];
|
||||
appState.trackingDigest = null;
|
||||
appState.reviews = [];
|
||||
appState.integrationHealth = null;
|
||||
appState.lastAction = null;
|
||||
appState.lastGeneratedCopy = null;
|
||||
appState.lastSimilaritySearch = null;
|
||||
@@ -547,11 +554,13 @@ async function bootstrap() {
|
||||
renderAll();
|
||||
return;
|
||||
}
|
||||
const [dashboard, contentSources, accounts, trackingAccountsPayload] = await Promise.all([
|
||||
const [dashboard, contentSources, accounts, trackingAccountsPayload, reviews, integrationHealth] = await Promise.all([
|
||||
storyforgeFetch("/v2/me/dashboard"),
|
||||
storyforgeFetch("/v2/content-sources").catch(() => []),
|
||||
storyforgeFetch("/v2/douyin/accounts").catch(() => []),
|
||||
storyforgeFetch("/v2/douyin/tracking/accounts").catch(() => ({ items: [], cursor_last_seen_at: "" }))
|
||||
storyforgeFetch("/v2/douyin/tracking/accounts").catch(() => ({ items: [], cursor_last_seen_at: "" })),
|
||||
storyforgeFetch("/v2/reviews").catch(() => []),
|
||||
storyforgeFetch("/v2/integrations/health").catch(() => null)
|
||||
]);
|
||||
const trackingCursorLastSeenAt = trackingAccountsPayload?.cursor_last_seen_at || "";
|
||||
if (trackingCursorLastSeenAt) {
|
||||
@@ -568,6 +577,8 @@ async function bootstrap() {
|
||||
appState.accounts = safeArray(accounts);
|
||||
appState.trackingAccounts = safeArray(trackingAccountsPayload.items || trackingAccountsPayload);
|
||||
appState.trackingDigest = trackingDigest;
|
||||
appState.reviews = safeArray(reviews);
|
||||
appState.integrationHealth = integrationHealth;
|
||||
appState.documents = await loadKnowledgeDocuments(dashboard.knowledge_bases);
|
||||
appState.selectedProjectId = appState.selectedProjectId || dashboard.projects?.[0]?.id || "";
|
||||
const selectedAssistantExists = safeArray(dashboard.assistants).some((item) => item.id === appState.selectedAssistantId);
|
||||
@@ -650,6 +661,14 @@ function getProjectStats(projectId) {
|
||||
return { knowledgeBases, assistants, jobs, sources };
|
||||
}
|
||||
|
||||
function getProjectReviews(projectId) {
|
||||
return safeArray(appState.reviews).filter((item) => item.project_id === projectId);
|
||||
}
|
||||
|
||||
function getReviewById(reviewId) {
|
||||
return safeArray(appState.reviews).find((item) => item.id === reviewId) || null;
|
||||
}
|
||||
|
||||
function getContentSourcesForAccount(account) {
|
||||
if (!account) return [];
|
||||
const profileUrl = String(account.profile_url || "").trim();
|
||||
@@ -1383,6 +1402,13 @@ function renderAutomationScreen() {
|
||||
const analysisJobs = jobs.filter((item) => item.line_type === "analysis").length;
|
||||
const aiVideoJobs = jobs.filter((item) => item.line_type === "ai_video").length;
|
||||
const realCutJobs = jobs.filter((item) => item.line_type === "real_cut").length;
|
||||
const integrations = appState.integrationHealth || {};
|
||||
const integrationCards = [
|
||||
{ key: "cutvideo", label: "自动剪辑", hint: "Windows cutvideo" },
|
||||
{ key: "huobao", label: "AI 视频", hint: "huobao-drama" },
|
||||
{ key: "n8n", label: "编排", hint: "n8n workflow" },
|
||||
{ key: "asr", label: "ASR", hint: "转写服务" }
|
||||
];
|
||||
return screenShell(
|
||||
"自动流程",
|
||||
"自动同步、日报生成和失败补跑先统一看这里。",
|
||||
@@ -1398,6 +1424,26 @@ function renderAutomationScreen() {
|
||||
<div class="mini-card"><small>内容源</small><strong>${escapeHtml(formatNumber(appState.contentSources.length))}</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel pad" style="margin-top:18px;">
|
||||
<div class="panel-head"><div><h3>集成状态</h3><div class="panel-subtitle">直接看关键依赖是否在线</div></div></div>
|
||||
<div class="layout-grid grid-4" style="margin-top:14px;">
|
||||
${integrationCards.map((item) => {
|
||||
const detail = integrations[item.key] || {};
|
||||
const tone = detail.reachable ? "green" : (detail.configured ? "red" : "orange");
|
||||
const summary = detail.reachable ? "在线" : (detail.configured ? "不可达" : "未配置");
|
||||
return `
|
||||
<div class="queue-card">
|
||||
<h4>${escapeHtml(item.label)}</h4>
|
||||
<p>${escapeHtml(item.hint)}</p>
|
||||
<div class="task-meta">
|
||||
<span class="tag ${tone}">${escapeHtml(summary)}</span>
|
||||
${detail.status_code ? `<span class="tag">HTTP ${escapeHtml(detail.status_code)}</span>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join("")}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
);
|
||||
}
|
||||
@@ -1441,7 +1487,7 @@ function renderPlaybookScreen() {
|
||||
return screenShell(
|
||||
"Agent",
|
||||
"这里接真实 Agent 列表,后面再继续补创建和编辑动作。",
|
||||
`${button("新建 Agent", "open-create-assistant")} ${button("生成文案", "open-generate-copy")} ${button("去生产", "goto-production", "primary")}`,
|
||||
`${button("设主模型", "open-preferred-model")} ${button("新建 Agent", "open-create-assistant")} ${button("生成文案", "open-generate-copy")} ${button("去生产", "goto-production", "primary")}`,
|
||||
`
|
||||
<div class="hero-card">
|
||||
<h3>Agent 概览</h3>
|
||||
@@ -1589,28 +1635,54 @@ function renderReviewScreen() {
|
||||
if (!appState.dashboard) {
|
||||
return screenShell("发布与复盘", "先连接后端。", `${button("连接后端", "open-auth", "primary")}`, renderEmptyState("复盘未加载", "登录后这里会先用最近任务生成一版复盘入口。"));
|
||||
}
|
||||
const project = getSelectedProject();
|
||||
const completed = safeArray(appState.dashboard.recent_jobs).filter((item) => item.status === "completed").slice(0, 4);
|
||||
const reviews = getProjectReviews(project?.id || "").slice(0, 8);
|
||||
return screenShell(
|
||||
"发布与复盘",
|
||||
"当前先用最近完成任务承接一版复盘视图。",
|
||||
`${button("刷新", "refresh-data")} ${button("去生产", "goto-production", "primary")}`,
|
||||
"先看已保存复盘,再把完成任务转成结构化复盘。",
|
||||
`${button("写复盘", "open-create-review")} ${button("刷新", "refresh-data")} ${button("去生产", "goto-production", "primary")}`,
|
||||
`
|
||||
<div class="panel pad">
|
||||
<div class="panel-head"><div><h3>最近完成</h3><div class="panel-subtitle">后续再接真实发布记录</div></div></div>
|
||||
<div class="list">
|
||||
${completed.map((job) => `
|
||||
<div class="review-card">
|
||||
<h4>${escapeHtml(job.title)}</h4>
|
||||
<p>${escapeHtml(brief(job.style_summary || job.transcript_text || "已完成,待补复盘。", 84))}</p>
|
||||
<div class="task-meta">
|
||||
<span class="tag green">已完成</span>
|
||||
<span class="tag">${escapeHtml(job.line_type || "analysis")}</span>
|
||||
${canDeriveAiVideo(job) ? `<span class="tag clickable-tag" data-action="job-to-ai-video" data-job-id="${escapeHtml(job.id)}">做 AI 视频</span>` : ""}
|
||||
${canDeriveRealCut(job) ? `<span class="tag clickable-tag" data-action="job-to-real-cut" data-job-id="${escapeHtml(job.id)}">做实拍剪辑</span>` : ""}
|
||||
<span class="tag clickable-tag" data-action="open-job-detail" data-job-id="${escapeHtml(job.id)}">看详情</span>
|
||||
</div>
|
||||
<div class="layout-grid grid-main">
|
||||
<div class="side-stack">
|
||||
<div class="panel pad">
|
||||
<div class="panel-head"><div><h3>已保存复盘</h3><div class="panel-subtitle">当前项目的真实复盘记录</div></div><span class="tag blue">${escapeHtml(formatNumber(reviews.length))} 条</span></div>
|
||||
<div class="list">
|
||||
${reviews.map((review) => `
|
||||
<div class="review-card compact">
|
||||
<h4>${escapeHtml(review.title)}</h4>
|
||||
<p>${escapeHtml(brief(review.highlights || review.next_actions || review.notes || "已保存复盘,待继续补充表现数据。", 92))}</p>
|
||||
<div class="task-meta">
|
||||
<span class="tag blue">${escapeHtml(review.platform || "douyin")}</span>
|
||||
<span class="tag ${statusTone(review.verdict || "blue")}">${escapeHtml(review.verdict || "已记录")}</span>
|
||||
${review.publish_url ? `<a class="tag" href="${escapeHtml(review.publish_url)}" target="_blank" rel="noreferrer">打开链接</a>` : ""}
|
||||
<span class="tag clickable-tag" data-action="open-review-edit" data-review-id="${escapeHtml(review.id)}">编辑</span>
|
||||
</div>
|
||||
</div>
|
||||
`).join("") || `<div class="review-card"><h4>还没有复盘</h4><p>可以把最近完成任务直接写成一条复盘。</p></div>`}
|
||||
</div>
|
||||
`).join("") || `<div class="review-card"><h4>还没有完成任务</h4><p>先去生产中心跑一条链路。</p></div>`}
|
||||
</div>
|
||||
</div>
|
||||
<div class="side-stack">
|
||||
<div class="panel pad">
|
||||
<div class="panel-head"><div><h3>最近完成</h3><div class="panel-subtitle">从完成任务继续写复盘或进入下一步生产</div></div></div>
|
||||
<div class="list">
|
||||
${completed.map((job) => `
|
||||
<div class="review-card compact">
|
||||
<h4>${escapeHtml(job.title)}</h4>
|
||||
<p>${escapeHtml(brief(job.style_summary || job.transcript_text || "已完成,待补复盘。", 84))}</p>
|
||||
<div class="task-meta">
|
||||
<span class="tag green">已完成</span>
|
||||
<span class="tag">${escapeHtml(job.line_type || "analysis")}</span>
|
||||
<span class="tag clickable-tag" data-action="open-review-from-job" data-job-id="${escapeHtml(job.id)}">写复盘</span>
|
||||
${canDeriveAiVideo(job) ? `<span class="tag clickable-tag" data-action="job-to-ai-video" data-job-id="${escapeHtml(job.id)}">做 AI 视频</span>` : ""}
|
||||
${canDeriveRealCut(job) ? `<span class="tag clickable-tag" data-action="job-to-real-cut" data-job-id="${escapeHtml(job.id)}">做实拍剪辑</span>` : ""}
|
||||
<span class="tag clickable-tag" data-action="open-job-detail" data-job-id="${escapeHtml(job.id)}">看详情</span>
|
||||
</div>
|
||||
</div>
|
||||
`).join("") || `<div class="review-card"><h4>还没有完成任务</h4><p>先去生产中心跑一条链路。</p></div>`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
${renderLastJobDetailCard()}
|
||||
@@ -1709,6 +1781,31 @@ async function createProject() {
|
||||
}
|
||||
}
|
||||
|
||||
function openPreferredModelAction() {
|
||||
const models = getModelOptions();
|
||||
const currentId = appState.me?.preferred_analysis_model_id
|
||||
|| safeArray(appState.dashboard?.model_profiles).find((item) => item.is_default)?.id
|
||||
|| models[0]?.value
|
||||
|| "";
|
||||
openActionModal({
|
||||
title: "设置分析主模型",
|
||||
description: "后续导入分析、市场调研和风格学习会优先使用这里设置的模型。",
|
||||
submitLabel: "保存模型",
|
||||
fields: [
|
||||
{ name: "modelProfileId", label: "主模型", type: "select", value: currentId, options: models }
|
||||
],
|
||||
onSubmit: async (values) => {
|
||||
if (!values.modelProfileId) throw new Error("请先选择一个模型");
|
||||
await storyforgeFetch("/v2/me/preferences/analysis-model", {
|
||||
method: "POST",
|
||||
body: { model_profile_id: values.modelProfileId }
|
||||
});
|
||||
rememberAction("主模型已更新", "新的分析主模型已经保存。", "green");
|
||||
await bootstrap();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function rememberAction(title, summary, tone = "blue", payload = null) {
|
||||
appState.lastAction = {
|
||||
title,
|
||||
@@ -1761,6 +1858,7 @@ function renderLastJobDetailCard() {
|
||||
<p>${escapeHtml(brief(detail.job.style_summary || detail.job.transcript_text || detail.job.error || "暂无摘要", 120))}</p>
|
||||
<div class="task-meta">
|
||||
<span class="tag">${escapeHtml(detail.job.line_type || "-")}</span>
|
||||
${detail.job.status === "completed" ? `<span class="tag clickable-tag" data-action="open-review-from-job" data-job-id="${escapeHtml(detail.job.id)}">写复盘</span>` : ""}
|
||||
${canDeriveAiVideo(detail.job) ? `<span class="tag clickable-tag" data-action="job-to-ai-video" data-job-id="${escapeHtml(detail.job.id)}">做 AI 视频</span>` : ""}
|
||||
${canDeriveRealCut(detail.job) ? `<span class="tag clickable-tag" data-action="job-to-real-cut" data-job-id="${escapeHtml(detail.job.id)}">做实拍剪辑</span>` : ""}
|
||||
<span class="tag clickable-tag" data-action="open-job-detail" data-job-id="${escapeHtml(detail.job.id)}">看详情</span>
|
||||
@@ -2464,6 +2562,89 @@ function openCreateRealCutAction(defaults = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
function openReviewAction(defaults = {}) {
|
||||
const project = requireSelectedProject();
|
||||
const assistants = getAssistantOptions(project.id);
|
||||
const sourceJob = defaults.sourceJob || null;
|
||||
const existingReview = defaults.review || null;
|
||||
const metrics = existingReview?.metrics || {};
|
||||
openActionModal({
|
||||
title: existingReview ? "编辑复盘" : "写复盘",
|
||||
description: existingReview
|
||||
? "补充表现数据、判断和下一步动作,持续迭代项目策略。"
|
||||
: "把完成任务写成一条可追踪复盘,后续可按项目累计。",
|
||||
submitLabel: existingReview ? "保存复盘" : "创建复盘",
|
||||
fields: [
|
||||
{ name: "title", label: "标题", value: existingReview?.title || defaults.title || sourceJob?.title || "", placeholder: "例如:创业口播 3 月 22 日复盘" },
|
||||
{ name: "sourceJobId", label: "关联任务", type: "select", value: existingReview?.source_job_id || defaults.sourceJobId || sourceJob?.id || "", options: [{ value: "", label: "不关联任务" }, ...getCompletedJobOptions()] },
|
||||
{ name: "assistantId", label: "负责 Agent", type: "select", value: existingReview?.assistant_id || getSelectedAssistant()?.id || assistants[0]?.value || "", options: [{ value: "", label: "先不绑定" }, ...assistants] },
|
||||
{ name: "platform", label: "平台", type: "select", value: existingReview?.platform || defaults.platform || "douyin", options: [
|
||||
{ value: "douyin", label: "抖音" },
|
||||
{ value: "xiaohongshu", label: "小红书" },
|
||||
{ value: "bilibili", label: "哔哩哔哩" },
|
||||
{ value: "youtube", label: "YouTube" },
|
||||
{ value: "kuaishou", label: "快手" },
|
||||
{ value: "wechat_video", label: "微信视频号" }
|
||||
] },
|
||||
{ name: "contentType", label: "内容类型", type: "select", value: existingReview?.content_type || "video", options: [
|
||||
{ value: "video", label: "视频" },
|
||||
{ value: "image_text", label: "图文" },
|
||||
{ value: "live_clip", label: "直播切片" }
|
||||
] },
|
||||
{ name: "publishUrl", label: "发布链接", type: "url", value: existingReview?.publish_url || "", placeholder: "https://..." },
|
||||
{ name: "publishedAt", label: "发布时间", value: existingReview?.published_at || "", placeholder: "2026-03-22T20:00:00+08:00" },
|
||||
{ name: "playCount", label: "播放", type: "number", value: metrics.play_count || 0, min: 0 },
|
||||
{ name: "likeCount", label: "点赞", type: "number", value: metrics.like_count || 0, min: 0 },
|
||||
{ name: "commentCount", label: "评论", type: "number", value: metrics.comment_count || 0, min: 0 },
|
||||
{ name: "shareCount", label: "分享", type: "number", value: metrics.share_count || 0, min: 0 },
|
||||
{ name: "verdict", label: "结论", type: "select", value: existingReview?.verdict || "", options: [
|
||||
{ value: "", label: "先不下结论" },
|
||||
{ value: "worth_scaling", label: "值得放大" },
|
||||
{ value: "needs_rework", label: "需要重做" },
|
||||
{ value: "good_reference", label: "适合借鉴" },
|
||||
{ value: "hold", label: "先观察" }
|
||||
] },
|
||||
{ name: "highlights", label: "亮点", type: "textarea", rows: 4, value: existingReview?.highlights || "", placeholder: "例如:开头 3 秒抓人、评论区问题很集中" },
|
||||
{ name: "nextActions", label: "下一步", type: "textarea", rows: 4, value: existingReview?.next_actions || "", placeholder: "例如:保留结构,换一个细分人群再做一条" },
|
||||
{ name: "notes", label: "备注", type: "textarea", rows: 4, value: existingReview?.notes || "", placeholder: "补充团队讨论、平台环境、发布时间段等信息" }
|
||||
],
|
||||
onSubmit: async (values) => {
|
||||
if (!values.title?.trim()) throw new Error("请填写复盘标题");
|
||||
const payload = {
|
||||
project_id: project.id,
|
||||
source_job_id: values.sourceJobId || "",
|
||||
assistant_id: values.assistantId || "",
|
||||
title: values.title.trim(),
|
||||
platform: values.platform || "douyin",
|
||||
content_type: values.contentType || "video",
|
||||
publish_url: values.publishUrl || "",
|
||||
published_at: values.publishedAt || "",
|
||||
metrics: {
|
||||
play_count: Number(values.playCount || 0),
|
||||
like_count: Number(values.likeCount || 0),
|
||||
comment_count: Number(values.commentCount || 0),
|
||||
share_count: Number(values.shareCount || 0)
|
||||
},
|
||||
verdict: values.verdict || "",
|
||||
highlights: values.highlights || "",
|
||||
next_actions: values.nextActions || "",
|
||||
notes: values.notes || ""
|
||||
};
|
||||
const review = existingReview
|
||||
? await storyforgeFetch(`/v2/reviews/${encodeURIComponent(existingReview.id)}`, {
|
||||
method: "PATCH",
|
||||
body: payload
|
||||
})
|
||||
: await storyforgeFetch("/v2/reviews", {
|
||||
method: "POST",
|
||||
body: payload
|
||||
});
|
||||
rememberAction(existingReview ? "复盘已更新" : "复盘已创建", `已保存「${review.title}」并回写到项目复盘。`, "green", review);
|
||||
await bootstrap();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("click", async (event) => {
|
||||
const action = event.target.closest("[data-action]");
|
||||
if (action) {
|
||||
@@ -2561,6 +2742,34 @@ document.addEventListener("click", async (event) => {
|
||||
openCreateRealCutAction();
|
||||
return;
|
||||
}
|
||||
if (name === "open-create-review") {
|
||||
openReviewAction();
|
||||
return;
|
||||
}
|
||||
if (name === "open-preferred-model") {
|
||||
openPreferredModelAction();
|
||||
return;
|
||||
}
|
||||
if (name === "open-review-from-job") {
|
||||
const jobId = action.dataset.jobId || "";
|
||||
const fromDashboard = safeArray(appState.dashboard?.recent_jobs).find((item) => item.id === jobId) || null;
|
||||
const fromDetail = appState.lastJobDetail?.job?.id === jobId ? appState.lastJobDetail.job : null;
|
||||
openReviewAction({
|
||||
sourceJobId: jobId,
|
||||
sourceJob: fromDetail || fromDashboard,
|
||||
title: (fromDetail || fromDashboard)?.title || ""
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (name === "open-review-edit") {
|
||||
const review = getReviewById(action.dataset.reviewId || "");
|
||||
if (!review) {
|
||||
alert("复盘记录不存在,请先刷新页面");
|
||||
return;
|
||||
}
|
||||
openReviewAction({ review });
|
||||
return;
|
||||
}
|
||||
if (name === "open-similar-search") {
|
||||
openSimilaritySearchAction();
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user