feat(desktop): 顶栏积分余额芯片 + 提交遇余额不足透出友好文案(P3 租户感知)

让计费对终端用户可见(守 desktop/admin 边界:只读自己租户,不碰 admin 配置):
- TopBar 加积分余额芯片(Coins 图标):常态显余额;硬拦截开+余额≤0 → 红「余额不足」;
  偏低 → 橙。tooltip 显租户名 + 是否拦截。api.tenantCurrent() 读 /tenants/current。
- App 登录后拉租户上下文,每 20s 轮询 + 运行结束即刷新(余额跟手)。
- submitTask 遇 402 解析后端 error 文案透出(如「租户积分余额不足,请充值后再试」),
  在运行面板报错处显示,用户知道为何被拦。

live 验证(preview):芯片显示 公司A 余额 15.9 ✓;enforce 开+余额0 时 /tenants/current
标 enforce+余额0(芯片转「余额不足」)、提交 402 带友好文案 ✓;tsc + 48 测试全过。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-08 09:18:30 +08:00
parent d5a4670c2f
commit 511ae199a4
3 changed files with 81 additions and 7 deletions
+31 -1
View File
@@ -104,11 +104,41 @@ export async function submitTask(dsl: TaskDsl, id: Identity): Promise<string> {
body: JSON.stringify(dsl),
}),
);
if (!res.ok) throw new Error(`submit failed: ${res.status} ${await res.text()}`);
if (!res.ok) {
// 402=积分/预算不足等业务拒绝:透出后端的友好文案(如「租户积分余额不足,请充值后再试」)。
let msg = `submit failed: ${res.status}`;
try {
const d = (await res.json()) as { error?: string };
if (d.error) msg = d.error;
} catch {
/* 非 JSON 响应,保留状态码文案 */
}
throw new Error(msg);
}
const data = (await res.json()) as { task_id: string };
return data.task_id;
}
// ---- 租户上下文 + 积分余额(面向当前用户自己的租户;只读,不碰 admin 口径)----
export interface TenantCtx {
tenant: { id: string; name: string; plan: string; status: string } | null;
role: string;
credit_balance_micro: number;
credit_enforce: boolean; // 平台是否开了余额硬拦截(开了且余额≤0 会拒绝提交)
}
export async function tenantCurrent(): Promise<TenantCtx | null> {
const res = guard401(await fetch(`${GATEWAY}/api/v1/tenants/current`, { headers: bearer() }));
if (!res.ok) return null;
const d = (await res.json()) as Partial<TenantCtx>;
return {
tenant: d.tenant ?? null,
role: d.role ?? "",
credit_balance_micro: d.credit_balance_micro ?? 0,
credit_enforce: !!d.credit_enforce,
};
}
// taskStatus: GET /api/v1/tasks/:id —— 任务生命周期状态(submitted/running/waiting/done/failed/timeout/rejected)。
// 轮询它来可靠捕获 waiting(审批中断)—— 不依赖易抢跑的实时 exec 事件。
export async function taskStatus(taskId: string): Promise<{ status: string; detail: string }> {