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
+72
View File
@@ -0,0 +1,72 @@
import { useState } from "react";
import { authLogin, authRegister, type AuthUser } from "../api";
import { Button, Field, Input } from "../ui";
// 登录/注册一页两态。注册即建个人默认租户(后端现成行为),登录后进壳。
export function AuthPage({ onAuthed }: { onAuthed: (u: AuthUser) => void }) {
const [mode, setMode] = useState<"login" | "register">("login");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [name, setName] = useState("");
const [err, setErr] = useState("");
const [busy, setBusy] = useState(false);
const submit = async () => {
if (!email.trim() || !password || busy) return;
setBusy(true);
setErr("");
try {
const u =
mode === "login"
? await authLogin(email.trim(), password)
: await authRegister(email.trim(), password, name.trim());
onAuthed(u);
} catch (e) {
setErr((e as Error).message);
} finally {
setBusy(false);
}
};
return (
<div className="flex h-screen w-screen items-center justify-center bg-ink-950">
<div className="w-[340px] rounded-xl border border-line bg-ink-900 p-6 shadow-card">
<div className="mb-1 text-lg font-semibold tracking-tight text-slate-100">sundynix</div>
<p className="mb-5 text-xs text-slate-500">
{mode === "login" ? "登录以管理你的组织、团队与账单" : "注册后自动创建你的个人工作区"}
</p>
<div className="space-y-3">
{mode === "register" && (
<Field label="名字(可选)">
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="怎么称呼你" />
</Field>
)}
<Field label="邮箱">
<Input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="you@example.com" />
</Field>
<Field label="密码" hint={mode === "register" ? "至少 6 位" : undefined}>
<Input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && submit()}
placeholder="••••••"
/>
</Field>
{err && <p className="text-xs text-danger">{err}</p>}
<Button variant="primary" className="w-full" onClick={submit} disabled={busy || !email.trim() || !password}>
{busy ? "请稍候…" : mode === "login" ? "登录" : "注册"}
</Button>
</div>
<button
onClick={() => {
setMode((m) => (m === "login" ? "register" : "login"));
setErr("");
}}
className="mt-4 text-xs text-slate-500 transition hover:text-slate-300">
{mode === "login" ? "还没有账户?去注册" : "已有账户?去登录"}
</button>
</div>
</div>
);
}
+89
View File
@@ -0,0 +1,89 @@
import { useState } from "react";
import { Building2, Plus, Check } from "lucide-react";
import { useTenant } from "../shell/AppShell";
import { createTenant, switchTenant, fmtCredits } from "../api";
import { Badge, Button, Input, Panel, useToast } from "../ui";
// 组织:我所属的组织列表(余额/成员数)+ 自助新建(创建者即 owner,建完后端自动切入)+ 切换。
export function Orgs() {
const toast = useToast();
const { tenants, active, refresh } = useTenant();
const [name, setName] = useState("");
const [busy, setBusy] = useState(false);
const create = async () => {
if (!name.trim() || busy) return;
setBusy(true);
try {
await createTenant(name.trim());
toast.push("success", "组织已创建,你是所有者");
setName("");
refresh();
} catch (e) {
toast.push("error", (e as Error).message);
} finally {
setBusy(false);
}
};
const activate = async (id: string) => {
if (id === active) return;
try {
await switchTenant(id);
refresh();
} catch (e) {
toast.push("error", (e as Error).message);
}
};
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"> {tenants.length} /</p>
</div>
<Panel title="新建组织" icon={Plus}>
<div className="flex flex-wrap items-center gap-2">
<Input
className="w-64"
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && create()}
placeholder="组织名称,如:某某科技"
/>
<Button variant="primary" icon={Plus} onClick={create} disabled={busy || !name.trim()}>
</Button>
<span className="text-[11px] text-slate-600"></span>
</div>
</Panel>
<Panel title="我的组织" icon={Building2} bodyClassName="p-2">
<div className="space-y-1">
{tenants.map((t) => (
<button key={t.id} onClick={() => activate(t.id)}
className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left transition-colors hover:bg-ink-850">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm text-slate-200">{t.name}</span>
{t.id === active && (
<Badge tone="accent">
<Check className="mr-0.5 inline h-3 w-3" />
</Badge>
)}
{t.shared_billing && <Badge tone="neutral"></Badge>}
</div>
<div className="mt-0.5 text-[11px] text-slate-500">
{t.members} · {t.plan}
</div>
</div>
<span className="text-xs tabular-nums text-slate-400">{fmtCredits(t.credit_balance_micro)} </span>
</button>
))}
{tenants.length === 0 && <p className="p-2 text-xs text-slate-600"></p>}
</div>
</Panel>
</div>
);
}
+62
View File
@@ -0,0 +1,62 @@
import { Link } from "react-router-dom";
import { Users, Building2, Coins, ArrowUpRight, Download } from "lucide-react";
import { useTenant } from "../shell/AppShell";
import { fmtCredits, ROLE_LABEL } from "../api";
import { Badge } from "../ui";
// 概览:当前组织 + 我的角色 + 余额,和去往各功能的入口。桌面端下载指引放这
// (薄 Web 面的定位就是「装桌面端之前能用的那些事」)。
export function Overview() {
const { ctx, tenants } = useTenant();
const cards = [
{ to: "/team", icon: Users, title: "团队", desc: "邀请成员、分配角色" },
{ to: "/orgs", icon: Building2, title: "组织", desc: `我所属的 ${tenants.length} 个组织` },
{ to: "/usage", icon: Coins, title: "用量与账单", desc: "余额、消耗趋势与明细" },
];
return (
<div className="mx-auto max-w-3xl p-8">
<h1 className="text-xl font-semibold tracking-tight text-slate-100">
{ctx?.tenant?.name ?? "…"}
</h1>
<div className="mt-2 flex items-center gap-2 text-xs text-slate-500">
{ctx?.role && <Badge tone="accent">{ROLE_LABEL[ctx.role] ?? ctx.role}</Badge>}
<span> {ctx?.tenant?.plan ?? "-"}</span>
<span>·</span>
<span>
<span className="tabular-nums text-slate-300">{fmtCredits(ctx?.credit_balance_micro ?? 0)}</span>
</span>
</div>
<div className="mt-8 grid grid-cols-1 gap-3 md:grid-cols-3">
{cards.map((c) => {
const Icon = c.icon;
return (
<Link key={c.to} to={c.to}
className="group flex flex-col rounded-xl border border-line bg-ink-900 p-4 shadow-card transition-colors hover:border-brand/40">
<div className="flex items-center justify-between">
<Icon className="h-5 w-5 text-slate-300 transition-colors group-hover:text-brand-400" strokeWidth={1.8} />
<ArrowUpRight className="h-4 w-4 text-slate-700 transition-colors group-hover:text-slate-400" />
</div>
<div className="mt-3 text-sm font-medium text-slate-100">{c.title}</div>
<div className="mt-1 text-xs text-slate-500">{c.desc}</div>
</Link>
);
})}
</div>
<div className="mt-8 flex items-start gap-3 rounded-xl border border-line bg-ink-900 p-4">
<Download className="mt-0.5 h-4 w-4 shrink-0 text-slate-500" />
<div className="text-xs leading-relaxed text-slate-500">
<span className="text-slate-300"></span>
sundynix
<a className="text-brand-400 hover:underline" href="https://github.com/blizzardzhang/sundynix-agentix/releases/latest" target="_blank" rel="noreferrer">
</a>
</div>
</div>
</div>
);
}
+131
View File
@@ -0,0 +1,131 @@
import { useCallback, useEffect, useState } from "react";
import { Users, UserPlus } from "lucide-react";
import { useTenant } from "../shell/AppShell";
import { listMembers, addMember, setMemberRole, removeMember, ROLE_LABEL, ASSIGNABLE_ROLES, type Member } from "../api";
import { Badge, Button, Input, Panel, Select, Table, Tr, Td, useToast } from "../ui";
// 团队:成员名册 + 邀请 + 改角色 + 移除。写操作按当前角色显隐(owner/admin 可写),
// 真闸在后端(RequireTenantRole ≥admin + owner 保护),这里只是 UX 不误导。
export function Team() {
const toast = useToast();
const { ctx, refresh } = useTenant();
const [members, setMembers] = useState<Member[]>([]);
const [email, setEmail] = useState("");
const [role, setRole] = useState<string>("member");
const [busy, setBusy] = useState(false);
const canWrite = ctx?.role === "owner" || ctx?.role === "admin";
const load = useCallback(() => {
listMembers().then(setMembers).catch(() => {});
}, []);
// 切组织后 ctx.tenant.id 变化 → 重拉名册。
useEffect(load, [load, ctx?.tenant?.id]);
const invite = async () => {
if (!email.trim() || busy) return;
setBusy(true);
try {
await addMember(email.trim(), role);
toast.push("success", "已加入团队");
setEmail("");
load();
refresh(); // 成员数变了
} catch (e) {
toast.push("error", (e as Error).message);
} finally {
setBusy(false);
}
};
const changeRole = async (m: Member, r: string) => {
try {
await setMemberRole(m.user_id, r);
toast.push("success", `${m.name || m.email}${ROLE_LABEL[r] ?? r}`);
load();
} catch (e) {
toast.push("error", (e as Error).message);
load(); // 失败回滚显示
}
};
const remove = async (m: Member) => {
if (!window.confirm(`确定把 ${m.name || m.email} 移出团队?`)) return;
try {
await removeMember(m.user_id);
toast.push("success", "已移除");
load();
refresh();
} catch (e) {
toast.push("error", (e as Error).message);
}
};
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 ?? "…"} · {members.length} </p>
</div>
{canWrite && (
<Panel title="邀请成员" icon={UserPlus}>
<div className="flex flex-wrap items-center gap-2">
<Input
className="w-64"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && invite()}
placeholder="对方注册时用的邮箱"
/>
<Select value={role} onChange={(e) => setRole(e.target.value)} className="w-28">
{ASSIGNABLE_ROLES.map((r) => (
<option key={r} value={r}>{ROLE_LABEL[r]}</option>
))}
</Select>
<Button variant="primary" icon={UserPlus} onClick={invite} disabled={busy || !email.trim()}>
</Button>
<span className="text-[11px] text-slate-600"> sundynix </span>
</div>
</Panel>
)}
<Panel title="成员" icon={Users} bodyClassName="p-0">
<Table cols={["成员", "角色", "加入时间", canWrite ? "操作" : ""]}>
{members.map((m) => (
<Tr key={m.user_id}>
<Td>
<div className="text-slate-200">{m.name || "—"}</div>
<div className="text-[11px] text-slate-500">{m.email}</div>
</Td>
<Td>
{canWrite && m.role !== "owner" ? (
<Select value={m.role} onChange={(e) => changeRole(m, e.target.value)} className="h-8 w-24 text-xs">
{ASSIGNABLE_ROLES.map((r) => (
<option key={r} value={r}>{ROLE_LABEL[r]}</option>
))}
</Select>
) : (
<Badge tone={m.role === "owner" ? "accent" : "neutral"}>{ROLE_LABEL[m.role] ?? m.role}</Badge>
)}
</Td>
<Td>
<span className="text-xs text-slate-500">{m.joined_at ? new Date(m.joined_at).toLocaleDateString() : "—"}</span>
</Td>
<Td>
{canWrite && m.role !== "owner" && (
<Button variant="ghost" onClick={() => remove(m)} className="text-danger">
</Button>
)}
</Td>
</Tr>
))}
</Table>
{members.length === 0 && <p className="p-4 text-xs text-slate-600"></p>}
</Panel>
</div>
);
}
+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>
);
}