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 && }