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
+266
View File
@@ -0,0 +1,266 @@
// 薄 Web 面的 Gateway 客户端。auth/tenant/usage 段与桌面端 lib/api.ts 同构(纯 fetch
// token 走 localStorage `sdx_token`——localStorage 按 origin 隔离,与桌面端预览不冲突);
// 成员管理/建组织打租户自助接口(/tenants/current/members、/me/tenants)。
export const GATEWAY: string =
(import.meta.env.VITE_GATEWAY as string | undefined) ?? "http://localhost:8080";
export interface AuthUser {
id: string;
email: string;
name?: string;
}
// ---- JWT 令牌存储 ----
const TOKEN_KEY = "sdx_token";
let authToken: string = typeof localStorage !== "undefined" ? localStorage.getItem(TOKEN_KEY) ?? "" : "";
export function setToken(t: string): void {
authToken = t;
try {
localStorage.setItem(TOKEN_KEY, t);
} catch {
/* 隐私模式忽略 */
}
}
export function clearToken(): void {
authToken = "";
try {
localStorage.removeItem(TOKEN_KEY);
} catch {
/* ignore */
}
}
function bearer(): Record<string, string> {
return authToken ? { Authorization: `Bearer ${authToken}` } : {};
}
// guard401 收到 401 清令牌并广播登出(App 监听后回登录页)。
function guard401(res: Response): Response {
if (res.status === 401) {
clearToken();
if (typeof window !== "undefined") window.dispatchEvent(new Event("sdx:logout"));
}
return res;
}
// jsonOrThrow 统一「非 2xx 抛后端 error 文案」的小助手。
async function jsonOrThrow<T>(res: Response, fallback: string): Promise<T> {
const d = (await res.json().catch(() => ({}))) as T & { error?: string };
if (!res.ok) throw new Error(d.error ?? `${fallback}: ${res.status}`);
return d;
}
// ---- 鉴权 ----
export async function authRegister(email: string, password: string, name: string): Promise<AuthUser> {
const res = await fetch(`${GATEWAY}/api/v1/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password, name }),
});
const d = await jsonOrThrow<{ token?: string; user?: AuthUser }>(res, "注册失败");
if (!d.token || !d.user) throw new Error("注册失败");
setToken(d.token);
return d.user;
}
export async function authLogin(email: string, password: string): Promise<AuthUser> {
const res = await fetch(`${GATEWAY}/api/v1/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
const d = await jsonOrThrow<{ token?: string; user?: AuthUser }>(res, "登录失败");
if (!d.token || !d.user) throw new Error("登录失败");
setToken(d.token);
return d.user;
}
export async function authMe(): Promise<AuthUser | null> {
if (!authToken) return null;
const res = await fetch(`${GATEWAY}/api/v1/auth/me`, { headers: bearer() });
if (!res.ok) {
clearToken();
return null;
}
const d = (await res.json()) as { user?: AuthUser };
return d.user ?? null;
}
export function logout(): void {
clearToken();
}
// ---- 租户上下文 ----
export interface TenantCtx {
tenant: { id: string; name: string; plan: string; status: string; shared_billing?: boolean } | null;
role: string;
credit_balance_micro: number;
credit_enforce: boolean;
}
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,
};
}
export interface MyTenant {
id: string;
name: string;
slug: string;
plan: string;
credit_balance_micro: number;
shared_billing: boolean;
members: number;
}
export async function myTenants(): Promise<{ tenants: MyTenant[]; active: string }> {
const res = guard401(await fetch(`${GATEWAY}/api/v1/me/tenants`, { headers: bearer() }));
if (!res.ok) return { tenants: [], active: "" };
const d = (await res.json()) as { tenants?: MyTenant[]; active?: string };
return { tenants: d.tenants ?? [], active: d.active ?? "" };
}
export async function switchTenant(tenantId: string): Promise<void> {
const res = guard401(
await fetch(`${GATEWAY}/api/v1/me/tenant`, {
method: "POST",
headers: { "Content-Type": "application/json", ...bearer() },
body: JSON.stringify({ tenant_id: tenantId }),
}),
);
await jsonOrThrow(res, "切换失败");
}
// createTenant 自助建组织:创建者即 owner,后端建完自动切入。
export async function createTenant(name: string): Promise<{ id: string; name: string }> {
const res = guard401(
await fetch(`${GATEWAY}/api/v1/me/tenants`, {
method: "POST",
headers: { "Content-Type": "application/json", ...bearer() },
body: JSON.stringify({ name }),
}),
);
const d = await jsonOrThrow<{ tenant?: { id: string; name: string } }>(res, "创建失败");
if (!d.tenant) throw new Error("创建失败");
return d.tenant;
}
// ---- 成员自助管理(作用于当前活跃租户;写操作后端要求 ≥admin)----
export interface Member {
user_id: string;
email: string;
name: string;
role: string;
status: string;
joined_at: string;
}
export async function listMembers(): Promise<Member[]> {
const res = guard401(await fetch(`${GATEWAY}/api/v1/tenants/current/members`, { headers: bearer() }));
if (!res.ok) return [];
const d = (await res.json()) as { members?: Member[] };
return d.members ?? [];
}
export async function addMember(email: string, role: string): Promise<Member> {
const res = guard401(
await fetch(`${GATEWAY}/api/v1/tenants/current/members`, {
method: "POST",
headers: { "Content-Type": "application/json", ...bearer() },
body: JSON.stringify({ email, role }),
}),
);
const d = await jsonOrThrow<{ member?: Member }>(res, "邀请失败");
if (!d.member) throw new Error("邀请失败");
return d.member;
}
export async function setMemberRole(userId: string, role: string): Promise<void> {
const res = guard401(
await fetch(`${GATEWAY}/api/v1/tenants/current/members/${userId}`, {
method: "PUT",
headers: { "Content-Type": "application/json", ...bearer() },
body: JSON.stringify({ role }),
}),
);
await jsonOrThrow(res, "改角色失败");
}
export async function removeMember(userId: string): Promise<void> {
const res = guard401(
await fetch(`${GATEWAY}/api/v1/tenants/current/members/${userId}`, {
method: "DELETE",
headers: bearer(),
}),
);
await jsonOrThrow(res, "移除失败");
}
// ---- 我的用量(余额 + 按天趋势 + 最近消耗)----
export interface UsageDay {
day: string; // YYYYMMDD
total_tok: number;
credits_micro: number;
cost_micros: number;
task_count: number;
}
export interface UsageRecent {
task_id: string;
model: string;
total_tok: number;
credits_micro: number;
cost_micros: number;
currency: string;
ts: number;
}
export interface MyUsage {
from: string;
to: string;
balance_micro: number;
credit_enforce: boolean;
trend: UsageDay[];
totals: { total_tok: number; credits_micro: number; cost_micros: number; task_count: number };
recent: UsageRecent[];
}
export async function myUsage(days = 30): Promise<MyUsage> {
const res = guard401(await fetch(`${GATEWAY}/api/v1/me/usage?days=${days}`, { headers: bearer() }));
if (!res.ok) throw new Error(`usage failed: ${res.status}`);
const d = (await res.json()) as Partial<MyUsage>;
return {
from: d.from ?? "",
to: d.to ?? "",
balance_micro: d.balance_micro ?? 0,
credit_enforce: !!d.credit_enforce,
trend: d.trend ?? [],
totals: d.totals ?? { total_tok: 0, credits_micro: 0, cost_micros: 0, task_count: 0 },
recent: d.recent ?? [],
};
}
// 积分显示:micro(1e6) → 人类可读。与桌面端同一约定;小数去尾零(1.50 → 1.5)。
export function fmtCredits(micro: number): string {
const v = micro / 1e6;
if (Math.abs(v) >= 1e6) return `${(v / 1e6).toFixed(1)}M`;
if (Math.abs(v) >= 1e3) return `${(v / 1e3).toFixed(1)}K`;
return `${parseFloat(v.toFixed(2))}`;
}
// 角色中文名(角色本身英文入库,仅展示层翻译)。
export const ROLE_LABEL: Record<string, string> = {
owner: "所有者",
admin: "管理员",
member: "成员",
viewer: "只读",
billing_admin: "财务",
};
// 邀请/改角色可选项(owner 不可授予——须转让,后端也拦)。
export const ASSIGNABLE_ROLES = ["admin", "member", "viewer", "billing_admin"] as const;