diff --git a/collector-service/app/douyin_features.py b/collector-service/app/douyin_features.py index 393cf81..5ca66b6 100644 --- a/collector-service/app/douyin_features.py +++ b/collector-service/app/douyin_features.py @@ -1885,6 +1885,33 @@ def register_douyin_routes(app: Any, legacy: Any) -> None: "items": items[: max(1, min(limit, 100))] } + async def _refresh_tracked_account_workspace( + owner: dict[str, Any], + tracked_account_id: str, + discovery_note: str = "tracking_refresh" + ) -> dict[str, Any]: + account_row = _require_owned_account(tracked_account_id, owner["id"]) + profile_url = _first_non_empty( + account_row.get("canonical_profile_url"), + account_row.get("profile_url") + ) + if not profile_url: + raise HTTPException(status_code=400, detail="Tracked account has no profile_url to refresh") + request = DouyinAccountSyncRequest( + profile_url=profile_url, + compact_response=True, + discovery_note=discovery_note + ) + public_data = await _collect_public_profile(profile_url, None) + creator_data = {"pages": [], "errors": []} + return await run_in_threadpool( + _finalize_sync_workspace, + owner, + request, + public_data, + creator_data + ) + def _normalize_report_text(value: Any) -> str: text = str(value or "").strip() if not text: @@ -3374,6 +3401,71 @@ def register_douyin_routes(app: Any, legacy: Any) -> None: "items": _list_tracked_accounts(account["id"]) } + @app.post("/v2/douyin/tracking/accounts/{tracked_account_id}/refresh") + async def refresh_douyin_tracked_account( + tracked_account_id: str, + account: dict[str, Any] = Depends(legacy.require_approved) + ) -> dict[str, Any]: + account_row = _require_owned_account(tracked_account_id, account["id"]) + account_payload = _build_account_payload(account_row, include_recent_videos=6) + try: + refreshed = await _refresh_tracked_account_workspace(account, tracked_account_id) + return { + "success": True, + "tracked_account_id": tracked_account_id, + "account": refreshed.get("account", {}), + "sync_errors": refreshed.get("sync_errors", []), + "public_video_count": refreshed.get("public_video_count", 0), + "creator_page_count": refreshed.get("creator_page_count", 0) + } + except HTTPException as exc: + detail = exc.detail if isinstance(exc.detail, dict) else {"message": str(exc.detail)} + return { + "success": False, + "tracked_account_id": tracked_account_id, + "account": account_payload, + "message": detail.get("message") or str(exc.detail), + "detail": detail, + "sync_errors": detail.get("public_errors", []) + detail.get("creator_errors", []) + } + + @app.post("/v2/douyin/tracking/refresh") + async def refresh_all_douyin_tracked_accounts( + account: dict[str, Any] = Depends(legacy.require_approved) + ) -> dict[str, Any]: + tracked_accounts = _list_tracked_accounts(account["id"]) + items: list[dict[str, Any]] = [] + errors: list[dict[str, Any]] = [] + for tracked in tracked_accounts: + try: + refreshed = await _refresh_tracked_account_workspace(account, tracked["tracked_account_id"]) + items.append({ + "tracking_id": tracked["id"], + "tracked_account_id": tracked["tracked_account_id"], + "nickname": (refreshed.get("account") or {}).get("nickname", ""), + "sync_errors": refreshed.get("sync_errors", []), + "public_video_count": refreshed.get("public_video_count", 0) + }) + except HTTPException as exc: + errors.append({ + "tracking_id": tracked["id"], + "tracked_account_id": tracked["tracked_account_id"], + "message": str(exc.detail) + }) + except Exception as exc: + errors.append({ + "tracking_id": tracked["id"], + "tracked_account_id": tracked["tracked_account_id"], + "message": str(exc) + }) + return { + "tracked_count": len(tracked_accounts), + "refreshed": len(items), + "failed": len(errors), + "items": items, + "errors": errors + } + @app.post("/v2/douyin/tracking/cursor") def update_douyin_tracking_cursor( request: DouyinTrackingCursorRequest, diff --git a/web/storyforge-web-v4/README.md b/web/storyforge-web-v4/README.md index db456df..57d40e3 100644 --- a/web/storyforge-web-v4/README.md +++ b/web/storyforge-web-v4/README.md @@ -35,6 +35,8 @@ - 单账号作品列表 `/v2/douyin/accounts/{id}/videos` - 跟踪账号 `/v2/douyin/tracking/accounts` - 跟踪日报 `/v2/douyin/tracking/digest` +- 发布复盘 `/v2/reviews` +- 集成健康 `/v2/integrations/health` - 最近知识库文档 `/v2/knowledge-bases/{id}/documents` ## 当前已接入的真实动作 @@ -51,10 +53,16 @@ - 查找相似对标账号 - 从相似候选一键保存对标关系 - 把当前对标账号加入跟踪,并绑定 Agent +- 单账号立即同步跟踪对象 +- 批量同步全部跟踪对象 +- 日报手动标记已读,不再在刷新页面时自动吞掉未读摘要 - 按上次打开后生成跟踪日报与借鉴点摘要 - 查看任务详情、事件、子任务和 artifacts/result - 从任务详情直接衔接 AI 视频 / 实拍剪辑 / 文案生成 - 在生产中心 / 发布与复盘常驻最近一次任务详情摘要 +- 在 Web 中直接创建和编辑复盘 +- 在页面里直接看到 `cutvideo / huobao / n8n / ASR` 的真实健康状态 +- 依赖不可达时,自动拦住 AI 视频 / 实拍剪辑动作并展示原因 - 使用 Agent 生成文案 - 创建 AI 视频任务 - 创建实拍剪辑任务 @@ -78,10 +86,10 @@ python3 -m http.server 3918 ## 后续建议 -- 继续补动作型接口,例如导入、绑定 Agent、触发分析与生产 +- 继续补多平台真实接入,而不只是一套 Douyin 工作流 - 把对标导入后的 Agent 绑定和知识库入库反馈做得更完整 -- 把跟踪日报从 Douyin 扩到多平台统一模型 +- 把跟踪日报从 Douyin 扩到多平台统一模型,并接入真正的定时调度 - 把全局搜索和页内搜索合并成统一搜索体验 -- 为 `生产中心 / 发布与复盘` 接入更完整的任务与成片对象 +- 为 `生产中心 / 发布与复盘` 接入更完整的成片预览与封面对象 - 不要把这套页面重新塞回 `scripts/douyin-browser-capture/control_panel.mjs` - 抖音采集控制台仍作为独立工具存在,这里才是正式业务应用壳 diff --git a/web/storyforge-web-v4/assets/app.js b/web/storyforge-web-v4/assets/app.js index fdb57a0..a2ef520 100644 --- a/web/storyforge-web-v4/assets/app.js +++ b/web/storyforge-web-v4/assets/app.js @@ -32,6 +32,44 @@ const appState = { lastJobDetail: null }; +const INTEGRATION_ORDER = ["cutvideo", "huobao", "n8n", "asr"]; +const INTEGRATION_META = { + cutvideo: { + label: "自动剪辑", + hint: "Windows cutvideo", + impacts: ["实拍剪辑"] + }, + huobao: { + label: "AI 视频", + hint: "huobao-drama", + impacts: ["AI 视频"] + }, + n8n: { + label: "编排", + hint: "n8n workflow", + impacts: ["AI 视频", "实拍剪辑", "自动链路"] + }, + asr: { + label: "ASR", + hint: "素材转写", + impacts: ["分析转写"] + } +}; +const PIPELINE_GUARDS = { + aiVideo: { + label: "AI 视频", + openAction: "open-ai-video", + jobAction: "job-to-ai-video", + dependencies: ["n8n", "huobao"] + }, + realCut: { + label: "实拍剪辑", + openAction: "open-real-cut", + jobAction: "job-to-real-cut", + dependencies: ["n8n", "cutvideo"] + } +}; + function safeArray(value) { return Array.isArray(value) ? value : []; } @@ -592,12 +630,6 @@ async function bootstrap() { appState.selectedWorkspace = null; appState.selectedVideos = { items: [], meta: {}, top_scored_video_ids: [], latest_video_ids: [], high_score_threshold: 60 }; } - const nextSeenAt = new Date().toISOString(); - storyforgeFetch("/v2/douyin/tracking/cursor", { - method: "POST", - body: { last_seen_at: nextSeenAt } - }).catch(() => null); - setLastSeenAt(nextSeenAt); } catch (error) { appState.message = error.message; if (String(error.message || "").includes("401") || String(error.message || "").includes("Not authenticated")) { @@ -609,6 +641,57 @@ async function bootstrap() { } } +async function markTrackingDigestRead() { + const nextSeenAt = new Date().toISOString(); + await storyforgeFetch("/v2/douyin/tracking/cursor", { + method: "POST", + body: { last_seen_at: nextSeenAt } + }); + setLastSeenAt(nextSeenAt); +} + +async function refreshTrackingAccountsAction() { + setBusy(true, "正在同步跟踪账号..."); + try { + const payload = await storyforgeFetch("/v2/douyin/tracking/refresh", { + method: "POST" + }); + rememberAction( + "跟踪已同步", + `已刷新 ${formatNumber(payload.refreshed || 0)} 个账号${payload.failed ? `,失败 ${formatNumber(payload.failed)} 个` : ""}。`, + payload.failed ? "orange" : "green", + payload + ); + await bootstrap(); + } finally { + setBusy(false, ""); + } +} + +async function refreshTrackedAccountAction(trackedAccountId) { + if (!trackedAccountId) { + throw new Error("trackedAccountId is required"); + } + setBusy(true, "正在同步该跟踪账号..."); + try { + const payload = await storyforgeFetch(`/v2/douyin/tracking/accounts/${encodeURIComponent(trackedAccountId)}/refresh`, { + method: "POST" + }); + const success = payload.success !== false; + rememberAction( + success ? "单账号已同步" : "单账号刷新失败", + success + ? `已刷新「${payload.account?.nickname || trackedAccountId}」的最新作品。` + : `暂时无法刷新「${payload.account?.nickname || trackedAccountId}」:${payload.message || "请稍后重试"}`, + success ? (safeArray(payload.sync_errors).length ? "orange" : "green") : "orange", + payload + ); + await bootstrap(); + } finally { + setBusy(false, ""); + } +} + function getSelectedProject() { const projects = safeArray(appState.dashboard?.projects); return projects.find((item) => item.id === appState.selectedProjectId) || projects[0] || null; @@ -778,6 +861,126 @@ function canDeriveRealCut(job) { return ["video_link", "upload_video"].includes(sourceType); } +function hasIntegrationHealthData() { + return Boolean(appState.integrationHealth && typeof appState.integrationHealth === "object"); +} + +function getIntegrationDetail(key) { + const raw = hasIntegrationHealthData() ? appState.integrationHealth?.[key] : null; + return { + key, + available: Boolean(raw && typeof raw === "object"), + configured: Boolean(raw?.configured), + reachable: Boolean(raw?.reachable), + statusCode: Number(raw?.status_code || 0), + error: String(raw?.error || ""), + url: String(raw?.url || raw?.base_url || ""), + baseUrl: String(raw?.base_url || "") + }; +} + +function getIntegrationStatus(detail) { + if (!detail.available) { + return { tone: "blue", summary: "未拉取" }; + } + if (detail.reachable) { + return { tone: "green", summary: "在线" }; + } + if (detail.configured) { + return { tone: "red", summary: "不可达" }; + } + return { tone: "orange", summary: "未配置" }; +} + +function describeIntegrationFailure(key) { + const detail = getIntegrationDetail(key); + const meta = INTEGRATION_META[key] || { label: key }; + if (!detail.available) return `${meta.label}健康状态未拉取`; + if (!detail.configured) return `${meta.label}未配置`; + if (detail.statusCode) return `${meta.label}返回 HTTP ${detail.statusCode}`; + if (detail.error) return `${meta.label}${brief(detail.error, 42)}`; + return `${meta.label}不可达`; +} + +function getPipelineGuard(kind) { + const config = PIPELINE_GUARDS[kind]; + if (!config) { + return { enabled: true, reason: "", blocked: [] }; + } + const blocked = config.dependencies + .map((key) => ({ key, detail: getIntegrationDetail(key), meta: INTEGRATION_META[key] || { label: key } })) + .filter((item) => item.detail.available && !item.detail.reachable); + if (!blocked.length) { + return { enabled: true, reason: "", blocked: [] }; + } + return { + enabled: false, + blocked, + reason: `${config.label}暂不可用:${blocked.map((item) => describeIntegrationFailure(item.key)).join(";")}` + }; +} + +function getIntegrationCards() { + return INTEGRATION_ORDER.map((key) => { + const detail = getIntegrationDetail(key); + const status = getIntegrationStatus(detail); + const meta = INTEGRATION_META[key] || { label: key, hint: key, impacts: [] }; + let note = "尚未获取健康检查数据"; + if (detail.available) { + if (detail.reachable) { + note = detail.statusCode + ? `健康探测返回 HTTP ${detail.statusCode}` + : "TCP 探测已通过"; + } else if (!detail.configured) { + note = "后端还没有配置该依赖地址"; + } else if (detail.statusCode) { + note = `探测返回 HTTP ${detail.statusCode}`; + } else if (detail.error) { + note = brief(detail.error, 72); + } else { + note = "探测失败,请检查服务进程和网络"; + } + } + return { + key, + meta, + detail, + status, + note + }; + }); +} + +function getIntegrationOverview() { + const cards = getIntegrationCards(); + const reachableCount = cards.filter((item) => item.detail.available && item.detail.reachable).length; + const availableCount = cards.filter((item) => item.detail.available).length; + const aiVideoGuard = getPipelineGuard("aiVideo"); + const realCutGuard = getPipelineGuard("realCut"); + const blockedActions = [ + !aiVideoGuard.enabled ? aiVideoGuard.reason : "", + !realCutGuard.enabled ? realCutGuard.reason : "" + ].filter(Boolean); + const tone = !availableCount + ? "blue" + : blockedActions.length + ? "red" + : cards.some((item) => item.detail.available && !item.detail.reachable) + ? "orange" + : "green"; + const headline = !availableCount + ? "依赖健康尚未拉取" + : blockedActions.length + ? `自动链路受阻:${blockedActions.length} 项` + : `${reachableCount}/${cards.length} 项依赖在线`; + const subtitle = !availableCount + ? "刷新后会显示 cutvideo / huobao / n8n / ASR 的真实状态。" + : blockedActions.length + ? blockedActions.join(";") + : "AI 视频与实拍剪辑链路当前可直接发起。"; + return { cards, tone, headline, subtitle }; +} + function getJobSeedBrief(job) { return [ job?.style_summary, @@ -885,8 +1088,103 @@ function screenShell(title, subtitle, actionsHtml, bodyHtml) { `; } -function button(label, action, tone = "secondary") { - return ``; +function button(label, action, tone = "secondary", options = {}) { + const classes = ["btn", `btn-${tone}`]; + if (options.className) classes.push(options.className); + if (options.disabledReason) classes.push("is-disabled"); + const targetAction = options.disabledReason ? "show-disabled-reason" : action; + const title = options.disabledReason || options.title || ""; + return ` + + `.replace(/\s+/g, " ").trim(); +} + +function actionTag(label, action, attrs = "", options = {}) { + const classes = ["tag"]; + const targetAction = options.disabledReason ? "show-disabled-reason" : action; + if (options.disabledReason) { + classes.push("tag-disabled"); + } else if (targetAction) { + classes.push("clickable-tag"); + } + const title = options.disabledReason || options.title || ""; + return ` + ${escapeHtml(label)} + `.replace(/\s+/g, " ").trim(); +} + +function renderPipelineButton(kind, tone = "secondary") { + const config = PIPELINE_GUARDS[kind]; + if (!config) return ""; + const guard = getPipelineGuard(kind); + return button(config.label, config.openAction, tone, { + disabledReason: guard.enabled ? "" : guard.reason + }); +} + +function renderPipelineJobTag(kind, job, label) { + const config = PIPELINE_GUARDS[kind]; + if (!config || !job?.id) return ""; + const guard = getPipelineGuard(kind); + return actionTag(label, config.jobAction, `data-job-id="${escapeHtml(job.id)}"`, { + disabledReason: guard.enabled ? "" : guard.reason + }); +} + +function renderIntegrationOverviewPanel(options = {}) { + const overview = getIntegrationOverview(); + const cards = overview.cards; + const showActions = options.showActions !== false; + return ` +
+
+
+ ${escapeHtml(overview.headline)} +

${escapeHtml(overview.subtitle)}

+
+ ${showActions ? ` +
+ ${renderPipelineButton("aiVideo", "primary")} + ${renderPipelineButton("realCut")} +
+ ` : ""} +
+
+ ${cards.map((item) => `${escapeHtml(item.meta.label)} ${escapeHtml(item.status.summary)}`).join("")} +
+
+ ${cards.map((item) => ` +
+
+
+

${escapeHtml(item.meta.label)}

+

${escapeHtml(item.meta.hint)}

+
+ ${escapeHtml(item.status.summary)} +
+
+ ${safeArray(item.meta.impacts).map((impact) => `${escapeHtml(impact)}`).join("")} + ${item.detail.statusCode ? `HTTP ${escapeHtml(item.detail.statusCode)}` : ""} +
+
${escapeHtml(item.note)}
+
${escapeHtml(item.detail.url || item.detail.baseUrl || "未提供探测地址")}
+
+ `).join("")} +
+
+ `; } function renderEmptyState(title, description) { @@ -936,6 +1234,9 @@ function renderDashboardScreen() {
Agent${escapeHtml(formatNumber(assistants.length))}
已创建${escapeHtml(formatNumber(assistants.filter((item) => !(item.model_profile_id || "")).length))} 个待补模型
生产任务${escapeHtml(formatNumber(jobs.length))}
最近 20 条${escapeHtml(formatNumber(jobs.filter((item) => item.status === "completed").length))} 条已完成
+
+ ${renderIntegrationOverviewPanel({ compact: true })} +
@@ -1330,10 +1631,11 @@ function renderTrackingScreen() { } const trackedAccounts = safeArray(appState.trackingAccounts); const digestItems = getTrackingDigestItems(12); + const cursorLabel = appState.lastSeenAt ? formatDateTime(appState.lastSeenAt) : "尚未记录"; return screenShell( "跟踪账号", "这里已经接上真实跟踪对象和按上次打开后的更新日报。", - `${button("刷新日报", "refresh-data")} ${button("跳到找对标", "goto-discovery", "primary")}`, + `${button("同步全部", "refresh-tracking")} ${button("标记已读", "mark-tracking-read")} ${button("跳到找对标", "goto-discovery", "primary")}`, `

日报逻辑

@@ -1342,6 +1644,7 @@ function renderTrackingScreen() { 按上次打开汇总 Agent 标借鉴点 高价值内容可进学习集 + 上次已读 ${escapeHtml(cursorLabel)}
@@ -1361,7 +1664,8 @@ function renderTrackingScreen() {
已跟踪 ${escapeHtml(item.assistant_name || "未绑 Agent")} - 看详情 + ${actionTag("立即同步", "refresh-tracked-account", `data-tracked-account-id="${escapeHtml(item.tracked_account_id)}"`)} + ${actionTag("看详情", "select-account", `data-account-id="${escapeHtml(item.tracked_account_id)}"`)}
`).join("") || `

暂无跟踪账号

先去找对标把重点账号加入跟踪。

`} @@ -1402,21 +1706,15 @@ 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: "转写服务" } - ]; + const overview = getIntegrationOverview(); return screenShell( "自动流程", "自动同步、日报生成和失败补跑先统一看这里。", - `${button("刷新", "refresh-data")} ${button("去生产", "goto-production", "primary")}`, + `${button("刷新", "refresh-data")} ${renderPipelineButton("aiVideo")} ${renderPipelineButton("realCut")} ${button("去生产", "goto-production", "primary")}`, `

自动流程

-

当前按真实任务量给出一版轻量看板,后续再接更完整的定时与重试配置。

+

当前按真实任务量和依赖健康状态给出看板,自动流程受阻时会直接在这里拦住动作。

分析任务${escapeHtml(formatNumber(analysisJobs))}
AI 视频${escapeHtml(formatNumber(aiVideoJobs))}
@@ -1424,25 +1722,27 @@ function renderAutomationScreen() {
内容源${escapeHtml(formatNumber(appState.contentSources.length))}
-
-

集成状态

直接看关键依赖是否在线
-
- ${integrationCards.map((item) => { - const detail = integrations[item.key] || {}; - const tone = detail.reachable ? "green" : (detail.configured ? "red" : "orange"); - const summary = detail.reachable ? "在线" : (detail.configured ? "不可达" : "未配置"); - return ` -
-

${escapeHtml(item.label)}

-

${escapeHtml(item.hint)}

-
- ${escapeHtml(summary)} - ${detail.status_code ? `HTTP ${escapeHtml(detail.status_code)}` : ""} -
-
- `; - }).join("")} +
+ ${renderIntegrationOverviewPanel({ showActions: false })} +
+
+
+
+

动作防呆

+
依赖不可用时,相关动作会在这里和生产页一起被拦住。
+
+ ${escapeHtml(overview.headline)}
+
+ AI 视频 ${escapeHtml(getPipelineGuard("aiVideo").enabled ? "可执行" : "已拦截")} + 实拍剪辑 ${escapeHtml(getPipelineGuard("realCut").enabled ? "可执行" : "已拦截")} + ASR ${escapeHtml(getIntegrationStatus(getIntegrationDetail("asr")).summary)} +
+
+ ${renderPipelineButton("aiVideo", "primary")} + ${renderPipelineButton("realCut")} +
+
${escapeHtml(overview.subtitle)}
` ); @@ -1563,7 +1863,7 @@ function renderProductionScreen() { return screenShell( "生产中心", "这里已经接上真实任务和知识库文档,后续再继续补任务创建动作。", - `${button("AI 视频", "open-ai-video")} ${button("实拍剪辑", "open-real-cut")} ${button("去复盘", "goto-review", "primary")}`, + `${renderPipelineButton("aiVideo")} ${renderPipelineButton("realCut")} ${button("去复盘", "goto-review", "primary")}`, `

生产队列

最近任务的真实状态
@@ -1591,9 +1891,9 @@ function renderProductionScreen() {
${escapeHtml(job.status)} ${escapeHtml(job.line_type || "analysis")} - ${canDeriveAiVideo(job) ? `做 AI 视频` : ""} - ${canDeriveRealCut(job) ? `做实拍剪辑` : ""} - 看详情 + ${canDeriveAiVideo(job) ? renderPipelineJobTag("aiVideo", job, "做 AI 视频") : ""} + ${canDeriveRealCut(job) ? renderPipelineJobTag("realCut", job, "做实拍剪辑") : ""} + ${actionTag("看详情", "open-job-detail", `data-job-id="${escapeHtml(job.id)}"`)}
`).join("") || `

还没有任务

先去找对标导入内容。

`} @@ -1674,10 +1974,10 @@ function renderReviewScreen() {
已完成 ${escapeHtml(job.line_type || "analysis")} - 写复盘 - ${canDeriveAiVideo(job) ? `做 AI 视频` : ""} - ${canDeriveRealCut(job) ? `做实拍剪辑` : ""} - 看详情 + ${actionTag("写复盘", "open-review-from-job", `data-job-id="${escapeHtml(job.id)}"`)} + ${canDeriveAiVideo(job) ? renderPipelineJobTag("aiVideo", job, "做 AI 视频") : ""} + ${canDeriveRealCut(job) ? renderPipelineJobTag("realCut", job, "做实拍剪辑") : ""} + ${actionTag("看详情", "open-job-detail", `data-job-id="${escapeHtml(job.id)}"`)}
`).join("") || `

还没有完成任务

先去生产中心跑一条链路。

`} @@ -1858,10 +2158,10 @@ function renderLastJobDetailCard() {

${escapeHtml(brief(detail.job.style_summary || detail.job.transcript_text || detail.job.error || "暂无摘要", 120))}

${escapeHtml(detail.job.line_type || "-")} - ${detail.job.status === "completed" ? `写复盘` : ""} - ${canDeriveAiVideo(detail.job) ? `做 AI 视频` : ""} - ${canDeriveRealCut(detail.job) ? `做实拍剪辑` : ""} - 看详情 + ${detail.job.status === "completed" ? actionTag("写复盘", "open-review-from-job", `data-job-id="${escapeHtml(detail.job.id)}"`) : ""} + ${canDeriveAiVideo(detail.job) ? renderPipelineJobTag("aiVideo", detail.job, "做 AI 视频") : ""} + ${canDeriveRealCut(detail.job) ? renderPipelineJobTag("realCut", detail.job, "做实拍剪辑") : ""} + ${actionTag("看详情", "open-job-detail", `data-job-id="${escapeHtml(detail.job.id)}"`)}
${previewLinks.length ? ` @@ -2405,9 +2705,9 @@ function openJobDetailAction(jobId) { label: "下一步动作", html: `
- ${canDeriveAiVideo(job) ? `继续做 AI 视频` : ""} - ${canDeriveRealCut(job) ? `继续做实拍剪辑` : ""} - 用摘要写文案 + ${canDeriveAiVideo(job) ? renderPipelineJobTag("aiVideo", job, "继续做 AI 视频") : ""} + ${canDeriveRealCut(job) ? renderPipelineJobTag("realCut", job, "继续做实拍剪辑") : ""} + ${actionTag("用摘要写文案", "job-to-generate-copy", `data-job-id="${escapeHtml(job.id)}"`)}
` }, @@ -2489,6 +2789,11 @@ function openGenerateCopyAction(defaults = {}) { } function openCreateAiVideoAction(defaults = {}) { + const guard = getPipelineGuard("aiVideo"); + if (!guard.enabled) { + alert(guard.reason); + return; + } const project = requireSelectedProject(); const assistant = getSelectedAssistant(); const kb = getProjectKnowledgeBases(project.id)[0]; @@ -2529,6 +2834,11 @@ function openCreateAiVideoAction(defaults = {}) { } function openCreateRealCutAction(defaults = {}) { + const guard = getPipelineGuard("realCut"); + if (!guard.enabled) { + alert(guard.reason); + return; + } const project = requireSelectedProject(); const sourceJob = defaults.sourceJob || null; openActionModal({ @@ -2670,10 +2980,27 @@ document.addEventListener("click", async (event) => { // button and pressing Enter share the same code path. return; } + if (name === "show-disabled-reason") { + const reason = action.dataset.disabledReason || action.title || "当前动作暂不可用"; + rememberAction("动作已拦截", reason, "orange"); + renderAll(); + alert(reason); + return; + } if (name === "auth-refresh" || name === "refresh-data") { await bootstrap(); return; } + if (name === "refresh-tracking") { + await refreshTrackingAccountsAction(); + return; + } + if (name === "mark-tracking-read") { + await markTrackingDigestRead(); + rememberAction("日报已标记", "当前跟踪摘要已更新为已读,下次会从新的时间点继续汇总。", "green"); + await bootstrap(); + return; + } if (name === "logout-session") { await logoutSession(); return; @@ -2706,6 +3033,10 @@ document.addEventListener("click", async (event) => { openTrackSelectedAccountAction(); return; } + if (name === "refresh-tracked-account") { + await refreshTrackedAccountAction(action.dataset.trackedAccountId || ""); + return; + } if (name === "open-import-video-link") { openImportVideoLinkAction(); return; diff --git a/web/storyforge-web-v4/assets/styles.css b/web/storyforge-web-v4/assets/styles.css index 3a55c27..f644f6d 100644 --- a/web/storyforge-web-v4/assets/styles.css +++ b/web/storyforge-web-v4/assets/styles.css @@ -503,6 +503,28 @@ select { transform: translateY(-1px); } +.btn.is-disabled { + cursor: not-allowed; + opacity: 0.62; + box-shadow: none; + transform: none; + background: linear-gradient(180deg, #f4f7fb 0%, #e9eff7 100%); + color: var(--muted); + border: 1px solid var(--line); +} + +.btn.is-disabled:hover { + transform: none; +} + +.btn.is-disabled, +.btn[aria-disabled="true"] { + cursor: not-allowed; + opacity: 0.68; + transform: none; + box-shadow: none; +} + .layout-grid { display: grid; gap: 18px; @@ -676,10 +698,153 @@ select { color: #b24c4c; } +.tag-disabled { + cursor: not-allowed; + opacity: 0.72; + background: #f3f6fa; + border-color: var(--line-strong); + color: var(--muted); +} + .clickable-tag { cursor: pointer; } +.integration-panel { + display: grid; + gap: 14px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.96) 0%, rgba(245, 249, 255, 0.96) 100%); +} + +.integration-panel-compact .integration-grid { + margin-top: 12px; +} + +.integration-summary { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 16px 18px; + border-radius: 18px; + border: 1px solid var(--line); + background: linear-gradient(180deg, #fbfdff 0%, #f5f9ff 100%); +} + +.integration-summary strong { + display: block; + font-size: 16px; +} + +.integration-summary p { + margin: 8px 0 0; + color: var(--muted); + font-size: 12px; + line-height: 1.5; +} + +.integration-summary.green { + border-color: rgba(45, 181, 132, 0.2); + background: linear-gradient(180deg, rgba(45, 181, 132, 0.12) 0%, rgba(255, 255, 255, 0.96) 100%); +} + +.integration-summary.orange { + border-color: rgba(242, 154, 56, 0.24); + background: linear-gradient(180deg, rgba(242, 154, 56, 0.13) 0%, rgba(255, 255, 255, 0.96) 100%); +} + +.integration-summary.red { + border-color: rgba(228, 103, 103, 0.24); + background: linear-gradient(180deg, rgba(228, 103, 103, 0.13) 0%, rgba(255, 255, 255, 0.97) 100%); +} + +.integration-summary.blue { + border-color: rgba(79, 143, 238, 0.18); + background: linear-gradient(180deg, rgba(79, 143, 238, 0.11) 0%, rgba(255, 255, 255, 0.97) 100%); +} + +.integration-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + justify-content: flex-end; +} + +.integration-highlights { + margin-top: 12px; +} + +.integration-grid { + gap: 14px; + align-items: stretch; +} + +.integration-card { + padding: 15px; + border-radius: 18px; + border: 1px solid var(--line); + background: linear-gradient(180deg, #fff 0%, #f8fbff 100%); + box-shadow: var(--shadow-soft); +} + +.integration-card.green { + border-color: rgba(45, 181, 132, 0.18); +} + +.integration-card.orange { + border-color: rgba(242, 154, 56, 0.18); +} + +.integration-card.red { + border-color: rgba(228, 103, 103, 0.2); +} + +.integration-card.blue { + border-color: rgba(79, 143, 238, 0.18); +} + +.integration-card-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.integration-card-head h4 { + margin: 0 0 4px; +} + +.integration-card-head p { + margin: 0; + color: var(--muted); + font-size: 11px; +} + +.integration-note { + margin-top: 12px; + color: var(--text); + font-size: 12px; + line-height: 1.5; +} + +.integration-url { + margin-top: 10px; + padding: 9px 11px; + border-radius: 12px; + background: var(--blue-50); + border: 1px solid rgba(106, 164, 255, 0.14); + color: var(--muted); + font-size: 11px; + line-height: 1.45; + word-break: break-all; +} + +.automation-guard-panel { + display: grid; + gap: 12px; +} + .mobile-only { display: none; } @@ -1300,6 +1465,20 @@ tbody tr:hover { min-width: 0; } + .integration-summary { + flex-direction: column; + align-items: flex-start; + } + + .integration-actions { + width: 100%; + } + + .integration-actions .btn { + flex: 1 1 calc(50% - 10px); + min-width: 0; + } + .grid-4, .grid-5, .mini-grid, @@ -1328,6 +1507,20 @@ tbody tr:hover { padding: 14px; } + .integration-summary { + flex-direction: column; + } + + .integration-actions { + width: 100%; + justify-content: stretch; + } + + .integration-actions .btn { + flex: 1 1 calc(50% - 5px); + min-width: 0; + } + .task-meta, .entity-meta, .row-meta, @@ -1527,6 +1720,10 @@ tbody tr:hover { text-align: center; } + .integration-card-head { + flex-direction: column; + } + .task-item.compact, .review-card.compact { padding: 12px;