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"/);