feat(web): modernize admin UI with slate-cyan design system

This commit is contained in:
chengkai3
2026-04-12 22:00:48 +08:00
parent 0fa6b043c6
commit 4001276c5d
15 changed files with 535 additions and 345 deletions
+13
View File
@@ -41,3 +41,16 @@
- 部署 compose 中 DB 镜像应通过 `POSTGRES_IMAGE` 可配置,默认使用镜像站的 pgvector 镜像(`docker.m.daocloud.io/pgvector/pgvector:pg16`)。
- 宿主机 DB 暴露端口统一走 `POSTGRES_PORT`(默认 `5433`),用于规避与宿主机已有 PostgreSQL(常见 `5432`)冲突;容器内连接仍保持 `db:5432`
- GitHub Actions 使用 `appleboy/ssh-action` 部署时,慢网环境需显式设置 `command_timeout`(建议 `45m`)并为 `docker compose pull` 增加重试,避免出现 `Run Command Timeout` 直接中断发布。
## 前端视觉口径(2026-04-12
- 后台视觉基线采用 `Slate + Cyan`(浅色)风格,优先使用 `web/src/app/globals.css` 中统一样式类:
- `surface-card` / `surface-card-muted`
- `notice` + `notice-error` / `notice-success`
- `btn-primary` / `btn-secondary` / `btn-danger`
- `control`
- `table-modern` / `table-head` / `table-body`
- 字体基线:
- 标题:`Space Grotesk`
- 正文:`Manrope`
- 等宽:`JetBrains Mono`
+22
View File
@@ -124,6 +124,28 @@
- 推送触发 `main` 发布,观察部署日志不再在固定时长点报 `Run Command Timeout`
- 远端 `docker compose ps` 应显示 `db/api/web` 均为 `Up`(或 `healthy`)。
## 追加改造(后台视觉风格方案 A)
- 目标:
- 将后台从默认黑白风格升级为更现代的 `Slate + Cyan` 视觉基线,保持业务逻辑不变。
- 处理:
- `web/src/app/layout.tsx`
- 字体切换为 `Space Grotesk`(标题)+ `Manrope`(正文)+ `JetBrains Mono`(等宽)。
- `web/src/app/globals.css`
- 新增统一设计 token(背景/边框/强调色/文本层级)。
- 新增通用样式类:`surface-card``surface-card-muted``notice``btn-*``control``table-*`
- 增加浅色渐变背景与柔和光斑,提升整体层次。
- `web/src/app/admin/layout.tsx`
- 后台侧栏、顶部标题区改为半透明磨砂卡片风格,激活态采用青色高亮。
- `web/src/app/admin/**``web/src/app/page.tsx`
- 统一替换卡片/按钮/表单/表格样式到新通用类,保持页面结构与交互逻辑不变。
- 验证:
- `npm run lint:web` 通过。
- `npm run build:web` 失败(环境问题,非本次样式改动引入):
- Turbopack 报 `Can't resolve '@tanstack/react-query'` / `Can't resolve 'react'`,但 `npm --workspace web ls react @tanstack/react-query --depth=0` 可见依赖存在。
- 风险:
- 本次改动覆盖前端多个后台页面,主要风险是视觉回归与局部间距细节,需要联调页面人工验收。
## 追加修复(DB 端口冲突 + pgvector 基线)
- 触发问题:
+40 -40
View File
@@ -379,14 +379,14 @@ export default function AdminFilesPage() {
uploadMutation.isPending;
if (initializing || filesQuery.isLoading) {
return <p className="text-sm text-zinc-500">Loading files...</p>;
return <p className="text-sm text-muted">Loading files...</p>;
}
if (!user) {
return (
<main className="mx-auto flex min-h-screen w-full max-w-4xl flex-col justify-center gap-4 px-6 py-20">
<p className="text-sm text-zinc-600 dark:text-zinc-300">访</p>
<Link href="/" className="text-sm underline"></Link>
<p className="text-sm text-muted">访</p>
<Link href="/" className="btn-secondary w-fit"></Link>
</main>
);
}
@@ -394,8 +394,8 @@ export default function AdminFilesPage() {
if (!canRead) {
return (
<main className="mx-auto flex min-h-screen w-full max-w-4xl flex-col justify-center gap-4 px-6 py-20">
<p className="text-sm text-zinc-600 dark:text-zinc-300">访 `file.read`</p>
<Link href="/" className="text-sm underline"></Link>
<p className="text-sm text-muted">访 `file.read`</p>
<Link href="/" className="btn-secondary w-fit"></Link>
</main>
);
}
@@ -403,20 +403,20 @@ export default function AdminFilesPage() {
return (
<div className="space-y-6">
{(listError || errorMessage) && (
<pre className="overflow-auto rounded-xl border border-red-500/30 bg-red-50 p-4 text-sm text-red-700 dark:border-red-500/30 dark:bg-red-950/30 dark:text-red-300">
<pre className="notice notice-error">
{listError || errorMessage}
</pre>
)}
{feedbackMessage && (
<pre className="overflow-auto rounded-xl border border-emerald-500/30 bg-emerald-50 p-4 text-sm text-emerald-700 dark:border-emerald-500/30 dark:bg-emerald-950/30 dark:text-emerald-300">
<pre className="notice notice-success">
{feedbackMessage}
</pre>
)}
<div className="grid gap-6 xl:grid-cols-[260px_minmax(0,1fr)]">
<section className="rounded-2xl border border-black/10 bg-white p-5 shadow-sm dark:border-white/10 dark:bg-zinc-900">
<section className="surface-card">
<h2 className="text-lg font-semibold"></h2>
<p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400"> VFS/S3</p>
<p className="mt-1 text-sm text-muted"> VFS/S3</p>
<div className="mt-4 space-y-2">
{mounts.map((mount) => {
const selected = mount.code === (listData?.current_mount.code ?? mountCode);
@@ -427,35 +427,35 @@ export default function AdminFilesPage() {
onClick={() => handleSelectMount(mount)}
className={`w-full rounded-lg border px-3 py-2 text-left text-sm transition ${
selected
? "border-black bg-black text-white dark:border-white dark:bg-white dark:text-black"
: "border-black/15 hover:bg-black/5 dark:border-white/20 dark:hover:bg-white/10"
? "border-cyan-400 bg-gradient-to-r from-cyan-500 to-cyan-600 text-white shadow-[0_10px_22px_rgba(8,145,178,0.28)]"
: "border-[var(--border)] bg-white/70 text-slate-700 hover:border-cyan-200 hover:bg-cyan-50/70"
}`}
>
<p className="font-medium">{mount.name}</p>
<p className={`text-xs ${selected ? "text-white/80 dark:text-black/70" : "text-zinc-500 dark:text-zinc-400"}`}>
<p className={`text-xs ${selected ? "text-cyan-100" : "text-muted"}`}>
{mount.backend.driver_type} · {mount.code}
</p>
</button>
);
})}
{mounts.length === 0 && (
<p className="text-sm text-zinc-500 dark:text-zinc-400"></p>
<p className="text-sm text-muted"></p>
)}
</div>
</section>
<section className="rounded-2xl border border-black/10 bg-white p-5 shadow-sm dark:border-white/10 dark:bg-zinc-900">
<section className="surface-card">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-lg font-semibold"></h2>
<p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
<p className="mt-1 text-sm text-muted">
{listData?.current_mount.backend.name ?? "-"}{listData?.current_mount.backend.driver_type ?? "-"}
</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
className="rounded-md border border-black/15 px-3 py-2 text-xs hover:bg-black/5 dark:border-white/20 dark:hover:bg-white/10"
className="btn-secondary btn-small"
onClick={() => void refreshCurrentPath()}
disabled={filesQuery.isFetching}
>
@@ -471,7 +471,7 @@ export default function AdminFilesPage() {
/>
<button
type="button"
className="rounded-md bg-black px-3 py-2 text-xs font-medium text-white hover:bg-zinc-800 disabled:opacity-60 dark:bg-white dark:text-black dark:hover:bg-zinc-200"
className="btn-primary btn-small"
onClick={handleUploadClick}
disabled={uploadMutation.isPending}
>
@@ -482,7 +482,7 @@ export default function AdminFilesPage() {
</div>
</div>
<div className="mt-4 flex flex-wrap items-center gap-2 rounded-lg border border-black/10 bg-black/[0.02] px-3 py-2 text-sm dark:border-white/10 dark:bg-white/[0.03]">
<div className="mt-4 flex flex-wrap items-center gap-2 rounded-lg border border-[var(--border)] bg-sky-50/70 px-3 py-2 text-sm">
{(listData?.breadcrumbs ?? [{ name: "根目录", path: "/" }]).map((crumb, index, all) => (
<div key={crumb.path} className="flex items-center gap-2">
<button
@@ -491,11 +491,11 @@ export default function AdminFilesPage() {
setCurrentPath(crumb.path);
resetActionPanels();
}}
className="rounded px-1 py-0.5 hover:bg-black/10 dark:hover:bg-white/10"
className="rounded px-1 py-0.5 hover:bg-cyan-100"
>
{crumb.name}
</button>
{index < all.length - 1 && <span className="text-zinc-400">/</span>}
{index < all.length - 1 && <span className="text-slate-400">/</span>}
</div>
))}
</div>
@@ -506,11 +506,11 @@ export default function AdminFilesPage() {
value={newDirectoryName}
onChange={(event) => setNewDirectoryName(event.target.value)}
placeholder="新建目录名"
className="w-full max-w-xs rounded-md border border-black/15 bg-transparent px-3 py-2 text-sm outline-none focus:border-black/40 dark:border-white/20 dark:focus:border-white/40"
className="w-full max-w-xs control"
/>
<button
type="button"
className="rounded-md bg-black px-3 py-2 text-xs font-medium text-white hover:bg-zinc-800 disabled:opacity-60 dark:bg-white dark:text-black dark:hover:bg-zinc-200"
className="btn-primary btn-small"
onClick={() => {
if (!newDirectoryName.trim()) {
setErrorMessage("目录名称不能为空");
@@ -526,8 +526,8 @@ export default function AdminFilesPage() {
)}
<div className="mt-4 overflow-x-auto">
<table className="min-w-full divide-y divide-black/10 text-left text-sm dark:divide-white/10">
<thead className="bg-black/[0.03] dark:bg-white/[0.04]">
<table className="table-modern min-w-full text-left text-sm">
<thead className="table-head">
<tr>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
@@ -537,7 +537,7 @@ export default function AdminFilesPage() {
<th className="px-4 py-3 font-medium"></th>
</tr>
</thead>
<tbody className="divide-y divide-black/10 dark:divide-white/10">
<tbody className="table-body divide-y">
{items.map((item) => {
const isActive = activeItemPath === item.path;
return (
@@ -551,17 +551,17 @@ export default function AdminFilesPage() {
{item.is_dir ? `[DIR] ${item.name}` : item.name}
</button>
{isActive && canManage && (
<div className="mt-2 space-y-2 rounded-md border border-black/10 bg-black/[0.03] p-2 text-xs dark:border-white/10 dark:bg-white/[0.03]">
<div className="mt-2 space-y-2 rounded-md border border-[var(--border)] bg-cyan-50/70 p-2 text-xs">
<div className="flex flex-wrap items-center gap-2">
<input
value={renameName}
onChange={(event) => setRenameName(event.target.value)}
placeholder="新名称"
className="w-48 rounded-md border border-black/15 bg-transparent px-2 py-1 text-xs outline-none focus:border-black/40 dark:border-white/20 dark:focus:border-white/40"
className="control w-48 px-2 py-1 text-xs"
/>
<button
type="button"
className="rounded-md border border-black/15 px-2 py-1 text-xs hover:bg-black/5 dark:border-white/20 dark:hover:bg-white/10"
className="btn-secondary btn-small px-2 py-1"
onClick={() => submitRename(item)}
disabled={renameMutation.isPending}
>
@@ -573,17 +573,17 @@ export default function AdminFilesPage() {
value={moveTargetParentPath}
onChange={(event) => setMoveTargetParentPath(event.target.value)}
placeholder="目标目录(如 /a/b"
className="w-48 rounded-md border border-black/15 bg-transparent px-2 py-1 text-xs outline-none focus:border-black/40 dark:border-white/20 dark:focus:border-white/40"
className="control w-48 px-2 py-1 text-xs"
/>
<input
value={moveNewName}
onChange={(event) => setMoveNewName(event.target.value)}
placeholder="新名称(可选)"
className="w-40 rounded-md border border-black/15 bg-transparent px-2 py-1 text-xs outline-none focus:border-black/40 dark:border-white/20 dark:focus:border-white/40"
className="control w-40 px-2 py-1 text-xs"
/>
<button
type="button"
className="rounded-md border border-black/15 px-2 py-1 text-xs hover:bg-black/5 dark:border-white/20 dark:hover:bg-white/10"
className="btn-secondary btn-small px-2 py-1"
onClick={() => submitMove(item)}
disabled={moveMutation.isPending}
>
@@ -591,7 +591,7 @@ export default function AdminFilesPage() {
</button>
<button
type="button"
className="rounded-md border border-black/15 px-2 py-1 text-xs hover:bg-black/5 dark:border-white/20 dark:hover:bg-white/10"
className="btn-secondary btn-small px-2 py-1"
onClick={resetActionPanels}
>
@@ -602,14 +602,14 @@ export default function AdminFilesPage() {
</td>
<td className="whitespace-nowrap px-4 py-3">{item.is_dir ? "目录" : item.mime_type ?? "文件"}</td>
<td className="whitespace-nowrap px-4 py-3">{item.is_dir ? "-" : formatFileSize(item.size)}</td>
<td className="whitespace-nowrap px-4 py-3 text-xs text-zinc-500">{formatDate(item.modified_at)}</td>
<td className="whitespace-nowrap px-4 py-3 text-xs text-zinc-500">{formatDate(item.synced_at)}</td>
<td className="whitespace-nowrap px-4 py-3 text-xs text-muted">{formatDate(item.modified_at)}</td>
<td className="whitespace-nowrap px-4 py-3 text-xs text-muted">{formatDate(item.synced_at)}</td>
<td className="whitespace-nowrap px-4 py-3">
<div className="flex flex-wrap gap-2">
{item.is_dir && (
<button
type="button"
className="rounded-md border border-black/15 px-2 py-1 text-xs hover:bg-black/5 dark:border-white/20 dark:hover:bg-white/10"
className="btn-secondary btn-small px-2 py-1"
onClick={() => handleOpenDirectory(item)}
>
@@ -618,7 +618,7 @@ export default function AdminFilesPage() {
{!item.is_dir && (
<button
type="button"
className="rounded-md border border-black/15 px-2 py-1 text-xs hover:bg-black/5 dark:border-white/20 dark:hover:bg-white/10"
className="btn-secondary btn-small px-2 py-1"
onClick={() => void handleDownload(item)}
>
@@ -628,7 +628,7 @@ export default function AdminFilesPage() {
<>
<button
type="button"
className="rounded-md border border-black/15 px-2 py-1 text-xs hover:bg-black/5 dark:border-white/20 dark:hover:bg-white/10"
className="btn-secondary btn-small px-2 py-1"
onClick={() => startRename(item)}
disabled={operationBusy}
>
@@ -636,7 +636,7 @@ export default function AdminFilesPage() {
</button>
<button
type="button"
className="rounded-md border border-black/15 px-2 py-1 text-xs hover:bg-black/5 dark:border-white/20 dark:hover:bg-white/10"
className="btn-secondary btn-small px-2 py-1"
onClick={() => startMove(item)}
disabled={operationBusy}
>
@@ -644,7 +644,7 @@ export default function AdminFilesPage() {
</button>
<button
type="button"
className="rounded-md border border-red-500/30 px-2 py-1 text-xs text-red-600 hover:bg-red-50 dark:text-red-300 dark:hover:bg-red-950/30"
className="btn-danger btn-small px-2 py-1"
onClick={() => handleDelete(item)}
disabled={deleteMutation.isPending}
>
@@ -659,7 +659,7 @@ export default function AdminFilesPage() {
})}
{items.length === 0 && (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-sm text-zinc-500 dark:text-zinc-400">
<td colSpan={6} className="px-4 py-8 text-center text-sm text-muted">
</td>
</tr>
+25 -20
View File
@@ -70,7 +70,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
if (initializing || loadingMenus) {
return (
<main className="mx-auto flex min-h-screen w-full max-w-6xl items-center justify-center px-6 py-20">
<p className="text-sm text-zinc-500">Loading admin workspace...</p>
<p className="text-sm text-muted">Loading admin workspace...</p>
</main>
);
}
@@ -78,19 +78,24 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
if (!user) {
return (
<main className="mx-auto flex min-h-screen w-full max-w-4xl flex-col justify-center gap-4 px-6 py-20">
<p className="text-sm text-zinc-600 dark:text-zinc-300">访</p>
<Link href="/" className="text-sm underline"></Link>
<p className="text-sm text-muted">访</p>
<Link href="/" className="btn-secondary w-fit"></Link>
</main>
);
}
return (
<div className="min-h-screen bg-zinc-50 text-zinc-950 dark:bg-zinc-950 dark:text-zinc-50">
<div className="mx-auto grid min-h-screen w-full max-w-7xl grid-cols-1 gap-0 md:grid-cols-[260px_minmax(0,1fr)]">
<aside className="border-r border-black/10 bg-white/90 p-6 dark:border-white/10 dark:bg-zinc-900/80">
<div className="relative min-h-screen text-slate-900">
<div className="pointer-events-none absolute inset-0 overflow-hidden">
<div className="absolute -left-24 top-[-5rem] h-72 w-72 rounded-full bg-cyan-300/30 blur-3xl" />
<div className="absolute right-[-6rem] top-20 h-96 w-96 rounded-full bg-sky-300/30 blur-3xl" />
</div>
<div className="relative mx-auto grid min-h-screen w-full max-w-[1360px] grid-cols-1 gap-0 md:grid-cols-[280px_minmax(0,1fr)]">
<aside className="border-r border-[var(--border)] bg-white/70 p-6 backdrop-blur-xl md:sticky md:top-0 md:h-screen md:overflow-y-auto">
<div className="mb-8">
<Link href="/" className="text-xl font-semibold tracking-tight">fquiz admin</Link>
<p className="mt-2 text-sm text-zinc-500 dark:text-zinc-400">{user.username} · {user.email}</p>
<Link href="/" className="text-xl font-bold tracking-tight text-slate-900">fquiz admin</Link>
<p className="mt-2 text-sm text-muted">{user.username} · {user.email}</p>
</div>
<nav className="space-y-2">
@@ -99,20 +104,20 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
{item.path ? (
<Link
href={item.path}
className={`block rounded-lg px-3 py-2 text-sm transition ${pathname === item.path ? "bg-black text-white dark:bg-white dark:text-black" : "hover:bg-black/5 dark:hover:bg-white/10"}`}
className={`block rounded-lg px-3 py-2 text-sm font-medium transition ${pathname === item.path ? "bg-cyan-500 text-white shadow-[0_10px_24px_rgba(8,145,178,0.28)]" : "text-slate-700 hover:bg-cyan-50"}`}
>
{item.name}
</Link>
) : (
<div className="px-3 py-2 text-sm font-medium text-zinc-500 dark:text-zinc-300">{item.name}</div>
<div className="px-3 py-2 text-sm font-medium text-muted">{item.name}</div>
)}
{item.children.length > 0 && (
<div className="ml-3 space-y-1 border-l border-black/10 pl-3 dark:border-white/10">
<div className="ml-3 space-y-1 border-l border-[var(--border)] pl-3">
{item.children.map((child) => (
<Link
key={child.id}
href={child.path ?? "/admin"}
className={`block rounded-lg px-3 py-2 text-sm transition ${pathname === child.path ? "bg-black text-white dark:bg-white dark:text-black" : "hover:bg-black/5 dark:hover:bg-white/10"}`}
className={`block rounded-lg px-3 py-2 text-sm transition ${pathname === child.path ? "bg-cyan-500 text-white shadow-[0_8px_20px_rgba(8,145,178,0.28)]" : "text-slate-700 hover:bg-cyan-50"}`}
>
{child.name}
</Link>
@@ -123,10 +128,10 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
))}
</nav>
<div className="mt-8 space-y-3 border-t border-black/10 pt-6 dark:border-white/10">
<p className="text-xs text-zinc-500 dark:text-zinc-400">{user.role_codes.join(", ") || "-"}</p>
<div className="mt-8 space-y-3 border-t border-[var(--border)] pt-6">
<p className="text-xs text-muted">{user.role_codes.join(", ") || "-"}</p>
<button
className="w-full rounded-md border border-black/15 px-4 py-2 text-sm font-medium transition hover:bg-black/5 dark:border-white/20 dark:hover:bg-white/10"
className="btn-secondary w-full"
onClick={() => void logout()}
type="button"
>
@@ -136,16 +141,16 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
</aside>
<main className="p-6 md:p-8">
<div className="mb-6 flex items-center justify-between gap-4 rounded-2xl border border-black/10 bg-white p-5 shadow-sm dark:border-white/10 dark:bg-zinc-900">
<div className="surface-card mb-6 flex items-center justify-between gap-4 bg-gradient-to-br from-white/95 via-cyan-50/65 to-sky-50/80">
<div>
<p className="text-sm text-zinc-500 dark:text-zinc-400"></p>
<h1 className="text-2xl font-semibold tracking-tight">{currentTitle}</h1>
<p className="text-sm text-muted"></p>
<h1 className="text-2xl font-bold tracking-tight">{currentTitle}</h1>
</div>
<Link href="/" className="text-sm underline"></Link>
<Link href="/" className="btn-secondary"></Link>
</div>
{menuError && (
<pre className="mb-6 overflow-auto rounded-xl border border-red-500/30 bg-red-50 p-4 text-sm text-red-700 dark:border-red-500/30 dark:bg-red-950/30 dark:text-red-300">
<pre className="notice notice-error mb-6">
{menuError}
</pre>
)}
+28 -28
View File
@@ -160,14 +160,14 @@ export default function AdminMenusPage() {
};
if (initializing || loading) {
return <p className="text-sm text-zinc-500">Loading menus...</p>;
return <p className="text-sm text-muted">Loading menus...</p>;
}
if (!user) {
return (
<main className="mx-auto flex min-h-screen w-full max-w-4xl flex-col justify-center gap-4 px-6 py-20">
<p className="text-sm text-zinc-600 dark:text-zinc-300">访</p>
<Link href="/" className="text-sm underline"></Link>
<p className="text-sm text-muted">访</p>
<Link href="/" className="btn-secondary w-fit"></Link>
</main>
);
}
@@ -175,8 +175,8 @@ export default function AdminMenusPage() {
if (!canRead) {
return (
<main className="mx-auto flex min-h-screen w-full max-w-4xl flex-col justify-center gap-4 px-6 py-20">
<p className="text-sm text-zinc-600 dark:text-zinc-300">访 `menu.read`</p>
<Link href="/" className="text-sm underline"></Link>
<p className="text-sm text-muted">访 `menu.read`</p>
<Link href="/" className="btn-secondary w-fit"></Link>
</main>
);
}
@@ -184,18 +184,18 @@ export default function AdminMenusPage() {
return (
<div className="space-y-6">
{error && (
<pre className="overflow-auto rounded-xl border border-red-500/30 bg-red-50 p-4 text-sm text-red-700 dark:border-red-500/30 dark:bg-red-950/30 dark:text-red-300">{error}</pre>
<pre className="notice notice-error">{error}</pre>
)}
{success && (
<pre className="overflow-auto rounded-xl border border-emerald-500/30 bg-emerald-50 p-4 text-sm text-emerald-700 dark:border-emerald-500/30 dark:bg-emerald-950/30 dark:text-emerald-300">{success}</pre>
<pre className="notice notice-success">{success}</pre>
)}
<section className="rounded-2xl border border-black/10 bg-white p-5 shadow-sm dark:border-white/10 dark:bg-zinc-900">
<section className="surface-card">
<h2 className="text-lg font-semibold"></h2>
<p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400">访</p>
<p className="mt-1 text-sm text-muted">访</p>
<div className="mt-4 overflow-x-auto">
<table className="min-w-full divide-y divide-black/10 text-left text-sm dark:divide-white/10">
<thead className="bg-black/[0.03] dark:bg-white/[0.04]">
<table className="table-modern min-w-full text-left text-sm">
<thead className="table-head">
<tr>
<th className="px-4 py-3 font-medium">ID</th>
<th className="px-4 py-3 font-medium">Code</th>
@@ -207,7 +207,7 @@ export default function AdminMenusPage() {
{canManage && <th className="px-4 py-3 font-medium"></th>}
</tr>
</thead>
<tbody className="divide-y divide-black/10 dark:divide-white/10">
<tbody className="table-body divide-y">
{menus.map((menu) => (
<tr key={menu.id}>
<td className="px-4 py-3">{menu.id}</td>
@@ -221,7 +221,7 @@ export default function AdminMenusPage() {
<td className="px-4 py-3">
<div className="flex gap-2">
<button
className="rounded-md border border-black/15 px-3 py-1 text-xs hover:bg-black/5 dark:border-white/20 dark:hover:bg-white/10"
className="btn-secondary btn-small"
onClick={() => startEdit(menu)}
type="button"
>
@@ -229,7 +229,7 @@ export default function AdminMenusPage() {
</button>
{!protectedMenuCodes.has(menu.code) && (
<button
className="rounded-md border border-red-500/30 px-3 py-1 text-xs text-red-600 hover:bg-red-50 dark:text-red-300 dark:hover:bg-red-950/30"
className="btn-danger btn-small"
onClick={() => void removeMenu(menu)}
type="button"
>
@@ -247,14 +247,14 @@ export default function AdminMenusPage() {
</section>
{canManage && (
<section className="rounded-2xl border border-black/10 bg-white p-5 shadow-sm dark:border-white/10 dark:bg-zinc-900">
<section className="surface-card">
<div className="mb-4 flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold">{editingMenuId ? "编辑菜单" : "新建菜单"}</h2>
<p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400"></p>
<p className="mt-1 text-sm text-muted"></p>
</div>
{editingMenuId && (
<button className="text-sm underline" type="button" onClick={resetForm}></button>
<button className="btn-secondary w-fit" type="button" onClick={resetForm}></button>
)}
</div>
@@ -265,7 +265,7 @@ export default function AdminMenusPage() {
value={form.code}
disabled={editingMenuId !== null}
onChange={(event) => setForm((prev) => ({ ...prev, code: event.target.value }))}
className="w-full rounded-md border border-black/15 bg-transparent px-3 py-2 outline-none focus:border-black/40 disabled:opacity-60 dark:border-white/20 dark:focus:border-white/40"
className="control w-full"
/>
</label>
<label className="space-y-2 text-sm">
@@ -273,7 +273,7 @@ export default function AdminMenusPage() {
<input
value={form.name}
onChange={(event) => setForm((prev) => ({ ...prev, name: event.target.value }))}
className="w-full rounded-md border border-black/15 bg-transparent px-3 py-2 outline-none focus:border-black/40 dark:border-white/20 dark:focus:border-white/40"
className="control w-full"
/>
</label>
<label className="space-y-2 text-sm">
@@ -282,7 +282,7 @@ export default function AdminMenusPage() {
value={form.path}
onChange={(event) => setForm((prev) => ({ ...prev, path: event.target.value }))}
placeholder="/admin/example"
className="w-full rounded-md border border-black/15 bg-transparent px-3 py-2 outline-none focus:border-black/40 dark:border-white/20 dark:focus:border-white/40"
className="control w-full"
/>
</label>
<label className="space-y-2 text-sm">
@@ -291,7 +291,7 @@ export default function AdminMenusPage() {
value={form.icon}
onChange={(event) => setForm((prev) => ({ ...prev, icon: event.target.value }))}
placeholder="LayoutDashboard"
className="w-full rounded-md border border-black/15 bg-transparent px-3 py-2 outline-none focus:border-black/40 dark:border-white/20 dark:focus:border-white/40"
className="control w-full"
/>
</label>
<label className="space-y-2 text-sm">
@@ -299,7 +299,7 @@ export default function AdminMenusPage() {
<select
value={form.parent_id}
onChange={(event) => setForm((prev) => ({ ...prev, parent_id: event.target.value }))}
className="w-full rounded-md border border-black/15 bg-transparent px-3 py-2 outline-none focus:border-black/40 dark:border-white/20 dark:focus:border-white/40"
className="control w-full"
>
<option value=""></option>
{parentOptions
@@ -314,7 +314,7 @@ export default function AdminMenusPage() {
<select
value={form.type}
onChange={(event) => setForm((prev) => ({ ...prev, type: event.target.value }))}
className="w-full rounded-md border border-black/15 bg-transparent px-3 py-2 outline-none focus:border-black/40 dark:border-white/20 dark:focus:border-white/40"
className="control w-full"
>
<option value="directory">directory</option>
<option value="menu">menu</option>
@@ -327,7 +327,7 @@ export default function AdminMenusPage() {
value={form.sort_order}
onChange={(event) => setForm((prev) => ({ ...prev, sort_order: event.target.value }))}
type="number"
className="w-full rounded-md border border-black/15 bg-transparent px-3 py-2 outline-none focus:border-black/40 dark:border-white/20 dark:focus:border-white/40"
className="control w-full"
/>
</label>
<label className="space-y-2 text-sm">
@@ -335,7 +335,7 @@ export default function AdminMenusPage() {
<select
value={form.status}
onChange={(event) => setForm((prev) => ({ ...prev, status: event.target.value }))}
className="w-full rounded-md border border-black/15 bg-transparent px-3 py-2 outline-none focus:border-black/40 dark:border-white/20 dark:focus:border-white/40"
className="control w-full"
>
<option value="enabled">enabled</option>
<option value="disabled">disabled</option>
@@ -347,7 +347,7 @@ export default function AdminMenusPage() {
value={form.component}
onChange={(event) => setForm((prev) => ({ ...prev, component: event.target.value }))}
placeholder="app/admin/users/page"
className="w-full rounded-md border border-black/15 bg-transparent px-3 py-2 outline-none focus:border-black/40 dark:border-white/20 dark:focus:border-white/40"
className="control w-full"
/>
</label>
<label className="space-y-2 text-sm">
@@ -356,7 +356,7 @@ export default function AdminMenusPage() {
value={form.permission_code}
onChange={(event) => setForm((prev) => ({ ...prev, permission_code: event.target.value }))}
placeholder="menu.read"
className="w-full rounded-md border border-black/15 bg-transparent px-3 py-2 outline-none focus:border-black/40 dark:border-white/20 dark:focus:border-white/40"
className="control w-full"
/>
</label>
<label className="flex items-center gap-2 text-sm">
@@ -371,7 +371,7 @@ export default function AdminMenusPage() {
<div className="mt-4">
<button
className="rounded-md bg-black px-4 py-2 text-sm font-medium text-white transition hover:bg-zinc-800 disabled:opacity-60 dark:bg-white dark:text-black dark:hover:bg-zinc-200"
className="btn-primary"
disabled={saving}
onClick={() => void submit()}
type="button"
+68 -68
View File
@@ -439,14 +439,14 @@ export default function AdminModelsPage() {
}, [modelsQuery.error, routesQuery.error, summaryQuery.error]);
if (initializing || modelsQuery.isLoading || summaryQuery.isLoading || routesQuery.isLoading) {
return <p className="text-sm text-zinc-500">Loading model management...</p>;
return <p className="text-sm text-muted">Loading model management...</p>;
}
if (!user) {
return (
<main className="mx-auto flex min-h-screen w-full max-w-4xl flex-col justify-center gap-4 px-6 py-20">
<p className="text-sm text-zinc-600 dark:text-zinc-300">访</p>
<Link href="/" className="text-sm underline"></Link>
<p className="text-sm text-muted">访</p>
<Link href="/" className="btn-secondary w-fit"></Link>
</main>
);
}
@@ -454,8 +454,8 @@ export default function AdminModelsPage() {
if (!canRead) {
return (
<main className="mx-auto flex min-h-screen w-full max-w-4xl flex-col justify-center gap-4 px-6 py-20">
<p className="text-sm text-zinc-600 dark:text-zinc-300">访 `model.read`</p>
<Link href="/" className="text-sm underline"></Link>
<p className="text-sm text-muted">访 `model.read`</p>
<Link href="/" className="btn-secondary w-fit"></Link>
</main>
);
}
@@ -463,52 +463,52 @@ export default function AdminModelsPage() {
return (
<div className="space-y-6">
{(error || queryError) && (
<pre className="overflow-auto rounded-xl border border-red-500/30 bg-red-50 p-4 text-sm text-red-700 dark:border-red-500/30 dark:bg-red-950/30 dark:text-red-300">{error || queryError}</pre>
<pre className="notice notice-error">{error || queryError}</pre>
)}
{success && (
<pre className="overflow-auto rounded-xl border border-emerald-500/30 bg-emerald-50 p-4 text-sm text-emerald-700 dark:border-emerald-500/30 dark:bg-emerald-950/30 dark:text-emerald-300">{success}</pre>
<pre className="notice notice-success">{success}</pre>
)}
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-2xl border border-black/10 bg-white p-5 shadow-sm dark:border-white/10 dark:bg-zinc-900">
<p className="text-sm text-zinc-500 dark:text-zinc-400"></p>
<div className="surface-card">
<p className="text-sm text-muted"></p>
<p className="mt-2 text-3xl font-semibold">{summary?.total_models ?? 0}</p>
<p className="mt-2 text-xs text-zinc-500">ENABLED: {summary?.status_counts.ENABLED ?? 0}</p>
<p className="mt-2 text-xs text-muted">ENABLED: {summary?.status_counts.ENABLED ?? 0}</p>
</div>
<div className="rounded-2xl border border-black/10 bg-white p-5 shadow-sm dark:border-white/10 dark:bg-zinc-900">
<p className="text-sm text-zinc-500 dark:text-zinc-400"></p>
<div className="surface-card">
<p className="text-sm text-muted"></p>
<p className="mt-2 text-3xl font-semibold">{summary?.total_route_rules ?? 0}</p>
<p className="mt-2 text-xs text-zinc-500">GLOBAL: {summary?.route_type_counts.GLOBAL ?? 0}</p>
<p className="mt-2 text-xs text-muted">GLOBAL: {summary?.route_type_counts.GLOBAL ?? 0}</p>
</div>
<div className="rounded-2xl border border-black/10 bg-white p-5 shadow-sm dark:border-white/10 dark:bg-zinc-900">
<p className="text-sm text-zinc-500 dark:text-zinc-400"> 7 </p>
<div className="surface-card">
<p className="text-sm text-muted"> 7 </p>
<p className="mt-2 text-3xl font-semibold">{summary?.usage_7d.request_count ?? 0}</p>
<p className="mt-2 text-xs text-zinc-500">: {formatPercent(summary?.usage_7d.success_rate ?? null)}</p>
<p className="mt-2 text-xs text-muted">: {formatPercent(summary?.usage_7d.success_rate ?? null)}</p>
</div>
<div className="rounded-2xl border border-black/10 bg-white p-5 shadow-sm dark:border-white/10 dark:bg-zinc-900">
<p className="text-sm text-zinc-500 dark:text-zinc-400"></p>
<div className="surface-card">
<p className="text-sm text-muted"></p>
<p className="mt-2 text-3xl font-semibold">{summary?.enabled_without_healthy_check ?? 0}</p>
<p className="mt-2 text-xs text-zinc-500">ENABLED </p>
<p className="mt-2 text-xs text-muted">ENABLED </p>
</div>
</section>
<section className="rounded-2xl border border-black/10 bg-white p-5 shadow-sm dark:border-white/10 dark:bg-zinc-900">
<section className="surface-card">
<div className="mb-4 flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
<div>
<h2 className="text-lg font-semibold"></h2>
<p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400"> `code` `name` </p>
<p className="mt-1 text-sm text-muted"> `code` `name` </p>
</div>
<div className="grid gap-2 md:grid-cols-2">
<input
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
placeholder="搜索 code/name/provider"
className="rounded-md border border-black/15 bg-transparent px-3 py-2 text-sm outline-none focus:border-black/40 dark:border-white/20 dark:focus:border-white/40"
className="control"
/>
<select
value={statusFilter}
onChange={(event) => setStatusFilter(event.target.value)}
className="rounded-md border border-black/15 bg-transparent px-3 py-2 text-sm outline-none focus:border-black/40 dark:border-white/20 dark:focus:border-white/40"
className="control"
>
<option value=""></option>
{MODEL_STATUS_OPTIONS.map((item) => (
@@ -519,8 +519,8 @@ export default function AdminModelsPage() {
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-black/10 text-left text-sm dark:divide-white/10">
<thead className="bg-black/[0.03] dark:bg-white/[0.04]">
<table className="table-modern min-w-full text-left text-sm">
<thead className="table-head">
<tr>
<th className="px-4 py-3 font-medium">Code</th>
<th className="px-4 py-3 font-medium">Provider/Model</th>
@@ -533,36 +533,36 @@ export default function AdminModelsPage() {
{canManage && <th className="px-4 py-3 font-medium"></th>}
</tr>
</thead>
<tbody className="divide-y divide-black/10 dark:divide-white/10">
<tbody className="table-body divide-y">
{models.map((model) => (
<tr key={model.id}>
<td className="px-4 py-3">
<p className="font-mono text-xs">{model.code}</p>
<p className="mt-1 text-xs text-zinc-500">{model.name}</p>
<p className="mt-1 text-xs text-muted">{model.name}</p>
</td>
<td className="px-4 py-3">
<p>{model.provider}</p>
<p className="mt-1 font-mono text-xs text-zinc-500">{model.provider_model}</p>
<p className="mt-1 font-mono text-xs text-muted">{model.provider_model}</p>
</td>
<td className="px-4 py-3">
<p>{model.status}</p>
<p className="mt-1 text-xs text-zinc-500">{model.capabilities.join(", ") || "-"}</p>
<p className="mt-1 text-xs text-muted">{model.capabilities.join(", ") || "-"}</p>
</td>
<td className="px-4 py-3 text-xs">
<p>{model.active_key_masked ?? "-"}</p>
<p className="mt-1 text-zinc-500">v{model.active_key_version ?? "-"}</p>
<p className="mt-1 text-muted">v{model.active_key_version ?? "-"}</p>
</td>
<td className="px-4 py-3 text-xs">
<p>{model.latest_health_status ?? "-"}</p>
<p className="mt-1 text-zinc-500">{model.latest_health_at ? new Date(model.latest_health_at).toLocaleString() : "-"}</p>
<p className="mt-1 text-muted">{model.latest_health_at ? new Date(model.latest_health_at).toLocaleString() : "-"}</p>
</td>
<td className="px-4 py-3 text-xs">
<p>: {model.usage_7d.request_count}</p>
<p className="mt-1 text-zinc-500">: {formatPercent(model.usage_7d.success_rate)}</p>
<p className="mt-1 text-muted">: {formatPercent(model.usage_7d.success_rate)}</p>
</td>
<td className="px-4 py-3 text-xs">
<p>Runs: {model.tests_7d.total_runs}</p>
<p className="mt-1 text-zinc-500">: {formatPercent(model.tests_7d.pass_rate)}</p>
<p className="mt-1 text-muted">: {formatPercent(model.tests_7d.pass_rate)}</p>
</td>
<td className="px-4 py-3">{model.route_bindings_count}</td>
{canManage && (
@@ -570,14 +570,14 @@ export default function AdminModelsPage() {
<div className="flex flex-wrap gap-2">
<button
type="button"
className="rounded-md border border-black/15 px-3 py-1 text-xs hover:bg-black/5 dark:border-white/20 dark:hover:bg-white/10"
className="btn-secondary btn-small"
onClick={() => startEditModel(model)}
>
</button>
<button
type="button"
className="rounded-md border border-black/15 px-3 py-1 text-xs hover:bg-black/5 dark:border-white/20 dark:hover:bg-white/10"
className="btn-secondary btn-small"
onClick={() => {
const key = window.prompt(`${model.code} 输入新 API Key`);
if (key && key.trim()) {
@@ -590,7 +590,7 @@ export default function AdminModelsPage() {
</button>
<button
type="button"
className="rounded-md border border-black/15 px-3 py-1 text-xs hover:bg-black/5 dark:border-white/20 dark:hover:bg-white/10"
className="btn-secondary btn-small"
onClick={() => healthCheckMutation.mutate(model.id)}
disabled={healthCheckMutation.isPending}
>
@@ -598,7 +598,7 @@ export default function AdminModelsPage() {
</button>
<button
type="button"
className="rounded-md border border-black/15 px-3 py-1 text-xs hover:bg-black/5 dark:border-white/20 dark:hover:bg-white/10"
className="btn-secondary btn-small"
onClick={() => testMutation.mutate(model.id)}
disabled={testMutation.isPending}
>
@@ -608,7 +608,7 @@ export default function AdminModelsPage() {
<button
key={`${model.id}:${nextStatus}`}
type="button"
className="rounded-md border border-black/15 px-3 py-1 text-xs hover:bg-black/5 dark:border-white/20 dark:hover:bg-white/10"
className="btn-secondary btn-small"
onClick={() => transitionMutation.mutate({ modelId: model.id, status: nextStatus })}
disabled={transitionMutation.isPending}
>
@@ -619,7 +619,7 @@ export default function AdminModelsPage() {
{model.status !== "ENABLED" && (
<button
type="button"
className="rounded-md border border-red-500/30 px-3 py-1 text-xs text-red-600 hover:bg-red-50 dark:text-red-300 dark:hover:bg-red-950/30"
className="btn-danger btn-small"
onClick={() => {
if (window.confirm(`确认删除模型 ${model.code} 吗?`)) {
deleteModelMutation.mutate(model);
@@ -641,16 +641,16 @@ export default function AdminModelsPage() {
</section>
{canManage && (
<section className="rounded-2xl border border-black/10 bg-white p-5 shadow-sm dark:border-white/10 dark:bg-zinc-900">
<section className="surface-card">
<div className="mb-4 flex items-center justify-between gap-4">
<div>
<h2 className="text-lg font-semibold">{editingModelId ? "编辑模型" : "新建模型"}</h2>
<p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400"></p>
<p className="mt-1 text-sm text-muted"></p>
</div>
{editingModelId && (
<button
type="button"
className="text-sm underline"
className="btn-secondary w-fit"
onClick={() => {
setEditingModelId(null);
setModelForm(EMPTY_MODEL_FORM);
@@ -669,7 +669,7 @@ export default function AdminModelsPage() {
disabled={editingModelId !== null}
onChange={(event) => setModelForm((prev) => ({ ...prev, code: event.target.value }))}
placeholder="openai.gpt-5"
className="w-full rounded-md border border-black/15 bg-transparent px-3 py-2 outline-none focus:border-black/40 disabled:opacity-60 dark:border-white/20 dark:focus:border-white/40"
className="control w-full"
/>
</label>
<label className="space-y-2 text-sm">
@@ -678,7 +678,7 @@ export default function AdminModelsPage() {
value={modelForm.name}
onChange={(event) => setModelForm((prev) => ({ ...prev, name: event.target.value }))}
placeholder="GPT-5 主模型"
className="w-full rounded-md border border-black/15 bg-transparent px-3 py-2 outline-none focus:border-black/40 dark:border-white/20 dark:focus:border-white/40"
className="control w-full"
/>
</label>
<label className="space-y-2 text-sm">
@@ -686,7 +686,7 @@ export default function AdminModelsPage() {
<input
value={modelForm.provider}
onChange={(event) => setModelForm((prev) => ({ ...prev, provider: event.target.value }))}
className="w-full rounded-md border border-black/15 bg-transparent px-3 py-2 outline-none focus:border-black/40 dark:border-white/20 dark:focus:border-white/40"
className="control w-full"
/>
</label>
<label className="space-y-2 text-sm">
@@ -695,7 +695,7 @@ export default function AdminModelsPage() {
value={modelForm.provider_model}
onChange={(event) => setModelForm((prev) => ({ ...prev, provider_model: event.target.value }))}
placeholder="gpt-5"
className="w-full rounded-md border border-black/15 bg-transparent px-3 py-2 outline-none focus:border-black/40 dark:border-white/20 dark:focus:border-white/40"
className="control w-full"
/>
</label>
<label className="space-y-2 text-sm">
@@ -704,7 +704,7 @@ export default function AdminModelsPage() {
value={modelForm.status}
disabled={editingModelId !== null}
onChange={(event) => setModelForm((prev) => ({ ...prev, status: event.target.value as ModelStatus }))}
className="w-full rounded-md border border-black/15 bg-transparent px-3 py-2 outline-none focus:border-black/40 disabled:opacity-60 dark:border-white/20 dark:focus:border-white/40"
className="control w-full"
>
{MODEL_STATUS_OPTIONS.map((item) => (
<option key={item} value={item}>{item}</option>
@@ -717,7 +717,7 @@ export default function AdminModelsPage() {
value={modelForm.capabilities}
onChange={(event) => setModelForm((prev) => ({ ...prev, capabilities: event.target.value }))}
placeholder="chat,reasoning"
className="w-full rounded-md border border-black/15 bg-transparent px-3 py-2 outline-none focus:border-black/40 dark:border-white/20 dark:focus:border-white/40"
className="control w-full"
/>
</label>
<label className="space-y-2 text-sm md:col-span-2">
@@ -726,7 +726,7 @@ export default function AdminModelsPage() {
value={modelForm.base_url}
onChange={(event) => setModelForm((prev) => ({ ...prev, base_url: event.target.value }))}
placeholder="https://api.example.com"
className="w-full rounded-md border border-black/15 bg-transparent px-3 py-2 outline-none focus:border-black/40 dark:border-white/20 dark:focus:border-white/40"
className="control w-full"
/>
</label>
{!editingModelId && (
@@ -736,7 +736,7 @@ export default function AdminModelsPage() {
value={modelForm.api_key}
onChange={(event) => setModelForm((prev) => ({ ...prev, api_key: event.target.value }))}
placeholder="sk-..."
className="w-full rounded-md border border-black/15 bg-transparent px-3 py-2 outline-none focus:border-black/40 dark:border-white/20 dark:focus:border-white/40"
className="control w-full"
/>
</label>
)}
@@ -747,7 +747,7 @@ export default function AdminModelsPage() {
value={modelForm.description}
onChange={(event) => setModelForm((prev) => ({ ...prev, description: event.target.value }))}
placeholder="模型用途、限制、成本策略..."
className="w-full rounded-md border border-black/15 bg-transparent px-3 py-2 outline-none focus:border-black/40 dark:border-white/20 dark:focus:border-white/40"
className="control w-full"
/>
</label>
</div>
@@ -755,7 +755,7 @@ export default function AdminModelsPage() {
<div className="mt-4">
<button
type="button"
className="rounded-md bg-black px-4 py-2 text-sm font-medium text-white transition hover:bg-zinc-800 disabled:opacity-60 dark:bg-white dark:text-black dark:hover:bg-zinc-200"
className="btn-primary"
disabled={saveModelMutation.isPending || !modelForm.code.trim() || !modelForm.name.trim() || !modelForm.provider_model.trim()}
onClick={() => saveModelMutation.mutate()}
>
@@ -765,15 +765,15 @@ export default function AdminModelsPage() {
</section>
)}
<section className="rounded-2xl border border-black/10 bg-white p-5 shadow-sm dark:border-white/10 dark:bg-zinc-900">
<section className="surface-card">
<div className="mb-4">
<h2 className="text-lg font-semibold"></h2>
<p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400"> GLOBAL / CAPABILITY / BUSINESS / AGENT </p>
<p className="mt-1 text-sm text-muted"> GLOBAL / CAPABILITY / BUSINESS / AGENT </p>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-black/10 text-left text-sm dark:divide-white/10">
<thead className="bg-black/[0.03] dark:bg-white/[0.04]">
<table className="table-modern min-w-full text-left text-sm">
<thead className="table-head">
<tr>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium">Key</th>
@@ -783,7 +783,7 @@ export default function AdminModelsPage() {
{canManage && <th className="px-4 py-3 font-medium"></th>}
</tr>
</thead>
<tbody className="divide-y divide-black/10 dark:divide-white/10">
<tbody className="table-body divide-y">
{routes.map((route) => (
<tr key={route.id}>
<td className="px-4 py-3">{route.route_type}</td>
@@ -796,14 +796,14 @@ export default function AdminModelsPage() {
<div className="flex gap-2">
<button
type="button"
className="rounded-md border border-black/15 px-3 py-1 text-xs hover:bg-black/5 dark:border-white/20 dark:hover:bg-white/10"
className="btn-secondary btn-small"
onClick={() => startEditRoute(route)}
>
</button>
<button
type="button"
className="rounded-md border border-red-500/30 px-3 py-1 text-xs text-red-600 hover:bg-red-50 dark:text-red-300 dark:hover:bg-red-950/30"
className="btn-danger btn-small"
onClick={() => {
if (window.confirm(`确认删除路由规则 ${route.route_type}:${route.route_key} 吗?`)) {
deleteRouteMutation.mutate(route.id);
@@ -824,16 +824,16 @@ export default function AdminModelsPage() {
</section>
{canManage && (
<section className="rounded-2xl border border-black/10 bg-white p-5 shadow-sm dark:border-white/10 dark:bg-zinc-900">
<section className="surface-card">
<div className="mb-4 flex items-center justify-between gap-4">
<div>
<h2 className="text-lg font-semibold">{editingRouteId ? "编辑路由规则" : "新建路由规则"}</h2>
<p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400">GLOBAL key {GLOBAL_ROUTE_KEY}</p>
<p className="mt-1 text-sm text-muted">GLOBAL key {GLOBAL_ROUTE_KEY}</p>
</div>
{editingRouteId && (
<button
type="button"
className="text-sm underline"
className="btn-secondary w-fit"
onClick={() => {
setEditingRouteId(null);
setRouteForm(EMPTY_ROUTE_FORM);
@@ -850,7 +850,7 @@ export default function AdminModelsPage() {
<select
value={routeForm.route_type}
onChange={(event) => setRouteForm((prev) => ({ ...prev, route_type: event.target.value as ModelRouteType }))}
className="w-full rounded-md border border-black/15 bg-transparent px-3 py-2 outline-none focus:border-black/40 dark:border-white/20 dark:focus:border-white/40"
className="control w-full"
>
{ROUTE_TYPE_OPTIONS.map((item) => (
<option key={item} value={item}>{item}</option>
@@ -864,7 +864,7 @@ export default function AdminModelsPage() {
disabled={routeForm.route_type === "GLOBAL"}
onChange={(event) => setRouteForm((prev) => ({ ...prev, route_key: event.target.value }))}
placeholder="chat.default"
className="w-full rounded-md border border-black/15 bg-transparent px-3 py-2 outline-none focus:border-black/40 disabled:opacity-60 dark:border-white/20 dark:focus:border-white/40"
className="control w-full"
/>
</label>
<label className="space-y-2 text-sm">
@@ -873,7 +873,7 @@ export default function AdminModelsPage() {
value={routeForm.target_model_code}
onChange={(event) => setRouteForm((prev) => ({ ...prev, target_model_code: event.target.value }))}
list="model-code-options"
className="w-full rounded-md border border-black/15 bg-transparent px-3 py-2 outline-none focus:border-black/40 dark:border-white/20 dark:focus:border-white/40"
className="control w-full"
/>
<datalist id="model-code-options">
{activeModelCodes.map((item) => (
@@ -887,7 +887,7 @@ export default function AdminModelsPage() {
type="number"
value={routeForm.priority}
onChange={(event) => setRouteForm((prev) => ({ ...prev, priority: event.target.value }))}
className="w-full rounded-md border border-black/15 bg-transparent px-3 py-2 outline-none focus:border-black/40 dark:border-white/20 dark:focus:border-white/40"
className="control w-full"
/>
</label>
<label className="space-y-2 text-sm md:col-span-2">
@@ -896,7 +896,7 @@ export default function AdminModelsPage() {
value={routeForm.note}
onChange={(event) => setRouteForm((prev) => ({ ...prev, note: event.target.value }))}
placeholder="例如:客服场景优先使用"
className="w-full rounded-md border border-black/15 bg-transparent px-3 py-2 outline-none focus:border-black/40 dark:border-white/20 dark:focus:border-white/40"
className="control w-full"
/>
</label>
<label className="flex items-center gap-2 text-sm md:col-span-2">
@@ -912,7 +912,7 @@ export default function AdminModelsPage() {
<div className="mt-4">
<button
type="button"
className="rounded-md bg-black px-4 py-2 text-sm font-medium text-white transition hover:bg-zinc-800 disabled:opacity-60 dark:bg-white dark:text-black dark:hover:bg-zinc-200"
className="btn-primary"
disabled={saveRouteMutation.isPending || !routeForm.target_model_code.trim()}
onClick={() => saveRouteMutation.mutate()}
>
+13 -5
View File
@@ -49,18 +49,26 @@ export default function AdminHomePage() {
if (visibleCards.length === 0) {
return (
<div className="rounded-2xl border border-black/10 bg-white p-5 text-sm text-zinc-500 shadow-sm dark:border-white/10 dark:bg-zinc-900 dark:text-zinc-400">
<div className="surface-card text-sm text-muted">
访
</div>
);
}
return (
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-4">
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
{visibleCards.map((item) => (
<Link key={item.href} href={item.href} className="rounded-2xl border border-black/10 bg-white p-5 shadow-sm transition hover:-translate-y-0.5 hover:shadow-md dark:border-white/10 dark:bg-zinc-900">
<h2 className="text-lg font-semibold">{item.title}</h2>
<p className="mt-2 text-sm text-zinc-500 dark:text-zinc-400">{item.description}</p>
<Link
key={item.href}
href={item.href}
className="surface-card group relative overflow-hidden transition hover:-translate-y-0.5 hover:border-cyan-200"
>
<div className="pointer-events-none absolute -right-10 -top-10 h-24 w-24 rounded-full bg-cyan-200/40 blur-xl transition group-hover:bg-cyan-300/45" />
<h2 className="text-lg font-semibold text-slate-900">{item.title}</h2>
<p className="mt-2 text-sm text-muted">{item.description}</p>
<p className="mt-5 inline-flex items-center text-xs font-medium text-cyan-700">
</p>
</Link>
))}
</div>
+60 -60
View File
@@ -102,14 +102,14 @@ function RequirementEditSection({
const error = updateMutation.error instanceof Error ? updateMutation.error.message : "";
return (
<section className="rounded-2xl border border-black/10 bg-white p-5 shadow-sm dark:border-white/10 dark:bg-zinc-900">
<section className="surface-card">
<div className="mb-4">
<h3 className="text-lg font-semibold"></h3>
<p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400"></p>
<p className="mt-1 text-sm text-muted"></p>
</div>
{error && (
<pre className="mb-4 overflow-auto rounded-xl border border-red-500/30 bg-red-50 p-4 text-sm text-red-700 dark:border-red-500/30 dark:bg-red-950/30 dark:text-red-300">{error}</pre>
<pre className="mb-4 notice notice-error">{error}</pre>
)}
<div className="grid gap-4 md:grid-cols-2">
@@ -118,7 +118,7 @@ function RequirementEditSection({
<input
value={title}
onChange={(event) => setTitle(event.target.value)}
className="w-full rounded-md border border-black/15 bg-transparent px-3 py-2 outline-none focus:border-black/40 dark:border-white/20 dark:focus:border-white/40"
className="control w-full"
/>
</label>
@@ -128,7 +128,7 @@ function RequirementEditSection({
value={description}
onChange={(event) => setDescription(event.target.value)}
rows={8}
className="w-full rounded-md border border-black/15 bg-transparent px-3 py-2 outline-none focus:border-black/40 dark:border-white/20 dark:focus:border-white/40"
className="control w-full"
/>
</label>
@@ -137,7 +137,7 @@ function RequirementEditSection({
<select
value={priority}
onChange={(event) => setPriority(event.target.value as RequirementPriority)}
className="w-full rounded-md border border-black/15 bg-transparent px-3 py-2 outline-none focus:border-black/40 dark:border-white/20 dark:focus:border-white/40"
className="control w-full"
>
{PRIORITY_OPTIONS.map((item) => <option key={item} value={item}>{item}</option>)}
</select>
@@ -149,7 +149,7 @@ function RequirementEditSection({
type="datetime-local"
value={dueAt}
onChange={(event) => setDueAt(event.target.value)}
className="w-full rounded-md border border-black/15 bg-transparent px-3 py-2 outline-none focus:border-black/40 dark:border-white/20 dark:focus:border-white/40"
className="control w-full"
/>
</label>
@@ -158,7 +158,7 @@ function RequirementEditSection({
<input
value={projectName}
onChange={(event) => setProjectName(event.target.value)}
className="w-full rounded-md border border-black/15 bg-transparent px-3 py-2 outline-none focus:border-black/40 dark:border-white/20 dark:focus:border-white/40"
className="control w-full"
/>
</label>
@@ -167,7 +167,7 @@ function RequirementEditSection({
<input
value={moduleName}
onChange={(event) => setModuleName(event.target.value)}
className="w-full rounded-md border border-black/15 bg-transparent px-3 py-2 outline-none focus:border-black/40 dark:border-white/20 dark:focus:border-white/40"
className="control w-full"
/>
</label>
@@ -176,7 +176,7 @@ function RequirementEditSection({
<input
value={source}
onChange={(event) => setSource(event.target.value)}
className="w-full rounded-md border border-black/15 bg-transparent px-3 py-2 outline-none focus:border-black/40 dark:border-white/20 dark:focus:border-white/40"
className="control w-full"
/>
</label>
</div>
@@ -184,7 +184,7 @@ function RequirementEditSection({
<div className="mt-4">
<button
type="button"
className="rounded-md bg-black px-4 py-2 text-sm font-medium text-white transition hover:bg-zinc-800 disabled:opacity-60 dark:bg-white dark:text-black dark:hover:bg-zinc-200"
className="btn-primary"
onClick={() => updateMutation.mutate()}
disabled={updateMutation.isPending || !title.trim()}
>
@@ -276,10 +276,10 @@ function RequirementActionsSection({
return (
<section className="grid gap-6 lg:grid-cols-2">
<div className="rounded-2xl border border-black/10 bg-white p-5 shadow-sm dark:border-white/10 dark:bg-zinc-900">
<div className="surface-card">
<h3 className="text-lg font-semibold"></h3>
{error instanceof Error && (
<pre className="mt-4 overflow-auto rounded-xl border border-red-500/30 bg-red-50 p-4 text-sm text-red-700 dark:border-red-500/30 dark:bg-red-950/30 dark:text-red-300">{error.message}</pre>
<pre className="mt-4 notice notice-error">{error.message}</pre>
)}
<div className="mt-4 space-y-4">
{canAssign && (
@@ -289,14 +289,14 @@ function RequirementActionsSection({
<select
value={assignUserId}
onChange={(event) => setAssignUserId(event.target.value)}
className="flex-1 rounded-md border border-black/15 bg-transparent px-3 py-2 text-sm outline-none focus:border-black/40 dark:border-white/20 dark:focus:border-white/40"
className="control flex-1"
>
<option value=""></option>
{users.map((item) => <option key={item.id} value={item.id}>{item.username}</option>)}
</select>
<button
type="button"
className="rounded-md border border-black/15 px-4 py-2 text-sm hover:bg-black/5 dark:border-white/20 dark:hover:bg-white/10"
className="btn-secondary"
onClick={() => assignMutation.mutate()}
disabled={assignMutation.isPending}
>
@@ -310,7 +310,7 @@ function RequirementActionsSection({
<p className="text-sm font-medium"></p>
<button
type="button"
className="rounded-md border border-black/15 px-4 py-2 text-sm hover:bg-black/5 dark:border-white/20 dark:hover:bg-white/10"
className="btn-secondary"
onClick={() => claimMutation.mutate()}
disabled={claimMutation.isPending}
>
@@ -325,7 +325,7 @@ function RequirementActionsSection({
<select
value={currentTransitionStatus}
onChange={(event) => setTransitionStatus(event.target.value as RequirementStatus)}
className="w-full rounded-md border border-black/15 bg-transparent px-3 py-2 text-sm outline-none focus:border-black/40 dark:border-white/20 dark:focus:border-white/40"
className="control w-full"
>
{availableTransitions.map((item) => <option key={item} value={item}>{item}</option>)}
</select>
@@ -334,11 +334,11 @@ function RequirementActionsSection({
onChange={(event) => setTransitionNote(event.target.value)}
rows={3}
placeholder="流转备注(可选)"
className="w-full rounded-md border border-black/15 bg-transparent px-3 py-2 text-sm outline-none focus:border-black/40 dark:border-white/20 dark:focus:border-white/40"
className="control w-full"
/>
<button
type="button"
className="rounded-md bg-black px-4 py-2 text-sm font-medium text-white transition hover:bg-zinc-800 disabled:opacity-60 dark:bg-white dark:text-black dark:hover:bg-zinc-200"
className="btn-primary"
onClick={() => transitionMutation.mutate()}
disabled={transitionMutation.isPending}
>
@@ -346,15 +346,15 @@ function RequirementActionsSection({
</button>
</>
) : (
<p className="text-sm text-zinc-500 dark:text-zinc-400"></p>
<p className="text-sm text-muted"></p>
)}
</div>
</div>
</div>
<div className="rounded-2xl border border-black/10 bg-white p-5 shadow-sm dark:border-white/10 dark:bg-zinc-900">
<div className="surface-card">
<h3 className="text-lg font-semibold"></h3>
<div className="mt-4 space-y-2 text-sm text-zinc-600 dark:text-zinc-300">
<div className="mt-4 space-y-2 text-sm text-muted">
<p>{detail.status}</p>
<p>{detail.assignee?.username ?? "-"}</p>
<p>{detail.reviewer?.username ?? "-"}</p>
@@ -402,16 +402,16 @@ function RequirementCommentSection({
const error = commentMutation.error instanceof Error ? commentMutation.error.message : "";
return (
<div className="rounded-2xl border border-black/10 bg-white p-5 shadow-sm dark:border-white/10 dark:bg-zinc-900">
<div className="surface-card">
<h3 className="text-lg font-semibold"></h3>
{error && (
<pre className="mt-4 overflow-auto rounded-xl border border-red-500/30 bg-red-50 p-4 text-sm text-red-700 dark:border-red-500/30 dark:bg-red-950/30 dark:text-red-300">{error}</pre>
<pre className="mt-4 notice notice-error">{error}</pre>
)}
<div className="mt-4 space-y-3">
<select
value={commentKind}
onChange={(event) => setCommentKind(event.target.value as RequirementCommentKind)}
className="w-full rounded-md border border-black/15 bg-transparent px-3 py-2 text-sm outline-none focus:border-black/40 dark:border-white/20 dark:focus:border-white/40"
className="control w-full"
>
{COMMENT_KIND_OPTIONS.map((item) => <option key={item} value={item}>{item}</option>)}
</select>
@@ -420,11 +420,11 @@ function RequirementCommentSection({
onChange={(event) => setCommentContent(event.target.value)}
rows={6}
placeholder="写点处理说明、分析结论或修订意见"
className="w-full rounded-md border border-black/15 bg-transparent px-3 py-2 text-sm outline-none focus:border-black/40 dark:border-white/20 dark:focus:border-white/40"
className="control w-full"
/>
<button
type="button"
className="rounded-md bg-black px-4 py-2 text-sm font-medium text-white transition hover:bg-zinc-800 disabled:opacity-60 dark:bg-white dark:text-black dark:hover:bg-zinc-200"
className="btn-primary"
onClick={() => commentMutation.mutate()}
disabled={commentMutation.isPending || !commentContent.trim()}
>
@@ -527,18 +527,18 @@ export default function RequirementDetailPage() {
}, [commentsQuery.error, detailQuery.error, eventsQuery.error, usersQuery.error]);
if (initializing || detailQuery.isLoading) {
return <p className="text-sm text-zinc-500">Loading requirement...</p>;
return <p className="text-sm text-muted">Loading requirement...</p>;
}
if (!requirementId) {
return <p className="text-sm text-zinc-500"> ID </p>;
return <p className="text-sm text-muted"> ID </p>;
}
if (!user) {
return (
<main className="mx-auto flex min-h-screen w-full max-w-4xl flex-col justify-center gap-4 px-6 py-20">
<p className="text-sm text-zinc-600 dark:text-zinc-300">访</p>
<Link href="/" className="text-sm underline"></Link>
<p className="text-sm text-muted">访</p>
<Link href="/" className="btn-secondary w-fit"></Link>
</main>
);
}
@@ -546,15 +546,15 @@ export default function RequirementDetailPage() {
if (!canRead) {
return (
<main className="mx-auto flex min-h-screen w-full max-w-4xl flex-col justify-center gap-4 px-6 py-20">
<p className="text-sm text-zinc-600 dark:text-zinc-300">访 `requirement.read`</p>
<Link href="/admin/requirements" className="text-sm underline"></Link>
<p className="text-sm text-muted">访 `requirement.read`</p>
<Link href="/admin/requirements" className="btn-secondary w-fit"></Link>
</main>
);
}
const detail = detailQuery.data;
if (!detail) {
return <p className="text-sm text-zinc-500"></p>;
return <p className="text-sm text-muted"></p>;
}
const comments = commentsQuery.data ?? [];
@@ -564,17 +564,17 @@ export default function RequirementDetailPage() {
return (
<div className="space-y-6">
{anyError && (
<pre className="overflow-auto rounded-xl border border-red-500/30 bg-red-50 p-4 text-sm text-red-700 dark:border-red-500/30 dark:bg-red-950/30 dark:text-red-300">{anyError}</pre>
<pre className="notice notice-error">{anyError}</pre>
)}
<section className="rounded-2xl border border-black/10 bg-white p-5 shadow-sm dark:border-white/10 dark:bg-zinc-900">
<section className="surface-card">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<p className="font-mono text-xs text-zinc-500">{detail.code}</p>
<p className="font-mono text-xs text-muted">{detail.code}</p>
<h2 className="mt-1 text-2xl font-semibold tracking-tight">{detail.title}</h2>
<p className="mt-3 whitespace-pre-wrap text-sm text-zinc-700 dark:text-zinc-200">{detail.description || "暂无描述"}</p>
<p className="mt-3 whitespace-pre-wrap text-sm text-slate-700">{detail.description || "暂无描述"}</p>
</div>
<div className="flex flex-col gap-2 text-sm text-zinc-500 dark:text-zinc-400">
<div className="flex flex-col gap-2 text-sm text-muted">
<Link href="/admin/requirements" className="underline"></Link>
<span>{detail.status}</span>
<span>{detail.priority}</span>
@@ -584,28 +584,28 @@ export default function RequirementDetailPage() {
</div>
<div className="mt-5 grid gap-3 text-sm md:grid-cols-3 xl:grid-cols-6">
<div className="rounded-xl border border-black/10 p-3 dark:border-white/10">
<p className="text-xs text-zinc-500"></p>
<div className="surface-card-muted p-3">
<p className="text-xs text-muted"></p>
<p className="mt-1">{detail.project_name ?? "-"}</p>
</div>
<div className="rounded-xl border border-black/10 p-3 dark:border-white/10">
<p className="text-xs text-zinc-500"></p>
<div className="surface-card-muted p-3">
<p className="text-xs text-muted"></p>
<p className="mt-1">{detail.module_name ?? "-"}</p>
</div>
<div className="rounded-xl border border-black/10 p-3 dark:border-white/10">
<p className="text-xs text-zinc-500"></p>
<div className="surface-card-muted p-3">
<p className="text-xs text-muted"></p>
<p className="mt-1">{detail.source ?? "-"}</p>
</div>
<div className="rounded-xl border border-black/10 p-3 dark:border-white/10">
<p className="text-xs text-zinc-500"></p>
<div className="surface-card-muted p-3">
<p className="text-xs text-muted"></p>
<p className="mt-1">{detail.due_at ? new Date(detail.due_at).toLocaleString() : "-"}</p>
</div>
<div className="rounded-xl border border-black/10 p-3 dark:border-white/10">
<p className="text-xs text-zinc-500"></p>
<div className="surface-card-muted p-3">
<p className="text-xs text-muted"></p>
<p className="mt-1">{detail.closed_at ? new Date(detail.closed_at).toLocaleString() : "-"}</p>
</div>
<div className="rounded-xl border border-black/10 p-3 dark:border-white/10">
<p className="text-xs text-zinc-500"></p>
<div className="surface-card-muted p-3">
<p className="text-xs text-muted"></p>
<p className="mt-1">{new Date(detail.updated_at).toLocaleString()}</p>
</div>
</div>
@@ -648,12 +648,12 @@ export default function RequirementDetailPage() {
)}
<section className="grid gap-6 lg:grid-cols-2">
<div className="rounded-2xl border border-black/10 bg-white p-5 shadow-sm dark:border-white/10 dark:bg-zinc-900">
<div className="surface-card">
<h3 className="text-lg font-semibold"></h3>
<div className="mt-4 space-y-3">
{comments.length === 0 ? <p className="text-sm text-zinc-500"></p> : comments.map((item) => (
<div key={item.id} className="rounded-xl border border-black/10 p-4 dark:border-white/10">
<div className="flex items-center justify-between gap-3 text-xs text-zinc-500">
{comments.length === 0 ? <p className="text-sm text-muted"></p> : comments.map((item) => (
<div key={item.id} className="surface-card-muted p-4">
<div className="flex items-center justify-between gap-3 text-xs text-muted">
<span>{item.author?.username ?? "系统"} · {item.kind}</span>
<span>{new Date(item.created_at).toLocaleString()}</span>
</div>
@@ -663,18 +663,18 @@ export default function RequirementDetailPage() {
</div>
</div>
<div className="rounded-2xl border border-black/10 bg-white p-5 shadow-sm dark:border-white/10 dark:bg-zinc-900">
<div className="surface-card">
<h3 className="text-lg font-semibold"></h3>
<div className="mt-4 space-y-3">
{events.length === 0 ? <p className="text-sm text-zinc-500"></p> : events.map((item) => (
<div key={item.id} className="rounded-xl border border-black/10 p-4 dark:border-white/10">
<div className="flex items-center justify-between gap-3 text-xs text-zinc-500">
{events.length === 0 ? <p className="text-sm text-muted"></p> : events.map((item) => (
<div key={item.id} className="surface-card-muted p-4">
<div className="flex items-center justify-between gap-3 text-xs text-muted">
<span>{item.actor?.username ?? "系统"} · {item.event_type}</span>
<span>{new Date(item.created_at).toLocaleString()}</span>
</div>
<p className="mt-2 text-sm">{item.from_status ?? "-"} {item.to_status ?? "-"}</p>
{item.payload_json && (
<pre className="mt-2 overflow-auto rounded-lg bg-black/[0.03] p-3 text-xs dark:bg-white/[0.04]">{JSON.stringify(item.payload_json, null, 2)}</pre>
<pre className="mt-2 overflow-auto rounded-lg rounded-lg border border-[var(--border)] bg-cyan-50/70 p-3 text-xs">{JSON.stringify(item.payload_json, null, 2)}</pre>
)}
</div>
))}
+19 -19
View File
@@ -77,14 +77,14 @@ export default function RequirementCreatePage() {
});
if (initializing) {
return <p className="text-sm text-zinc-500">Loading...</p>;
return <p className="text-sm text-muted">Loading...</p>;
}
if (!user) {
return (
<main className="mx-auto flex min-h-screen w-full max-w-4xl flex-col justify-center gap-4 px-6 py-20">
<p className="text-sm text-zinc-600 dark:text-zinc-300"></p>
<Link href="/" className="text-sm underline"></Link>
<p className="text-sm text-muted"></p>
<Link href="/" className="btn-secondary w-fit"></Link>
</main>
);
}
@@ -92,8 +92,8 @@ export default function RequirementCreatePage() {
if (!canCreate) {
return (
<main className="mx-auto flex min-h-screen w-full max-w-4xl flex-col justify-center gap-4 px-6 py-20">
<p className="text-sm text-zinc-600 dark:text-zinc-300"> `requirement.create`</p>
<Link href="/admin/requirements" className="text-sm underline"></Link>
<p className="text-sm text-muted"> `requirement.create`</p>
<Link href="/admin/requirements" className="btn-secondary w-fit"></Link>
</main>
);
}
@@ -104,16 +104,16 @@ export default function RequirementCreatePage() {
return (
<div className="space-y-6">
{error && (
<pre className="overflow-auto rounded-xl border border-red-500/30 bg-red-50 p-4 text-sm text-red-700 dark:border-red-500/30 dark:bg-red-950/30 dark:text-red-300">{error}</pre>
<pre className="notice notice-error">{error}</pre>
)}
<section className="rounded-2xl border border-black/10 bg-white p-5 shadow-sm dark:border-white/10 dark:bg-zinc-900">
<section className="surface-card">
<div className="mb-4 flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold"></h2>
<p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400"></p>
<p className="mt-1 text-sm text-muted"></p>
</div>
<Link href="/admin/requirements" className="text-sm underline"></Link>
<Link href="/admin/requirements" className="btn-secondary w-fit"></Link>
</div>
<div className="grid gap-4 md:grid-cols-2">
@@ -122,7 +122,7 @@ export default function RequirementCreatePage() {
<input
value={title}
onChange={(event) => setTitle(event.target.value)}
className="w-full rounded-md border border-black/15 bg-transparent px-3 py-2 outline-none focus:border-black/40 dark:border-white/20 dark:focus:border-white/40"
className="control w-full"
/>
</label>
@@ -132,42 +132,42 @@ export default function RequirementCreatePage() {
value={description}
onChange={(event) => setDescription(event.target.value)}
rows={8}
className="w-full rounded-md border border-black/15 bg-transparent px-3 py-2 outline-none focus:border-black/40 dark:border-white/20 dark:focus:border-white/40"
className="control w-full"
/>
</label>
<label className="space-y-2 text-sm">
<span></span>
<select value={status} onChange={(event) => setStatus(event.target.value as RequirementStatus)} className="w-full rounded-md border border-black/15 bg-transparent px-3 py-2 outline-none focus:border-black/40 dark:border-white/20 dark:focus:border-white/40">
<select value={status} onChange={(event) => setStatus(event.target.value as RequirementStatus)} className="control w-full">
{STATUS_OPTIONS.map((item) => <option key={item} value={item}>{item}</option>)}
</select>
</label>
<label className="space-y-2 text-sm">
<span></span>
<select value={priority} onChange={(event) => setPriority(event.target.value as RequirementPriority)} className="w-full rounded-md border border-black/15 bg-transparent px-3 py-2 outline-none focus:border-black/40 dark:border-white/20 dark:focus:border-white/40">
<select value={priority} onChange={(event) => setPriority(event.target.value as RequirementPriority)} className="control w-full">
{PRIORITY_OPTIONS.map((item) => <option key={item} value={item}>{item}</option>)}
</select>
</label>
<label className="space-y-2 text-sm">
<span></span>
<input value={projectName} onChange={(event) => setProjectName(event.target.value)} className="w-full rounded-md border border-black/15 bg-transparent px-3 py-2 outline-none focus:border-black/40 dark:border-white/20 dark:focus:border-white/40" />
<input value={projectName} onChange={(event) => setProjectName(event.target.value)} className="control w-full" />
</label>
<label className="space-y-2 text-sm">
<span></span>
<input value={moduleName} onChange={(event) => setModuleName(event.target.value)} className="w-full rounded-md border border-black/15 bg-transparent px-3 py-2 outline-none focus:border-black/40 dark:border-white/20 dark:focus:border-white/40" />
<input value={moduleName} onChange={(event) => setModuleName(event.target.value)} className="control w-full" />
</label>
<label className="space-y-2 text-sm">
<span></span>
<input value={source} onChange={(event) => setSource(event.target.value)} className="w-full rounded-md border border-black/15 bg-transparent px-3 py-2 outline-none focus:border-black/40 dark:border-white/20 dark:focus:border-white/40" />
<input value={source} onChange={(event) => setSource(event.target.value)} className="control w-full" />
</label>
<label className="space-y-2 text-sm">
<span></span>
<select value={assigneeUserId} onChange={(event) => setAssigneeUserId(event.target.value)} disabled={!canManageUsers} className="w-full rounded-md border border-black/15 bg-transparent px-3 py-2 outline-none focus:border-black/40 disabled:opacity-60 dark:border-white/20 dark:focus:border-white/40">
<select value={assigneeUserId} onChange={(event) => setAssigneeUserId(event.target.value)} disabled={!canManageUsers} className="control w-full">
<option value=""></option>
{users.map((item) => <option key={item.id} value={item.id}>{item.username}</option>)}
</select>
@@ -175,14 +175,14 @@ export default function RequirementCreatePage() {
<label className="space-y-2 text-sm">
<span></span>
<input type="datetime-local" value={dueAt} onChange={(event) => setDueAt(event.target.value)} className="w-full rounded-md border border-black/15 bg-transparent px-3 py-2 outline-none focus:border-black/40 dark:border-white/20 dark:focus:border-white/40" />
<input type="datetime-local" value={dueAt} onChange={(event) => setDueAt(event.target.value)} className="control w-full" />
</label>
</div>
<div className="mt-4">
<button
type="button"
className="rounded-md bg-black px-4 py-2 text-sm font-medium text-white transition hover:bg-zinc-800 disabled:opacity-60 dark:bg-white dark:text-black dark:hover:bg-zinc-200"
className="btn-primary"
onClick={() => createMutation.mutate()}
disabled={createMutation.isPending || !title.trim()}
>
+23 -23
View File
@@ -122,14 +122,14 @@ export default function RequirementsPage() {
});
if (initializing || requirementsQuery.isLoading) {
return <p className="text-sm text-zinc-500">Loading requirements...</p>;
return <p className="text-sm text-muted">Loading requirements...</p>;
}
if (!user) {
return (
<main className="mx-auto flex min-h-screen w-full max-w-4xl flex-col justify-center gap-4 px-6 py-20">
<p className="text-sm text-zinc-600 dark:text-zinc-300">访</p>
<Link href="/" className="text-sm underline"></Link>
<p className="text-sm text-muted">访</p>
<Link href="/" className="btn-secondary w-fit"></Link>
</main>
);
}
@@ -137,8 +137,8 @@ export default function RequirementsPage() {
if (!canRead) {
return (
<main className="mx-auto flex min-h-screen w-full max-w-4xl flex-col justify-center gap-4 px-6 py-20">
<p className="text-sm text-zinc-600 dark:text-zinc-300">访 `requirement.read`</p>
<Link href="/" className="text-sm underline"></Link>
<p className="text-sm text-muted">访 `requirement.read`</p>
<Link href="/" className="btn-secondary w-fit"></Link>
</main>
);
}
@@ -150,19 +150,19 @@ export default function RequirementsPage() {
return (
<div className="space-y-6">
{error && (
<pre className="overflow-auto rounded-xl border border-red-500/30 bg-red-50 p-4 text-sm text-red-700 dark:border-red-500/30 dark:bg-red-950/30 dark:text-red-300">{error}</pre>
<pre className="notice notice-error">{error}</pre>
)}
<section className="rounded-2xl border border-black/10 bg-white p-5 shadow-sm dark:border-white/10 dark:bg-zinc-900">
<section className="surface-card">
<div className="mb-4 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<h2 className="text-lg font-semibold"></h2>
<p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400"></p>
<p className="mt-1 text-sm text-muted"></p>
</div>
{canCreate && (
<Link
href="/admin/requirements/new"
className="inline-flex rounded-md bg-black px-4 py-2 text-sm font-medium text-white transition hover:bg-zinc-800 dark:bg-white dark:text-black dark:hover:bg-zinc-200"
className="btn-primary"
>
</Link>
@@ -174,12 +174,12 @@ export default function RequirementsPage() {
value={filters.keyword}
onChange={(event) => setFilters((prev) => ({ ...prev, keyword: event.target.value }))}
placeholder="关键词 / 编号"
className="rounded-md border border-black/15 bg-transparent px-3 py-2 text-sm outline-none focus:border-black/40 dark:border-white/20 dark:focus:border-white/40"
className="control"
/>
<select
value={filters.status}
onChange={(event) => setFilters((prev) => ({ ...prev, status: event.target.value }))}
className="rounded-md border border-black/15 bg-transparent px-3 py-2 text-sm outline-none focus:border-black/40 dark:border-white/20 dark:focus:border-white/40"
className="control"
>
<option value=""></option>
{STATUS_OPTIONS.map((item) => (
@@ -189,7 +189,7 @@ export default function RequirementsPage() {
<select
value={filters.priority}
onChange={(event) => setFilters((prev) => ({ ...prev, priority: event.target.value }))}
className="rounded-md border border-black/15 bg-transparent px-3 py-2 text-sm outline-none focus:border-black/40 dark:border-white/20 dark:focus:border-white/40"
className="control"
>
<option value=""></option>
{PRIORITY_OPTIONS.map((item) => (
@@ -199,7 +199,7 @@ export default function RequirementsPage() {
<select
value={filters.assignee_user_id}
onChange={(event) => setFilters((prev) => ({ ...prev, assignee_user_id: event.target.value }))}
className="rounded-md border border-black/15 bg-transparent px-3 py-2 text-sm outline-none focus:border-black/40 dark:border-white/20 dark:focus:border-white/40"
className="control"
disabled={!canManageUsers}
>
<option value=""></option>
@@ -210,15 +210,15 @@ export default function RequirementsPage() {
</div>
</section>
<section className="rounded-2xl border border-black/10 bg-white p-5 shadow-sm dark:border-white/10 dark:bg-zinc-900">
<section className="surface-card">
<div className="mb-4 flex items-center justify-between">
<p className="text-sm text-zinc-500 dark:text-zinc-400"> {requirementsQuery.data?.total ?? 0} </p>
{requirementsQuery.isFetching && <p className="text-xs text-zinc-500">...</p>}
<p className="text-sm text-muted"> {requirementsQuery.data?.total ?? 0} </p>
{requirementsQuery.isFetching && <p className="text-xs text-muted">...</p>}
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-black/10 text-left text-sm dark:divide-white/10">
<thead className="bg-black/[0.03] dark:bg-white/[0.04]">
<table className="table-modern min-w-full text-left text-sm">
<thead className="table-head">
<tr>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
@@ -230,7 +230,7 @@ export default function RequirementsPage() {
<th className="px-4 py-3 font-medium"></th>
</tr>
</thead>
<tbody className="divide-y divide-black/10 dark:divide-white/10">
<tbody className="table-body divide-y">
{items.map((item) => (
<tr key={item.id}>
<td className="whitespace-nowrap px-4 py-3 font-mono text-xs">{item.code}</td>
@@ -238,19 +238,19 @@ export default function RequirementsPage() {
<Link href={`/admin/requirements/${item.id}`} className="font-medium underline-offset-2 hover:underline">
{item.title}
</Link>
<p className="mt-1 line-clamp-2 text-xs text-zinc-500 dark:text-zinc-400">{item.description || "-"}</p>
<p className="mt-1 line-clamp-2 text-xs text-muted">{item.description || "-"}</p>
</td>
<td className="whitespace-nowrap px-4 py-3">{item.status}</td>
<td className="whitespace-nowrap px-4 py-3">{item.priority}</td>
<td className="whitespace-nowrap px-4 py-3">{item.project_name ?? "-"}</td>
<td className="whitespace-nowrap px-4 py-3">{item.assignee?.username ?? "-"}</td>
<td className="whitespace-nowrap px-4 py-3 text-xs text-zinc-500">{new Date(item.updated_at).toLocaleString()}</td>
<td className="whitespace-nowrap px-4 py-3 text-xs text-muted">{new Date(item.updated_at).toLocaleString()}</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-2">
{canProcess && (
<button
type="button"
className="rounded-md border border-black/15 px-3 py-1 text-xs hover:bg-black/5 dark:border-white/20 dark:hover:bg-white/10"
className="btn-secondary btn-small"
onClick={() => claimMutation.mutate(item.id)}
disabled={claimMutation.isPending}
>
@@ -260,7 +260,7 @@ export default function RequirementsPage() {
{canProcess && item.status === "OPEN" && (
<button
type="button"
className="rounded-md border border-black/15 px-3 py-1 text-xs hover:bg-black/5 dark:border-white/20 dark:hover:bg-white/10"
className="btn-secondary btn-small"
onClick={() => transitionMutation.mutate({ requirementId: item.id, status: "IN_PROGRESS" })}
disabled={transitionMutation.isPending}
>
+22 -22
View File
@@ -179,14 +179,14 @@ export default function AdminRolesPage() {
};
if (initializing || loading) {
return <p className="text-sm text-zinc-500">Loading roles...</p>;
return <p className="text-sm text-muted">Loading roles...</p>;
}
if (!user) {
return (
<main className="mx-auto flex min-h-screen w-full max-w-4xl flex-col justify-center gap-4 px-6 py-20">
<p className="text-sm text-zinc-600 dark:text-zinc-300">访</p>
<Link href="/" className="text-sm underline"></Link>
<p className="text-sm text-muted">访</p>
<Link href="/" className="btn-secondary w-fit"></Link>
</main>
);
}
@@ -194,8 +194,8 @@ export default function AdminRolesPage() {
if (!canRead) {
return (
<main className="mx-auto flex min-h-screen w-full max-w-4xl flex-col justify-center gap-4 px-6 py-20">
<p className="text-sm text-zinc-600 dark:text-zinc-300">访 `role.read`</p>
<Link href="/" className="text-sm underline"></Link>
<p className="text-sm text-muted">访 `role.read`</p>
<Link href="/" className="btn-secondary w-fit"></Link>
</main>
);
}
@@ -203,23 +203,23 @@ export default function AdminRolesPage() {
return (
<div className="space-y-6">
{error && (
<pre className="overflow-auto rounded-xl border border-red-500/30 bg-red-50 p-4 text-sm text-red-700 dark:border-red-500/30 dark:bg-red-950/30 dark:text-red-300">{error}</pre>
<pre className="notice notice-error">{error}</pre>
)}
{success && (
<pre className="overflow-auto rounded-xl border border-emerald-500/30 bg-emerald-50 p-4 text-sm text-emerald-700 dark:border-emerald-500/30 dark:bg-emerald-950/30 dark:text-emerald-300">{success}</pre>
<pre className="notice notice-success">{success}</pre>
)}
<section className="rounded-2xl border border-black/10 bg-white p-5 shadow-sm dark:border-white/10 dark:bg-zinc-900">
<section className="surface-card">
<div className="mb-4 flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold"></h2>
<p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400"> {roles.length} </p>
<p className="mt-1 text-sm text-muted"> {roles.length} </p>
</div>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-black/10 text-left text-sm dark:divide-white/10">
<thead className="bg-black/[0.03] dark:bg-white/[0.04]">
<table className="table-modern min-w-full text-left text-sm">
<thead className="table-head">
<tr>
<th className="px-4 py-3 font-medium">Code</th>
<th className="px-4 py-3 font-medium">Name</th>
@@ -228,7 +228,7 @@ export default function AdminRolesPage() {
{canManage && <th className="px-4 py-3 font-medium"></th>}
</tr>
</thead>
<tbody className="divide-y divide-black/10 dark:divide-white/10">
<tbody className="table-body divide-y">
{roles.map((role) => (
<tr key={role.id}>
<td className="px-4 py-3 font-mono text-xs">{role.code}</td>
@@ -239,7 +239,7 @@ export default function AdminRolesPage() {
<td className="px-4 py-3">
<div className="flex gap-2">
<button
className="rounded-md border border-black/15 px-3 py-1 text-xs hover:bg-black/5 dark:border-white/20 dark:hover:bg-white/10"
className="btn-secondary btn-small"
onClick={() => startEdit(role)}
type="button"
>
@@ -247,7 +247,7 @@ export default function AdminRolesPage() {
</button>
{!['admin', 'user'].includes(role.code) && (
<button
className="rounded-md border border-red-500/30 px-3 py-1 text-xs text-red-600 hover:bg-red-50 dark:text-red-300 dark:hover:bg-red-950/30"
className="btn-danger btn-small"
onClick={() => void removeRole(role)}
type="button"
>
@@ -265,14 +265,14 @@ export default function AdminRolesPage() {
</section>
{canManage && (
<section className="rounded-2xl border border-black/10 bg-white p-5 shadow-sm dark:border-white/10 dark:bg-zinc-900">
<section className="surface-card">
<div className="mb-4 flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold">{editingRoleId ? "编辑角色" : "新建角色"}</h2>
<p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400"></p>
<p className="mt-1 text-sm text-muted"></p>
</div>
{editingRoleId && (
<button className="text-sm underline" type="button" onClick={resetForm}></button>
<button className="btn-secondary w-fit" type="button" onClick={resetForm}></button>
)}
</div>
@@ -283,7 +283,7 @@ export default function AdminRolesPage() {
value={form.code}
disabled={editingRoleId !== null}
onChange={(event) => setForm((prev) => ({ ...prev, code: event.target.value }))}
className="w-full rounded-md border border-black/15 bg-transparent px-3 py-2 outline-none focus:border-black/40 disabled:opacity-60 dark:border-white/20 dark:focus:border-white/40"
className="control w-full"
/>
</label>
<label className="space-y-2 text-sm">
@@ -291,7 +291,7 @@ export default function AdminRolesPage() {
<input
value={form.name}
onChange={(event) => setForm((prev) => ({ ...prev, name: event.target.value }))}
className="w-full rounded-md border border-black/15 bg-transparent px-3 py-2 outline-none focus:border-black/40 dark:border-white/20 dark:focus:border-white/40"
className="control w-full"
/>
</label>
<label className="space-y-2 text-sm md:col-span-2">
@@ -300,12 +300,12 @@ export default function AdminRolesPage() {
value={form.permission_codes}
onChange={(event) => setForm((prev) => ({ ...prev, permission_codes: event.target.value }))}
placeholder={permissions.map((item) => item.code).join(", ")}
className="w-full rounded-md border border-black/15 bg-transparent px-3 py-2 outline-none focus:border-black/40 dark:border-white/20 dark:focus:border-white/40"
className="control w-full"
/>
</label>
<div className="space-y-2 text-sm md:col-span-2">
<span></span>
<div className="grid gap-2 rounded-xl border border-black/10 p-3 dark:border-white/10 md:grid-cols-2">
<div className="grid gap-2 surface-card-muted p-3 md:grid-cols-2">
{menuOptions.map((item) => {
const checked = form.menu_ids.includes(item.value);
return (
@@ -332,7 +332,7 @@ export default function AdminRolesPage() {
<div className="mt-4">
<button
className="rounded-md bg-black px-4 py-2 text-sm font-medium text-white transition hover:bg-zinc-800 disabled:opacity-60 dark:bg-white dark:text-black dark:hover:bg-zinc-200"
className="btn-primary"
disabled={saving}
onClick={() => void submit()}
type="button"
+15 -14
View File
@@ -117,14 +117,14 @@ export default function AdminUsersPage() {
|| (rolesQuery.error instanceof Error ? rolesQuery.error.message : "");
if (initializing || usersQuery.isLoading || rolesQuery.isLoading) {
return <p className="text-sm text-zinc-500">Loading users...</p>;
return <p className="text-sm text-muted">Loading users...</p>;
}
if (!user) {
return (
<main className="mx-auto flex min-h-screen w-full max-w-4xl flex-col justify-center gap-4 px-6 py-20">
<p className="text-sm text-zinc-600 dark:text-zinc-300">访</p>
<Link href="/" className="text-sm underline"></Link>
<p className="text-sm text-muted">访</p>
<Link href="/" className="btn-secondary w-fit"></Link>
</main>
);
}
@@ -132,8 +132,8 @@ export default function AdminUsersPage() {
if (!canManage) {
return (
<main className="mx-auto flex min-h-screen w-full max-w-4xl flex-col justify-center gap-4 px-6 py-20">
<p className="text-sm text-zinc-600 dark:text-zinc-300">访 `user.manage`</p>
<Link href="/" className="text-sm underline"></Link>
<p className="text-sm text-muted">访 `user.manage`</p>
<Link href="/" className="btn-secondary w-fit"></Link>
</main>
);
}
@@ -141,23 +141,23 @@ export default function AdminUsersPage() {
return (
<div className="space-y-6">
{anyError && (
<pre className="overflow-auto rounded-xl border border-red-500/30 bg-red-50 p-4 text-sm text-red-700 dark:border-red-500/30 dark:bg-red-950/30 dark:text-red-300">{anyError}</pre>
<pre className="notice notice-error">{anyError}</pre>
)}
{success && (
<pre className="overflow-auto rounded-xl border border-emerald-500/30 bg-emerald-50 p-4 text-sm text-emerald-700 dark:border-emerald-500/30 dark:bg-emerald-950/30 dark:text-emerald-300">{success}</pre>
<pre className="notice notice-success">{success}</pre>
)}
<section className="rounded-2xl border border-black/10 bg-white p-5 shadow-sm dark:border-white/10 dark:bg-zinc-900">
<section className="surface-card">
<div className="mb-4 flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold"></h2>
<p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400"></p>
<p className="mt-1 text-sm text-muted"></p>
</div>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-black/10 text-left text-sm dark:divide-white/15">
<thead className="bg-black/[0.03] dark:bg-white/[0.06]">
<table className="table-modern min-w-full text-left text-sm">
<thead className="table-head">
<tr>
<th className="px-4 py-3 font-medium">ID</th>
<th className="px-4 py-3 font-medium">Email</th>
@@ -168,7 +168,7 @@ export default function AdminUsersPage() {
<th className="px-4 py-3 font-medium"></th>
</tr>
</thead>
<tbody className="divide-y divide-black/10 dark:divide-white/15">
<tbody className="table-body divide-y">
{users.map((item) => (
<tr key={item.id}>
<td className="whitespace-nowrap px-4 py-3 font-mono text-xs">{item.id}</td>
@@ -180,11 +180,12 @@ export default function AdminUsersPage() {
{roleOptions.map((roleCode) => {
const checked = item.role_codes.includes(roleCode);
return (
<label key={roleCode} className="flex items-center gap-1 rounded-full border border-black/10 px-2 py-1 text-xs dark:border-white/10">
<label key={roleCode} className="flex items-center gap-1 rounded-full border border-[var(--border)] bg-white/80 px-2 py-1 text-xs">
<input
type="checkbox"
checked={checked}
disabled={savingUserId === item.id}
className="accent-cyan-600"
onChange={(event) => {
const nextRoles = event.target.checked
? [...item.role_codes, roleCode]
@@ -199,7 +200,7 @@ export default function AdminUsersPage() {
</div>
</td>
<td className="px-4 py-3">{item.permission_codes.join(", ") || "-"}</td>
<td className="px-4 py-3 text-xs text-zinc-500">{savingUserId === item.id ? "保存中..." : "自动保存"}</td>
<td className="px-4 py-3 text-xs text-muted">{savingUserId === item.id ? "保存中..." : "自动保存"}</td>
</tr>
))}
</tbody>
+149 -13
View File
@@ -1,26 +1,162 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
--background: #f3f6fb;
--foreground: #0f172a;
--card: #ffffffd9;
--card-solid: #ffffff;
--muted: #5b697f;
--border: #d7e3f3;
--accent: #06b6d4;
--accent-strong: #0891b2;
--danger: #dc2626;
--success: #0f766e;
--heading-shadow: 0 1px 0 rgba(255, 255, 255, 0.8);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
--font-sans: var(--font-body);
--font-mono: var(--font-mono);
}
body {
background: var(--background);
background:
radial-gradient(1100px 420px at -10% -18%, rgba(6, 182, 212, 0.2), transparent 58%),
radial-gradient(860px 340px at 110% 8%, rgba(14, 165, 233, 0.16), transparent 56%),
linear-gradient(180deg, #f7fafd 0%, #f3f6fb 46%, #edf3fa 100%);
color: var(--foreground);
font-family: var(--font-geist-sans), sans-serif;
font-family: var(--font-body), sans-serif;
min-height: 100vh;
}
h1,
h2,
h3,
h4 {
font-family: var(--font-heading), var(--font-body), sans-serif;
letter-spacing: -0.02em;
text-shadow: var(--heading-shadow);
}
a {
color: inherit;
}
.surface-card {
@apply rounded-2xl border p-5 shadow-sm;
border-color: var(--border);
background:
linear-gradient(165deg, rgba(255, 255, 255, 0.96) 0%, rgba(247, 251, 255, 0.93) 100%);
box-shadow:
0 18px 40px rgba(15, 23, 42, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.9);
backdrop-filter: blur(8px);
}
.surface-card-muted {
@apply rounded-2xl border;
border-color: var(--border);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.72) 0%, rgba(243, 248, 255, 0.8) 100%);
}
.notice {
@apply overflow-auto rounded-xl border p-4 text-sm;
}
.notice-error {
border-color: rgba(220, 38, 38, 0.28);
background: rgba(254, 242, 242, 0.82);
color: #991b1b;
}
.notice-success {
border-color: rgba(15, 118, 110, 0.28);
background: rgba(240, 253, 250, 0.88);
color: #115e59;
}
.btn-primary {
@apply inline-flex items-center justify-center rounded-md px-4 py-2 text-sm font-semibold transition disabled:cursor-not-allowed disabled:opacity-60;
border: 1px solid rgba(8, 145, 178, 0.2);
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-strong) 100%);
color: #f8fafc;
box-shadow: 0 10px 24px rgba(8, 145, 178, 0.28);
}
.btn-primary:hover {
filter: brightness(1.03);
box-shadow: 0 14px 30px rgba(8, 145, 178, 0.34);
}
.btn-secondary {
@apply inline-flex items-center justify-center rounded-md px-4 py-2 text-sm font-medium transition disabled:cursor-not-allowed disabled:opacity-60;
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.78);
color: var(--foreground);
}
.btn-secondary:hover {
background: rgba(240, 249, 255, 0.95);
border-color: #a5d8f1;
}
.btn-danger {
@apply inline-flex items-center justify-center rounded-md px-4 py-2 text-sm font-medium transition disabled:cursor-not-allowed disabled:opacity-60;
border: 1px solid rgba(220, 38, 38, 0.25);
background: rgba(254, 242, 242, 0.86);
color: var(--danger);
}
.btn-danger:hover {
background: rgba(254, 226, 226, 0.95);
}
.btn-small {
@apply px-3 py-1 text-xs;
}
.control {
@apply rounded-md border px-3 py-2 text-sm outline-none transition disabled:cursor-not-allowed disabled:opacity-60;
border-color: var(--border);
background: rgba(255, 255, 255, 0.9);
color: var(--foreground);
}
.control::placeholder {
color: var(--muted);
}
.control:focus {
border-color: #79d4ec;
box-shadow: 0 0 0 3px rgba(6, 182, 212, 0.14);
}
.table-modern {
border-color: var(--border);
}
.table-head {
background: linear-gradient(180deg, rgba(236, 246, 255, 0.9) 0%, rgba(230, 242, 254, 0.88) 100%);
}
.table-head th {
color: #1e293b;
}
.table-body {
border-color: #e4edf8;
}
.table-body tr {
transition: background-color 0.18s ease;
}
.table-body tr:hover {
background: rgba(238, 248, 255, 0.64);
}
.text-muted {
color: var(--muted);
}
+12 -7
View File
@@ -1,5 +1,5 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { JetBrains_Mono, Manrope, Space_Grotesk } from "next/font/google";
import { AppQueryProvider } from "@/components/app-query-provider";
import { AuthProvider } from "@/components/auth-provider";
@@ -7,19 +7,24 @@ import { WSProvider } from "@/components/ws-provider";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
const headingFont = Space_Grotesk({
variable: "--font-heading",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
const bodyFont = Manrope({
variable: "--font-body",
subsets: ["latin"],
});
const monoFont = JetBrains_Mono({
variable: "--font-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "fquiz",
description: "Next.js + FastAPI full-stack starter",
description: "fquiz admin workspace",
};
export default function RootLayout({
@@ -30,7 +35,7 @@ export default function RootLayout({
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
className={`${headingFont.variable} ${bodyFont.variable} ${monoFont.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">
<AppQueryProvider>
+26 -26
View File
@@ -61,7 +61,7 @@ export default function Home() {
if (initializing) {
return (
<main className="mx-auto flex min-h-screen w-full max-w-3xl items-center justify-center px-6 py-20">
<p className="text-sm text-zinc-500">Initializing session...</p>
<p className="text-sm text-muted">Initializing session...</p>
</main>
);
}
@@ -69,29 +69,29 @@ export default function Home() {
return (
<main className="mx-auto flex min-h-screen w-full max-w-3xl flex-col justify-center gap-6 px-6 py-20 sm:px-10">
<h1 className="text-3xl font-semibold tracking-tight">fquiz</h1>
<p className="text-base text-zinc-600 dark:text-zinc-300">
<p className="text-base text-muted">
JWT + Refresh Session + RBAC + Menu + WS
</p>
<section className="rounded-xl border border-black/10 bg-white p-5 shadow-sm dark:border-white/15 dark:bg-black">
<p className="text-sm text-zinc-500 dark:text-zinc-400">API Base URL</p>
<section className="surface-card">
<p className="text-sm text-muted">API Base URL</p>
<p className="mt-1 font-mono text-sm">{API_BASE_URL}</p>
</section>
{user ? (
<section className="rounded-xl border border-black/10 bg-white p-5 shadow-sm dark:border-white/15 dark:bg-black">
<section className="surface-card">
<p className="text-lg font-medium">{user.username}</p>
<p className="mt-1 text-sm text-zinc-500">{user.email}</p>
<p className="mt-2 text-xs text-zinc-500">
<p className="mt-1 text-sm text-muted">{user.email}</p>
<p className="mt-2 text-xs text-muted">
Roles: {user.role_codes.join(", ") || "-"}
</p>
<p className="mt-1 text-xs text-zinc-500">
<p className="mt-1 text-xs text-muted">
Permissions: {user.permission_codes.join(", ") || "-"}
</p>
<div className="mt-4 flex flex-wrap items-center gap-3">
<button
className="rounded-md bg-black px-4 py-2 text-sm font-medium text-white transition hover:bg-zinc-800 dark:bg-white dark:text-black dark:hover:bg-zinc-200"
className="btn-primary"
onClick={handlePing}
type="button"
>
@@ -99,14 +99,14 @@ export default function Home() {
</button>
<Link
href="/admin"
className="rounded-md border border-black/15 px-4 py-2 text-sm font-medium transition hover:bg-black/5 dark:border-white/20 dark:hover:bg-white/10"
className="btn-secondary"
>
</Link>
{hasPermission("user.manage") && (
<Link
href="/admin/users"
className="rounded-md border border-black/15 px-4 py-2 text-sm font-medium transition hover:bg-black/5 dark:border-white/20 dark:hover:bg-white/10"
className="btn-secondary"
>
</Link>
@@ -114,13 +114,13 @@ export default function Home() {
{hasPermission("requirement.read") && (
<Link
href="/admin/requirements"
className="rounded-md border border-black/15 px-4 py-2 text-sm font-medium transition hover:bg-black/5 dark:border-white/20 dark:hover:bg-white/10"
className="btn-secondary"
>
</Link>
)}
<button
className="rounded-md border border-black/15 px-4 py-2 text-sm font-medium transition hover:bg-black/5 dark:border-white/20 dark:hover:bg-white/10"
className="btn-secondary"
onClick={() => void logout()}
type="button"
>
@@ -129,13 +129,13 @@ export default function Home() {
</div>
</section>
) : (
<section className="rounded-xl border border-black/10 bg-white p-5 shadow-sm dark:border-white/15 dark:bg-black">
<section className="surface-card">
<div className="mb-4 flex gap-2">
<button
className={`rounded-md px-3 py-1 text-sm ${
className={`btn-small ${
mode === "login"
? "bg-black text-white dark:bg-white dark:text-black"
: "border border-black/15 dark:border-white/20"
? "btn-primary"
: "btn-secondary"
}`}
onClick={() => setMode("login")}
type="button"
@@ -143,10 +143,10 @@ export default function Home() {
</button>
<button
className={`rounded-md px-3 py-1 text-sm ${
className={`btn-small ${
mode === "register"
? "bg-black text-white dark:bg-white dark:text-black"
: "border border-black/15 dark:border-white/20"
? "btn-primary"
: "btn-secondary"
}`}
onClick={() => setMode("register")}
type="button"
@@ -158,7 +158,7 @@ export default function Home() {
<form className="space-y-3" onSubmit={handleSubmit}>
<h2 className="text-base font-medium">{title}</h2>
<input
className="w-full rounded-md border border-black/15 bg-transparent px-3 py-2 text-sm outline-none focus:border-black/40 dark:border-white/20 dark:focus:border-white/40"
className="control w-full"
placeholder="Email"
type="email"
value={email}
@@ -167,7 +167,7 @@ export default function Home() {
/>
{mode === "register" && (
<input
className="w-full rounded-md border border-black/15 bg-transparent px-3 py-2 text-sm outline-none focus:border-black/40 dark:border-white/20 dark:focus:border-white/40"
className="control w-full"
placeholder="Username"
type="text"
value={username}
@@ -178,7 +178,7 @@ export default function Home() {
/>
)}
<input
className="w-full rounded-md border border-black/15 bg-transparent px-3 py-2 text-sm outline-none focus:border-black/40 dark:border-white/20 dark:focus:border-white/40"
className="control w-full"
placeholder="Password (>= 8 chars)"
type="password"
value={password}
@@ -188,7 +188,7 @@ export default function Home() {
required
/>
<button
className="rounded-md bg-black px-4 py-2 text-sm font-medium text-white transition hover:bg-zinc-800 disabled:opacity-60 dark:bg-white dark:text-black dark:hover:bg-zinc-200"
className="btn-primary"
disabled={busy}
type="submit"
>
@@ -199,13 +199,13 @@ export default function Home() {
)}
{pingResult && (
<pre className="overflow-auto rounded-xl border border-emerald-500/30 bg-emerald-50 p-4 text-sm text-emerald-800 dark:border-emerald-500/30 dark:bg-emerald-950/30 dark:text-emerald-300">
<pre className="notice notice-success">
{JSON.stringify(pingResult, null, 2)}
</pre>
)}
{error && (
<pre className="overflow-auto rounded-xl border border-red-500/30 bg-red-50 p-4 text-sm text-red-700 dark:border-red-500/30 dark:bg-red-950/30 dark:text-red-300">
<pre className="notice notice-error">
{error}
</pre>
)}