feat: recommend quota packages from live usage
This commit is contained in:
@@ -4,6 +4,12 @@
|
|||||||
|
|
||||||
## 2026-04-07
|
## 2026-04-07
|
||||||
|
|
||||||
|
### 额度策略开始按真实用量给出套餐建议
|
||||||
|
|
||||||
|
- `租户额度与审计` 和 `额度` 工作区现在会根据当前项目最近的预算消耗、视频动作量、文案动作量和存储使用,直接给出 `试用 / 增长 / 规模 / 自定义` 的套餐建议。
|
||||||
|
- `编辑租户额度` 弹层里的套餐预览也开始带上这层建议,不再只是静态展示当前选择的套餐说明;切换预设或继续调整自定义额度时,建议会跟着实时刷新。
|
||||||
|
- 这让额度页从“只展示当前配额”继续往“告诉你现在更适合哪档套餐”收了一层,也把预算、动作池和真实使用节奏更明确地连在一起。
|
||||||
|
|
||||||
### 创作表单开始跟随来源任务动态刷新推荐值
|
### 创作表单开始跟随来源任务动态刷新推荐值
|
||||||
|
|
||||||
- `生成文案 / 创建 AI 视频 / 创建实拍剪辑 / 写复盘` 这四类创作表单,现在不只会在打开时算一次默认值;如果你在表单里切换来源任务,平台、标题、受众、画幅、时长、目标这些推荐值会继续跟着刷新。
|
- `生成文案 / 创建 AI 视频 / 创建实拍剪辑 / 写复盘` 这四类创作表单,现在不只会在打开时算一次默认值;如果你在表单里切换来源任务,平台、标题、受众、画幅、时长、目标这些推荐值会继续跟着刷新。
|
||||||
|
|||||||
@@ -4848,6 +4848,10 @@ function renderTenantQuotaPanel() {
|
|||||||
const packageFocus = String(quotaConfig.package_focus || "").trim();
|
const packageFocus = String(quotaConfig.package_focus || "").trim();
|
||||||
const warnThreshold = Number(quotaConfig.warn_threshold ?? 0.8);
|
const warnThreshold = Number(quotaConfig.warn_threshold ?? 0.8);
|
||||||
const forecast = getTenantQuotaForecastValues(quota || {}, usage);
|
const forecast = getTenantQuotaForecastValues(quota || {}, usage);
|
||||||
|
const packageRecommendation = getTenantQuotaPackageRecommendation({
|
||||||
|
...quota,
|
||||||
|
package_label: quotaConfig.package_label || "custom"
|
||||||
|
}, usage);
|
||||||
const quotaTaskTitle = quota?.storage_over_limit
|
const quotaTaskTitle = quota?.storage_over_limit
|
||||||
? "先处理存储超限"
|
? "先处理存储超限"
|
||||||
: quota?.enabled === false
|
: quota?.enabled === false
|
||||||
@@ -4872,7 +4876,8 @@ function renderTenantQuotaPanel() {
|
|||||||
const quotaHealthTags = [
|
const quotaHealthTags = [
|
||||||
quota?.enabled === false ? `<span class="tag orange">额度保护关闭</span>` : `<span class="tag green">额度保护开启</span>`,
|
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>`,
|
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 = [
|
const cards = [
|
||||||
{ label: "套餐档位", value: packageLabel, sub: packageFocus || `预警阈值 ${formatNumber((warnThreshold || 0.8) * 100)}%` },
|
{ label: "套餐档位", value: packageLabel, sub: packageFocus || `预警阈值 ${formatNumber((warnThreshold || 0.8) * 100)}%` },
|
||||||
@@ -8244,6 +8249,14 @@ function renderCreditsScreen() {
|
|||||||
<h4>使用建议</h4>
|
<h4>使用建议</h4>
|
||||||
<p>${escapeHtml((quota?.enabled === false) ? "当前额度保护已关闭,适合内部联调,不适合正式对外。":"当前额度保护已开启,适合逐步转向对外产品表达。")}</p>
|
<p>${escapeHtml((quota?.enabled === false) ? "当前额度保护已关闭,适合内部联调,不适合正式对外。":"当前额度保护已开启,适合逐步转向对外产品表达。")}</p>
|
||||||
</div>
|
</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>
|
</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 preset = getTenantQuotaPackagePreset(label);
|
||||||
const packageLabel = String(label || "custom").trim() || "custom";
|
const packageLabel = String(label || "custom").trim() || "custom";
|
||||||
const packageTitle = preset?.title || "自定义套餐";
|
const packageTitle = preset?.title || "自定义套餐";
|
||||||
@@ -8368,6 +8438,7 @@ function renderTenantQuotaPackagePreview(label, values = null) {
|
|||||||
const packageFocus = preset?.focus || "适合已经明确成本模型或需要特殊额度策略的项目。";
|
const packageFocus = preset?.focus || "适合已经明确成本模型或需要特殊额度策略的项目。";
|
||||||
const currentValues = values || {};
|
const currentValues = values || {};
|
||||||
const warnThreshold = Number(currentValues.warnThreshold ?? preset?.warnThreshold ?? 0.8) || 0.8;
|
const warnThreshold = Number(currentValues.warnThreshold ?? preset?.warnThreshold ?? 0.8) || 0.8;
|
||||||
|
const recommendation = getTenantQuotaPackageRecommendation({ ...currentValues, packageLabel }, usage);
|
||||||
return `
|
return `
|
||||||
<div class="task-item compact">
|
<div class="task-item compact">
|
||||||
<h4>${escapeHtml(packageTitle)}</h4>
|
<h4>${escapeHtml(packageTitle)}</h4>
|
||||||
@@ -8378,6 +8449,14 @@ function renderTenantQuotaPackagePreview(label, values = null) {
|
|||||||
${preset ? `<span class="tag green">预设已锁定</span>` : `<span class="tag orange">支持自定义</span>`}
|
${preset ? `<span class="tag green">预设已锁定</span>` : `<span class="tag orange">支持自定义</span>`}
|
||||||
</div>
|
</div>
|
||||||
<p>${escapeHtml(packageFocus)}</p>
|
<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">
|
<div class="task-meta">
|
||||||
<span class="tag">${escapeHtml(`预算 ${formatNumber(Number(currentValues.monthlyBudgetCents || 0) / 100)} 元`)}</span>
|
<span class="tag">${escapeHtml(`预算 ${formatNumber(Number(currentValues.monthlyBudgetCents || 0) / 100)} 元`)}</span>
|
||||||
<span class="tag">${escapeHtml(`文案 ${formatNumber(currentValues.copyQuota || 0)}`)}</span>
|
<span class="tag">${escapeHtml(`文案 ${formatNumber(currentValues.copyQuota || 0)}`)}</span>
|
||||||
@@ -11071,6 +11150,7 @@ function openActionRegistryEditAction(actionKey) {
|
|||||||
function openTenantQuotaAction() {
|
function openTenantQuotaAction() {
|
||||||
const project = requireSelectedProject();
|
const project = requireSelectedProject();
|
||||||
const quota = appState.tenantQuota || {};
|
const quota = appState.tenantQuota || {};
|
||||||
|
const usage = appState.tenantUsage || {};
|
||||||
const quotaConfig = quota.config || {};
|
const quotaConfig = quota.config || {};
|
||||||
const defaultDraft = {
|
const defaultDraft = {
|
||||||
warnThreshold: Number(quotaConfig.warn_threshold ?? 0.8) || 0.8,
|
warnThreshold: Number(quotaConfig.warn_threshold ?? 0.8) || 0.8,
|
||||||
@@ -11094,7 +11174,7 @@ function openTenantQuotaAction() {
|
|||||||
{ value: "scale", label: "规模套餐" },
|
{ value: "scale", label: "规模套餐" },
|
||||||
{ value: "custom", 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: "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: "monthlyBudgetCents", label: "月预算(分)", type: "number", value: defaultDraft.monthlyBudgetCents, min: 0, step: 100 },
|
||||||
{ name: "storageLimitBytes", label: "存储上限(字节)", type: "number", value: defaultDraft.storageLimitBytes, min: 0, step: 1024 },
|
{ name: "storageLimitBytes", label: "存储上限(字节)", type: "number", value: defaultDraft.storageLimitBytes, min: 0, step: 1024 },
|
||||||
@@ -11149,7 +11229,7 @@ function openTenantQuotaAction() {
|
|||||||
input.disabled = Boolean(preset);
|
input.disabled = Boolean(preset);
|
||||||
});
|
});
|
||||||
if (preview) {
|
if (preview) {
|
||||||
preview.innerHTML = renderTenantQuotaPackagePreview(packageLabel, nextValues);
|
preview.innerHTML = renderTenantQuotaPackagePreview(packageLabel, nextValues, usage);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
packageSelect?.addEventListener("change", syncPreview);
|
packageSelect?.addEventListener("change", syncPreview);
|
||||||
@@ -11157,7 +11237,7 @@ function openTenantQuotaAction() {
|
|||||||
getField(name)?.addEventListener("input", () => {
|
getField(name)?.addEventListener("input", () => {
|
||||||
if ((packageSelect?.value || "custom") !== "custom") return;
|
if ((packageSelect?.value || "custom") !== "custom") return;
|
||||||
currentCustomDraft[name] = Number(getField(name)?.value || 0);
|
currentCustomDraft[name] = Number(getField(name)?.value || 0);
|
||||||
if (preview) preview.innerHTML = renderTenantQuotaPackagePreview("custom", currentCustomDraft);
|
if (preview) preview.innerHTML = renderTenantQuotaPackagePreview("custom", currentCustomDraft, usage);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
syncPreview();
|
syncPreview();
|
||||||
|
|||||||
@@ -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, /const TENANT_QUOTA_PACKAGE_PRESETS =/);
|
||||||
assert.match(APP, /function getTenantQuotaForecastValues\(values = \{\}, usage = null\)/);
|
assert.match(APP, /function getTenantQuotaForecastValues\(values = \{\}, usage = null\)/);
|
||||||
assert.match(APP, /function renderTenantQuotaForecastTags\(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, /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, /renderTenantQuotaForecastTags\(quota \|\| \{\}, usage\)/);
|
||||||
|
assert.match(tenantQuota, /packageRecommendation\.title/);
|
||||||
assert.match(credits, /renderTenantQuotaForecastTags\(quota \|\| \{\}, usage\)/);
|
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, /剩余 \${escapeHtml\(formatNumber\(forecast\.remainingBudgetCents \/ 100\)\)} 元/);
|
||||||
assert.match(credits, /文案剩余 \${formatNumber\(forecast\.remainingCopyQuota\)}/);
|
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(APP, /预设已锁定|支持自定义/);
|
||||||
assert.match(quotaAction, /input\.disabled = Boolean\(preset\)/);
|
assert.match(quotaAction, /input\.disabled = Boolean\(preset\)/);
|
||||||
assert.match(quotaAction, /name: "packageLabel"/);
|
assert.match(quotaAction, /name: "packageLabel"/);
|
||||||
|
|||||||
Reference in New Issue
Block a user