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
+7
View File
@@ -14,6 +14,13 @@
"runtimeArgs": ["run", "dev", "--", "--port", "5174"], "runtimeArgs": ["run", "dev", "--", "--port", "5174"],
"cwd": "sundynix-admin", "cwd": "sundynix-admin",
"port": 5174 "port": 5174
},
{
"name": "web-face",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev", "--", "--port", "5175"],
"cwd": "sundynix-web",
"port": 5175
} }
] ]
} }
+1 -1
View File
@@ -38,7 +38,7 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
dir: [sundynix-desktop/frontend, sundynix-admin] dir: [sundynix-desktop/frontend, sundynix-admin, sundynix-web]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
+4 -1
View File
@@ -1,4 +1,4 @@
.PHONY: infra infra-down devnats demo e2e test test-go test-web test-py gateway dispatcher mcp-go mcp-py mcp-py-setup web admin desktop desktop-build tidy .PHONY: infra infra-down devnats demo e2e test test-go test-web test-py gateway dispatcher mcp-go mcp-py mcp-py-setup web admin webface desktop desktop-build tidy
infra: ## 启动基础设施 (NATS / Postgres / Redis / Milvus / Neo4j) infra: ## 启动基础设施 (NATS / Postgres / Redis / Milvus / Neo4j)
docker compose up -d docker compose up -d
@@ -54,6 +54,9 @@ web: ## 桌面端前端 devVite :5173,连本地 Gateway:8080
admin: ## 运维控制台 devVite :5174,配置 LLM 模型等控制面) admin: ## 运维控制台 devVite :5174,配置 LLM 模型等控制面)
cd sundynix-admin && npm install && npm run dev cd sundynix-admin && npm install && npm run dev
webface: ## 薄 Web 面 devVite :5175,租户自助:注册/组织/团队/账单)
cd sundynix-web && npm install && npm run dev
desktop: ## 真实桌面端开发模式(Wails 原生窗口 + 热重载;需先 make infra + 后端 + 配模型) desktop: ## 真实桌面端开发模式(Wails 原生窗口 + 热重载;需先 make infra + 后端 + 配模型)
cd sundynix-desktop && GOWORK=off wails3 dev cd sundynix-desktop && GOWORK=off wails3 dev
+1
View File
@@ -67,6 +67,7 @@ make dispatcher
make mcp-go make mcp-go
make mcp-py # 首次自动建 venv + 装解析依赖 make mcp-py # 首次自动建 venv + 装解析依赖
make admin # 3) 运维控制台 (Vite :5174) make admin # 3) 运维控制台 (Vite :5174)
make webface # 3b) 薄 Web 面:租户自助注册/组织/团队/账单 (Vite :5175)
``` ```
4) 打开运维控制台(`:5174`),在「模型 / 数据源」登记并激活:**对话模型(chat)** 与 **Embedding 模型** 4) 打开运维控制台(`:5174`),在「模型 / 数据源」登记并激活:**对话模型(chat)** 与 **Embedding 模型**
+12
View File
@@ -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>
+4881
View File
File diff suppressed because it is too large Load Diff
+31
View File
@@ -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"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+45
View File
@@ -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>
);
}
+21
View File
@@ -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");
});
});
+266
View File
@@ -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;
+122
View File
@@ -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;
}
+13
View File
@@ -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>,
);
+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>
);
}
+23
View File
@@ -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;
+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>
);
}
+35
View File
@@ -0,0 +1,35 @@
// 主题(亮/暗)管理:持久化到 localStorage,切换在 <html> 上加/去 .dark 类。
// 默认亮色(暗色为可选)。初始化在 applyInitialThememain 启动时调,先于渲染避免闪烁)。
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 };
}
+38
View File
@@ -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")} />;
}
+37
View File
@@ -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>
);
}
+36
View File
@@ -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>
);
}
+51
View File
@@ -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>
</>
);
}
+34
View File
@@ -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>
);
}
+34
View File
@@ -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>
);
}
+34
View File
@@ -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>;
}
+44
View File
@@ -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>
);
}
+64
View File
@@ -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>
);
}
+4
View File
@@ -0,0 +1,4 @@
// cn 合并 className —— 过滤掉 falsy,空格连接(零依赖的轻量 clsx)。
export function cn(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(" ");
}
+11
View File
@@ -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";
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+47
View File
@@ -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: [],
};
+16
View File
@@ -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"]
}
+13
View File
@@ -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,
},
});