diff --git a/CHANGELOG.md b/CHANGELOG.md index 92516ca..5d473fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ ## 2026-04-07 +### 额度策略开始按真实用量给出套餐建议 + +- `租户额度与审计` 和 `额度` 工作区现在会根据当前项目最近的预算消耗、视频动作量、文案动作量和存储使用,直接给出 `试用 / 增长 / 规模 / 自定义` 的套餐建议。 +- `编辑租户额度` 弹层里的套餐预览也开始带上这层建议,不再只是静态展示当前选择的套餐说明;切换预设或继续调整自定义额度时,建议会跟着实时刷新。 +- 这让额度页从“只展示当前配额”继续往“告诉你现在更适合哪档套餐”收了一层,也把预算、动作池和真实使用节奏更明确地连在一起。 + ### 创作表单开始跟随来源任务动态刷新推荐值 - `生成文案 / 创建 AI 视频 / 创建实拍剪辑 / 写复盘` 这四类创作表单,现在不只会在打开时算一次默认值;如果你在表单里切换来源任务,平台、标题、受众、画幅、时长、目标这些推荐值会继续跟着刷新。 diff --git a/web/storyforge-web-v4/assets/app.js b/web/storyforge-web-v4/assets/app.js index 7382e31..17c6e05 100644 --- a/web/storyforge-web-v4/assets/app.js +++ b/web/storyforge-web-v4/assets/app.js @@ -4848,6 +4848,10 @@ function renderTenantQuotaPanel() { const packageFocus = String(quotaConfig.package_focus || "").trim(); const warnThreshold = Number(quotaConfig.warn_threshold ?? 0.8); const forecast = getTenantQuotaForecastValues(quota || {}, usage); + const packageRecommendation = getTenantQuotaPackageRecommendation({ + ...quota, + package_label: quotaConfig.package_label || "custom" + }, usage); const quotaTaskTitle = quota?.storage_over_limit ? "先处理存储超限" : quota?.enabled === false @@ -4872,7 +4876,8 @@ function renderTenantQuotaPanel() { const quotaHealthTags = [ quota?.enabled === false ? `额度保护关闭` : `额度保护开启`, quota?.storage_over_limit ? `存储超限` : `存储正常`, - topCategory ? `${escapeHtml(`主要消耗 ${topCategory.category || "usage"}`)}` : `本周期未产生消耗` + topCategory ? `${escapeHtml(`主要消耗 ${topCategory.category || "usage"}`)}` : `本周期未产生消耗`, + `${escapeHtml(`推荐 ${packageRecommendation.title}`)}` ]; const cards = [ { label: "套餐档位", value: packageLabel, sub: packageFocus || `预警阈值 ${formatNumber((warnThreshold || 0.8) * 100)}%` }, @@ -8244,6 +8249,14 @@ function renderCreditsScreen() {

使用建议

${escapeHtml((quota?.enabled === false) ? "当前额度保护已关闭,适合内部联调,不适合正式对外。":"当前额度保护已开启,适合逐步转向对外产品表达。")}

+
+

套餐建议

+

${escapeHtml(packageRecommendation.summary)}

