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,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>sundynix · 组织与账单</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+4881
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "sundynix-web",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-react": "^1.17.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"autoprefixer": "^10.4.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"postcss": "^8.4.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vite": "^5.4.0",
|
||||
"vitest": "^4.1.9"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { HashRouter } from "react-router-dom";
|
||||
import { AppShell } from "./shell/AppShell";
|
||||
import { AuthPage } from "./pages/AuthPage";
|
||||
import { authMe, logout, type AuthUser } from "./api";
|
||||
import { ToastProvider } from "./ui";
|
||||
|
||||
// 薄 Web 面:租户客户的自助柜台(注册/组织/团队/账单)。
|
||||
// 与 desktop(用户工作产品)、admin(平台超管控制塔)是三个独立产品面。
|
||||
// HashRouter:纯静态托管即可深链,无需服务端路由配置(与 admin 同款理由)。
|
||||
export default function App() {
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
authMe()
|
||||
.then(setUser)
|
||||
.catch(() => setUser(null))
|
||||
.finally(() => setLoading(false));
|
||||
const onLogout = () => setUser(null);
|
||||
window.addEventListener("sdx:logout", onLogout);
|
||||
return () => window.removeEventListener("sdx:logout", onLogout);
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex h-screen w-screen items-center justify-center bg-ink-950 text-sm text-slate-500">加载中…</div>;
|
||||
}
|
||||
return (
|
||||
<ToastProvider>
|
||||
{user ? (
|
||||
<HashRouter>
|
||||
<AppShell
|
||||
user={user}
|
||||
onLogout={() => {
|
||||
logout();
|
||||
setUser(null);
|
||||
}}
|
||||
/>
|
||||
</HashRouter>
|
||||
) : (
|
||||
<AuthPage onAuthed={setUser} />
|
||||
)}
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { fmtCredits, ASSIGNABLE_ROLES } from "./api";
|
||||
|
||||
describe("fmtCredits", () => {
|
||||
it("micro → 人类可读", () => {
|
||||
expect(fmtCredits(0)).toBe("0");
|
||||
expect(fmtCredits(1_500_000)).toBe("1.5"); // 1.5 积分
|
||||
expect(fmtCredits(2_000_000)).toBe("2"); // 整数不留小数尾巴
|
||||
expect(fmtCredits(1_234_000_000)).toBe("1.2K");
|
||||
expect(fmtCredits(5_000_000_000_000)).toBe("5.0M");
|
||||
});
|
||||
it("负余额(软扣可为负)不丢符号", () => {
|
||||
expect(fmtCredits(-1_500_000)).toBe("-1.5");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ASSIGNABLE_ROLES", () => {
|
||||
it("不含 owner —— owner 只能转让,后端同样拦", () => {
|
||||
expect(ASSIGNABLE_ROLES).not.toContain("owner");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -0,0 +1,122 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* 主题令牌::root=亮色,.dark=暗色。值为 RGB 三元组,供 tailwind 的 rgb(var() / α) 取用。
|
||||
亮色走 shadcn 中性灰(zinc),暗色为精炼中性暗(zinc 系,替代原偏蓝的 ink)。 */
|
||||
:root {
|
||||
--ink-950: 249 249 250; /* 页面底 */
|
||||
--ink-900: 255 255 255; /* 顶栏/面板 */
|
||||
--ink-850: 255 255 255; /* 卡片 */
|
||||
--ink-800: 244 244 245; /* hover/抬升 */
|
||||
--ink-700: 228 228 231;
|
||||
--ink-600: 212 212 216;
|
||||
--line: 228 228 231; /* 边框 zinc-200 */
|
||||
--slate-100: 24 24 27; /* 最强文字 */
|
||||
--slate-200: 24 24 27; /* 主文字 */
|
||||
--slate-300: 63 63 70;
|
||||
--slate-400: 82 82 91;
|
||||
--slate-500: 113 113 122; /* 次要 */
|
||||
--slate-600: 161 161 170; /* 弱 */
|
||||
--slate-700: 212 212 216;
|
||||
--sb-thumb: 212 212 216;
|
||||
--sb-thumb-hover: 161 161 170;
|
||||
--shadow-card: 0 1px 2px rgba(0, 0, 0, 0.06), 0 8px 24px rgba(0, 0, 0, 0.06);
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--ink-950: 10 10 12;
|
||||
--ink-900: 20 20 24;
|
||||
--ink-850: 24 24 27;
|
||||
--ink-800: 31 31 35;
|
||||
--ink-700: 38 38 43;
|
||||
--ink-600: 50 50 56;
|
||||
--line: 39 39 42; /* zinc-800 */
|
||||
--slate-100: 244 244 245;
|
||||
--slate-200: 228 228 231;
|
||||
--slate-300: 212 212 216;
|
||||
--slate-400: 161 161 170;
|
||||
--slate-500: 138 138 147;
|
||||
--slate-600: 113 113 122;
|
||||
--slate-700: 82 82 91;
|
||||
--sb-thumb: 39 39 42;
|
||||
--sb-thumb-hover: 63 63 70;
|
||||
--shadow-card: 0 1px 2px rgba(0, 0, 0, 0.4), 0 8px 24px rgba(0, 0, 0, 0.25);
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background: rgb(var(--ink-950));
|
||||
color: rgb(var(--slate-300));
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
/* 滚动条随主题 */
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgb(var(--sb-thumb));
|
||||
border-radius: 8px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgb(var(--sb-thumb-hover));
|
||||
background-clip: padding-box;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* 紫→青品牌渐变文字 */
|
||||
.brand-gradient {
|
||||
background: linear-gradient(90deg, #a78bfa, #22d3ee);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
/* 输入控件随主题(color-scheme 由 :root/.dark 设定,控件自动继承) */
|
||||
|
||||
/* react-flow 画布主题化:控件/连线/迷你图与设计系统对齐(colorMode 已处理大半,此处精修) */
|
||||
.react-flow__controls {
|
||||
box-shadow: none;
|
||||
border: 1px solid rgb(var(--line));
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.react-flow__controls-button {
|
||||
background: rgb(var(--ink-900));
|
||||
border-bottom: 1px solid rgb(var(--line));
|
||||
color: rgb(var(--slate-400));
|
||||
}
|
||||
.react-flow__controls-button:hover {
|
||||
background: rgb(var(--ink-800));
|
||||
}
|
||||
.react-flow__controls-button svg {
|
||||
fill: currentColor;
|
||||
}
|
||||
.react-flow__edge-path {
|
||||
stroke: rgb(var(--slate-600));
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
.react-flow__edge.selected .react-flow__edge-path,
|
||||
.react-flow__edge:focus .react-flow__edge-path {
|
||||
stroke: #8b5cf6;
|
||||
}
|
||||
.react-flow__minimap {
|
||||
border: 1px solid rgb(var(--line));
|
||||
border-radius: 8px;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
import { applyInitialTheme } from "./theme";
|
||||
import "./index.css";
|
||||
|
||||
applyInitialTheme(); // 渲染前设好 .dark,避免首屏闪烁
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { ComponentType } from "react";
|
||||
import { LayoutDashboard, Users, Building2, Coins, type LucideIcon } from "lucide-react";
|
||||
import { Overview } from "./pages/Overview";
|
||||
import { Team } from "./pages/Team";
|
||||
import { Orgs } from "./pages/Orgs";
|
||||
import { Usage } from "./pages/Usage";
|
||||
|
||||
// 路由注册表(单一事实源):导航与内容都从这派生(与 admin 同款模式)。
|
||||
export interface RouteDef {
|
||||
path: string;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
page: ComponentType;
|
||||
}
|
||||
|
||||
export const routes: RouteDef[] = [
|
||||
{ path: "/overview", label: "概览", icon: LayoutDashboard, page: Overview },
|
||||
{ path: "/team", label: "团队", icon: Users, page: Team },
|
||||
{ path: "/orgs", label: "组织", icon: Building2, page: Orgs },
|
||||
{ path: "/usage", label: "用量与账单", icon: Coins, page: Usage },
|
||||
];
|
||||
|
||||
export const defaultPath = routes[0].path;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// 主题(亮/暗)管理:持久化到 localStorage,切换在 <html> 上加/去 .dark 类。
|
||||
// 默认亮色(暗色为可选)。初始化在 applyInitialTheme(main 启动时调,先于渲染避免闪烁)。
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
export type Theme = "light" | "dark";
|
||||
|
||||
const KEY = "sdx-theme";
|
||||
|
||||
export function getStoredTheme(): Theme {
|
||||
const t = localStorage.getItem(KEY);
|
||||
return t === "dark" ? "dark" : "light"; // 默认亮色
|
||||
}
|
||||
|
||||
function apply(theme: Theme): void {
|
||||
document.documentElement.classList.toggle("dark", theme === "dark");
|
||||
}
|
||||
|
||||
// applyInitialTheme 在渲染前调用,依据存储值设好 .dark 类(避免首屏闪烁)。
|
||||
export function applyInitialTheme(): void {
|
||||
apply(getStoredTheme());
|
||||
}
|
||||
|
||||
// useTheme 暴露当前主题与切换函数(写 localStorage + 切 .dark 类)。
|
||||
export function useTheme(): { theme: Theme; toggle: () => void; setTheme: (t: Theme) => void } {
|
||||
const [theme, setThemeState] = useState<Theme>(getStoredTheme);
|
||||
|
||||
useEffect(() => {
|
||||
apply(theme);
|
||||
localStorage.setItem(KEY, theme);
|
||||
}, [theme]);
|
||||
|
||||
const setTheme = useCallback((t: Theme) => setThemeState(t), []);
|
||||
const toggle = useCallback(() => setThemeState((t) => (t === "dark" ? "light" : "dark")), []);
|
||||
return { theme, toggle, setTheme };
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "./cn";
|
||||
|
||||
type Tone = "neutral" | "brand" | "accent" | "success" | "warn" | "danger";
|
||||
|
||||
const tones: Record<Tone, string> = {
|
||||
neutral: "bg-ink-800 text-slate-400",
|
||||
brand: "bg-brand/15 text-brand-400",
|
||||
accent: "bg-accent/15 text-accent-400",
|
||||
success: "bg-success/15 text-success",
|
||||
warn: "bg-warn/15 text-warn",
|
||||
danger: "bg-danger/15 text-danger",
|
||||
};
|
||||
|
||||
export function Badge({ tone = "neutral", className, children }: { tone?: Tone; className?: string; children: ReactNode }) {
|
||||
return (
|
||||
<span className={cn("inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[9px] font-medium leading-none", tones[tone], className)}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Dot 状态小圆点(可脉冲)。
|
||||
export function Dot({ tone = "neutral", pulse }: { tone?: Tone | "running"; pulse?: boolean }) {
|
||||
const color =
|
||||
tone === "success"
|
||||
? "bg-success"
|
||||
: tone === "danger"
|
||||
? "bg-danger"
|
||||
: tone === "warn"
|
||||
? "bg-warn"
|
||||
: tone === "running" || tone === "accent"
|
||||
? "bg-accent"
|
||||
: tone === "brand"
|
||||
? "bg-brand"
|
||||
: "bg-slate-600";
|
||||
return <span className={cn("h-2 w-2 shrink-0 rounded-full", color, pulse && "animate-pulse")} />;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
||||
import { type LucideIcon } from "lucide-react";
|
||||
import { cn } from "./cn";
|
||||
|
||||
type Variant = "primary" | "secondary" | "ghost" | "danger";
|
||||
type Size = "sm" | "md";
|
||||
|
||||
const base =
|
||||
"inline-flex items-center justify-center gap-1.5 rounded-md font-medium transition select-none disabled:cursor-not-allowed disabled:opacity-40 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand/50";
|
||||
|
||||
const variants: Record<Variant, string> = {
|
||||
primary: "bg-brand text-white hover:bg-brand-500 active:scale-[0.98]",
|
||||
secondary: "border border-line bg-ink-850 text-slate-200 hover:bg-ink-800 hover:border-ink-600",
|
||||
ghost: "text-slate-400 hover:bg-ink-800 hover:text-slate-200",
|
||||
danger: "border border-danger/50 text-danger hover:bg-danger/10",
|
||||
};
|
||||
|
||||
const sizes: Record<Size, string> = {
|
||||
sm: "h-8 px-3 text-xs",
|
||||
md: "h-9 px-4 text-sm",
|
||||
};
|
||||
|
||||
interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: Variant;
|
||||
size?: Size;
|
||||
icon?: LucideIcon;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function Button({ variant = "secondary", size = "md", icon: Icon, className, children, ...rest }: Props) {
|
||||
return (
|
||||
<button className={cn(base, variants[variant], sizes[size], className)} {...rest}>
|
||||
{Icon && <Icon className={size === "sm" ? "h-3.5 w-3.5" : "h-4 w-4"} strokeWidth={2} />}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { type LucideIcon } from "lucide-react";
|
||||
import { cn } from "./cn";
|
||||
|
||||
// Card 基础卡片容器。
|
||||
export function Card({ className, children }: { className?: string; children: ReactNode }) {
|
||||
return <div className={cn("rounded-lg border border-line bg-ink-900 shadow-card", className)}>{children}</div>;
|
||||
}
|
||||
|
||||
// Panel 带表头的分区面板(表头 icon+标题+右侧动作,主体可滚动)。
|
||||
export function Panel({
|
||||
title,
|
||||
icon: Icon,
|
||||
actions,
|
||||
className,
|
||||
bodyClassName,
|
||||
children,
|
||||
}: {
|
||||
title: ReactNode;
|
||||
icon?: LucideIcon;
|
||||
actions?: ReactNode;
|
||||
className?: string;
|
||||
bodyClassName?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className={cn("flex min-h-0 flex-col rounded-lg border border-line bg-ink-900 shadow-card", className)}>
|
||||
<div className="flex items-center gap-2 border-b border-line px-4 py-2.5">
|
||||
{Icon && <Icon className="h-3.5 w-3.5 text-slate-500" strokeWidth={2} />}
|
||||
<span className="text-[11px] font-medium text-slate-400">{title}</span>
|
||||
{actions && <div className="ml-auto flex items-center gap-2">{actions}</div>}
|
||||
</div>
|
||||
<div className={cn("min-h-0 flex-1 overflow-y-auto p-4", bodyClassName)}>{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { Button } from "./Button";
|
||||
|
||||
// Dialog 轻量模态:遮罩 + 居中卡片。open=false 不渲染。
|
||||
export function Dialog({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
footer,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
}) {
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/55 p-6" onClick={onClose}>
|
||||
<div
|
||||
className="w-full max-w-md rounded-lg border border-line bg-ink-900 shadow-card"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center border-b border-line px-4 py-3">
|
||||
<h3 className="text-sm font-medium text-slate-100">{title}</h3>
|
||||
<button onClick={onClose} className="ml-auto text-slate-500 hover:text-slate-300">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-4 py-4 text-sm text-slate-300">{children}</div>
|
||||
{footer && <div className="flex justify-end gap-2 border-t border-line px-4 py-3">{footer}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ConfirmFooter 常用的取消/确认按钮组。
|
||||
export function ConfirmFooter({ onCancel, onConfirm, confirmLabel = "确认", danger }: { onCancel: () => void; onConfirm: () => void; confirmLabel?: string; danger?: boolean }) {
|
||||
return (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" onClick={onCancel}>
|
||||
取消
|
||||
</Button>
|
||||
<Button variant={danger ? "danger" : "primary"} size="sm" onClick={onConfirm}>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { type LucideIcon } from "lucide-react";
|
||||
import { cn } from "./cn";
|
||||
|
||||
// Skeleton 占位骨架(加载态)。
|
||||
export function Skeleton({ className }: { className?: string }) {
|
||||
return <div className={cn("animate-pulse rounded-md bg-ink-800", className)} />;
|
||||
}
|
||||
|
||||
// EmptyState 空状态:图标 + 标题 + 说明 + 可选动作。
|
||||
export function EmptyState({
|
||||
icon: Icon,
|
||||
title,
|
||||
desc,
|
||||
action,
|
||||
className,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
desc?: ReactNode;
|
||||
action?: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("flex h-full flex-col items-center justify-center gap-3 px-6 py-10 text-center", className)}>
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl border border-line bg-ink-850 text-slate-500">
|
||||
<Icon className="h-6 w-6" strokeWidth={1.5} />
|
||||
</div>
|
||||
<div className="text-sm font-medium text-slate-300">{title}</div>
|
||||
{desc && <div className="max-w-sm text-xs leading-relaxed text-slate-500">{desc}</div>}
|
||||
{action && <div className="mt-1">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { InputHTMLAttributes, TextareaHTMLAttributes, SelectHTMLAttributes, ReactNode } from "react";
|
||||
import { cn } from "./cn";
|
||||
|
||||
// 注意:基类不含宽度。宽度由调用方决定(Field 内为 flex-col 自动撑满,
|
||||
// 或显式 w-full / flex-1 / w-16),避免 w-full 与 flex-1/w-16 冲突塌陷。
|
||||
const fieldBase =
|
||||
"rounded-md border border-line bg-ink-900 text-sm text-slate-200 placeholder:text-slate-600 transition focus:border-brand focus:outline-none focus:ring-2 focus:ring-brand/25 disabled:opacity-50";
|
||||
|
||||
export function Input({ className, ...rest }: InputHTMLAttributes<HTMLInputElement>) {
|
||||
return <input className={cn(fieldBase, "h-9 px-3", className)} {...rest} />;
|
||||
}
|
||||
|
||||
export function Textarea({ className, ...rest }: TextareaHTMLAttributes<HTMLTextAreaElement>) {
|
||||
return <textarea className={cn(fieldBase, "px-3 py-2 leading-relaxed", className)} {...rest} />;
|
||||
}
|
||||
|
||||
export function Select({ className, children, ...rest }: SelectHTMLAttributes<HTMLSelectElement>) {
|
||||
return (
|
||||
<select className={cn(fieldBase, "h-9 cursor-pointer px-3", className)} {...rest}>
|
||||
{children}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
// Field 包一层标签 + 控件,统一表单纵向节奏。
|
||||
export function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
|
||||
return (
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className="text-[11px] font-medium text-slate-400">{label}</span>
|
||||
{children}
|
||||
{hint && <span className="text-[10px] text-slate-600">{hint}</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "./cn";
|
||||
|
||||
// 轻量数据表格:细分隔线、左对齐表头(弱化)、行 hover。运维/数据场景的标准呈现。
|
||||
// 用法:<Table cols={["任务","状态","耗时"]}><Tr>...<Td>...</Td></Tr></Table>
|
||||
|
||||
export function Table({ cols, children, className }: { cols: ReactNode[]; children: ReactNode; className?: string }) {
|
||||
return (
|
||||
<table className={cn("w-full border-collapse text-xs", className)}>
|
||||
<thead>
|
||||
<tr>
|
||||
{cols.map((c, i) => (
|
||||
<th key={i} className="border-b border-line px-2.5 py-2 text-left font-medium text-slate-500">
|
||||
{c}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>{children}</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
export function Tr({ children, onClick, className }: { children: ReactNode; onClick?: () => void; className?: string }) {
|
||||
return (
|
||||
<tr onClick={onClick} className={cn("border-b border-line/70 transition hover:bg-ink-800", onClick && "cursor-pointer", className)}>
|
||||
{children}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
export function Td({ children, className }: { children: ReactNode; className?: string }) {
|
||||
return <td className={cn("px-2.5 py-2 align-middle text-slate-300", className)}>{children}</td>;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { cn } from "./cn";
|
||||
|
||||
export interface TabDef<T extends string> {
|
||||
key: T;
|
||||
label: string;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
// Tabs 受控标签条(下划线高亮)。
|
||||
export function Tabs<T extends string>({
|
||||
tabs,
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
}: {
|
||||
tabs: TabDef<T>[];
|
||||
value: T;
|
||||
onChange: (v: T) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("flex items-center gap-1", className)}>
|
||||
{tabs.map((t) => {
|
||||
const active = t.key === value;
|
||||
return (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => onChange(t.key)}
|
||||
className={cn(
|
||||
"relative px-3 py-2 text-xs transition",
|
||||
active ? "font-medium text-brand-400" : "text-slate-500 hover:text-slate-300",
|
||||
)}
|
||||
>
|
||||
{t.label}
|
||||
{t.count != null && t.count > 0 && (
|
||||
<span className="ml-1 rounded bg-ink-800 px-1 text-[9px] text-slate-400">{t.count}</span>
|
||||
)}
|
||||
{active && <span className="absolute inset-x-2 -bottom-px h-0.5 rounded bg-brand" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { createContext, useCallback, useContext, useRef, useState, type ReactNode } from "react";
|
||||
import { CheckCircle2, AlertTriangle, Info, X } from "lucide-react";
|
||||
import { cn } from "./cn";
|
||||
|
||||
type ToastTone = "success" | "error" | "info";
|
||||
interface Toast {
|
||||
id: number;
|
||||
tone: ToastTone;
|
||||
msg: string;
|
||||
}
|
||||
|
||||
interface ToastCtx {
|
||||
push: (tone: ToastTone, msg: string) => void;
|
||||
}
|
||||
|
||||
const Ctx = createContext<ToastCtx>({ push: () => {} });
|
||||
|
||||
// useToast 在任意组件里弹出全局通知。
|
||||
export function useToast() {
|
||||
return useContext(Ctx);
|
||||
}
|
||||
|
||||
const icons = { success: CheckCircle2, error: AlertTriangle, info: Info };
|
||||
const accent = {
|
||||
success: "text-success",
|
||||
error: "text-danger",
|
||||
info: "text-accent-400",
|
||||
};
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
const seq = useRef(0);
|
||||
|
||||
const push = useCallback((tone: ToastTone, msg: string) => {
|
||||
const id = ++seq.current;
|
||||
setToasts((t) => [...t, { id, tone, msg }]);
|
||||
setTimeout(() => setToasts((t) => t.filter((x) => x.id !== id)), 4200);
|
||||
}, []);
|
||||
|
||||
const dismiss = (id: number) => setToasts((t) => t.filter((x) => x.id !== id));
|
||||
|
||||
return (
|
||||
<Ctx.Provider value={{ push }}>
|
||||
{children}
|
||||
<div className="pointer-events-none fixed bottom-4 right-4 z-50 flex w-80 flex-col gap-2">
|
||||
{toasts.map((t) => {
|
||||
const Icon = icons[t.tone];
|
||||
return (
|
||||
<div
|
||||
key={t.id}
|
||||
className="pointer-events-auto flex items-start gap-2.5 rounded-lg border border-line bg-ink-850 px-3 py-2.5 shadow-card"
|
||||
>
|
||||
<Icon className={cn("mt-0.5 h-4 w-4 shrink-0", accent[t.tone])} strokeWidth={2} />
|
||||
<span className="flex-1 text-xs leading-relaxed text-slate-200">{t.msg}</span>
|
||||
<button onClick={() => dismiss(t.id)} className="text-slate-600 hover:text-slate-300">
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Ctx.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// cn 合并 className —— 过滤掉 falsy,空格连接(零依赖的轻量 clsx)。
|
||||
export function cn(...parts: Array<string | false | null | undefined>): string {
|
||||
return parts.filter(Boolean).join(" ");
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// UI primitives 桶文件 —— 统一从 "../ui" 引入。
|
||||
export { cn } from "./cn";
|
||||
export { Button } from "./Button";
|
||||
export { Input, Textarea, Select, Field } from "./Input";
|
||||
export { Card, Panel } from "./Card";
|
||||
export { Badge, Dot } from "./Badge";
|
||||
export { Table, Tr, Td } from "./Table";
|
||||
export { Tabs, type TabDef } from "./Tabs";
|
||||
export { Skeleton, EmptyState } from "./Feedback";
|
||||
export { Dialog, ConfirmFooter } from "./Dialog";
|
||||
export { ToastProvider, useToast } from "./Toast";
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,47 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// 分层表面 / 边框 / 文字均由 CSS 变量驱动(RGB 三元组,保留 /透明度 修饰符)。
|
||||
// 变量在 index.css 的 :root(亮) 与 .dark(暗) 两套值间切换 —— 一处定义,全 app 换肤。
|
||||
ink: {
|
||||
950: "rgb(var(--ink-950) / <alpha-value>)",
|
||||
900: "rgb(var(--ink-900) / <alpha-value>)",
|
||||
850: "rgb(var(--ink-850) / <alpha-value>)",
|
||||
800: "rgb(var(--ink-800) / <alpha-value>)",
|
||||
700: "rgb(var(--ink-700) / <alpha-value>)",
|
||||
600: "rgb(var(--ink-600) / <alpha-value>)",
|
||||
},
|
||||
line: "rgb(var(--line) / <alpha-value>)",
|
||||
slate: {
|
||||
100: "rgb(var(--slate-100) / <alpha-value>)",
|
||||
200: "rgb(var(--slate-200) / <alpha-value>)",
|
||||
300: "rgb(var(--slate-300) / <alpha-value>)",
|
||||
400: "rgb(var(--slate-400) / <alpha-value>)",
|
||||
500: "rgb(var(--slate-500) / <alpha-value>)",
|
||||
600: "rgb(var(--slate-600) / <alpha-value>)",
|
||||
700: "rgb(var(--slate-700) / <alpha-value>)",
|
||||
},
|
||||
// 语义强调色 —— 两套主题通用(紫=brand,青=accent)。
|
||||
brand: { DEFAULT: "#7c5cf6", 400: "#a78bfa", 500: "#8b5cf6", 600: "#6d28d9" },
|
||||
accent: { DEFAULT: "#22d3ee", 400: "#22d3ee", 500: "#06b6d4" },
|
||||
success: { DEFAULT: "#34d399", 500: "#10b981" },
|
||||
warn: { DEFAULT: "#fbbf24", 500: "#f59e0b" },
|
||||
danger: { DEFAULT: "#fb7185", 500: "#f43f5e" },
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "0.625rem", // 10px —— 卡片/面板
|
||||
md: "0.5rem", // 8px —— 控件
|
||||
},
|
||||
boxShadow: {
|
||||
glow: "0 0 0 1px rgba(139,92,246,0.45), 0 0 18px rgba(139,92,246,0.28)",
|
||||
"glow-cyan": "0 0 0 1px rgba(34,211,238,0.4), 0 0 16px rgba(34,211,238,0.22)",
|
||||
card: "var(--shadow-card)",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/// <reference types="vitest/config" />
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
// 薄 Web 面(租户自助入口)。5173=桌面端前端、5174=admin、这里 5175。
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: { port: 5175 },
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
globals: true,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user