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