feat: recommend quota packages from live usage
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-07 13:50:28 +08:00
parent b31c338120
commit 206599551a
3 changed files with 99 additions and 7 deletions

View File

@@ -4,6 +4,12 @@
## 2026-04-07
### 额度策略开始按真实用量给出套餐建议
- `租户额度与审计``额度` 工作区现在会根据当前项目最近的预算消耗、视频动作量、文案动作量和存储使用,直接给出 `试用 / 增长 / 规模 / 自定义` 的套餐建议。
- `编辑租户额度` 弹层里的套餐预览也开始带上这层建议,不再只是静态展示当前选择的套餐说明;切换预设或继续调整自定义额度时,建议会跟着实时刷新。
- 这让额度页从“只展示当前配额”继续往“告诉你现在更适合哪档套餐”收了一层,也把预算、动作池和真实使用节奏更明确地连在一起。
### 创作表单开始跟随来源任务动态刷新推荐值
- `生成文案 / 创建 AI 视频 / 创建实拍剪辑 / 写复盘` 这四类创作表单,现在不只会在打开时算一次默认值;如果你在表单里切换来源任务,平台、标题、受众、画幅、时长、目标这些推荐值会继续跟着刷新。

View File

@@ -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 ? `<span class="tag orange">额度保护关闭</span>` : `<span class="tag green">额度保护开启</span>`,
quota?.storage_over_limit ? `<span class="tag red">存储超限</span>` : `<span class="tag blue">存储正常</span>`,
topCategory ? `<span class="tag">${escapeHtml(`主要消耗 ${topCategory.category || "usage"}`)}</span>` : `<span class="tag">本周期未产生消耗</span>`
topCategory ? `<span class="tag">${escapeHtml(`主要消耗 ${topCategory.category || "usage"}`)}</span>` : `<span class="tag">本周期未产生消耗</span>`,
`<span class="tag ${escapeHtml(packageRecommendation.statusTone)}">${escapeHtml(`推荐 ${packageRecommendation.title}`)}</span>`
];
const cards = [
{ label: "套餐档位", value: packageLabel, sub: packageFocus || `预警阈值 ${formatNumber((warnThreshold || 0.8) * 100)}%` },
@@ -8244,6 +8249,14 @@ function renderCreditsScreen() {
<h4>使用建议</h4>
<p>${escapeHtml((quota?.enabled === false) ? "当前额度保护已关闭,适合内部联调,不适合正式对外。":"当前额度保护已开启,适合逐步转向对外产品表达。")}</p>
</div>
<div class="task-item">
<h4>套餐建议</h4>
<p>${escapeHtml(packageRecommendation.summary)}</p>
<div class="task-meta">
<span class="tag ${escapeHtml(packageRecommendation.statusTone)}">${escapeHtml(`推荐 ${packageRecommendation.title}`)}</span>
<span class="tag">${escapeHtml(packageRecommendation.reason)}</span>
</div>
</div>
</div>
</div>
</div>
@@ -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 `
<div class="task-item compact">
<h4>${escapeHtml(packageTitle)}</h4>
@@ -8378,6 +8449,14 @@ function renderTenantQuotaPackagePreview(label, values = null) {
${preset ? `<span class="tag green">预设已锁定</span>` : `<span class="tag orange">支持自定义</span>`}
</div>
<p>${escapeHtml(packageFocus)}</p>
<div class="task-item compact" style="margin-top:10px;">
<h4>套餐建议</h4>
<p>${escapeHtml(recommendation.summary)}</p>
<div class="task-meta">
<span class="tag ${escapeHtml(recommendation.statusTone)}">${escapeHtml(`推荐 ${recommendation.title}`)}</span>
<span class="tag">${escapeHtml(recommendation.reason)}</span>
</div>
</div>
<div class="task-meta">
<span class="tag">${escapeHtml(`预算 ${formatNumber(Number(currentValues.monthlyBudgetCents || 0) / 100)}`)}</span>
<span class="tag">${escapeHtml(`文案 ${formatNumber(currentValues.copyQuota || 0)}`)}</span>
@@ -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: `<div data-role="quota-package-preview">${renderTenantQuotaPackagePreview(quotaConfig.package_label || "custom", defaultDraft)}</div>` },
{ type: "html", label: "套餐预览", html: `<div data-role="quota-package-preview">${renderTenantQuotaPackagePreview(quotaConfig.package_label || "custom", defaultDraft, usage)}</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 },
@@ -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();

View File

@@ -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>套餐建议<\/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"/);