feat: productize quota packages and recovery guidance
This commit is contained in:
@@ -4155,7 +4155,8 @@ function renderTenantQuotaPanel() {
|
||||
(quota?.recorder_quota || 0) > 0
|
||||
);
|
||||
const usageCount = recentItems.length;
|
||||
const packageLabel = String(quotaConfig.package_label || "").trim() || (hasHardLimit ? "自定义套餐" : "未设套餐");
|
||||
const packageLabel = String(quotaConfig.package_title || quotaConfig.package_label || "").trim() || (hasHardLimit ? "自定义套餐" : "未设套餐");
|
||||
const packageFocus = String(quotaConfig.package_focus || "").trim();
|
||||
const warnThreshold = Number(quotaConfig.warn_threshold ?? 0.8);
|
||||
const quotaTaskTitle = quota?.storage_over_limit
|
||||
? "先处理存储超限"
|
||||
@@ -4184,7 +4185,7 @@ function renderTenantQuotaPanel() {
|
||||
topCategory ? `<span class="tag">${escapeHtml(`主要消耗 ${topCategory.category || "usage"}`)}</span>` : `<span class="tag">本周期未产生消耗</span>`
|
||||
];
|
||||
const cards = [
|
||||
{ label: "套餐档位", value: packageLabel, sub: `预警阈值 ${formatNumber((warnThreshold || 0.8) * 100)}%` },
|
||||
{ label: "套餐档位", value: packageLabel, sub: packageFocus || `预警阈值 ${formatNumber((warnThreshold || 0.8) * 100)}%` },
|
||||
{ label: "预算", value: `${formatNumber((quota?.monthly_budget_cents || 0) / 100)} 元`, sub: `已用 ${formatNumber((usage?.total_cost_cents || 0) / 100)} 元` },
|
||||
{ label: "分析配额", value: formatNumber(quota?.analysis_quota || 0), sub: `已用 ${formatNumber(categories.analysis?.quantity || 0)}` },
|
||||
{ label: "文案配额", value: formatNumber(quota?.copy_quota || 0), sub: `已用 ${formatNumber(categories.copy?.quantity || 0)}` },
|
||||
@@ -4197,7 +4198,7 @@ function renderTenantQuotaPanel() {
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h3>租户额度与审计</h3>
|
||||
<div class="panel-subtitle">预算、动作配额和最近计量都按租户 + 项目隔离,首屏先看风险和下一步。</div>
|
||||
<div class="panel-subtitle">${escapeHtml(packageFocus || "预算、动作配额和最近计量都按租户 + 项目隔离,首屏先看风险和下一步。")}</div>
|
||||
</div>
|
||||
<div class="task-meta">
|
||||
<span class="tag ${quota?.enabled === false ? "orange" : "green"}">${escapeHtml(quota?.enabled === false ? "已停用额度保护" : "额度保护开启")}</span>
|
||||
@@ -8250,14 +8251,212 @@ function getJobRecoverability(job) {
|
||||
return {
|
||||
...base,
|
||||
state: "manual",
|
||||
label: "需人工处理",
|
||||
reason: "当前链路没有可自动恢复的模板,建议交给管理员处理。",
|
||||
label: "需站内处理",
|
||||
reason: "当前链路没有命中可自动恢复模板,先在站内补齐缺失的素材、源任务、主页或额度,再继续推进。",
|
||||
recoverable: false,
|
||||
actionLabel: "管理员处理",
|
||||
actionLabel: "去生产中心",
|
||||
actionKey: "goto-production"
|
||||
};
|
||||
}
|
||||
|
||||
function getRecoverJobGuidance(job, recovery) {
|
||||
const jobId = String(job?.id || "").trim();
|
||||
const jobTitle = String(job?.title || jobId || "失败任务").trim();
|
||||
const sourceJobId = String(recovery?.sourceJobId || "").trim();
|
||||
const jobDetailAction = jobId
|
||||
? { label: "看任务详情", action: "open-job-detail", attrs: `data-job-id="${escapeHtml(jobId)}"` }
|
||||
: null;
|
||||
const sourceDetailAction = sourceJobId
|
||||
? { label: "看源任务", action: "open-job-detail", attrs: `data-job-id="${escapeHtml(sourceJobId)}"` }
|
||||
: jobDetailAction;
|
||||
const productionAction = { label: "去生产中心", action: "goto-production", attrs: "" };
|
||||
const quotaAction = { label: "去额度", action: "open-tenant-quota", attrs: "" };
|
||||
const uploadAction = { label: "重新上传", action: "open-upload-video", attrs: "" };
|
||||
const importHomepageAction = { label: "去导入主页", action: "open-import-homepage", attrs: "" };
|
||||
const handoffAction = {
|
||||
label: "交给主 Agent",
|
||||
action: "handoff-to-main-agent",
|
||||
attrs: buildMainAgentHandoffAttrs({
|
||||
sourceScreen: "production",
|
||||
sourceActionKey: "recover-job-handoff",
|
||||
intentKey: "custom",
|
||||
title: `处理失败任务 ${jobTitle}`,
|
||||
goal: "结合失败原因和当前任务上下文,给出下一步恢复建议",
|
||||
summary: "主 Agent 会先判断这条任务缺的是素材、额度还是源任务,再给出具体处理路径。",
|
||||
planSteps: ["读取失败任务详情", "判断缺失输入或依赖", "生成下一步恢复建议"]
|
||||
})
|
||||
};
|
||||
const steps = [];
|
||||
const addStep = (title, body, label, action, tone = "orange") => {
|
||||
steps.push({ title, body, label, action, tone });
|
||||
};
|
||||
let primaryAction = sourceDetailAction || productionAction;
|
||||
const secondaryActions = [];
|
||||
const pushSecondaryAction = (action) => {
|
||||
if (!action) return;
|
||||
if (primaryAction && primaryAction.action === action.action && primaryAction.attrs === action.attrs) return;
|
||||
if (secondaryActions.some((item) => item.action === action.action && item.attrs === action.attrs)) return;
|
||||
secondaryActions.push(action);
|
||||
};
|
||||
|
||||
let heading = "先补链路再继续";
|
||||
let summary = recovery?.reason || "当前链路没有命中自动恢复模板,先补齐缺失项后再继续。";
|
||||
|
||||
if (recovery?.state === "blocked") {
|
||||
heading = "先补额度再恢复";
|
||||
summary = recovery.reason || "当前任务被额度拦截,先补完额度后再回到生产中心重试。";
|
||||
addStep("打开额度面板", "查看本周期剩余额度和拦截原因。", "去额度", quotaAction, "orange");
|
||||
addStep("补完额度后回到生产中心", "额度恢复后,再回到生产中心重新发起这条任务。", "去生产中心", productionAction, "blue");
|
||||
pushSecondaryAction(productionAction);
|
||||
pushSecondaryAction(handoffAction);
|
||||
primaryAction = quotaAction;
|
||||
return {
|
||||
heading,
|
||||
summary,
|
||||
primaryAction: quotaAction,
|
||||
secondaryActions,
|
||||
steps,
|
||||
categoryLabel: "额度拦截"
|
||||
};
|
||||
}
|
||||
|
||||
if (recovery?.sourceType === "upload_video") {
|
||||
heading = "先补上传素材";
|
||||
summary = recovery.reason || "上传素材缺失时,先重新上传再继续恢复。";
|
||||
addStep("重新上传原始素材", "补回原文件后,这条任务才能再次入队。", "重新上传", uploadAction, "orange");
|
||||
addStep("如果不确定缺什么,先看任务详情", "任务详情里会保留失败原因和素材信息。", "看任务详情", jobDetailAction, "blue");
|
||||
pushSecondaryAction(jobDetailAction);
|
||||
pushSecondaryAction(handoffAction);
|
||||
primaryAction = uploadAction;
|
||||
return {
|
||||
heading,
|
||||
summary,
|
||||
primaryAction: uploadAction,
|
||||
secondaryActions,
|
||||
steps,
|
||||
categoryLabel: "素材缺失"
|
||||
};
|
||||
}
|
||||
|
||||
if (recovery?.lineType === "real_cut") {
|
||||
heading = "先补实拍源任务";
|
||||
summary = recovery.reason || "实拍剪辑缺少源任务时,先补回源任务再重跑。";
|
||||
addStep("打开源任务", "确认源任务是否已完成,或者是否需要先补回源任务。", "看源任务", sourceDetailAction, "orange");
|
||||
addStep("补完源任务后再发起实拍剪辑", "源任务恢复后,再回到生产中心继续处理。", "去生产中心", productionAction, "blue");
|
||||
pushSecondaryAction(productionAction);
|
||||
pushSecondaryAction(handoffAction);
|
||||
primaryAction = sourceDetailAction;
|
||||
return {
|
||||
heading,
|
||||
summary,
|
||||
primaryAction: sourceDetailAction,
|
||||
secondaryActions,
|
||||
steps,
|
||||
categoryLabel: "实拍剪辑"
|
||||
};
|
||||
}
|
||||
|
||||
if (recovery?.lineType === "ai_video") {
|
||||
heading = "先补 AI 视频源任务";
|
||||
summary = recovery.reason || "AI 视频缺少源任务时,先补回源任务再重跑。";
|
||||
addStep("打开源任务", "确认源任务和当前 brief 是否需要一起补齐。", "看源任务", sourceDetailAction, "orange");
|
||||
addStep("源任务补齐后再继续 AI 视频", "回到生产中心重新发起这条任务。", "去生产中心", productionAction, "blue");
|
||||
pushSecondaryAction(productionAction);
|
||||
pushSecondaryAction(handoffAction);
|
||||
primaryAction = sourceDetailAction;
|
||||
return {
|
||||
heading,
|
||||
summary,
|
||||
primaryAction: sourceDetailAction,
|
||||
secondaryActions,
|
||||
steps,
|
||||
categoryLabel: "AI 视频"
|
||||
};
|
||||
}
|
||||
|
||||
if (recovery?.sourceType === "content_source_sync") {
|
||||
heading = "先补主页再同步";
|
||||
summary = recovery.reason || "内容源同步缺少主页时,先补回主页再触发同步。";
|
||||
addStep("去导入主页", "先把主页地址补回,再重新触发内容源同步。", "去导入主页", importHomepageAction, "orange");
|
||||
addStep("如果主页已存在,直接看任务详情", "任务详情里会保留失败时的输入和状态。", "看任务详情", jobDetailAction, "blue");
|
||||
pushSecondaryAction(jobDetailAction);
|
||||
pushSecondaryAction(handoffAction);
|
||||
primaryAction = importHomepageAction;
|
||||
return {
|
||||
heading,
|
||||
summary,
|
||||
primaryAction: importHomepageAction,
|
||||
secondaryActions,
|
||||
steps,
|
||||
categoryLabel: "主页缺失"
|
||||
};
|
||||
}
|
||||
|
||||
if (recovery?.sourceType === "text" || recovery?.sourceType === "video_link") {
|
||||
heading = "先补输入再恢复";
|
||||
summary = recovery.reason || "原始输入缺失时,先补回输入再继续恢复。";
|
||||
addStep("打开任务详情补输入", "把原始文本或视频链接补回后,再重新发起恢复。", "看任务详情", jobDetailAction, "orange");
|
||||
addStep("如果想让系统代办,交给主 Agent", "主 Agent 会先判断应该补哪一段输入,再给出下一步。", "交给主 Agent", handoffAction, "blue");
|
||||
pushSecondaryAction(handoffAction);
|
||||
pushSecondaryAction(productionAction);
|
||||
primaryAction = jobDetailAction || productionAction;
|
||||
return {
|
||||
heading,
|
||||
summary,
|
||||
primaryAction: jobDetailAction || productionAction,
|
||||
secondaryActions,
|
||||
steps,
|
||||
categoryLabel: "输入缺失"
|
||||
};
|
||||
}
|
||||
|
||||
addStep("打开任务详情", "先确认这条失败任务缺的是素材、源任务、主页还是额度。", "看任务详情", jobDetailAction, "orange");
|
||||
addStep("回到生产中心继续处理", "补完信息后回到生产中心重新判断是否能恢复。", "去生产中心", productionAction, "blue");
|
||||
addStep("交给主 Agent", "如果不确定该补哪一步,可以让主 Agent 先给出处理建议。", "交给主 Agent", handoffAction, "blue");
|
||||
pushSecondaryAction(jobDetailAction);
|
||||
pushSecondaryAction(handoffAction);
|
||||
return {
|
||||
heading,
|
||||
summary,
|
||||
primaryAction: primaryAction || productionAction,
|
||||
secondaryActions,
|
||||
steps,
|
||||
categoryLabel: "站内处理"
|
||||
};
|
||||
}
|
||||
|
||||
function renderRecoverJobGuidanceHtml(job, recovery, guidance) {
|
||||
const primaryAction = guidance?.primaryAction || null;
|
||||
const secondaryActions = safeArray(guidance?.secondaryActions);
|
||||
const steps = safeArray(guidance?.steps);
|
||||
return `
|
||||
<div class="sheet-html">
|
||||
<div class="task-item compact">
|
||||
<h4>${escapeHtml(guidance?.heading || "失败任务处理建议")}</h4>
|
||||
<p>${escapeHtml(guidance?.summary || recovery?.reason || "先补齐缺失项,再继续推进。")}</p>
|
||||
<div class="task-meta">
|
||||
<span class="tag ${recovery?.state === "blocked" ? "red" : "orange"}">${escapeHtml(recovery?.label || "需站内处理")}</span>
|
||||
<span class="tag">${escapeHtml(guidance?.categoryLabel || recovery?.lineType || recovery?.sourceType || "manual")}</span>
|
||||
${primaryAction ? actionTag(primaryAction.label, primaryAction.action, primaryAction.attrs) : ""}
|
||||
${secondaryActions.map((item) => actionTag(item.label, item.action, item.attrs)).join("")}
|
||||
</div>
|
||||
</div>
|
||||
<div class="list">
|
||||
${steps.map((step) => `
|
||||
<div class="task-item compact">
|
||||
<h4>${escapeHtml(step.title)}</h4>
|
||||
<p>${escapeHtml(step.body)}</p>
|
||||
<div class="task-meta">
|
||||
<span class="tag ${step.tone || "orange"}">${escapeHtml(step.label)}</span>
|
||||
${step.action ? actionTag(step.action.label, step.action.action, step.action.attrs) : ""}
|
||||
</div>
|
||||
</div>
|
||||
`).join("")}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function getJobRecoveryRequest(job) {
|
||||
const recovery = getJobRecoverability(job);
|
||||
if (!recovery.recoverable) {
|
||||
@@ -10047,7 +10246,7 @@ function openTenantQuotaAction() {
|
||||
const quotaConfig = quota.config || {};
|
||||
openActionModal({
|
||||
title: "编辑租户额度",
|
||||
description: "当前额度按租户 + 项目隔离,用于商业化预算、动作配额和存储保护。",
|
||||
description: "当前额度按租户 + 项目隔离;选择预设套餐时,服务端会自动应用对应预算、动作池和存储保护。",
|
||||
submitLabel: "保存额度",
|
||||
fields: [
|
||||
{ name: "enabled", label: "启用额度保护", type: "checkbox", value: quota.enabled !== false },
|
||||
@@ -10070,6 +10269,7 @@ function openTenantQuotaAction() {
|
||||
const saved = await storyforgeFetch(`/v2/tenant/quota?project_id=${encodeURIComponent(project.id)}`, {
|
||||
method: "PUT",
|
||||
body: {
|
||||
package_label: String(values.packageLabel || "custom"),
|
||||
enabled: Boolean(values.enabled),
|
||||
monthly_budget_cents: Number(values.monthlyBudgetCents || 0),
|
||||
storage_limit_bytes: Number(values.storageLimitBytes || 0),
|
||||
@@ -10687,45 +10887,16 @@ function openRecoverJobAction(jobId) {
|
||||
}
|
||||
const recovery = getJobRecoverability(job);
|
||||
if (!recovery.recoverable) {
|
||||
const nextActionTag = (() => {
|
||||
if (recovery.actionKey === "open-job-detail") {
|
||||
const targetJobId = recovery.sourceJobId || job.id;
|
||||
return actionTag(recovery.actionLabel || "查看详情", "open-job-detail", `data-job-id="${escapeHtml(targetJobId)}"`);
|
||||
}
|
||||
if (recovery.actionKey) {
|
||||
return actionTag(recovery.actionLabel || "继续处理", recovery.actionKey);
|
||||
}
|
||||
return "";
|
||||
})();
|
||||
const guidance = getRecoverJobGuidance(job, recovery);
|
||||
openActionModal({
|
||||
title: "当前任务需要先处理依赖",
|
||||
description: recovery.reason || "这条任务需要先补信息或转人工处理。",
|
||||
title: "失败任务处理建议",
|
||||
description: guidance.summary || recovery.reason || "先补信息,再继续推进。",
|
||||
hideSubmit: true,
|
||||
fields: [
|
||||
{
|
||||
type: "html",
|
||||
label: "处理建议",
|
||||
html: `
|
||||
<div class="sheet-html">
|
||||
<div class="task-item compact">
|
||||
<h4>${escapeHtml(job.title || job.id)}</h4>
|
||||
<p>${escapeHtml(recovery.reason || "先补回缺失信息,再继续推进这条任务。")}</p>
|
||||
<div class="task-meta">
|
||||
<span class="tag ${recovery.state === "blocked" ? "red" : "orange"}">${escapeHtml(recovery.label || "需人工处理")}</span>
|
||||
${nextActionTag}
|
||||
${actionTag("交给主 Agent", "handoff-to-main-agent", buildMainAgentHandoffAttrs({
|
||||
sourceScreen: "production",
|
||||
sourceActionKey: "recover-job-handoff",
|
||||
intentKey: "custom",
|
||||
title: `处理失败任务 ${job.title || job.id}`,
|
||||
goal: "结合失败原因和当前任务上下文,给出下一步恢复建议",
|
||||
summary: "主 Agent 会先判断这条任务缺的是素材、额度还是源任务,再给出具体处理路径。",
|
||||
planSteps: ["读取失败任务详情", "判断缺失输入或依赖", "生成下一步恢复建议"]
|
||||
}))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
html: renderRecoverJobGuidanceHtml(job, recovery, guidance)
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
@@ -369,6 +369,7 @@ test("tracking refresh and top-video analysis flows expose async feedback inside
|
||||
assert.doesNotMatch(topVideoAction, /当前后端暂不支持.*高分作品批量分析/s);
|
||||
assert.doesNotMatch(topVideoAction, /当前实例未提供/);
|
||||
assert.doesNotMatch(jobRecoverability, /暂时无法自动恢复/);
|
||||
assert.doesNotMatch(jobRecoverability, /当前链路没有可自动恢复的模板/);
|
||||
assert.ok(!jobRecoverability.includes('backendSupports("/v2/explore/jobs/{job_id}/retry") && uploadedPath'));
|
||||
assert.doesNotMatch(recoveryAction, /暂不支持自动恢复|暂不支持恢复/);
|
||||
assert.ok(!recoveryAction.includes('if (backendSupports("/v2/explore/jobs/{job_id}/retry"))'));
|
||||
@@ -1046,13 +1047,26 @@ test("recovery and copy actions continue into the most useful result view", () =
|
||||
const singleRecover = extractBetween(APP, "function openRecoverJobAction(jobId)", "function openBatchRecoverJobsAction()");
|
||||
const batchRecover = extractBetween(APP, "function openBatchRecoverJobsAction()", "function openGenerateCopyAction(defaults = {})");
|
||||
const generateCopy = extractBetween(APP, "function openGenerateCopyAction(defaults = {})", "function openCreateAiVideoAction(defaults = {})");
|
||||
const recoveryGuide = extractBetween(APP, "function getRecoverJobGuidance(job, recovery) {", "function getJobRecoveryRequest(job) {");
|
||||
assert.match(APP, /function focusProductionDetailTab\(tabValue\)/);
|
||||
assert.match(APP, /function focusRecentGeneratedCopy\(\)/);
|
||||
assert.match(singleRecover, /if \(result\?\.created\?\.id\) \{\s*openJobDetailAction\(result\.created\.id\);/);
|
||||
assert.match(singleRecover, /focusProductionDetailTab\("recovery"\)/);
|
||||
assert.match(singleRecover, /当前任务需要先处理依赖/);
|
||||
assert.match(singleRecover, /recover-job-handoff/);
|
||||
assert.match(singleRecover, /actionTag\(recovery\.actionLabel \|\| "查看详情", "open-job-detail"/);
|
||||
assert.match(singleRecover, /失败任务处理建议/);
|
||||
assert.match(singleRecover, /getRecoverJobGuidance\(job, recovery\)/);
|
||||
assert.match(singleRecover, /renderRecoverJobGuidanceHtml\(job, recovery, guidance\)/);
|
||||
assert.match(singleRecover, /sheet-html/);
|
||||
assert.match(singleRecover, /先补信息,再继续推进/);
|
||||
assert.match(recoveryGuide, /state === "blocked"/);
|
||||
assert.match(recoveryGuide, /upload_video/);
|
||||
assert.match(recoveryGuide, /real_cut/);
|
||||
assert.match(recoveryGuide, /ai_video/);
|
||||
assert.match(recoveryGuide, /content_source_sync/);
|
||||
assert.match(recoveryGuide, /text/);
|
||||
assert.match(recoveryGuide, /video_link/);
|
||||
assert.match(recoveryGuide, /去导入主页/);
|
||||
assert.match(recoveryGuide, /交给主 Agent/);
|
||||
assert.match(recoveryGuide, /recover-job-handoff/);
|
||||
assert.match(batchRecover, /if \(successes\[0\]\?\.result\?\.created\?\.id\) \{\s*openJobDetailAction\(successes\[0\]\.result\.created\.id\);/);
|
||||
assert.match(batchRecover, /focusProductionDetailTab\("recovery"\)/);
|
||||
assert.match(generateCopy, /focusRecentGeneratedCopy\(\)/);
|
||||
|
||||
Reference in New Issue
Block a user