From 511ae199a4c0c645a72d040f001adf12a5e65ebe Mon Sep 17 00:00:00 2001 From: Blizzard Date: Wed, 8 Jul 2026 09:18:30 +0800 Subject: [PATCH] =?UTF-8?q?feat(desktop):=20=E9=A1=B6=E6=A0=8F=E7=A7=AF?= =?UTF-8?q?=E5=88=86=E4=BD=99=E9=A2=9D=E8=8A=AF=E7=89=87=20+=20=E6=8F=90?= =?UTF-8?q?=E4=BA=A4=E9=81=87=E4=BD=99=E9=A2=9D=E4=B8=8D=E8=B6=B3=E9=80=8F?= =?UTF-8?q?=E5=87=BA=E5=8F=8B=E5=A5=BD=E6=96=87=E6=A1=88=EF=BC=88P3=20?= =?UTF-8?q?=E7=A7=9F=E6=88=B7=E6=84=9F=E7=9F=A5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 让计费对终端用户可见(守 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 --- sundynix-desktop/frontend/src/App.tsx | 26 +++++++++++++-- sundynix-desktop/frontend/src/lib/api.ts | 32 ++++++++++++++++++- .../frontend/src/shell/TopBar.tsx | 30 ++++++++++++++--- 3 files changed, 81 insertions(+), 7 deletions(-) diff --git a/sundynix-desktop/frontend/src/App.tsx b/sundynix-desktop/frontend/src/App.tsx index eddd0e8..15353d0 100644 --- a/sundynix-desktop/frontend/src/App.tsx +++ b/sundynix-desktop/frontend/src/App.tsx @@ -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> = export default function App() { const [view, setView] = useState("home"); const [user, setUser] = useState(null); + const [tenant, setTenant] = useState(null); const [authLoading, setAuthLoading] = useState(true); const identity = useMemo(() => ({ userId: user?.id ?? "", sessionId: getSessionId() }), [user]); const [run, setRun] = useState(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(() => { 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%)" }} /> - setCmdOpen(true)} /> + setCmdOpen(true)} />
diff --git a/sundynix-desktop/frontend/src/lib/api.ts b/sundynix-desktop/frontend/src/lib/api.ts index db500a6..ffaf16f 100644 --- a/sundynix-desktop/frontend/src/lib/api.ts +++ b/sundynix-desktop/frontend/src/lib/api.ts @@ -104,11 +104,41 @@ export async function submitTask(dsl: TaskDsl, id: Identity): Promise { 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 { + const res = guard401(await fetch(`${GATEWAY}/api/v1/tenants/current`, { headers: bearer() })); + if (!res.ok) return null; + const d = (await res.json()) as Partial; + 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 }> { diff --git a/sundynix-desktop/frontend/src/shell/TopBar.tsx b/sundynix-desktop/frontend/src/shell/TopBar.tsx index f2484d5..946efa2 100644 --- a/sundynix-desktop/frontend/src/shell/TopBar.tsx +++ b/sundynix-desktop/frontend/src/shell/TopBar.tsx @@ -1,6 +1,6 @@ import type { CSSProperties } from "react"; -import { User, ChevronDown, Search as SearchIcon, LogOut, Sun, Moon } from "lucide-react"; -import type { AuthUser } from "../lib/api"; +import { User, ChevronDown, Search as SearchIcon, LogOut, Sun, Moon, Coins } from "lucide-react"; +import type { AuthUser, TenantCtx } from "../lib/api"; import { useHealth } from "../lib/health"; import { isMacDesktop } from "../lib/desktop"; import { useTheme } from "../lib/theme"; @@ -20,8 +20,29 @@ function Light({ on, label }: { on: boolean; label: string }) { ); } -// 顶栏:品牌 · 垂直切换 · 健康灯 · 登录用户 + 登出(深色 + 毛玻璃)。 -export function TopBar({ user, onLogout, onCommand }: { user: AuthUser; onLogout: () => void; onCommand?: () => void }) { +// 积分余额芯片:让用户在主产品里随时看到自己租户剩多少积分。 +// 硬拦截开启且余额≤0 → 红色「余额不足」;偏低 → 橙;否则常态。 +function CreditChip({ tenant }: { tenant: TenantCtx }) { + const credits = tenant.credit_balance_micro / 1_000_000; + const empty = tenant.credit_enforce && credits <= 0; + const low = !empty && credits > 0 && credits < 10; + const tone = empty ? "border-danger/50 text-danger" : low ? "border-warn/50 text-warn" : "border-line text-slate-300"; + return ( + + + {empty ? "余额不足" : credits.toLocaleString("zh-CN", { maximumFractionDigits: credits < 100 ? 1 : 0 })} + + ); +} + +// 顶栏:品牌 · 垂直切换 · 健康灯 · 积分余额 · 登录用户 + 登出(深色 + 毛玻璃)。 +export function TopBar({ user, tenant, onLogout, onCommand }: { user: AuthUser; tenant?: TenantCtx | null; onLogout: () => void; onCommand?: () => void }) { const h = useHealth(); const { theme, toggle } = useTheme(); return ( @@ -64,6 +85,7 @@ export function TopBar({ user, onLogout, onCommand }: { user: AuthUser; onLogout
+ {tenant?.tenant && }