feat(web): 薄 Web 面 sundynix-web —— 注册/组织/团队/账单自助入口
CI / Go · build + vet + test (pull_request) Failing after 30m3s
CI / Frontend · tsc (sundynix-admin) (pull_request) Failing after 9m42s
CI / Frontend · tsc (sundynix-desktop/frontend) (pull_request) Failing after 16m53s
CI / Frontend · tsc (sundynix-web) (pull_request) Has been cancelled
CI / mcp-py · sandbox guard (pull_request) Has been cancelled

SaaS P3 收口第二刀(设计见 SAAS_DESIGN.md §8)。第三个产品面:desktop=用户
工作产品、admin=平台超管控制塔、web=租户客户的自助柜台——做「装桌面端之前
就要能用」的那些事,三者不合并。

- 骨架抄 admin(HashRouter+路由注册表+me()/sdx:logout 鉴权门+vite/vitest
  单文件配置);UI 整目录搬 desktop 的 ui/ 组件+ink/brand 主题 token(亮暗
  双主题),品牌观感与桌面端一致;api.ts 抄 desktop 的 auth/tenant/usage 段
  (纯 fetch 零 Wails 依赖),成员管理/建组织打新的租户自助接口。
- 页面:登录注册(一页两态)/概览(组织+角色+余额+桌面端下载指引)/团队(名册+
  邀请+改角色+移除,写控件按角色显隐、真闸在后端)/组织(列表+自助新建+切换)/
  用量与账单(余额 hero+趋势+合计+最近消耗,数据全来自现成 /me/usage;充值
  只放说明占位,支付 P5 再接,不做假入口)。
- 工程:vite :5175、launch.json 配置、make webface、ci.yml web 矩阵纳入。
- 测试:tsc 干净;vitest 3 例(fmtCredits 去尾零——测试先行抓出 1.50 毛刺;
  ASSIGNABLE_ROLES 不含 owner)。浏览器 live 全流程:甲(owner)登录→概览→
  团队邀请乙→乙(member)登录写控件全隐藏、名册只读,组织/用量页真数据渲染。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-16 17:47:23 +08:00
parent 65340616df
commit 7c67256f87
36 changed files with 6505 additions and 2 deletions
+95
View File
@@ -0,0 +1,95 @@
import { useEffect, useState } from "react";
import { Coins, ReceiptText } from "lucide-react";
import { useTenant } from "../shell/AppShell";
import { myUsage, fmtCredits, type MyUsage } from "../api";
import { Badge, Panel, Table, Tr, Td, cn } from "../ui";
// 用量与账单:余额 + 消耗趋势 + 区间合计 + 最近消耗(数据全部来自 /me/usage)。
// 充值本期只有说明占位——支付是 P5,先不做假入口。
export function Usage() {
const { ctx } = useTenant();
const [days, setDays] = useState(7);
const [u, setU] = useState<MyUsage | null>(null);
useEffect(() => {
myUsage(days).then(setU).catch(() => {});
}, [days, ctx?.tenant?.id]);
const maxTok = Math.max(...(u?.trend.map((d) => d.total_tok) ?? []), 1);
const fmtTok = (n: number) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}K` : `${n}`);
return (
<div className="mx-auto max-w-3xl space-y-4 p-8">
<div>
<h1 className="text-xl font-semibold tracking-tight text-slate-100"></h1>
<p className="mt-1 text-xs text-slate-500">{ctx?.tenant?.name ?? "…"} · </p>
</div>
{/* 余额 hero */}
<div className="rounded-xl border border-line bg-ink-900 p-5 shadow-card">
<div className="flex items-end justify-between">
<div>
<div className="text-xs text-slate-500"></div>
<div className="mt-1 text-3xl font-semibold tabular-nums text-slate-100">
{fmtCredits(u?.balance_micro ?? ctx?.credit_balance_micro ?? 0)}
<span className="ml-1.5 text-sm font-normal text-slate-500"></span>
</div>
</div>
{u?.credit_enforce && (u?.balance_micro ?? 0) <= 0 ? (
<Badge tone="danger"></Badge>
) : null}
</div>
<p className="mt-3 text-[11px] leading-relaxed text-slate-600">
线线
</p>
</div>
{/* 趋势 */}
<Panel
title="消耗趋势"
icon={Coins}
actions={
<div className="flex items-center gap-1 rounded-md border border-line p-0.5">
{[7, 30].map((d) => (
<button key={d} onClick={() => setDays(d)}
className={cn("rounded px-2 py-0.5 text-[11px] transition", days === d ? "bg-ink-800 text-slate-100" : "text-slate-500 hover:text-slate-300")}>
{d}
</button>
))}
</div>
}>
<div className="flex h-28 items-end gap-1.5">
{(u?.trend ?? []).map((d) => (
<div key={d.day} className="flex flex-1 flex-col items-center gap-1">
<div className="w-full rounded-t bg-brand/60 transition-all hover:bg-brand"
style={{ height: `${Math.max((d.total_tok / maxTok) * 100, 2)}%` }}
title={`${d.day}${fmtTok(d.total_tok)} tok · ${fmtCredits(d.credits_micro)} 积分 · ${d.task_count}`} />
<span className="text-[9px] tabular-nums text-slate-600">{d.day.slice(-2)}</span>
</div>
))}
{(!u || u.trend.length === 0) && <p className="flex-1 self-center text-center text-xs text-slate-600"></p>}
</div>
<div className="mt-3 flex gap-4 border-t border-line pt-3 text-[11px] text-slate-500">
<span> <span className="tabular-nums text-slate-300">{fmtTok(u?.totals.total_tok ?? 0)}</span> tok</span>
<span><span className="tabular-nums text-slate-300">{fmtCredits(u?.totals.credits_micro ?? 0)}</span> </span>
<span><span className="tabular-nums text-slate-300">{u?.totals.task_count ?? 0}</span> </span>
</div>
</Panel>
{/* 最近消耗 */}
<Panel title="最近消耗" icon={ReceiptText} bodyClassName="p-0">
<Table cols={["任务", "模型", "tokens", "积分"]}>
{(u?.recent ?? []).map((r) => (
<Tr key={r.task_id}>
<Td><span className="font-mono text-[11px] text-slate-400">{r.task_id}</span></Td>
<Td><span className="text-xs text-slate-300">{r.model || "—"}</span></Td>
<Td><span className="text-xs tabular-nums text-slate-300">{fmtTok(r.total_tok)}</span></Td>
<Td><span className="text-xs tabular-nums text-slate-300">{fmtCredits(r.credits_micro)}</span></Td>
</Tr>
))}
</Table>
{(!u || u.recent.length === 0) && <p className="p-4 text-xs text-slate-600"></p>}
</Panel>
</div>
);
}