diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6321201..ba61c1f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,12 @@
## 2026-04-05
+### 额度编辑弹层补成真正的套餐配置器
+
+- `编辑租户额度` 不再只是裸数字表单,而是会即时预览当前套餐的预算、动作池和预警阈值。
+- 选择 `试用 / 增长 / 规模` 这类预设套餐时,前端会直接预填并锁定对应额度字段,避免用户误以为这些数值需要手工对齐。
+- 切回 `自定义套餐` 时,会恢复当前项目自己的手工额度草稿,继续支持精细化配置。
+
### 套餐档位真正变成服务端额度预设
- `/v2/tenant/quota` 现在会把 `trial / growth / scale / custom` 视为真正的服务端套餐档位,而不只是前端标签。
diff --git a/web/storyforge-web-v4/assets/app.js b/web/storyforge-web-v4/assets/app.js
index 900df2b..9b6a400 100644
--- a/web/storyforge-web-v4/assets/app.js
+++ b/web/storyforge-web-v4/assets/app.js
@@ -972,6 +972,9 @@ function renderActionFields(fields) {
placeholder="${escapeHtml(field.placeholder || "")}"
${field.min != null ? `min="${escapeHtml(field.min)}"` : ""}
${field.max != null ? `max="${escapeHtml(field.max)}"` : ""}
+ ${field.step != null ? `step="${escapeHtml(field.step)}"` : ""}
+ ${field.readonly ? "readonly" : ""}
+ ${field.disabled ? "disabled" : ""}
/>
`;
@@ -7569,6 +7572,80 @@ function renderCreditsScreen() {
);
}
+const TENANT_QUOTA_PACKAGE_PRESETS = {
+ trial: {
+ title: "试用套餐",
+ description: "适合先跑通主流程的小规模项目,预算和动作池会优先保护试错成本。",
+ focus: "先验证项目是否跑得通,再决定是否扩容。",
+ warnThreshold: 0.7,
+ monthlyBudgetCents: 9900,
+ storageLimitBytes: 5 * 1024 * 1024 * 1024,
+ analysisQuota: 30,
+ copyQuota: 60,
+ aiVideoQuota: 2,
+ realCutQuota: 1,
+ recorderQuota: 4
+ },
+ growth: {
+ title: "增长套餐",
+ description: "适合已经形成固定内容节奏的项目,兼顾分析、文案和视频动作的持续投放。",
+ focus: "先把稳定增长跑顺,再看哪里需要单独加码。",
+ warnThreshold: 0.8,
+ monthlyBudgetCents: 49900,
+ storageLimitBytes: 20 * 1024 * 1024 * 1024,
+ analysisQuota: 160,
+ copyQuota: 320,
+ aiVideoQuota: 12,
+ realCutQuota: 8,
+ recorderQuota: 20
+ },
+ scale: {
+ title: "规模套餐",
+ description: "适合多账号、多批次的量产项目,预算、存储和视频动作都会按高负载配置。",
+ focus: "优先保证量产吞吐,再按平台专项做局部优化。",
+ warnThreshold: 0.85,
+ monthlyBudgetCents: 199000,
+ storageLimitBytes: 80 * 1024 * 1024 * 1024,
+ analysisQuota: 800,
+ copyQuota: 1600,
+ aiVideoQuota: 40,
+ realCutQuota: 24,
+ recorderQuota: 80
+ }
+};
+
+function getTenantQuotaPackagePreset(label) {
+ return TENANT_QUOTA_PACKAGE_PRESETS[String(label || "").trim().toLowerCase()] || null;
+}
+
+function renderTenantQuotaPackagePreview(label, values = null) {
+ const preset = getTenantQuotaPackagePreset(label);
+ const packageLabel = String(label || "custom").trim() || "custom";
+ const packageTitle = preset?.title || "自定义套餐";
+ const packageDescription = preset?.description || "按当前项目的预算、动作池和阶段手动配置套餐。";
+ const packageFocus = preset?.focus || "适合已经明确成本模型或需要特殊额度策略的项目。";
+ const currentValues = values || {};
+ const warnThreshold = Number(currentValues.warnThreshold ?? preset?.warnThreshold ?? 0.8) || 0.8;
+ return `
+
+
${escapeHtml(packageTitle)}
+
${escapeHtml(packageDescription)}
+
+ ${escapeHtml(packageLabel)}
+ ${escapeHtml(`预警 ${(warnThreshold * 100).toFixed(0)}%`)}
+ ${preset ? `预设已锁定` : `支持自定义`}
+
+
${escapeHtml(packageFocus)}
+
+ ${escapeHtml(`预算 ${formatNumber(Number(currentValues.monthlyBudgetCents || 0) / 100)} 元`)}
+ ${escapeHtml(`文案 ${formatNumber(currentValues.copyQuota || 0)}`)}
+ ${escapeHtml(`AI 视频 ${formatNumber(currentValues.aiVideoQuota || 0)}`)}
+ ${escapeHtml(`实拍剪辑 ${formatNumber(currentValues.realCutQuota || 0)}`)}
+
+
+ `;
+}
+
function renderSettingsScreen() {
const session = appState.session;
const project = getSelectedProject();
@@ -10244,6 +10321,16 @@ function openTenantQuotaAction() {
const project = requireSelectedProject();
const quota = appState.tenantQuota || {};
const quotaConfig = quota.config || {};
+ const defaultDraft = {
+ warnThreshold: Number(quotaConfig.warn_threshold ?? 0.8) || 0.8,
+ monthlyBudgetCents: Number(quota.monthly_budget_cents || 0),
+ storageLimitBytes: Number(quota.storage_limit_bytes || 0),
+ analysisQuota: Number(quota.analysis_quota || 0),
+ copyQuota: Number(quota.copy_quota || 0),
+ aiVideoQuota: Number(quota.ai_video_quota || 0),
+ realCutQuota: Number(quota.real_cut_quota || 0),
+ recorderQuota: Number(quota.recorder_quota || 0)
+ };
openActionModal({
title: "编辑租户额度",
description: "当前额度按租户 + 项目隔离;选择预设套餐时,服务端会自动应用对应预算、动作池和存储保护。",
@@ -10256,15 +10343,74 @@ function openTenantQuotaAction() {
{ 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 },
- { name: "copyQuota", label: "文案配额", type: "number", value: quota.copy_quota || 0, min: 0 },
- { name: "aiVideoQuota", label: "AI 视频配额", type: "number", value: quota.ai_video_quota || 0, min: 0 },
- { name: "realCutQuota", label: "实拍剪辑配额", type: "number", value: quota.real_cut_quota || 0, min: 0 },
- { name: "recorderQuota", label: "录制配额", type: "number", value: quota.recorder_quota || 0, min: 0 }
+ { type: "html", label: "套餐预览", html: `${renderTenantQuotaPackagePreview(quotaConfig.package_label || "custom", defaultDraft)}
` },
+ { 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 },
+ { name: "analysisQuota", label: "分析配额", type: "number", value: defaultDraft.analysisQuota, min: 0, step: 1 },
+ { name: "copyQuota", label: "文案配额", type: "number", value: defaultDraft.copyQuota, min: 0, step: 1 },
+ { name: "aiVideoQuota", label: "AI 视频配额", type: "number", value: defaultDraft.aiVideoQuota, min: 0, step: 1 },
+ { name: "realCutQuota", label: "实拍剪辑配额", type: "number", value: defaultDraft.realCutQuota, min: 0, step: 1 },
+ { name: "recorderQuota", label: "录制配额", type: "number", value: defaultDraft.recorderQuota, min: 0, step: 1 }
],
+ onOpen: ({ fields }) => {
+ const packageSelect = fields.querySelector('[data-action-field="packageLabel"]');
+ const preview = fields.querySelector('[data-role="quota-package-preview"]');
+ const numericFieldNames = [
+ "warnThreshold",
+ "monthlyBudgetCents",
+ "storageLimitBytes",
+ "analysisQuota",
+ "copyQuota",
+ "aiVideoQuota",
+ "realCutQuota",
+ "recorderQuota"
+ ];
+ const currentCustomDraft = { ...defaultDraft };
+ const getField = (name) => fields.querySelector(`[data-action-field="${name}"]`);
+ const syncPreview = () => {
+ const packageLabel = packageSelect?.value || "custom";
+ const preset = getTenantQuotaPackagePreset(packageLabel);
+ if (preset) {
+ currentCustomDraft.warnThreshold = Number(getField("warnThreshold")?.value || currentCustomDraft.warnThreshold || 0.8);
+ currentCustomDraft.monthlyBudgetCents = Number(getField("monthlyBudgetCents")?.value || currentCustomDraft.monthlyBudgetCents || 0);
+ currentCustomDraft.storageLimitBytes = Number(getField("storageLimitBytes")?.value || currentCustomDraft.storageLimitBytes || 0);
+ currentCustomDraft.analysisQuota = Number(getField("analysisQuota")?.value || currentCustomDraft.analysisQuota || 0);
+ currentCustomDraft.copyQuota = Number(getField("copyQuota")?.value || currentCustomDraft.copyQuota || 0);
+ currentCustomDraft.aiVideoQuota = Number(getField("aiVideoQuota")?.value || currentCustomDraft.aiVideoQuota || 0);
+ currentCustomDraft.realCutQuota = Number(getField("realCutQuota")?.value || currentCustomDraft.realCutQuota || 0);
+ currentCustomDraft.recorderQuota = Number(getField("recorderQuota")?.value || currentCustomDraft.recorderQuota || 0);
+ }
+ const nextValues = preset ? {
+ warnThreshold: preset.warnThreshold,
+ monthlyBudgetCents: preset.monthlyBudgetCents,
+ storageLimitBytes: preset.storageLimitBytes,
+ analysisQuota: preset.analysisQuota,
+ copyQuota: preset.copyQuota,
+ aiVideoQuota: preset.aiVideoQuota,
+ realCutQuota: preset.realCutQuota,
+ recorderQuota: preset.recorderQuota
+ } : currentCustomDraft;
+ numericFieldNames.forEach((name) => {
+ const input = getField(name);
+ if (!input) return;
+ input.value = String(nextValues[name] ?? "");
+ input.disabled = Boolean(preset);
+ });
+ if (preview) {
+ preview.innerHTML = renderTenantQuotaPackagePreview(packageLabel, nextValues);
+ }
+ };
+ packageSelect?.addEventListener("change", syncPreview);
+ numericFieldNames.forEach((name) => {
+ getField(name)?.addEventListener("input", () => {
+ if ((packageSelect?.value || "custom") !== "custom") return;
+ currentCustomDraft[name] = Number(getField(name)?.value || 0);
+ if (preview) preview.innerHTML = renderTenantQuotaPackagePreview("custom", currentCustomDraft);
+ });
+ });
+ syncPreview();
+ },
onSubmit: async (values) => {
const saved = await storyforgeFetch(`/v2/tenant/quota?project_id=${encodeURIComponent(project.id)}`, {
method: "PUT",
diff --git a/web/storyforge-web-v4/tests/workbench-pages.test.mjs b/web/storyforge-web-v4/tests/workbench-pages.test.mjs
index 86351be..248edee 100644
--- a/web/storyforge-web-v4/tests/workbench-pages.test.mjs
+++ b/web/storyforge-web-v4/tests/workbench-pages.test.mjs
@@ -318,6 +318,11 @@ test("quota and review screens foreground live next-step guidance", () => {
assert.match(credits, /预算、动作池和项目阶段绑定成正式套餐/);
assert.match(tenantQuota, /套餐档位/);
assert.match(tenantQuota, /预警阈值|预警 80%/);
+ assert.match(APP, /const TENANT_QUOTA_PACKAGE_PRESETS =/);
+ assert.match(APP, /function renderTenantQuotaPackagePreview/);
+ assert.match(quotaAction, /renderTenantQuotaPackagePreview/);
+ assert.match(APP, /预设已锁定|支持自定义/);
+ assert.match(quotaAction, /input\.disabled = Boolean\(preset\)/);
assert.match(quotaAction, /name: "packageLabel"/);
assert.match(quotaAction, /name: "warnThreshold"/);
assert.match(quotaAction, /package_label/);