feat: add reviews and integration health controls
This commit is contained in:
@@ -211,6 +211,30 @@ class Database:
|
||||
FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS publish_reviews (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
project_id TEXT,
|
||||
source_job_id TEXT,
|
||||
assistant_id TEXT,
|
||||
title TEXT NOT NULL,
|
||||
platform TEXT NOT NULL DEFAULT 'douyin',
|
||||
content_type TEXT NOT NULL DEFAULT 'video',
|
||||
publish_url TEXT NOT NULL DEFAULT '',
|
||||
published_at TEXT NOT NULL DEFAULT '',
|
||||
metrics_json TEXT NOT NULL DEFAULT '{}',
|
||||
verdict TEXT NOT NULL DEFAULT '',
|
||||
highlights TEXT NOT NULL DEFAULT '',
|
||||
next_actions TEXT NOT NULL DEFAULT '',
|
||||
notes TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY(user_id) REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY(source_job_id) REFERENCES jobs(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY(assistant_id) REFERENCES assistants(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS job_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
job_id TEXT NOT NULL,
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import httpx
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
from fastapi import Body, Depends, FastAPI, File, Form, Header, HTTPException, Query, UploadFile
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
@@ -251,6 +254,36 @@ class AiVideoJobRequest(BaseModel):
|
||||
duration: int = 5
|
||||
|
||||
|
||||
class ReviewCreateRequest(BaseModel):
|
||||
project_id: str = ""
|
||||
source_job_id: str = ""
|
||||
assistant_id: str = ""
|
||||
title: str = ""
|
||||
platform: str = "douyin"
|
||||
content_type: str = "video"
|
||||
publish_url: str = ""
|
||||
published_at: str = ""
|
||||
metrics: dict[str, Any] = Field(default_factory=dict)
|
||||
verdict: str = ""
|
||||
highlights: str = ""
|
||||
next_actions: str = ""
|
||||
notes: str = ""
|
||||
|
||||
|
||||
class ReviewUpdateRequest(BaseModel):
|
||||
title: str | None = None
|
||||
platform: str | None = None
|
||||
content_type: str | None = None
|
||||
publish_url: str | None = None
|
||||
published_at: str | None = None
|
||||
metrics: dict[str, Any] | None = None
|
||||
verdict: str | None = None
|
||||
highlights: str | None = None
|
||||
next_actions: str | None = None
|
||||
notes: str | None = None
|
||||
assistant_id: str | None = None
|
||||
|
||||
|
||||
class InternalStepRequest(BaseModel):
|
||||
job_id: str = ""
|
||||
jobId: str = ""
|
||||
@@ -521,6 +554,41 @@ def assistant_payload(row: dict[str, Any]) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def review_payload(row: dict[str, Any]) -> dict[str, Any]:
|
||||
metrics = parse_json_object(row.get("metrics_json") or "{}")
|
||||
source_job = None
|
||||
assistant = None
|
||||
if row.get("source_job_id"):
|
||||
source_job_row = db.fetch_one("SELECT * FROM jobs WHERE id = ?", (row["source_job_id"],))
|
||||
if source_job_row:
|
||||
source_job = job_payload(source_job_row)
|
||||
if row.get("assistant_id"):
|
||||
assistant_row = db.fetch_one("SELECT * FROM assistants WHERE id = ?", (row["assistant_id"],))
|
||||
if assistant_row:
|
||||
assistant = assistant_payload(assistant_row)
|
||||
return {
|
||||
"id": row["id"],
|
||||
"user_id": row["user_id"],
|
||||
"project_id": row.get("project_id", ""),
|
||||
"source_job_id": row.get("source_job_id", ""),
|
||||
"assistant_id": row.get("assistant_id", ""),
|
||||
"title": row.get("title", ""),
|
||||
"platform": row.get("platform", "douyin"),
|
||||
"content_type": row.get("content_type", "video"),
|
||||
"publish_url": row.get("publish_url", ""),
|
||||
"published_at": row.get("published_at", ""),
|
||||
"metrics": metrics,
|
||||
"verdict": row.get("verdict", ""),
|
||||
"highlights": row.get("highlights", ""),
|
||||
"next_actions": row.get("next_actions", ""),
|
||||
"notes": row.get("notes", ""),
|
||||
"source_job": source_job,
|
||||
"assistant": assistant,
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
}
|
||||
|
||||
|
||||
def document_payload(row: dict[str, Any]) -> dict[str, Any]:
|
||||
analysis_map = parse_json_object(row.get("analysis_json") or "{}")
|
||||
source_artifacts = parse_json_object(row.get("source_artifact_json") or "{}")
|
||||
@@ -1353,6 +1421,44 @@ async def process_job(job_id: str) -> None:
|
||||
update_job_state(job_id, status="failed", error=str(exc))
|
||||
|
||||
|
||||
def probe_tcp(url: str, timeout: float = 3.0) -> dict[str, Any]:
|
||||
if not url:
|
||||
return {"configured": False, "reachable": False, "status_code": 0, "error": "not_configured", "url": ""}
|
||||
parsed = urlparse(url)
|
||||
host = parsed.hostname
|
||||
port = parsed.port or (443 if parsed.scheme == "https" else 80)
|
||||
if not host:
|
||||
return {"configured": True, "reachable": False, "status_code": 0, "error": "invalid_url", "url": url}
|
||||
sock = socket.socket()
|
||||
sock.settimeout(timeout)
|
||||
try:
|
||||
sock.connect((host, port))
|
||||
return {"configured": True, "reachable": True, "status_code": 0, "error": "", "url": url}
|
||||
except Exception as exc: # pragma: no cover - operational probe
|
||||
return {"configured": True, "reachable": False, "status_code": 0, "error": str(exc), "url": url}
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
|
||||
def probe_http(url: str, path: str = "", timeout: float = 3.0) -> dict[str, Any]:
|
||||
tcp = probe_tcp(url, timeout=timeout)
|
||||
target_url = urljoin(url if url.endswith("/") else f"{url}/", path.lstrip("/")) if url else ""
|
||||
if not tcp["configured"] or not tcp["reachable"]:
|
||||
if target_url:
|
||||
tcp["url"] = target_url
|
||||
return tcp
|
||||
try:
|
||||
response = httpx.get(target_url or url, timeout=timeout, follow_redirects=True)
|
||||
tcp["status_code"] = response.status_code
|
||||
tcp["reachable"] = response.status_code < 500
|
||||
tcp["error"] = "" if response.status_code < 500 else f"http_{response.status_code}"
|
||||
except Exception as exc: # pragma: no cover - operational probe
|
||||
tcp["reachable"] = False
|
||||
tcp["error"] = str(exc)
|
||||
tcp["url"] = target_url or url
|
||||
return tcp
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def on_startup() -> None:
|
||||
db.init_schema()
|
||||
@@ -1374,6 +1480,29 @@ def healthz() -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
@app.get("/v2/integrations/health")
|
||||
def integrations_health(account: dict[str, Any] = Depends(require_approved)) -> dict[str, Any]:
|
||||
_ = account
|
||||
return {
|
||||
"cutvideo": {
|
||||
"base_url": CUTVIDEO_BASE_URL,
|
||||
**probe_http(CUTVIDEO_BASE_URL, "/api/bootstrap"),
|
||||
},
|
||||
"huobao": {
|
||||
"base_url": HUOBAO_BASE_URL,
|
||||
**probe_http(HUOBAO_BASE_URL, "/health"),
|
||||
},
|
||||
"n8n": {
|
||||
"base_url": N8N_BASE_URL,
|
||||
**probe_http(N8N_BASE_URL, "/healthz"),
|
||||
},
|
||||
"asr": {
|
||||
"base_url": ASR_HTTP_BASE_URL,
|
||||
**probe_tcp(ASR_HTTP_BASE_URL),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def seed_defaults() -> None:
|
||||
if not db.fetch_one("SELECT id FROM model_profiles WHERE is_default = 1 LIMIT 1"):
|
||||
profile_id = make_id("model")
|
||||
@@ -1746,6 +1875,107 @@ def list_knowledge_documents(knowledge_base_id: str, account: dict[str, Any] = D
|
||||
return [document_payload(row) for row in rows]
|
||||
|
||||
|
||||
@app.get("/v2/reviews")
|
||||
def list_reviews(
|
||||
project_id: str | None = Query(default=None),
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
account: dict[str, Any] = Depends(require_approved),
|
||||
) -> list[dict[str, Any]]:
|
||||
clauses = ["user_id = ?"]
|
||||
params: list[Any] = [account["id"]]
|
||||
if project_id is not None:
|
||||
normalized_project = project_id.strip()
|
||||
if normalized_project:
|
||||
clauses.append("project_id = ?")
|
||||
params.append(normalized_project)
|
||||
else:
|
||||
clauses.append("(project_id IS NULL OR project_id = '')")
|
||||
sql = f"SELECT * FROM publish_reviews WHERE {' AND '.join(clauses)} ORDER BY COALESCE(NULLIF(published_at, ''), created_at) DESC, created_at DESC LIMIT ?"
|
||||
params.append(limit)
|
||||
return [review_payload(row) for row in db.fetch_all(sql, tuple(params))]
|
||||
|
||||
|
||||
@app.post("/v2/reviews")
|
||||
def create_review(request: ReviewCreateRequest, account: dict[str, Any] = Depends(require_approved)) -> dict[str, Any]:
|
||||
source_job = None
|
||||
if request.source_job_id.strip():
|
||||
source_job = load_owned_job(request.source_job_id.strip(), account["id"])
|
||||
requested_project_id = request.project_id.strip() or (source_job.get("project_id", "") if source_job else "")
|
||||
project = resolve_target_project(account["id"], requested_project_id or None, username=account["username"])
|
||||
assistant = resolve_target_assistant(account["id"], request.assistant_id or None, project["id"])
|
||||
review_id = make_id("review")
|
||||
title = request.title.strip() or (source_job.get("title", "") if source_job else "")
|
||||
if not title:
|
||||
title = f"{project['name']} 复盘"
|
||||
timestamp = utc_now()
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO publish_reviews (
|
||||
id, user_id, project_id, source_job_id, assistant_id, title, platform, content_type,
|
||||
publish_url, published_at, metrics_json, verdict, highlights, next_actions, notes, created_at, updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
review_id,
|
||||
account["id"],
|
||||
project["id"],
|
||||
source_job["id"] if source_job else None,
|
||||
(assistant or {}).get("id") or None,
|
||||
title,
|
||||
request.platform or "douyin",
|
||||
request.content_type or "video",
|
||||
request.publish_url.strip(),
|
||||
request.published_at.strip(),
|
||||
json.dumps(request.metrics, ensure_ascii=False),
|
||||
request.verdict.strip(),
|
||||
request.highlights.strip(),
|
||||
request.next_actions.strip(),
|
||||
request.notes.strip(),
|
||||
timestamp,
|
||||
timestamp,
|
||||
),
|
||||
)
|
||||
row = db.fetch_one("SELECT * FROM publish_reviews WHERE id = ?", (review_id,))
|
||||
return review_payload(row)
|
||||
|
||||
|
||||
@app.patch("/v2/reviews/{review_id}")
|
||||
def update_review(review_id: str, request: ReviewUpdateRequest, account: dict[str, Any] = Depends(require_approved)) -> dict[str, Any]:
|
||||
current = load_owned_review(review_id, account["id"])
|
||||
assistant_id = current.get("assistant_id") or None
|
||||
if request.assistant_id is not None:
|
||||
assistant = resolve_target_assistant(account["id"], request.assistant_id or None, current.get("project_id", ""))
|
||||
assistant_id = (assistant or {}).get("id") or None
|
||||
db.execute(
|
||||
"""
|
||||
UPDATE publish_reviews
|
||||
SET title = ?, platform = ?, content_type = ?, publish_url = ?, published_at = ?,
|
||||
metrics_json = ?, verdict = ?, highlights = ?, next_actions = ?, notes = ?,
|
||||
assistant_id = ?, updated_at = ?
|
||||
WHERE id = ? AND user_id = ?
|
||||
""",
|
||||
(
|
||||
request.title if request.title is not None else current.get("title", ""),
|
||||
request.platform if request.platform is not None else current.get("platform", "douyin"),
|
||||
request.content_type if request.content_type is not None else current.get("content_type", "video"),
|
||||
request.publish_url if request.publish_url is not None else current.get("publish_url", ""),
|
||||
request.published_at if request.published_at is not None else current.get("published_at", ""),
|
||||
json.dumps(request.metrics if request.metrics is not None else parse_json_object(current.get("metrics_json") or "{}"), ensure_ascii=False),
|
||||
request.verdict if request.verdict is not None else current.get("verdict", ""),
|
||||
request.highlights if request.highlights is not None else current.get("highlights", ""),
|
||||
request.next_actions if request.next_actions is not None else current.get("next_actions", ""),
|
||||
request.notes if request.notes is not None else current.get("notes", ""),
|
||||
assistant_id,
|
||||
utc_now(),
|
||||
review_id,
|
||||
account["id"],
|
||||
),
|
||||
)
|
||||
row = db.fetch_one("SELECT * FROM publish_reviews WHERE id = ?", (review_id,))
|
||||
return review_payload(row)
|
||||
|
||||
|
||||
@app.get("/v2/explore/jobs")
|
||||
def list_jobs(
|
||||
parent_job_id: str | None = Query(default=None),
|
||||
@@ -2178,6 +2408,13 @@ def load_owned_content_source(source_id: str, account_id: str) -> dict[str, Any]
|
||||
return row
|
||||
|
||||
|
||||
def load_owned_review(review_id: str, account_id: str) -> dict[str, Any]:
|
||||
row = db.fetch_one("SELECT * FROM publish_reviews WHERE id = ? AND user_id = ?", (review_id, account_id))
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Review not found")
|
||||
return row
|
||||
|
||||
|
||||
def load_internal_job(job_id: str) -> dict[str, Any]:
|
||||
row = db.fetch_one("SELECT * FROM jobs WHERE id = ?", (job_id,))
|
||||
if not row:
|
||||
|
||||
@@ -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