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
+24 -2
View File
@@ -14,7 +14,7 @@ import { Placeholder } from "./views/Placeholder";
import { CommandPalette, type Command } from "./components/CommandPalette";
import { UpdateBanner } from "./components/UpdateBanner";
import { Login } from "./views/Login";
import { submitTask, streamTokens, streamExec, taskStatus, listRuns, authMe, logout, type Identity, type AuthUser } from "./lib/api";
import { submitTask, streamTokens, streamExec, taskStatus, listRuns, authMe, logout, tenantCurrent, type Identity, type AuthUser, type TenantCtx } from "./lib/api";
import type { TaskDsl } from "./lib/dsl";
import { emptyRun, type RunState } from "./lib/run";
import { ToastProvider } from "./ui";
@@ -41,6 +41,7 @@ const PLACEHOLDERS: Partial<Record<ViewKey, { title: string; desc: string }>> =
export default function App() {
const [view, setView] = useState<ViewKey>("home");
const [user, setUser] = useState<AuthUser | null>(null);
const [tenant, setTenant] = useState<TenantCtx | null>(null);
const [authLoading, setAuthLoading] = useState(true);
const identity = useMemo<Identity>(() => ({ userId: user?.id ?? "", sessionId: getSessionId() }), [user]);
const [run, setRun] = useState<RunState>(emptyRun);
@@ -82,8 +83,29 @@ export default function App() {
const onLogout = useCallback(() => {
logout();
setUser(null);
setTenant(null);
}, []);
// 租户上下文 + 积分余额:登录后拉取,并每 20s 轮询保持大致实时(顶栏余额芯片用)。
const refreshTenant = useCallback(() => {
tenantCurrent()
.then(setTenant)
.catch(() => {});
}, []);
useEffect(() => {
if (!user) {
setTenant(null);
return;
}
refreshTenant();
const t = window.setInterval(refreshTenant, 20000);
return () => window.clearInterval(t);
}, [user, refreshTenant]);
// 运行结束余额会被扣,立即刷新一次(比等轮询更跟手)。
useEffect(() => {
if (run.phase === "done") refreshTenant();
}, [run.phase, refreshTenant]);
const commands = useMemo<Command[]>(() => {
const go = (key: ViewKey) => () => setView(key);
return [
@@ -216,7 +238,7 @@ export default function App() {
style={{ background: "radial-gradient(60% 100% at 50% 0%, rgba(124,92,246,0.10), transparent 70%)" }}
/>
<UpdateBanner />
<TopBar user={user} onLogout={onLogout} onCommand={() => setCmdOpen(true)} />
<TopBar user={user} tenant={tenant} onLogout={onLogout} onCommand={() => setCmdOpen(true)} />
<ApprovalBar run={run} />
<div className="relative flex min-h-0 flex-1">
<LeftNav active={view} onSelect={setView} />