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
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:
@@ -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;
|
||||
Reference in New Issue
Block a user