diff --git a/CHANGELOG.md b/CHANGELOG.md index 85a0ac9..103974c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -335,3 +335,9 @@ - `生产中心` 不再用“后续再补任务创建动作”这类半成品口径,当前页面直接按真实任务、恢复和复盘来表达。 - 任务恢复链里的失败提示统一成“先补信息 / 需人工处理”,不再弹出“暂不支持自动恢复”这类生硬口径。 - `额度` 页把“后续再接真实套餐”改成当前就能落地的套餐表达,明确按预算、动作池和项目阶段去配置套餐。 + +### 套餐档位与恢复引导继续补齐 + +- `额度` 页和租户额度编辑弹层新增了 `套餐档位` 与 `预算预警阈值`,现在能直接按试用、增长、规模、自定义四档去配置项目套餐。 +- 租户额度面板会直接展示当前套餐档位和预警阈值,便于把预算和动作池表达成正式产品能力,而不是只看裸配额数字。 +- 不可自动恢复的失败任务现在会打开站内“处理建议”面板,直接给出补信息、查看详情或交给主 Agent 的下一步,而不是只停在失败提示。 diff --git a/web/storyforge-web-v4/assets/app.js b/web/storyforge-web-v4/assets/app.js index ffb09cf..fd18381 100644 --- a/web/storyforge-web-v4/assets/app.js +++ b/web/storyforge-web-v4/assets/app.js @@ -4113,6 +4113,7 @@ function renderOneLinerActionRegistryPanel() { function renderTenantQuotaPanel() { const quota = appState.tenantQuota; const usage = appState.tenantUsage || quota?.usage || {}; + const quotaConfig = quota?.config || {}; const quotaNotice = renderQuotaBlockingNotice(); if (!quota && !usage) { return ` @@ -4148,6 +4149,8 @@ function renderTenantQuotaPanel() { (quota?.recorder_quota || 0) > 0 ); const usageCount = recentItems.length; + const packageLabel = String(quotaConfig.package_label || "").trim() || (hasHardLimit ? "自定义套餐" : "未设套餐"); + const warnThreshold = Number(quotaConfig.warn_threshold ?? 0.8); const quotaTaskTitle = quota?.storage_over_limit ? "先处理存储超限" : quota?.enabled === false @@ -4175,6 +4178,7 @@ function renderTenantQuotaPanel() { topCategory ? `${escapeHtml(`主要消耗 ${topCategory.category || "usage"}`)}` : `本周期未产生消耗` ]; const cards = [ + { label: "套餐档位", value: packageLabel, sub: `预警阈值 ${formatNumber((warnThreshold || 0.8) * 100)}%` }, { label: "预算", value: `${formatNumber((quota?.monthly_budget_cents || 0) / 100)} 元`, sub: `已用 ${formatNumber((usage?.total_cost_cents || 0) / 100)} 元` }, { label: "分析配额", value: formatNumber(quota?.analysis_quota || 0), sub: `已用 ${formatNumber(categories.analysis?.quantity || 0)}` }, { label: "文案配额", value: formatNumber(quota?.copy_quota || 0), sub: `已用 ${formatNumber(categories.copy?.quantity || 0)}` }, @@ -4191,6 +4195,7 @@ function renderTenantQuotaPanel() {
${escapeHtml(quota?.enabled === false ? "已停用额度保护" : "额度保护开启")} + ${escapeHtml(packageLabel)} ${quota?.storage_over_limit ? `存储超限` : ""} 编辑额度
@@ -4204,10 +4209,11 @@ function renderTenantQuotaPanel() {
+ ${escapeHtml(`套餐 ${packageLabel}`)} ${escapeHtml(`周期 ${usagePeriodLabel}`)} ${escapeHtml(`计量 ${formatNumber(usageCount)} 条`)} ${escapeHtml(`成本 ${(usage?.total_cost_cents || 0) / 100} 元`)} - ${escapeHtml(`主要消耗 ${topCategory?.category || "暂无"}`)} + ${escapeHtml(`预警 ${(warnThreshold || 0.8) * 100}%`)}
${quotaNotice}
@@ -10031,12 +10037,20 @@ function openActionRegistryEditAction(actionKey) { function openTenantQuotaAction() { const project = requireSelectedProject(); const quota = appState.tenantQuota || {}; + const quotaConfig = quota.config || {}; openActionModal({ title: "编辑租户额度", description: "当前额度按租户 + 项目隔离,用于商业化预算、动作配额和存储保护。", submitLabel: "保存额度", fields: [ { name: "enabled", label: "启用额度保护", type: "checkbox", value: quota.enabled !== false }, + { name: "packageLabel", label: "套餐档位", type: "select", value: quotaConfig.package_label || "custom", options: [ + { value: "trial", label: "试用套餐" }, + { value: "growth", label: "增长套餐" }, + { value: "scale", label: "规模套餐" }, + { value: "custom", label: "自定义套餐" } + ] }, + { name: "warnThreshold", label: "预算预警阈值", type: "number", value: quotaConfig.warn_threshold ?? 0.8, min: 0, max: 1, step: 0.05 }, { name: "monthlyBudgetCents", label: "月预算(分)", type: "number", value: quota.monthly_budget_cents || 0, min: 0 }, { name: "storageLimitBytes", label: "存储上限(字节)", type: "number", value: quota.storage_limit_bytes || 0, min: 0 }, { name: "analysisQuota", label: "分析配额", type: "number", value: quota.analysis_quota || 0, min: 0 }, @@ -10057,7 +10071,11 @@ function openTenantQuotaAction() { ai_video_quota: Number(values.aiVideoQuota || 0), real_cut_quota: Number(values.realCutQuota || 0), recorder_quota: Number(values.recorderQuota || 0), - config: quota.config || {} + config: { + ...(quota.config || {}), + package_label: String(values.packageLabel || "custom"), + warn_threshold: Number(values.warnThreshold ?? 0.8) || 0.8 + } } }); rememberAction("租户额度已更新", "当前项目的预算与配额已经保存。", "green", saved); @@ -10662,7 +10680,48 @@ function openRecoverJobAction(jobId) { } const recovery = getJobRecoverability(job); if (!recovery.recoverable) { - presentActionFailure(new Error(recovery.reason || "当前任务需要人工处理后再继续"), "当前任务需要先补信息或转人工处理"); + const nextActionTag = (() => { + if (recovery.actionKey === "open-job-detail") { + const targetJobId = recovery.sourceJobId || job.id; + return actionTag(recovery.actionLabel || "查看详情", "open-job-detail", `data-job-id="${escapeHtml(targetJobId)}"`); + } + if (recovery.actionKey) { + return actionTag(recovery.actionLabel || "继续处理", recovery.actionKey); + } + return ""; + })(); + openActionModal({ + title: "当前任务需要先处理依赖", + description: recovery.reason || "这条任务需要先补信息或转人工处理。", + hideSubmit: true, + fields: [ + { + type: "html", + label: "处理建议", + html: ` +
+
+

${escapeHtml(job.title || job.id)}

+

${escapeHtml(recovery.reason || "先补回缺失信息,再继续推进这条任务。")}

+
+ ${escapeHtml(recovery.label || "需人工处理")} + ${nextActionTag} + ${actionTag("交给主 Agent", "handoff-to-main-agent", buildMainAgentHandoffAttrs({ + sourceScreen: "production", + sourceActionKey: "recover-job-handoff", + intentKey: "custom", + title: `处理失败任务 ${job.title || job.id}`, + goal: "结合失败原因和当前任务上下文,给出下一步恢复建议", + summary: "主 Agent 会先判断这条任务缺的是素材、额度还是源任务,再给出具体处理路径。", + planSteps: ["读取失败任务详情", "判断缺失输入或依赖", "生成下一步恢复建议"] + }))} +
+
+
+ ` + } + ] + }); return; } openActionModal({ diff --git a/web/storyforge-web-v4/tests/workbench-pages.test.mjs b/web/storyforge-web-v4/tests/workbench-pages.test.mjs index 1e0a61f..4bbc1f7 100644 --- a/web/storyforge-web-v4/tests/workbench-pages.test.mjs +++ b/web/storyforge-web-v4/tests/workbench-pages.test.mjs @@ -296,6 +296,7 @@ test("platform agent surfaces recent execution feedback from main agent runs", ( test("quota and review screens foreground live next-step guidance", () => { const tenantQuota = extractBetween(APP, "function renderTenantQuotaPanel()", "function policyScopeTagLabel("); const credits = extractBetween(APP, "function renderCreditsScreen()", "function renderSettingsScreen()"); + const quotaAction = extractBetween(APP, "function openTenantQuotaAction()", "function openCreateAssistantAction()"); const review = extractBetween(APP, "function renderReviewScreen()", "function renderStrategyScreen()"); const storage = extractBetween(APP, "function renderStorageStatusPanel()", "function renderAutomationScreen()"); @@ -313,6 +314,12 @@ test("quota and review screens foreground live next-step guidance", () => { assert.doesNotMatch(credits, /后续再接真实套餐/); assert.match(credits, /按项目阶段配置套餐/); assert.match(credits, /预算、动作池和项目阶段绑定成正式套餐/); + assert.match(tenantQuota, /套餐档位/); + assert.match(tenantQuota, /预警阈值|预警 80%/); + assert.match(quotaAction, /name: "packageLabel"/); + assert.match(quotaAction, /name: "warnThreshold"/); + assert.match(quotaAction, /package_label/); + assert.match(quotaAction, /warn_threshold/); }); test("tracking refresh and top-video analysis flows expose async feedback inside the workbench", () => { @@ -1015,6 +1022,9 @@ test("recovery and copy actions continue into the most useful result view", () = assert.match(APP, /function focusRecentGeneratedCopy\(\)/); assert.match(singleRecover, /if \(result\?\.created\?\.id\) \{\s*openJobDetailAction\(result\.created\.id\);/); assert.match(singleRecover, /focusProductionDetailTab\("recovery"\)/); + assert.match(singleRecover, /当前任务需要先处理依赖/); + assert.match(singleRecover, /recover-job-handoff/); + assert.match(singleRecover, /actionTag\(recovery\.actionLabel \|\| "查看详情", "open-job-detail"/); assert.match(batchRecover, /if \(successes\[0\]\?\.result\?\.created\?\.id\) \{\s*openJobDetailAction\(successes\[0\]\.result\.created\.id\);/); assert.match(batchRecover, /focusProductionDetailTab\("recovery"\)/); assert.match(generateCopy, /focusRecentGeneratedCopy\(\)/);