feat(web): modernize admin UI with slate-cyan design system
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -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 基线)
|
||||
|
||||
- 触发问题:
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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()}
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user