feat: add live quota package configurator
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled

This commit is contained in:
kris
2026-04-05 06:04:33 +08:00
parent 2cb6d6b1aa
commit 5dab485e81
3 changed files with 165 additions and 8 deletions

View File

@@ -4,6 +4,12 @@
## 2026-04-05
### 额度编辑弹层补成真正的套餐配置器
- `编辑租户额度` 不再只是裸数字表单,而是会即时预览当前套餐的预算、动作池和预警阈值。
- 选择 `试用 / 增长 / 规模` 这类预设套餐时,前端会直接预填并锁定对应额度字段,避免用户误以为这些数值需要手工对齐。
- 切回 `自定义套餐` 时,会恢复当前项目自己的手工额度草稿,继续支持精细化配置。
### 套餐档位真正变成服务端额度预设
- `/v2/tenant/quota` 现在会把 `trial / growth / scale / custom` 视为真正的服务端套餐档位,而不只是前端标签。

View File

@@ -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" : ""}
/>
</div>
`;
@@ -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 `
<div class="task-item compact">
<h4>${escapeHtml(packageTitle)}</h4>
<p>${escapeHtml(packageDescription)}</p>
<div class="task-meta">
<span class="tag blue">${escapeHtml(packageLabel)}</span>
<span class="tag">${escapeHtml(`预警 ${(warnThreshold * 100).toFixed(0)}%`)}</span>
${preset ? `<span class="tag green">预设已锁定</span>` : `<span class="tag orange">支持自定义</span>`}
</div>
<p>${escapeHtml(packageFocus)}</p>
<div class="task-meta">
<span class="tag">${escapeHtml(`预算 ${formatNumber(Number(currentValues.monthlyBudgetCents || 0) / 100)}`)}</span>
<span class="tag">${escapeHtml(`文案 ${formatNumber(currentValues.copyQuota || 0)}`)}</span>
<span class="tag">${escapeHtml(`AI 视频 ${formatNumber(currentValues.aiVideoQuota || 0)}`)}</span>
<span class="tag">${escapeHtml(`实拍剪辑 ${formatNumber(currentValues.realCutQuota || 0)}`)}</span>
</div>
</div>
`;
}
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: `<div data-role="quota-package-preview">${renderTenantQuotaPackagePreview(quotaConfig.package_label || "custom", defaultDraft)}</div>` },
{ 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",

View File

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