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
+124
View File
@@ -0,0 +1,124 @@
import { useCallback, useEffect, useState, createContext, useContext } from "react";
import { NavLink, Routes, Route, Navigate } from "react-router-dom";
import { LogOut, Sun, Moon, ChevronDown } from "lucide-react";
import { routes, defaultPath } from "../routes";
import { tenantCurrent, myTenants, switchTenant, type AuthUser, type TenantCtx, type MyTenant } from "../api";
import { useTheme } from "../theme";
import { cn, useToast } from "../ui";
// 租户上下文共享给各页面:当前租户/角色/余额 + 主动刷新(切换组织、邀请成员后要立即反映)。
interface TenantState {
ctx: TenantCtx | null;
tenants: MyTenant[];
active: string;
refresh: () => void;
}
const TenantStateCtx = createContext<TenantState>({ ctx: null, tenants: [], active: "", refresh: () => {} });
export const useTenant = () => useContext(TenantStateCtx);
export function AppShell({ user, onLogout }: { user: AuthUser; onLogout: () => void }) {
const toast = useToast();
const { theme, toggle } = useTheme();
const [ctx, setCtx] = useState<TenantCtx | null>(null);
const [tenants, setTenants] = useState<MyTenant[]>([]);
const [active, setActive] = useState("");
const [switcherOpen, setSwitcherOpen] = useState(false);
const refresh = useCallback(() => {
tenantCurrent().then(setCtx).catch(() => {});
myTenants()
.then((r) => {
setTenants(r.tenants);
setActive(r.active);
})
.catch(() => {});
}, []);
useEffect(() => {
refresh();
const id = setInterval(refresh, 20000);
return () => clearInterval(id);
}, [refresh]);
const onSwitch = async (id: string) => {
setSwitcherOpen(false);
if (id === active) return;
try {
await switchTenant(id);
refresh();
} catch (e) {
toast.push("error", (e as Error).message);
}
};
return (
<TenantStateCtx.Provider value={{ ctx, tenants, active, refresh }}>
<div className="flex h-screen w-screen flex-col bg-ink-950 text-slate-200">
{/* 顶栏:品牌 + 组织切换 + 主题 + 用户 */}
<header className="flex h-12 shrink-0 items-center gap-3 border-b border-line bg-ink-900 px-4">
<span className="text-sm font-semibold tracking-tight text-slate-100">sundynix</span>
<span className="text-[10px] text-slate-600"></span>
{/* 组织切换器 */}
<div className="relative ml-2">
<button
onClick={() => setSwitcherOpen((o) => !o)}
className="flex items-center gap-1.5 rounded-md border border-line bg-ink-850 px-2.5 py-1 text-xs text-slate-300 transition hover:border-ink-600">
{ctx?.tenant?.name ?? "…"}
<ChevronDown className="h-3 w-3 text-slate-500" />
</button>
{switcherOpen && (
<div className="absolute left-0 top-8 z-20 w-56 rounded-md border border-line bg-ink-900 py-1 shadow-card">
{tenants.map((t) => (
<button key={t.id} onClick={() => onSwitch(t.id)}
className={cn("flex w-full items-center justify-between px-3 py-1.5 text-left text-xs hover:bg-ink-850", t.id === active ? "text-brand-400" : "text-slate-300")}>
<span className="truncate">{t.name}</span>
<span className="text-[10px] text-slate-600">{t.members} </span>
</button>
))}
</div>
)}
</div>
<div className="ml-auto flex items-center gap-3">
<button onClick={toggle} className="text-slate-500 transition hover:text-slate-300" title="切换主题">
{theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</button>
<span className="max-w-40 truncate text-xs text-slate-500" title={user.email}>{user.name || user.email}</span>
<button onClick={onLogout} className="text-slate-500 transition hover:text-danger" title="退出登录">
<LogOut className="h-4 w-4" />
</button>
</div>
</header>
<div className="flex min-h-0 flex-1">
{/* 左导航(由路由注册表派生) */}
<nav className="flex w-44 shrink-0 flex-col gap-1 border-r border-line bg-ink-900 p-2">
{routes.map((r) => {
const Icon = r.icon;
return (
<NavLink key={r.path} to={r.path}
className={({ isActive }) =>
cn("flex items-center gap-2 rounded-md px-3 py-2 text-xs transition",
isActive ? "bg-ink-800 font-medium text-slate-100" : "text-slate-500 hover:bg-ink-850 hover:text-slate-300")
}>
<Icon className="h-3.5 w-3.5" strokeWidth={2} />
{r.label}
</NavLink>
);
})}
</nav>
<main className="min-w-0 flex-1 overflow-auto">
<Routes>
{routes.map((r) => {
const Page = r.page;
return <Route key={r.path} path={r.path} element={<Page />} />;
})}
<Route path="*" element={<Navigate to={defaultPath} replace />} />
</Routes>
</main>
</div>
</div>
</TenantStateCtx.Provider>
);
}