refactor(web): update admin pages and remove legacy ui components
This commit is contained in:
@@ -93,6 +93,7 @@
|
||||
- 阶段 B(表单控件统一)已覆盖:`/admin/requirements`、`/admin/requirements/new`、`/admin/requirements/[id]`、`/admin/menus`、`/admin/models`、`/admin/users`;上述页面不再使用原生 `select/textarea` 与 `control` 样式输入,统一走 `@/components/ui` 的 `Input/Select/TextArea` 组件。
|
||||
- 阶段 B 收尾已完成:`/admin/chat` 的消息输入框已统一为 `TextArea`;当前 `web/src/app/admin/**` 已无原生 `select/textarea` 与 `control` 直连输入写法。
|
||||
- 阶段 C(弹窗统一)已完成:`/admin/models` 与 `/admin/roles` 的手写遮罩弹窗(`fixed inset-0`)已统一迁移为 `@/components/ui` 的 `Dialog`,并保留原有新建/编辑/关闭与提交行为。
|
||||
- 在当前 `@radix-ui/themes` + TS 配置下,`TextField.Root` / `TextArea` 的 `onChange` 处理函数应显式标注 `ChangeEvent<HTMLInputElement | HTMLTextAreaElement>`;否则 `next build` 的 TypeScript 阶段可能出现 `Parameter implicitly has an 'any' type`,且输入/文本域混用时会触发事件类型不兼容。
|
||||
|
||||
## 前端主题口径(2026-04-17)
|
||||
|
||||
|
||||
@@ -56,21 +56,10 @@
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## 常用命令
|
||||
## 前端 UI 约定
|
||||
|
||||
```bash
|
||||
# 启动前端
|
||||
npm run dev:web
|
||||
|
||||
# 仅启动后端
|
||||
npm run dev:api
|
||||
|
||||
# 构建前端
|
||||
npm run build:web
|
||||
|
||||
# 前端 lint
|
||||
npm run lint:web
|
||||
```
|
||||
- 后台与业务页面统一使用 **Radix** 体系组件(优先 `@radix-ui/themes`,必要时使用 `@radix-ui/react-*` primitives)。
|
||||
- 不再使用 `web/src/components/ui/*` 自定义 UI 封装层(button/input/select/dialog/table/checkbox/textarea 等)。
|
||||
|
||||
## 认证接口
|
||||
|
||||
|
||||
@@ -419,3 +419,21 @@
|
||||
- `curl http://localhost:3000/admin` 响应中 `data-accent-color=\"indigo\"` 生效。
|
||||
- 风险:
|
||||
- 部分页面仍存在 `sky-*` 辅助渐变色(非主强调色),如需完全统一到单一主题色可在下一步继续收口。
|
||||
|
||||
## Work Log (2026-04-17, 修复 web 构建 TypeScript onChange 类型阻断)
|
||||
|
||||
- 背景: 用户反馈 `docker build web` 在 `web/src/app/admin/models/page.tsx` 报错,`TextField.Root` 的 `onChange` 使用了 `ChangeEvent<HTMLTextAreaElement>` 导致类型不兼容。
|
||||
- 改动:
|
||||
- `web/src/app/admin/models/page.tsx`:将路由规则“备注”输入从 `TextField.Root` 改为 `TextArea`,并保留 `ChangeEvent<HTMLTextAreaElement>` 处理。
|
||||
- 进一步收口同类阻断(`onChange` 参数隐式 `any`):
|
||||
- `web/src/app/admin/requirements/new/page.tsx`
|
||||
- `web/src/app/admin/requirements/page.tsx`
|
||||
- `web/src/app/admin/requirements/[id]/page.tsx`
|
||||
- `web/src/app/admin/todos/page.tsx`
|
||||
- `web/src/app/admin/roles/page.tsx`
|
||||
- `web/src/app/admin/users/page.tsx`
|
||||
- 统一为 `ChangeEvent<HTMLInputElement>` / `ChangeEvent<HTMLTextAreaElement>`,并使用 `event.currentTarget` 取值。
|
||||
- 验证:
|
||||
- `npm run build:web` 通过(Next.js 16 + TypeScript 校验通过,`/admin/models`、`/admin/requirements*`、`/admin/todos`、`/admin/roles`、`/admin/users` 均在构建路由列表中)。
|
||||
- 风险:
|
||||
- 仅调整前端事件类型与输入组件语义,不改变接口请求结构与业务流程,风险低。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { FormEvent, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ChangeEvent, FormEvent, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { useAuth } from "@/components/auth-provider";
|
||||
import { TextArea } from "@radix-ui/themes";
|
||||
@@ -253,7 +253,7 @@ export default function AdminChatPage() {
|
||||
className="w-full"
|
||||
placeholder="请输入你的问题..."
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onChange={(event: ChangeEvent<HTMLTextAreaElement>) => setDraft(event.currentTarget.value)}
|
||||
disabled={!effectiveSessionId || sendMessageMutation.isPending}
|
||||
/>
|
||||
<div className="mt-3 flex items-center justify-end gap-2">
|
||||
|
||||
@@ -504,7 +504,7 @@ export default function AdminFilesPage() {
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
value={newDirectoryName}
|
||||
onChange={(event) => setNewDirectoryName(event.target.value)}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => setNewDirectoryName(event.currentTarget.value)}
|
||||
placeholder="新建目录名"
|
||||
className="w-full max-w-xs control"
|
||||
/>
|
||||
@@ -555,7 +555,7 @@ export default function AdminFilesPage() {
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
value={renameName}
|
||||
onChange={(event) => setRenameName(event.target.value)}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => setRenameName(event.currentTarget.value)}
|
||||
placeholder="新名称"
|
||||
className="control w-48 px-2 py-1 text-xs"
|
||||
/>
|
||||
@@ -571,13 +571,13 @@ export default function AdminFilesPage() {
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
value={moveTargetParentPath}
|
||||
onChange={(event) => setMoveTargetParentPath(event.target.value)}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => setMoveTargetParentPath(event.currentTarget.value)}
|
||||
placeholder="目标目录(如 /a/b)"
|
||||
className="control w-48 px-2 py-1 text-xs"
|
||||
/>
|
||||
<input
|
||||
value={moveNewName}
|
||||
onChange={(event) => setMoveNewName(event.target.value)}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => setMoveNewName(event.currentTarget.value)}
|
||||
placeholder="新名称(可选)"
|
||||
className="control w-40 px-2 py-1 text-xs"
|
||||
/>
|
||||
|
||||
@@ -23,6 +23,40 @@ function flattenMenuTree(tree: MenuTreeItem[]): MenuTreeItem[] {
|
||||
return result;
|
||||
}
|
||||
|
||||
function isActivePath(pathname: string, menuPath: string | null): boolean {
|
||||
if (!menuPath) {
|
||||
return false;
|
||||
}
|
||||
return pathname === menuPath || pathname.startsWith(`${menuPath}/`);
|
||||
}
|
||||
|
||||
function renderMenuNodes(items: MenuTreeItem[], pathname: string): React.ReactNode {
|
||||
return items.map((item) => {
|
||||
const active = isActivePath(pathname, item.path);
|
||||
|
||||
return (
|
||||
<div key={item.id} className="space-y-1">
|
||||
{item.path ? (
|
||||
<Link
|
||||
href={item.path}
|
||||
className={`block rounded-lg px-3 py-2 text-sm font-medium transition ${active ? "bg-indigo-500 text-white shadow-[0_10px_24px_rgba(79,70,229,0.28)]" : "text-slate-700 hover:bg-indigo-50"}`}
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
) : (
|
||||
<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-[var(--border)] pl-3">
|
||||
{renderMenuNodes(item.children, pathname)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const { user, initializing, fetchWithAuth, logout } = useAuth();
|
||||
@@ -36,12 +70,17 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
setLoadingMenus(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingMenus(true);
|
||||
setMenuError("");
|
||||
|
||||
const response = await fetchWithAuth("/api/v1/admin/me/menus");
|
||||
if (!response.ok) {
|
||||
setMenuError(await readApiError(response));
|
||||
setLoadingMenus(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as MenuTreeItem[];
|
||||
setMenuTree(payload);
|
||||
setLoadingMenus(false);
|
||||
@@ -63,7 +102,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
|
||||
const flatMenus = useMemo(() => flattenMenuTree(menuTree), [menuTree]);
|
||||
const currentTitle = useMemo(() => {
|
||||
const current = flatMenus.find((item) => item.path === pathname);
|
||||
const current = flatMenus.find((item) => isActivePath(pathname, item.path));
|
||||
return current?.name ?? "后台管理";
|
||||
}, [flatMenus, pathname]);
|
||||
|
||||
@@ -95,59 +134,41 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
<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-bold tracking-tight text-slate-900">fquiz admin</Link>
|
||||
<p className="mt-2 text-sm text-muted">{user.username} · {user.email}</p>
|
||||
<p className="mt-2 text-sm text-muted">系统菜单</p>
|
||||
</div>
|
||||
|
||||
<nav className="space-y-2">
|
||||
{menuTree.map((item) => (
|
||||
<div key={item.id} className="space-y-1">
|
||||
{item.path ? (
|
||||
<Link
|
||||
href={item.path}
|
||||
className={`block rounded-lg px-3 py-2 text-sm font-medium transition ${pathname === item.path ? "bg-indigo-500 text-white shadow-[0_10px_24px_rgba(79,70,229,0.28)]" : "text-slate-700 hover:bg-indigo-50"}`}
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
) : (
|
||||
<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-[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-indigo-500 text-white shadow-[0_8px_20px_rgba(79,70,229,0.28)]" : "text-slate-700 hover:bg-indigo-50"}`}
|
||||
>
|
||||
{child.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{renderMenuNodes(menuTree, pathname)}
|
||||
</nav>
|
||||
|
||||
<div className="mt-8 space-y-3 border-t border-[var(--border)] pt-6">
|
||||
<div className="mt-8 space-y-2 border-t border-[var(--border)] pt-6">
|
||||
<p className="text-xs text-muted">当前角色:{user.role_codes.join(", ") || "-"}</p>
|
||||
<p className="text-xs text-muted">账号状态:{user.status || "-"}</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="p-4 md:p-6">
|
||||
<header className="surface-card mb-6 flex flex-wrap items-start justify-between gap-4 bg-gradient-to-br from-white/95 via-indigo-50/65 to-sky-50/80">
|
||||
<div>
|
||||
<p className="text-sm text-muted">后台管理</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{currentTitle}</h1>
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex flex-wrap items-center justify-end gap-2">
|
||||
<div className="min-w-[160px] text-right">
|
||||
<p className="text-sm font-semibold text-slate-900">{user.username}</p>
|
||||
<p className="text-xs text-muted">{user.email}</p>
|
||||
</div>
|
||||
<button
|
||||
className="btn-secondary w-full"
|
||||
className="btn-secondary btn-small"
|
||||
onClick={() => void logout()}
|
||||
type="button"
|
||||
>
|
||||
退出登录
|
||||
</button>
|
||||
<Link href="/" className="btn-secondary btn-small">返回首页</Link>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="p-4 md:p-6">
|
||||
<div className="surface-card mb-6 flex items-center justify-between gap-4 bg-gradient-to-br from-white/95 via-indigo-50/65 to-sky-50/80">
|
||||
<div>
|
||||
<p className="text-sm text-muted">后台管理</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{currentTitle}</h1>
|
||||
</div>
|
||||
<Link href="/" className="btn-secondary">返回首页</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{menuError && (
|
||||
<pre className="notice notice-error mb-6">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState, useCallback } from "react";
|
||||
import { ChangeEvent, useEffect, useMemo, useState, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { useAuth } from "@/components/auth-provider";
|
||||
@@ -280,7 +280,7 @@ export default function AdminMenusPage() {
|
||||
<span className="text-muted">关键词</span>
|
||||
<TextField.Root
|
||||
value={keyword}
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => setKeyword(event.currentTarget.value)}
|
||||
placeholder="按编码/名称/路径/权限筛选"
|
||||
className="w-full"
|
||||
/>
|
||||
@@ -392,7 +392,7 @@ export default function AdminMenusPage() {
|
||||
<TextField.Root
|
||||
value={form.code}
|
||||
disabled={editingMenuId !== null}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, code: event.target.value }))}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => setForm((prev) => ({ ...prev, code: event.currentTarget.value }))}
|
||||
className="w-full"
|
||||
/>
|
||||
</label>
|
||||
@@ -400,7 +400,7 @@ export default function AdminMenusPage() {
|
||||
<span>菜单名称</span>
|
||||
<TextField.Root
|
||||
value={form.name}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, name: event.target.value }))}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => setForm((prev) => ({ ...prev, name: event.currentTarget.value }))}
|
||||
className="w-full"
|
||||
/>
|
||||
</label>
|
||||
@@ -408,7 +408,7 @@ export default function AdminMenusPage() {
|
||||
<span>路由路径</span>
|
||||
<TextField.Root
|
||||
value={form.path}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, path: event.target.value }))}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => setForm((prev) => ({ ...prev, path: event.currentTarget.value }))}
|
||||
placeholder="/admin/example"
|
||||
className="w-full"
|
||||
/>
|
||||
@@ -417,7 +417,7 @@ export default function AdminMenusPage() {
|
||||
<span>图标名</span>
|
||||
<TextField.Root
|
||||
value={form.icon}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, icon: event.target.value }))}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => setForm((prev) => ({ ...prev, icon: event.currentTarget.value }))}
|
||||
placeholder="LayoutDashboard"
|
||||
className="w-full"
|
||||
/>
|
||||
@@ -458,7 +458,7 @@ export default function AdminMenusPage() {
|
||||
<span>排序</span>
|
||||
<TextField.Root
|
||||
value={form.sort_order}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, sort_order: event.target.value }))}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => setForm((prev) => ({ ...prev, sort_order: event.currentTarget.value }))}
|
||||
type="number"
|
||||
className="w-full"
|
||||
/>
|
||||
@@ -477,7 +477,7 @@ export default function AdminMenusPage() {
|
||||
<span>组件标识</span>
|
||||
<TextField.Root
|
||||
value={form.component}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, component: event.target.value }))}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => setForm((prev) => ({ ...prev, component: event.currentTarget.value }))}
|
||||
placeholder="app/admin/users/page"
|
||||
className="w-full"
|
||||
/>
|
||||
@@ -486,7 +486,7 @@ export default function AdminMenusPage() {
|
||||
<span>权限码</span>
|
||||
<TextField.Root
|
||||
value={form.permission_code}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, permission_code: event.target.value }))}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => setForm((prev) => ({ ...prev, permission_code: event.currentTarget.value }))}
|
||||
placeholder="menu.read"
|
||||
className="w-full"
|
||||
/>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useMutation, useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { ChangeEvent, useCallback, useMemo, useState } from "react";
|
||||
|
||||
import { useAuth } from "@/components/auth-provider";
|
||||
import { Dialog, Select, TextArea, TextField } from "@radix-ui/themes";
|
||||
@@ -553,7 +553,7 @@ export default function AdminModelsPage() {
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
<TextField.Root
|
||||
value={keyword}
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => setKeyword(event.currentTarget.value)}
|
||||
placeholder="搜索 code/name/provider"
|
||||
className="w-full"
|
||||
/>
|
||||
@@ -793,7 +793,7 @@ export default function AdminModelsPage() {
|
||||
</section>
|
||||
|
||||
{canManage && (
|
||||
<Dialog
|
||||
<Dialog.Root
|
||||
open={showModelModal}
|
||||
onOpenChange={(open: boolean) => {
|
||||
if (!open) {
|
||||
@@ -803,7 +803,7 @@ export default function AdminModelsPage() {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-h-[90vh] w-full max-w-3xl overflow-auto">
|
||||
<Dialog.Content className="max-h-[90vh] w-full max-w-3xl overflow-auto">
|
||||
<div className="mb-4 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">{editingModelId ? "编辑模型" : "新建模型"}</h2>
|
||||
@@ -825,83 +825,81 @@ export default function AdminModelsPage() {
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label className="space-y-2 text-sm">
|
||||
<span>模型编码(稳定引用键)</span>
|
||||
<Input
|
||||
<TextField.Root
|
||||
value={modelForm.code}
|
||||
disabled={editingModelId !== null}
|
||||
onChange={(event) => setModelForm((prev) => ({ ...prev, code: event.target.value }))}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => setModelForm((prev) => ({ ...prev, code: event.currentTarget.value }))}
|
||||
placeholder="openai.gpt-5"
|
||||
className="w-full"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-2 text-sm">
|
||||
<span>模型名称(展示用)</span>
|
||||
<Input
|
||||
<TextField.Root
|
||||
value={modelForm.name}
|
||||
onChange={(event) => setModelForm((prev) => ({ ...prev, name: event.target.value }))}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => setModelForm((prev) => ({ ...prev, name: event.currentTarget.value }))}
|
||||
placeholder="GPT-5 主模型"
|
||||
className="w-full"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-2 text-sm">
|
||||
<span>Provider</span>
|
||||
<Select
|
||||
<Select.Root
|
||||
value={modelForm.provider}
|
||||
onValueChange={(value: string) => setModelForm((prev) => ({ ...prev, provider: value }))}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="请选择 Provider" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<Select.Trigger className="w-full">
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{providerOptions.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
<Select.Item key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
</Select.Item>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</label>
|
||||
<label className="space-y-2 text-sm">
|
||||
<span>Provider Model</span>
|
||||
<Input
|
||||
<TextField.Root
|
||||
value={modelForm.provider_model}
|
||||
onChange={(event) => setModelForm((prev) => ({ ...prev, provider_model: event.target.value }))}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => setModelForm((prev) => ({ ...prev, provider_model: event.currentTarget.value }))}
|
||||
placeholder="gpt-5"
|
||||
className="w-full"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-2 text-sm">
|
||||
<span>初始状态</span>
|
||||
<Select
|
||||
<Select.Root
|
||||
value={modelForm.status}
|
||||
disabled={editingModelId !== null}
|
||||
onValueChange={(value: string) => setModelForm((prev) => ({ ...prev, status: value as ModelStatus }))}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="请选择状态" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<Select.Trigger className="w-full">
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{MODEL_STATUS_OPTIONS.map((item) => (
|
||||
<SelectItem key={item} value={item}>
|
||||
<Select.Item key={item} value={item}>
|
||||
{formatModelStatus(item)}
|
||||
</SelectItem>
|
||||
</Select.Item>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</label>
|
||||
<label className="space-y-2 text-sm">
|
||||
<span>能力标签(逗号分隔)</span>
|
||||
<Input
|
||||
<TextField.Root
|
||||
value={modelForm.capabilities}
|
||||
onChange={(event) => setModelForm((prev) => ({ ...prev, capabilities: event.target.value }))}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => setModelForm((prev) => ({ ...prev, capabilities: event.currentTarget.value }))}
|
||||
placeholder="chat,reasoning"
|
||||
className="w-full"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-2 text-sm md:col-span-2">
|
||||
<span>Base URL</span>
|
||||
<Input
|
||||
<TextField.Root
|
||||
value={modelForm.base_url}
|
||||
onChange={(event) => setModelForm((prev) => ({ ...prev, base_url: event.target.value }))}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => setModelForm((prev) => ({ ...prev, base_url: event.currentTarget.value }))}
|
||||
placeholder="https://api.example.com"
|
||||
className="w-full"
|
||||
/>
|
||||
@@ -909,9 +907,9 @@ export default function AdminModelsPage() {
|
||||
{!editingModelId && (
|
||||
<label className="space-y-2 text-sm md:col-span-2">
|
||||
<span>初始 API Key(仅创建时)</span>
|
||||
<Input
|
||||
<TextField.Root
|
||||
value={modelForm.api_key}
|
||||
onChange={(event) => setModelForm((prev) => ({ ...prev, api_key: event.target.value }))}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => setModelForm((prev) => ({ ...prev, api_key: event.currentTarget.value }))}
|
||||
placeholder="sk-..."
|
||||
className="w-full"
|
||||
/>
|
||||
@@ -922,7 +920,7 @@ export default function AdminModelsPage() {
|
||||
<TextArea
|
||||
rows={4}
|
||||
value={modelForm.description}
|
||||
onChange={(event) => setModelForm((prev) => ({ ...prev, description: event.target.value }))}
|
||||
onChange={(event: ChangeEvent<HTMLTextAreaElement>) => setModelForm((prev) => ({ ...prev, description: event.currentTarget.value }))}
|
||||
placeholder="模型用途、限制、成本策略..."
|
||||
className="w-full"
|
||||
/>
|
||||
@@ -939,12 +937,12 @@ export default function AdminModelsPage() {
|
||||
{saveModelMutation.isPending ? "提交中..." : editingModelId ? "保存模型" : "创建模型"}
|
||||
</button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
)}
|
||||
|
||||
{canManage && (
|
||||
<Dialog
|
||||
<Dialog.Root
|
||||
open={showRouteModal}
|
||||
onOpenChange={(open: boolean) => {
|
||||
if (!open) {
|
||||
@@ -954,7 +952,7 @@ export default function AdminModelsPage() {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-h-[90vh] w-full max-w-3xl overflow-auto">
|
||||
<Dialog.Content className="max-h-[90vh] w-full max-w-3xl overflow-auto">
|
||||
<div className="mb-4 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">{editingRouteId ? "编辑路由规则" : "新建路由规则"}</h2>
|
||||
@@ -976,37 +974,36 @@ export default function AdminModelsPage() {
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label className="space-y-2 text-sm">
|
||||
<span>路由类型</span>
|
||||
<Select
|
||||
<Select.Root
|
||||
value={routeForm.route_type}
|
||||
onValueChange={(value: string) => setRouteForm((prev) => ({ ...prev, route_type: value as ModelRouteType }))}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="请选择路由类型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<Select.Trigger className="w-full">
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{ROUTE_TYPE_OPTIONS.map((item) => (
|
||||
<SelectItem key={item} value={item}>
|
||||
<Select.Item key={item} value={item}>
|
||||
{item}
|
||||
</SelectItem>
|
||||
</Select.Item>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</label>
|
||||
<label className="space-y-2 text-sm">
|
||||
<span>Route Key</span>
|
||||
<Input
|
||||
<TextField.Root
|
||||
value={routeForm.route_type === "GLOBAL" ? GLOBAL_ROUTE_KEY : routeForm.route_key}
|
||||
disabled={routeForm.route_type === "GLOBAL"}
|
||||
onChange={(event) => setRouteForm((prev) => ({ ...prev, route_key: event.target.value }))}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => setRouteForm((prev) => ({ ...prev, route_key: event.currentTarget.value }))}
|
||||
placeholder="chat.default"
|
||||
className="w-full"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-2 text-sm">
|
||||
<span>目标模型 Code</span>
|
||||
<Input
|
||||
<TextField.Root
|
||||
value={routeForm.target_model_code}
|
||||
onChange={(event) => setRouteForm((prev) => ({ ...prev, target_model_code: event.target.value }))}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => setRouteForm((prev) => ({ ...prev, target_model_code: event.currentTarget.value }))}
|
||||
list="model-code-options"
|
||||
className="w-full"
|
||||
/>
|
||||
@@ -1018,19 +1015,20 @@ export default function AdminModelsPage() {
|
||||
</label>
|
||||
<label className="space-y-2 text-sm">
|
||||
<span>优先级(越小越高)</span>
|
||||
<Input
|
||||
<TextField.Root
|
||||
type="number"
|
||||
value={routeForm.priority}
|
||||
onChange={(event) => setRouteForm((prev) => ({ ...prev, priority: event.target.value }))}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => setRouteForm((prev) => ({ ...prev, priority: event.currentTarget.value }))}
|
||||
className="w-full"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-2 text-sm md:col-span-2">
|
||||
<span>备注</span>
|
||||
<Input
|
||||
<TextArea
|
||||
value={routeForm.note}
|
||||
onChange={(event) => setRouteForm((prev) => ({ ...prev, note: event.target.value }))}
|
||||
onChange={(event: ChangeEvent<HTMLTextAreaElement>) => setRouteForm((prev) => ({ ...prev, note: event.currentTarget.value }))}
|
||||
placeholder="例如:客服场景优先使用"
|
||||
rows={2}
|
||||
className="w-full"
|
||||
/>
|
||||
</label>
|
||||
@@ -1038,7 +1036,7 @@ export default function AdminModelsPage() {
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={routeForm.enabled}
|
||||
onChange={(event) => setRouteForm((prev) => ({ ...prev, enabled: event.target.checked }))}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => setRouteForm((prev) => ({ ...prev, enabled: event.currentTarget.checked }))}
|
||||
/>
|
||||
<span>启用规则</span>
|
||||
</label>
|
||||
@@ -1054,8 +1052,8 @@ export default function AdminModelsPage() {
|
||||
{saveRouteMutation.isPending ? "提交中..." : editingRouteId ? "保存规则" : "创建规则"}
|
||||
</button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useMutation, useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { ChangeEvent, useCallback, useMemo, useState } from "react";
|
||||
|
||||
import { useAuth } from "@/components/auth-provider";
|
||||
import { Button, Select, TextArea, TextField } from "@radix-ui/themes";
|
||||
@@ -119,7 +119,7 @@ function RequirementEditSection({
|
||||
<span>标题</span>
|
||||
<TextField.Root
|
||||
value={title}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => setTitle(event.currentTarget.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
</label>
|
||||
@@ -128,7 +128,7 @@ function RequirementEditSection({
|
||||
<span>描述</span>
|
||||
<TextArea
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
onChange={(event: ChangeEvent<HTMLTextAreaElement>) => setDescription(event.currentTarget.value)}
|
||||
rows={8}
|
||||
className="w-full"
|
||||
/>
|
||||
@@ -153,7 +153,7 @@ function RequirementEditSection({
|
||||
<TextField.Root
|
||||
type="datetime-local"
|
||||
value={dueAt}
|
||||
onChange={(event) => setDueAt(event.target.value)}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => setDueAt(event.currentTarget.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
</label>
|
||||
@@ -162,7 +162,7 @@ function RequirementEditSection({
|
||||
<span>项目</span>
|
||||
<TextField.Root
|
||||
value={projectName}
|
||||
onChange={(event) => setProjectName(event.target.value)}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => setProjectName(event.currentTarget.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
</label>
|
||||
@@ -171,7 +171,7 @@ function RequirementEditSection({
|
||||
<span>模块</span>
|
||||
<TextField.Root
|
||||
value={moduleName}
|
||||
onChange={(event) => setModuleName(event.target.value)}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => setModuleName(event.currentTarget.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
</label>
|
||||
@@ -180,7 +180,7 @@ function RequirementEditSection({
|
||||
<span>来源</span>
|
||||
<TextField.Root
|
||||
value={source}
|
||||
onChange={(event) => setSource(event.target.value)}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => setSource(event.currentTarget.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
</label>
|
||||
@@ -333,7 +333,7 @@ function RequirementActionsSection({
|
||||
</Select.Root>
|
||||
<TextArea
|
||||
value={transitionNote}
|
||||
onChange={(event) => setTransitionNote(event.target.value)}
|
||||
onChange={(event: ChangeEvent<HTMLTextAreaElement>) => setTransitionNote(event.currentTarget.value)}
|
||||
rows={3}
|
||||
placeholder="流转备注(可选)"
|
||||
className="w-full"
|
||||
@@ -417,7 +417,7 @@ function RequirementCommentSection({
|
||||
</Select.Root>
|
||||
<TextArea
|
||||
value={commentContent}
|
||||
onChange={(event) => setCommentContent(event.target.value)}
|
||||
onChange={(event: ChangeEvent<HTMLTextAreaElement>) => setCommentContent(event.currentTarget.value)}
|
||||
rows={6}
|
||||
placeholder="写点处理说明、分析结论或修订意见"
|
||||
className="w-full"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useState } from "react";
|
||||
import { ChangeEvent, useCallback, useState } from "react";
|
||||
|
||||
import { useAuth } from "@/components/auth-provider";
|
||||
import { Button, Select, TextArea, TextField } from "@radix-ui/themes";
|
||||
@@ -123,7 +123,7 @@ export default function RequirementCreatePage() {
|
||||
<span>标题</span>
|
||||
<TextField.Root
|
||||
value={title}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => setTitle(event.currentTarget.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
</label>
|
||||
@@ -132,7 +132,7 @@ export default function RequirementCreatePage() {
|
||||
<span>描述</span>
|
||||
<TextArea
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
onChange={(event: ChangeEvent<HTMLTextAreaElement>) => setDescription(event.currentTarget.value)}
|
||||
rows={8}
|
||||
className="w-full"
|
||||
/>
|
||||
@@ -168,17 +168,29 @@ export default function RequirementCreatePage() {
|
||||
|
||||
<label className="space-y-2 text-sm">
|
||||
<span>项目</span>
|
||||
<TextField.Root value={projectName} onChange={(event) => setProjectName(event.target.value)} className="w-full" />
|
||||
<TextField.Root
|
||||
value={projectName}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => setProjectName(event.currentTarget.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="space-y-2 text-sm">
|
||||
<span>模块</span>
|
||||
<TextField.Root value={moduleName} onChange={(event) => setModuleName(event.target.value)} className="w-full" />
|
||||
<TextField.Root
|
||||
value={moduleName}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => setModuleName(event.currentTarget.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="space-y-2 text-sm">
|
||||
<span>来源</span>
|
||||
<TextField.Root value={source} onChange={(event) => setSource(event.target.value)} className="w-full" />
|
||||
<TextField.Root
|
||||
value={source}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => setSource(event.currentTarget.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="space-y-2 text-sm">
|
||||
@@ -202,7 +214,12 @@ export default function RequirementCreatePage() {
|
||||
|
||||
<label className="space-y-2 text-sm">
|
||||
<span>截止时间</span>
|
||||
<TextField.Root type="datetime-local" value={dueAt} onChange={(event) => setDueAt(event.target.value)} className="w-full" />
|
||||
<TextField.Root
|
||||
type="datetime-local"
|
||||
value={dueAt}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => setDueAt(event.currentTarget.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { ChangeEvent, useCallback, useMemo, useState } from "react";
|
||||
|
||||
import { useAuth } from "@/components/auth-provider";
|
||||
import { Select, TextField } from "@radix-ui/themes";
|
||||
@@ -177,7 +177,9 @@ export default function RequirementsPage() {
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-4">
|
||||
<TextField.Root
|
||||
value={filters.keyword}
|
||||
onChange={(event) => setFilters((prev) => ({ ...prev, keyword: event.target.value }))}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) =>
|
||||
setFilters((prev) => ({ ...prev, keyword: event.currentTarget.value }))
|
||||
}
|
||||
placeholder="关键词 / 编号"
|
||||
className="w-full"
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState, useCallback } from "react";
|
||||
import { ChangeEvent, useEffect, useMemo, useState, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { useAuth } from "@/components/auth-provider";
|
||||
@@ -301,7 +301,9 @@ export default function AdminRolesPage() {
|
||||
<TextField.Root
|
||||
value={form.code}
|
||||
disabled={editingRoleId !== null}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, code: event.target.value }))}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) =>
|
||||
setForm((prev) => ({ ...prev, code: event.currentTarget.value }))
|
||||
}
|
||||
className="w-full"
|
||||
/>
|
||||
</label>
|
||||
@@ -309,7 +311,9 @@ export default function AdminRolesPage() {
|
||||
<span>角色名称</span>
|
||||
<TextField.Root
|
||||
value={form.name}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, name: event.target.value }))}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) =>
|
||||
setForm((prev) => ({ ...prev, name: event.currentTarget.value }))
|
||||
}
|
||||
className="w-full"
|
||||
/>
|
||||
</label>
|
||||
@@ -317,7 +321,9 @@ export default function AdminRolesPage() {
|
||||
<span>权限编码(逗号分隔)</span>
|
||||
<TextField.Root
|
||||
value={form.permission_codes}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, permission_codes: event.target.value }))}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) =>
|
||||
setForm((prev) => ({ ...prev, permission_codes: event.currentTarget.value }))
|
||||
}
|
||||
placeholder={permissions.map((item) => item.code).join(", ")}
|
||||
className="w-full"
|
||||
/>
|
||||
@@ -332,10 +338,10 @@ export default function AdminRolesPage() {
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(event) => {
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
menu_ids: event.target.checked
|
||||
menu_ids: event.currentTarget.checked
|
||||
? [...prev.menu_ids, item.value]
|
||||
: prev.menu_ids.filter((menuId) => menuId !== item.value),
|
||||
}));
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { ChangeEvent, useCallback, useMemo, useState } from "react";
|
||||
|
||||
import { useAuth } from "@/components/auth-provider";
|
||||
import { Button, Dialog, Select, Table, TextArea, TextField } from "@radix-ui/themes";
|
||||
@@ -264,7 +264,9 @@ export default function TodoPage() {
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-4">
|
||||
<TextField.Root
|
||||
value={filters.keyword}
|
||||
onChange={(event) => setFilters((prev) => ({ ...prev, keyword: event.target.value }))}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) =>
|
||||
setFilters((prev) => ({ ...prev, keyword: event.currentTarget.value }))
|
||||
}
|
||||
placeholder="关键词"
|
||||
className="w-full"
|
||||
/>
|
||||
@@ -404,14 +406,18 @@ export default function TodoPage() {
|
||||
<TextField.Root
|
||||
className="w-full"
|
||||
value={createDraft.title}
|
||||
onChange={(event) => setCreateDraft((prev) => ({ ...prev, title: event.target.value }))}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) =>
|
||||
setCreateDraft((prev) => ({ ...prev, title: event.currentTarget.value }))
|
||||
}
|
||||
placeholder="标题"
|
||||
/>
|
||||
<TextArea
|
||||
className="w-full"
|
||||
rows={4}
|
||||
value={createDraft.description}
|
||||
onChange={(event) => setCreateDraft((prev) => ({ ...prev, description: event.target.value }))}
|
||||
onChange={(event: ChangeEvent<HTMLTextAreaElement>) =>
|
||||
setCreateDraft((prev) => ({ ...prev, description: event.currentTarget.value }))
|
||||
}
|
||||
placeholder="描述"
|
||||
/>
|
||||
|
||||
@@ -456,7 +462,9 @@ export default function TodoPage() {
|
||||
type="datetime-local"
|
||||
className="w-full"
|
||||
value={createDraft.due_at}
|
||||
onChange={(event) => setCreateDraft((prev) => ({ ...prev, due_at: event.target.value }))}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) =>
|
||||
setCreateDraft((prev) => ({ ...prev, due_at: event.currentTarget.value }))
|
||||
}
|
||||
/>
|
||||
|
||||
<Select.Root
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import { FormEvent, useCallback, useMemo, useState } from "react";
|
||||
import { ChangeEvent, FormEvent, useCallback, useMemo, useState } from "react";
|
||||
|
||||
import { useAuth } from "@/components/auth-provider";
|
||||
import { Button, TextField } from "@radix-ui/themes";
|
||||
@@ -232,10 +232,38 @@ export default function AdminUsersPage() {
|
||||
<h2 className="text-lg font-semibold">新增用户</h2>
|
||||
<p className="mt-1 text-sm text-muted">用户 ID 由管理员手动填写,系统会校验重复。</p>
|
||||
<form className="mt-4 grid gap-3 md:grid-cols-2" onSubmit={handleCreateUser}>
|
||||
<TextField.Root placeholder="用户 ID(例如 ck001)" value={newUserId} onChange={(e) => setNewUserId(e.target.value)} minLength={3} maxLength={64} required />
|
||||
<TextField.Root placeholder="邮箱" type="email" value={newEmail} onChange={(e) => setNewEmail(e.target.value)} required />
|
||||
<TextField.Root placeholder="用户名" value={newUsername} onChange={(e) => setNewUsername(e.target.value)} minLength={3} maxLength={64} required />
|
||||
<TextField.Root placeholder="初始密码(至少8位)" type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} minLength={8} maxLength={128} required />
|
||||
<TextField.Root
|
||||
placeholder="用户 ID(例如 ck001)"
|
||||
value={newUserId}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => setNewUserId(event.currentTarget.value)}
|
||||
minLength={3}
|
||||
maxLength={64}
|
||||
required
|
||||
/>
|
||||
<TextField.Root
|
||||
placeholder="邮箱"
|
||||
type="email"
|
||||
value={newEmail}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => setNewEmail(event.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
<TextField.Root
|
||||
placeholder="用户名"
|
||||
value={newUsername}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => setNewUsername(event.currentTarget.value)}
|
||||
minLength={3}
|
||||
maxLength={64}
|
||||
required
|
||||
/>
|
||||
<TextField.Root
|
||||
placeholder="初始密码(至少8位)"
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => setNewPassword(event.currentTarget.value)}
|
||||
minLength={8}
|
||||
maxLength={128}
|
||||
required
|
||||
/>
|
||||
<div className="md:col-span-2">
|
||||
<Button type="submit" disabled={createUserMutation.isPending}>
|
||||
{createUserMutation.isPending ? "创建中..." : "创建用户"}
|
||||
@@ -283,8 +311,8 @@ export default function AdminUsersPage() {
|
||||
checked={checked}
|
||||
disabled={savingUserId === item.id}
|
||||
className="accent-indigo-600"
|
||||
onChange={(event) => {
|
||||
const nextRoles = event.target.checked
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => {
|
||||
const nextRoles = event.currentTarget.checked
|
||||
? [...item.role_codes, roleCode]
|
||||
: item.role_codes.filter((code) => code !== roleCode);
|
||||
updateRolesMutation.mutate({ userId: item.id, roleCodes: nextRoles });
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { FormEvent, ChangeEvent, useEffect, useState } from "react";
|
||||
|
||||
import { useAuth } from "@/components/auth-provider";
|
||||
import { API_BASE_URL, getApiBaseUrl, readApiError } from "@/lib/api";
|
||||
@@ -201,7 +201,7 @@ export default function Home() {
|
||||
type="email"
|
||||
autoComplete="username"
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => setEmail(event.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
{mode === "register" && (
|
||||
@@ -210,7 +210,7 @@ export default function Home() {
|
||||
placeholder="Username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => setUsername(event.currentTarget.value)}
|
||||
minLength={3}
|
||||
maxLength={64}
|
||||
required
|
||||
@@ -222,7 +222,7 @@ export default function Home() {
|
||||
type="password"
|
||||
autoComplete={mode === "login" ? "current-password" : "new-password"}
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => setPassword(event.currentTarget.value)}
|
||||
minLength={8}
|
||||
maxLength={128}
|
||||
required
|
||||
@@ -232,7 +232,7 @@ export default function Home() {
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rememberPassword}
|
||||
onChange={(event) => setRememberPassword(event.target.checked)}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => setRememberPassword(event.currentTarget.checked)}
|
||||
/>
|
||||
记住密码
|
||||
</label>
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
export { Button, type ButtonProps } from "@/components/ui/button";
|
||||
export { Checkbox, type CheckboxProps } from "@/components/ui/checkbox";
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
export { Input } from "@/components/ui/input";
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
export { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
export { Textarea, Textarea as TextArea } from "@/components/ui/textarea";
|
||||
@@ -1,62 +0,0 @@
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import type { ButtonHTMLAttributes, MouseEventHandler } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md text-sm transition disabled:cursor-not-allowed disabled:opacity-60",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "btn-primary",
|
||||
secondary: "btn-secondary",
|
||||
destructive: "btn-danger",
|
||||
ghost: "btn-ghost",
|
||||
},
|
||||
size: {
|
||||
default: "px-4 py-2 font-semibold",
|
||||
sm: "btn-small px-3 py-1 font-medium",
|
||||
lg: "px-6 py-2.5 font-semibold",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
type ButtonProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "onClick"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
onClick?: MouseEventHandler<HTMLButtonElement>;
|
||||
onPress?: () => void;
|
||||
isDisabled?: boolean;
|
||||
};
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
onClick,
|
||||
onPress,
|
||||
isDisabled,
|
||||
disabled,
|
||||
type,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
const resolvedDisabled = isDisabled ?? disabled;
|
||||
const handleClick = onClick ?? (onPress ? () => onPress() : undefined);
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(buttonVariants({ variant, size }), className)}
|
||||
disabled={resolvedDisabled}
|
||||
onClick={handleClick}
|
||||
type={type ?? "button"}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Button, buttonVariants };
|
||||
export type { ButtonProps };
|
||||
@@ -1,53 +0,0 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type CheckboxProps = Omit<
|
||||
React.InputHTMLAttributes<HTMLInputElement>,
|
||||
"type" | "onChange" | "checked" | "defaultChecked"
|
||||
> & {
|
||||
children?: React.ReactNode;
|
||||
isSelected?: boolean;
|
||||
defaultSelected?: boolean;
|
||||
isDisabled?: boolean;
|
||||
onChange?: (selected: boolean) => void;
|
||||
onValueChange?: (selected: boolean) => void;
|
||||
};
|
||||
|
||||
function Checkbox({
|
||||
className,
|
||||
children,
|
||||
isSelected,
|
||||
defaultSelected,
|
||||
isDisabled,
|
||||
disabled,
|
||||
onChange,
|
||||
onValueChange,
|
||||
...props
|
||||
}: CheckboxProps) {
|
||||
const resolvedDisabled = isDisabled ?? disabled;
|
||||
|
||||
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const next = event.target.checked;
|
||||
onChange?.(next);
|
||||
onValueChange?.(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<label className={cn("inline-flex items-center gap-2", className)}>
|
||||
<input
|
||||
{...props}
|
||||
checked={isSelected}
|
||||
className="checkbox-control"
|
||||
defaultChecked={defaultSelected}
|
||||
disabled={resolvedDisabled}
|
||||
onChange={handleChange}
|
||||
type="checkbox"
|
||||
/>
|
||||
{children ? <span>{children}</span> : null}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export { Checkbox };
|
||||
export type { CheckboxProps };
|
||||
@@ -1,74 +0,0 @@
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn("dialog-overlay fixed inset-0 z-50 backdrop-blur-[1px]", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"dialog-content fixed left-1/2 top-1/2 z-50 grid w-full max-w-2xl -translate-x-1/2 -translate-y-1/2 gap-4 rounded-2xl border p-6",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
function DialogHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("flex flex-col space-y-1.5 text-left", className)} {...props} />;
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)} {...props} />;
|
||||
}
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title ref={ref} className={cn("text-lg font-semibold", className)} {...props} />
|
||||
));
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description ref={ref} className={cn("text-sm text-muted", className)} {...props} />
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
};
|
||||
@@ -1,13 +0,0 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
({ className, type = "text", ...props }, ref) => {
|
||||
return <input ref={ref} type={type} className={cn("control", className)} {...props} />;
|
||||
},
|
||||
);
|
||||
|
||||
Input.displayName = "Input";
|
||||
|
||||
export { Input };
|
||||
@@ -1,90 +0,0 @@
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Select = SelectPrimitive.Root;
|
||||
const SelectGroup = SelectPrimitive.Group;
|
||||
const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"control inline-flex w-full items-center justify-between gap-2 text-left",
|
||||
"data-[placeholder]:text-muted",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<span aria-hidden className="text-xs text-muted">
|
||||
▾
|
||||
</span>
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
));
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"select-content relative z-50 max-h-80 min-w-[12rem] overflow-hidden rounded-xl border p-1",
|
||||
position === "popper" && "translate-y-1",
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.Viewport className="p-1">{children}</SelectPrimitive.Viewport>
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
));
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label ref={ref} className={cn("px-2 py-1 text-xs font-medium text-muted", className)} {...props} />
|
||||
));
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"select-item relative flex w-full cursor-default select-none items-center rounded-md py-1.5 pl-2 pr-8 text-sm outline-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>✓</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
</SelectPrimitive.Item>
|
||||
));
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
};
|
||||
@@ -1,41 +0,0 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table ref={ref} className={cn("w-full caption-bottom text-sm table-modern", className)} {...props} />
|
||||
</div>
|
||||
),
|
||||
);
|
||||
Table.displayName = "Table";
|
||||
|
||||
const TableHeader = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
|
||||
({ className, ...props }, ref) => <thead ref={ref} className={cn("table-head [&_tr]:border-b", className)} {...props} />,
|
||||
);
|
||||
TableHeader.displayName = "TableHeader";
|
||||
|
||||
const TableBody = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
|
||||
({ className, ...props }, ref) => <tbody ref={ref} className={cn("table-body [&_tr:last-child]:border-0", className)} {...props} />,
|
||||
);
|
||||
TableBody.displayName = "TableBody";
|
||||
|
||||
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
|
||||
({ className, ...props }, ref) => <tr ref={ref} className={cn("border-b", className)} {...props} />,
|
||||
);
|
||||
TableRow.displayName = "TableRow";
|
||||
|
||||
const TableHead = React.forwardRef<HTMLTableCellElement, React.ThHTMLAttributes<HTMLTableCellElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<th ref={ref} className={cn("h-10 px-3 text-left align-middle text-xs font-semibold", className)} {...props} />
|
||||
),
|
||||
);
|
||||
TableHead.displayName = "TableHead";
|
||||
|
||||
const TableCell = React.forwardRef<HTMLTableCellElement, React.TdHTMLAttributes<HTMLTableCellElement>>(
|
||||
({ className, ...props }, ref) => <td ref={ref} className={cn("px-3 py-2 align-middle", className)} {...props} />,
|
||||
);
|
||||
TableCell.displayName = "TableCell";
|
||||
|
||||
export { Table, TableBody, TableCell, TableHead, TableHeader, TableRow };
|
||||
@@ -1,13 +0,0 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, React.ComponentProps<"textarea">>(
|
||||
({ className, ...props }, ref) => {
|
||||
return <textarea ref={ref} className={cn("control min-h-24", className)} {...props} />;
|
||||
},
|
||||
);
|
||||
|
||||
Textarea.displayName = "Textarea";
|
||||
|
||||
export { Textarea };
|
||||
Reference in New Issue
Block a user