From 5dab485e81c0282e5a9dbabffb16dc342398e941 Mon Sep 17 00:00:00 2001 From: kris Date: Sun, 5 Apr 2026 06:04:33 +0800 Subject: [PATCH] feat: add live quota package configurator --- CHANGELOG.md | 6 + web/storyforge-web-v4/assets/app.js | 162 +++++++++++++++++- .../tests/workbench-pages.test.mjs | 5 + 3 files changed, 165 insertions(+), 8 deletions(-) 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/);