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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user