// 薄 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 { 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(res: Response, fallback: string): Promise { 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 { 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 { 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; } // ---- 微信扫码登录(带参二维码 + 关注/扫码)---- // PC 建票拿到微信二维码图 URL → 展示 → 轮询登录态。全公开接口。 export async function wxTicket(): Promise<{ ticket: string; qr_image: string; expires_in: number }> { const res = await fetch(`${GATEWAY}/api/v1/wx/mp/ticket`, { method: "POST" }); return jsonOrThrow<{ ticket: string; qr_image: string; expires_in: number }>(res, "创建登录二维码失败"); } // 轮询:pending | authorized(带 token/user) | expired | consumed。 export async function wxPoll(ticket: string): Promise<{ status: string; user?: AuthUser }> { const res = await fetch(`${GATEWAY}/api/v1/wx/mp/poll?t=${encodeURIComponent(ticket)}`); if (!res.ok) return { status: "expired" }; const d = (await res.json()) as { status?: string; token?: string; user?: AuthUser }; if (d.token && d.user) { setToken(d.token); // authorized:后端走 issueToken 返回 token+user return { status: "authorized", user: d.user }; } return { status: d.status ?? "pending" }; } export async function authMe(): Promise { 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 { 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, }; } 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 { 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 { 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 { 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 { 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 { 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 { 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; 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 ?? [], }; } // ---- 充值(P5.1 兑换码;微信支付 P5.2 上线)---- export interface TopupOrder { id: string; credits_micro: number; amount_fen: number; channel: string; status: string; created_at: string; } export interface Pack { id: string; name: string; credits_micro: number; price_fen: number; } // billingPacks 在售积分包 + 可用渠道(服务端配了微信 env 才会出现 "wechat")。 export async function billingPacks(): Promise<{ packs: Pack[]; channels: string[] }> { const res = guard401(await fetch(`${GATEWAY}/api/v1/billing/packs`, { headers: bearer() })); if (!res.ok) return { packs: [], channels: [] }; const d = (await res.json()) as { packs?: Pack[]; channels?: string[] }; return { packs: d.packs ?? [], channels: d.channels ?? [] }; } // createWechatOrder 微信 Native 下单:返回订单号 + code_url(渲染成二维码扫码付) // + expires_at(二维码有效期,由服务端 orderTTL 决定,前端只负责倒计时展示)。 export async function createWechatOrder( target: { packId: string } | { planId: string }, ): Promise<{ order_id: string; code_url: string; amount_fen: number; expires_at: string }> { // 积分包与订阅走同一条支付链路(下单/回调/查单/掉单补偿全复用),只是订单内容不同。 const body = "packId" in target ? { pack_id: target.packId } : { plan_id: target.planId }; const res = guard401( await fetch(`${GATEWAY}/api/v1/billing/orders`, { method: "POST", headers: { "Content-Type": "application/json", ...bearer() }, body: JSON.stringify(body), }), ); return jsonOrThrow(res, "下单失败"); } // ---- 订阅(手动购买制:买一个周期,期内每 N 天发一次积分,到期即失效)---- export interface SubPlan { id: string; name: string; price_fen: number; duration_days: number; refill_credits_micro: number; refill_interval_days: number; } export interface MySub { id: string; plan_id: string; status: string; started_at: string; expires_at: string; refill_seq: number; } export async function subPlans(): Promise { const res = guard401(await fetch(`${GATEWAY}/api/v1/billing/sub-plans`, { headers: bearer() })); if (!res.ok) return []; return ((await res.json()) as { plans?: SubPlan[] }).plans ?? []; } // 我的订阅(无则 null)。到期即失效、不自动续费,所以到期时间要显眼地告诉用户。 export async function mySubscription(): Promise<{ subscription: MySub | null; plan: SubPlan | null }> { const res = guard401(await fetch(`${GATEWAY}/api/v1/billing/subscription`, { headers: bearer() })); if (!res.ok) return { subscription: null, plan: null }; const d = (await res.json()) as { subscription?: MySub | null; plan?: SubPlan | null }; return { subscription: d.subscription ?? null, plan: d.plan ?? null }; } // orderStatus 轮询订单态(pending 时服务端顺路主动查单,本地也能确认到账)。 // warn 必须一并返回:服务端在「已付但金额与订单不符」时不入账、挂起人工核对, // 订单会一直停在 pending —— 丢掉 warn 的话用户付了钱、界面却只会一直转圈等下去。 export async function orderStatus(orderId: string): Promise<{ order: TopupOrder; warn?: string }> { const res = guard401(await fetch(`${GATEWAY}/api/v1/billing/orders/${orderId}`, { headers: bearer() })); return jsonOrThrow<{ order: TopupOrder; warn?: string }>(res, "查询失败"); } // redeemCode 核销兑换码,返回入账后的余额。 export async function redeemCode(code: string): Promise<{ balance_micro: number }> { const res = guard401( await fetch(`${GATEWAY}/api/v1/billing/redeem`, { method: "POST", headers: { "Content-Type": "application/json", ...bearer() }, body: JSON.stringify({ code }), }), ); return jsonOrThrow<{ balance_micro: number }>(res, "兑换失败"); } // billingOrders 计费租户最近充值记录。 export async function billingOrders(): Promise { const res = guard401(await fetch(`${GATEWAY}/api/v1/billing/orders`, { headers: bearer() })); if (!res.ok) return []; const d = (await res.json()) as { orders?: TopupOrder[] }; return d.orders ?? []; } // 积分显示: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 = { owner: "所有者", admin: "管理员", member: "成员", viewer: "只读", billing_admin: "财务", }; // 邀请/改角色可选项(owner 不可授予——须转让,后端也拦)。 export const ASSIGNABLE_ROLES = ["admin", "member", "viewer", "billing_admin"] as const;