+
+ ${escapeHtml(`推荐 ${packageRecommendation.title}`)} + ${escapeHtml(packageRecommendation.reason)} +
+
@@ -8360,7 +8373,64 @@ function renderTenantQuotaForecastTags(values, usage = null) { `; } -function renderTenantQuotaPackagePreview(label, values = null) { +function getTenantQuotaPackageRecommendation(values = {}, usage = null) { + const resolvedUsage = usage || appState.tenantUsage || {}; + const categories = resolvedUsage?.categories || {}; + const totalCostCents = Number(resolvedUsage?.total_cost_cents || 0); + const storageBytes = Number(resolvedUsage?.storage_bytes || 0); + const usageCounts = { + analysis: Number(categories.analysis?.quantity || 0), + copy: Number(categories.copy?.quantity || 0), + aiVideo: Number(categories.ai_video?.quantity || 0), + realCut: Number(categories.real_cut?.quantity || 0), + recorder: Number(categories.live_recorder?.quantity || 0) + }; + const hasUsage = totalCostCents > 0 || storageBytes > 0 || Object.values(usageCounts).some((value) => value > 0); + const requiredCapacity = { + monthlyBudgetCents: hasUsage ? Math.max(Math.ceil(totalCostCents * 1.5), 9900) : 9900, + storageLimitBytes: hasUsage ? Math.max(Math.ceil(storageBytes * 1.5), 5 * 1024 * 1024 * 1024) : 5 * 1024 * 1024 * 1024, + analysisQuota: usageCounts.analysis > 0 ? Math.max(usageCounts.analysis * 2, 30) : 30, + copyQuota: usageCounts.copy > 0 ? Math.max(usageCounts.copy * 2, 60) : 60, + aiVideoQuota: usageCounts.aiVideo > 0 ? Math.max(usageCounts.aiVideo * 2, 2) : 2, + realCutQuota: usageCounts.realCut > 0 ? Math.max(usageCounts.realCut * 2, 1) : 1, + recorderQuota: usageCounts.recorder > 0 ? Math.max(usageCounts.recorder * 2, 4) : 4 + }; + const currentLabel = String(values?.packageLabel ?? values?.package_label ?? "custom").trim().toLowerCase() || "custom"; + const presetOrder = ["trial", "growth", "scale"]; + const recommendedLabel = presetOrder.find((label) => { + const preset = getTenantQuotaPackagePreset(label); + if (!preset) return false; + return preset.monthlyBudgetCents >= requiredCapacity.monthlyBudgetCents + && preset.storageLimitBytes >= requiredCapacity.storageLimitBytes + && preset.analysisQuota >= requiredCapacity.analysisQuota + && preset.copyQuota >= requiredCapacity.copyQuota + && preset.aiVideoQuota >= requiredCapacity.aiVideoQuota + && preset.realCutQuota >= requiredCapacity.realCutQuota + && preset.recorderQuota >= requiredCapacity.recorderQuota; + }) || "custom"; + const preset = getTenantQuotaPackagePreset(recommendedLabel); + const packageTitle = preset?.title || "自定义套餐"; + const reasonParts = []; + if (totalCostCents > 0) reasonParts.push(`本周期已用 ${formatNumber(totalCostCents / 100)} 元`); + if (usageCounts.aiVideo > 0 || usageCounts.realCut > 0) reasonParts.push(`视频动作 ${formatNumber(usageCounts.aiVideo + usageCounts.realCut)} 次`); + if (usageCounts.copy > 0) reasonParts.push(`文案动作 ${formatNumber(usageCounts.copy)} 次`); + if (storageBytes > 0) reasonParts.push(`存储 ${formatBytes(storageBytes)}`); + return { + label: recommendedLabel, + title: packageTitle, + currentLabel, + matchesCurrent: currentLabel === recommendedLabel, + statusTone: recommendedLabel === "custom" ? "orange" : currentLabel === recommendedLabel ? "green" : "blue", + summary: currentLabel === recommendedLabel + ? `${packageTitle} 已覆盖当前使用节奏,可以继续观察本周期消耗。` + : `按当前使用节奏,更适合切到 ${packageTitle}。`, + reason: reasonParts[0] + ? `重点参考 ${reasonParts.slice(0, 2).join(",")}` + : "当前还在起步阶段,建议先从试用套餐起步。" + }; +} + +function renderTenantQuotaPackagePreview(label, values = null, usage = null) { const preset = getTenantQuotaPackagePreset(label); const packageLabel = String(label || "custom").trim() || "custom"; const packageTitle = preset?.title || "自定义套餐"; @@ -8368,6 +8438,7 @@ function renderTenantQuotaPackagePreview(label, values = null) { const packageFocus = preset?.focus || "适合已经明确成本模型或需要特殊额度策略的项目。"; const currentValues = values || {}; const warnThreshold = Number(currentValues.warnThreshold ?? preset?.warnThreshold ?? 0.8) || 0.8; + const recommendation = getTenantQuotaPackageRecommendation({ ...currentValues, packageLabel }, usage); return `

${escapeHtml(packageTitle)}

@@ -8378,6 +8449,14 @@ function renderTenantQuotaPackagePreview(label, values = null) { ${preset ? `预设已锁定` : `支持自定义`}

${escapeHtml(packageFocus)}

+
+

套餐建议

+

${escapeHtml(recommendation.summary)}

+
+ ${escapeHtml(`推荐 ${recommendation.title}`)} + ${escapeHtml(recommendation.reason)} +
+
${escapeHtml(`预算 ${formatNumber(Number(currentValues.monthlyBudgetCents || 0) / 100)} 元`)} ${escapeHtml(`文案 ${formatNumber(currentValues.copyQuota || 0)}`)} @@ -11071,6 +11150,7 @@ function openActionRegistryEditAction(actionKey) { function openTenantQuotaAction() { const project = requireSelectedProject(); const quota = appState.tenantQuota || {}; + const usage = appState.tenantUsage || {}; const quotaConfig = quota.config || {}; const defaultDraft = { warnThreshold: Number(quotaConfig.warn_threshold ?? 0.8) || 0.8, @@ -11094,7 +11174,7 @@ function openTenantQuotaAction() { { value: "scale", label: "规模套餐" }, { value: "custom", label: "自定义套餐" } ] }, - { type: "html", label: "套餐预览", html: `
${renderTenantQuotaPackagePreview(quotaConfig.package_label || "custom", defaultDraft)}
` }, + { type: "html", label: "套餐预览", html: `
${renderTenantQuotaPackagePreview(quotaConfig.package_label || "custom", defaultDraft, usage)}
` }, { name: "warnThreshold", label: "预算预警阈值", type: "number", value: defaultDraft.warnThreshold, min: 0, max: 1, step: 0.05 }, { name: "monthlyBudgetCents", label: "月预算(分)", type: "number", value: defaultDraft.monthlyBudgetCents, min: 0, step: 100 }, { name: "storageLimitBytes", label: "存储上限(字节)", type: "number", value: defaultDraft.storageLimitBytes, min: 0, step: 1024 }, @@ -11149,7 +11229,7 @@ function openTenantQuotaAction() { input.disabled = Boolean(preset); }); if (preview) { - preview.innerHTML = renderTenantQuotaPackagePreview(packageLabel, nextValues); + preview.innerHTML = renderTenantQuotaPackagePreview(packageLabel, nextValues, usage); } }; packageSelect?.addEventListener("change", syncPreview); @@ -11157,7 +11237,7 @@ function openTenantQuotaAction() { getField(name)?.addEventListener("input", () => { if ((packageSelect?.value || "custom") !== "custom") return; currentCustomDraft[name] = Number(getField(name)?.value || 0); - if (preview) preview.innerHTML = renderTenantQuotaPackagePreview("custom", currentCustomDraft); + if (preview) preview.innerHTML = renderTenantQuotaPackagePreview("custom", currentCustomDraft, usage); }); }); syncPreview(); diff --git a/web/storyforge-web-v4/tests/workbench-pages.test.mjs b/web/storyforge-web-v4/tests/workbench-pages.test.mjs index 5099892..55e58c1 100644 --- a/web/storyforge-web-v4/tests/workbench-pages.test.mjs +++ b/web/storyforge-web-v4/tests/workbench-pages.test.mjs @@ -325,13 +325,19 @@ test("quota and review screens foreground live next-step guidance", () => { assert.match(APP, /const TENANT_QUOTA_PACKAGE_PRESETS =/); assert.match(APP, /function getTenantQuotaForecastValues\(values = \{\}, usage = null\)/); assert.match(APP, /function renderTenantQuotaForecastTags\(values, usage = null\)/); + assert.match(APP, /function getTenantQuotaPackageRecommendation\(values = \{\}, usage = null\)/); assert.match(APP, /renderTenantQuotaForecastTags\(currentValues\)/); - assert.match(APP, /function renderTenantQuotaPackagePreview/); + assert.match(APP, /function renderTenantQuotaPackagePreview\(label, values = null, usage = null\)/); assert.match(tenantQuota, /renderTenantQuotaForecastTags\(quota \|\| \{\}, usage\)/); + assert.match(tenantQuota, /packageRecommendation\.title/); assert.match(credits, /renderTenantQuotaForecastTags\(quota \|\| \{\}, usage\)/); + assert.match(credits, /

套餐建议<\/h4>/); + assert.match(credits, /packageRecommendation\.summary/); assert.match(credits, /剩余 \${escapeHtml\(formatNumber\(forecast\.remainingBudgetCents \/ 100\)\)} 元/); assert.match(credits, /文案剩余 \${formatNumber\(forecast\.remainingCopyQuota\)}/); - assert.match(quotaAction, /renderTenantQuotaPackagePreview/); + assert.match(quotaAction, /const usage = appState\.tenantUsage \|\| \{\}/); + assert.match(quotaAction, /renderTenantQuotaPackagePreview\(quotaConfig\.package_label \|\| "custom", defaultDraft, usage\)/); + assert.match(quotaAction, /renderTenantQuotaPackagePreview\("custom", currentCustomDraft, usage\)/); assert.match(APP, /预设已锁定|支持自定义/); assert.match(quotaAction, /input\.disabled = Boolean\(preset\)/); assert.match(quotaAction, /name: "packageLabel"/);