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/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` 直连输入写法。
|
- 阶段 B 收尾已完成:`/admin/chat` 的消息输入框已统一为 `TextArea`;当前 `web/src/app/admin/**` 已无原生 `select/textarea` 与 `control` 直连输入写法。
|
||||||
- 阶段 C(弹窗统一)已完成:`/admin/models` 与 `/admin/roles` 的手写遮罩弹窗(`fixed inset-0`)已统一迁移为 `@/components/ui` 的 `Dialog`,并保留原有新建/编辑/关闭与提交行为。
|
- 阶段 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)
|
## 前端主题口径(2026-04-17)
|
||||||
|
|
||||||
|
|||||||
@@ -56,21 +56,10 @@
|
|||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
## 常用命令
|
## 前端 UI 约定
|
||||||
|
|
||||||
```bash
|
- 后台与业务页面统一使用 **Radix** 体系组件(优先 `@radix-ui/themes`,必要时使用 `@radix-ui/react-*` primitives)。
|
||||||
# 启动前端
|
- 不再使用 `web/src/components/ui/*` 自定义 UI 封装层(button/input/select/dialog/table/checkbox/textarea 等)。
|
||||||
npm run dev:web
|
|
||||||
|
|
||||||
# 仅启动后端
|
|
||||||
npm run dev:api
|
|
||||||
|
|
||||||
# 构建前端
|
|
||||||
npm run build:web
|
|
||||||
|
|
||||||
# 前端 lint
|
|
||||||
npm run lint:web
|
|
||||||
```
|
|
||||||
|
|
||||||
## 认证接口
|
## 认证接口
|
||||||
|
|
||||||
|
|||||||
@@ -419,3 +419,21 @@
|
|||||||
- `curl http://localhost:3000/admin` 响应中 `data-accent-color=\"indigo\"` 生效。
|
- `curl http://localhost:3000/admin` 响应中 `data-accent-color=\"indigo\"` 生效。
|
||||||
- 风险:
|
- 风险:
|
||||||
- 部分页面仍存在 `sky-*` 辅助渐变色(非主强调色),如需完全统一到单一主题色可在下一步继续收口。
|
- 部分页面仍存在 `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";
|
"use client";
|
||||||
|
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
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 { useAuth } from "@/components/auth-provider";
|
||||||
import { TextArea } from "@radix-ui/themes";
|
import { TextArea } from "@radix-ui/themes";
|
||||||
@@ -253,7 +253,7 @@ export default function AdminChatPage() {
|
|||||||
className="w-full"
|
className="w-full"
|
||||||
placeholder="请输入你的问题..."
|
placeholder="请输入你的问题..."
|
||||||
value={draft}
|
value={draft}
|
||||||
onChange={(event) => setDraft(event.target.value)}
|
onChange={(event: ChangeEvent<HTMLTextAreaElement>) => setDraft(event.currentTarget.value)}
|
||||||
disabled={!effectiveSessionId || sendMessageMutation.isPending}
|
disabled={!effectiveSessionId || sendMessageMutation.isPending}
|
||||||
/>
|
/>
|
||||||
<div className="mt-3 flex items-center justify-end gap-2">
|
<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">
|
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||||
<input
|
<input
|
||||||
value={newDirectoryName}
|
value={newDirectoryName}
|
||||||
onChange={(event) => setNewDirectoryName(event.target.value)}
|
onChange={(event: ChangeEvent<HTMLInputElement>) => setNewDirectoryName(event.currentTarget.value)}
|
||||||
placeholder="新建目录名"
|
placeholder="新建目录名"
|
||||||
className="w-full max-w-xs control"
|
className="w-full max-w-xs control"
|
||||||
/>
|
/>
|
||||||
@@ -555,7 +555,7 @@ export default function AdminFilesPage() {
|
|||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<input
|
<input
|
||||||
value={renameName}
|
value={renameName}
|
||||||
onChange={(event) => setRenameName(event.target.value)}
|
onChange={(event: ChangeEvent<HTMLInputElement>) => setRenameName(event.currentTarget.value)}
|
||||||
placeholder="新名称"
|
placeholder="新名称"
|
||||||
className="control w-48 px-2 py-1 text-xs"
|
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">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<input
|
<input
|
||||||
value={moveTargetParentPath}
|
value={moveTargetParentPath}
|
||||||
onChange={(event) => setMoveTargetParentPath(event.target.value)}
|
onChange={(event: ChangeEvent<HTMLInputElement>) => setMoveTargetParentPath(event.currentTarget.value)}
|
||||||
placeholder="目标目录(如 /a/b)"
|
placeholder="目标目录(如 /a/b)"
|
||||||
className="control w-48 px-2 py-1 text-xs"
|
className="control w-48 px-2 py-1 text-xs"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
value={moveNewName}
|
value={moveNewName}
|
||||||
onChange={(event) => setMoveNewName(event.target.value)}
|
onChange={(event: ChangeEvent<HTMLInputElement>) => setMoveNewName(event.currentTarget.value)}
|
||||||
placeholder="新名称(可选)"
|
placeholder="新名称(可选)"
|
||||||
className="control w-40 px-2 py-1 text-xs"
|
className="control w-40 px-2 py-1 text-xs"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -23,6 +23,40 @@ function flattenMenuTree(tree: MenuTreeItem[]): MenuTreeItem[] {
|
|||||||
return result;
|
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 }) {
|
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { user, initializing, fetchWithAuth, logout } = useAuth();
|
const { user, initializing, fetchWithAuth, logout } = useAuth();
|
||||||
@@ -36,12 +70,17 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
setLoadingMenus(false);
|
setLoadingMenus(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setLoadingMenus(true);
|
||||||
|
setMenuError("");
|
||||||
|
|
||||||
const response = await fetchWithAuth("/api/v1/admin/me/menus");
|
const response = await fetchWithAuth("/api/v1/admin/me/menus");
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
setMenuError(await readApiError(response));
|
setMenuError(await readApiError(response));
|
||||||
setLoadingMenus(false);
|
setLoadingMenus(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = (await response.json()) as MenuTreeItem[];
|
const payload = (await response.json()) as MenuTreeItem[];
|
||||||
setMenuTree(payload);
|
setMenuTree(payload);
|
||||||
setLoadingMenus(false);
|
setLoadingMenus(false);
|
||||||
@@ -63,7 +102,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
|
|
||||||
const flatMenus = useMemo(() => flattenMenuTree(menuTree), [menuTree]);
|
const flatMenus = useMemo(() => flattenMenuTree(menuTree), [menuTree]);
|
||||||
const currentTitle = useMemo(() => {
|
const currentTitle = useMemo(() => {
|
||||||
const current = flatMenus.find((item) => item.path === pathname);
|
const current = flatMenus.find((item) => isActivePath(pathname, item.path));
|
||||||
return current?.name ?? "后台管理";
|
return current?.name ?? "后台管理";
|
||||||
}, [flatMenus, pathname]);
|
}, [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">
|
<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">
|
<div className="mb-8">
|
||||||
<Link href="/" className="text-xl font-bold tracking-tight text-slate-900">fquiz admin</Link>
|
<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>
|
</div>
|
||||||
|
|
||||||
<nav className="space-y-2">
|
<nav className="space-y-2">
|
||||||
{menuTree.map((item) => (
|
{renderMenuNodes(menuTree, pathname)}
|
||||||
<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>
|
|
||||||
))}
|
|
||||||
</nav>
|
</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.role_codes.join(", ") || "-"}</p>
|
||||||
<button
|
<p className="text-xs text-muted">账号状态:{user.status || "-"}</p>
|
||||||
className="btn-secondary w-full"
|
|
||||||
onClick={() => void logout()}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
退出登录
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main className="p-4 md:p-6">
|
<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">
|
<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>
|
<div>
|
||||||
<p className="text-sm text-muted">后台管理</p>
|
<p className="text-sm text-muted">后台管理</p>
|
||||||
<h1 className="text-2xl font-bold tracking-tight">{currentTitle}</h1>
|
<h1 className="text-2xl font-bold tracking-tight">{currentTitle}</h1>
|
||||||
</div>
|
</div>
|
||||||
<Link href="/" className="btn-secondary">返回首页</Link>
|
|
||||||
</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 btn-small"
|
||||||
|
onClick={() => void logout()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
退出登录
|
||||||
|
</button>
|
||||||
|
<Link href="/" className="btn-secondary btn-small">返回首页</Link>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
{menuError && (
|
{menuError && (
|
||||||
<pre className="notice notice-error mb-6">
|
<pre className="notice notice-error mb-6">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useState, useCallback } from "react";
|
import { ChangeEvent, useEffect, useMemo, useState, useCallback } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
import { useAuth } from "@/components/auth-provider";
|
import { useAuth } from "@/components/auth-provider";
|
||||||
@@ -280,7 +280,7 @@ export default function AdminMenusPage() {
|
|||||||
<span className="text-muted">关键词</span>
|
<span className="text-muted">关键词</span>
|
||||||
<TextField.Root
|
<TextField.Root
|
||||||
value={keyword}
|
value={keyword}
|
||||||
onChange={(event) => setKeyword(event.target.value)}
|
onChange={(event: ChangeEvent<HTMLInputElement>) => setKeyword(event.currentTarget.value)}
|
||||||
placeholder="按编码/名称/路径/权限筛选"
|
placeholder="按编码/名称/路径/权限筛选"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
@@ -392,7 +392,7 @@ export default function AdminMenusPage() {
|
|||||||
<TextField.Root
|
<TextField.Root
|
||||||
value={form.code}
|
value={form.code}
|
||||||
disabled={editingMenuId !== null}
|
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"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
@@ -400,7 +400,7 @@ export default function AdminMenusPage() {
|
|||||||
<span>菜单名称</span>
|
<span>菜单名称</span>
|
||||||
<TextField.Root
|
<TextField.Root
|
||||||
value={form.name}
|
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"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
@@ -408,7 +408,7 @@ export default function AdminMenusPage() {
|
|||||||
<span>路由路径</span>
|
<span>路由路径</span>
|
||||||
<TextField.Root
|
<TextField.Root
|
||||||
value={form.path}
|
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"
|
placeholder="/admin/example"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
@@ -417,7 +417,7 @@ export default function AdminMenusPage() {
|
|||||||
<span>图标名</span>
|
<span>图标名</span>
|
||||||
<TextField.Root
|
<TextField.Root
|
||||||
value={form.icon}
|
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"
|
placeholder="LayoutDashboard"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
@@ -458,7 +458,7 @@ export default function AdminMenusPage() {
|
|||||||
<span>排序</span>
|
<span>排序</span>
|
||||||
<TextField.Root
|
<TextField.Root
|
||||||
value={form.sort_order}
|
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"
|
type="number"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
@@ -477,7 +477,7 @@ export default function AdminMenusPage() {
|
|||||||
<span>组件标识</span>
|
<span>组件标识</span>
|
||||||
<TextField.Root
|
<TextField.Root
|
||||||
value={form.component}
|
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"
|
placeholder="app/admin/users/page"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
@@ -486,7 +486,7 @@ export default function AdminMenusPage() {
|
|||||||
<span>权限码</span>
|
<span>权限码</span>
|
||||||
<TextField.Root
|
<TextField.Root
|
||||||
value={form.permission_code}
|
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"
|
placeholder="menu.read"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useMutation, useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query";
|
||||||
import Link from "next/link";
|
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 { useAuth } from "@/components/auth-provider";
|
||||||
import { Dialog, Select, TextArea, TextField } from "@radix-ui/themes";
|
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">
|
<div className="grid gap-2 md:grid-cols-2">
|
||||||
<TextField.Root
|
<TextField.Root
|
||||||
value={keyword}
|
value={keyword}
|
||||||
onChange={(event) => setKeyword(event.target.value)}
|
onChange={(event: ChangeEvent<HTMLInputElement>) => setKeyword(event.currentTarget.value)}
|
||||||
placeholder="搜索 code/name/provider"
|
placeholder="搜索 code/name/provider"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
@@ -793,7 +793,7 @@ export default function AdminModelsPage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{canManage && (
|
{canManage && (
|
||||||
<Dialog
|
<Dialog.Root
|
||||||
open={showModelModal}
|
open={showModelModal}
|
||||||
onOpenChange={(open: boolean) => {
|
onOpenChange={(open: boolean) => {
|
||||||
if (!open) {
|
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 className="mb-4 flex items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold">{editingModelId ? "编辑模型" : "新建模型"}</h2>
|
<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">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<label className="space-y-2 text-sm">
|
<label className="space-y-2 text-sm">
|
||||||
<span>模型编码(稳定引用键)</span>
|
<span>模型编码(稳定引用键)</span>
|
||||||
<Input
|
<TextField.Root
|
||||||
value={modelForm.code}
|
value={modelForm.code}
|
||||||
disabled={editingModelId !== null}
|
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"
|
placeholder="openai.gpt-5"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="space-y-2 text-sm">
|
<label className="space-y-2 text-sm">
|
||||||
<span>模型名称(展示用)</span>
|
<span>模型名称(展示用)</span>
|
||||||
<Input
|
<TextField.Root
|
||||||
value={modelForm.name}
|
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 主模型"
|
placeholder="GPT-5 主模型"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="space-y-2 text-sm">
|
<label className="space-y-2 text-sm">
|
||||||
<span>Provider</span>
|
<span>Provider</span>
|
||||||
<Select
|
<Select.Root
|
||||||
value={modelForm.provider}
|
value={modelForm.provider}
|
||||||
onValueChange={(value: string) => setModelForm((prev) => ({ ...prev, provider: value }))}
|
onValueChange={(value: string) => setModelForm((prev) => ({ ...prev, provider: value }))}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-full">
|
<Select.Trigger className="w-full">
|
||||||
<SelectValue placeholder="请选择 Provider" />
|
</Select.Trigger>
|
||||||
</SelectTrigger>
|
<Select.Content>
|
||||||
<SelectContent>
|
|
||||||
{providerOptions.map((item) => (
|
{providerOptions.map((item) => (
|
||||||
<SelectItem key={item.value} value={item.value}>
|
<Select.Item key={item.value} value={item.value}>
|
||||||
{item.label}
|
{item.label}
|
||||||
</SelectItem>
|
</Select.Item>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</Select.Content>
|
||||||
</Select>
|
</Select.Root>
|
||||||
</label>
|
</label>
|
||||||
<label className="space-y-2 text-sm">
|
<label className="space-y-2 text-sm">
|
||||||
<span>Provider Model</span>
|
<span>Provider Model</span>
|
||||||
<Input
|
<TextField.Root
|
||||||
value={modelForm.provider_model}
|
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"
|
placeholder="gpt-5"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="space-y-2 text-sm">
|
<label className="space-y-2 text-sm">
|
||||||
<span>初始状态</span>
|
<span>初始状态</span>
|
||||||
<Select
|
<Select.Root
|
||||||
value={modelForm.status}
|
value={modelForm.status}
|
||||||
disabled={editingModelId !== null}
|
disabled={editingModelId !== null}
|
||||||
onValueChange={(value: string) => setModelForm((prev) => ({ ...prev, status: value as ModelStatus }))}
|
onValueChange={(value: string) => setModelForm((prev) => ({ ...prev, status: value as ModelStatus }))}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-full">
|
<Select.Trigger className="w-full">
|
||||||
<SelectValue placeholder="请选择状态" />
|
</Select.Trigger>
|
||||||
</SelectTrigger>
|
<Select.Content>
|
||||||
<SelectContent>
|
|
||||||
{MODEL_STATUS_OPTIONS.map((item) => (
|
{MODEL_STATUS_OPTIONS.map((item) => (
|
||||||
<SelectItem key={item} value={item}>
|
<Select.Item key={item} value={item}>
|
||||||
{formatModelStatus(item)}
|
{formatModelStatus(item)}
|
||||||
</SelectItem>
|
</Select.Item>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</Select.Content>
|
||||||
</Select>
|
</Select.Root>
|
||||||
</label>
|
</label>
|
||||||
<label className="space-y-2 text-sm">
|
<label className="space-y-2 text-sm">
|
||||||
<span>能力标签(逗号分隔)</span>
|
<span>能力标签(逗号分隔)</span>
|
||||||
<Input
|
<TextField.Root
|
||||||
value={modelForm.capabilities}
|
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"
|
placeholder="chat,reasoning"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="space-y-2 text-sm md:col-span-2">
|
<label className="space-y-2 text-sm md:col-span-2">
|
||||||
<span>Base URL</span>
|
<span>Base URL</span>
|
||||||
<Input
|
<TextField.Root
|
||||||
value={modelForm.base_url}
|
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"
|
placeholder="https://api.example.com"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
@@ -909,9 +907,9 @@ export default function AdminModelsPage() {
|
|||||||
{!editingModelId && (
|
{!editingModelId && (
|
||||||
<label className="space-y-2 text-sm md:col-span-2">
|
<label className="space-y-2 text-sm md:col-span-2">
|
||||||
<span>初始 API Key(仅创建时)</span>
|
<span>初始 API Key(仅创建时)</span>
|
||||||
<Input
|
<TextField.Root
|
||||||
value={modelForm.api_key}
|
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-..."
|
placeholder="sk-..."
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
@@ -922,7 +920,7 @@ export default function AdminModelsPage() {
|
|||||||
<TextArea
|
<TextArea
|
||||||
rows={4}
|
rows={4}
|
||||||
value={modelForm.description}
|
value={modelForm.description}
|
||||||
onChange={(event) => setModelForm((prev) => ({ ...prev, description: event.target.value }))}
|
onChange={(event: ChangeEvent<HTMLTextAreaElement>) => setModelForm((prev) => ({ ...prev, description: event.currentTarget.value }))}
|
||||||
placeholder="模型用途、限制、成本策略..."
|
placeholder="模型用途、限制、成本策略..."
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
@@ -939,12 +937,12 @@ export default function AdminModelsPage() {
|
|||||||
{saveModelMutation.isPending ? "提交中..." : editingModelId ? "保存模型" : "创建模型"}
|
{saveModelMutation.isPending ? "提交中..." : editingModelId ? "保存模型" : "创建模型"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</Dialog.Content>
|
||||||
</Dialog>
|
</Dialog.Root>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{canManage && (
|
{canManage && (
|
||||||
<Dialog
|
<Dialog.Root
|
||||||
open={showRouteModal}
|
open={showRouteModal}
|
||||||
onOpenChange={(open: boolean) => {
|
onOpenChange={(open: boolean) => {
|
||||||
if (!open) {
|
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 className="mb-4 flex items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold">{editingRouteId ? "编辑路由规则" : "新建路由规则"}</h2>
|
<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">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<label className="space-y-2 text-sm">
|
<label className="space-y-2 text-sm">
|
||||||
<span>路由类型</span>
|
<span>路由类型</span>
|
||||||
<Select
|
<Select.Root
|
||||||
value={routeForm.route_type}
|
value={routeForm.route_type}
|
||||||
onValueChange={(value: string) => setRouteForm((prev) => ({ ...prev, route_type: value as ModelRouteType }))}
|
onValueChange={(value: string) => setRouteForm((prev) => ({ ...prev, route_type: value as ModelRouteType }))}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-full">
|
<Select.Trigger className="w-full">
|
||||||
<SelectValue placeholder="请选择路由类型" />
|
</Select.Trigger>
|
||||||
</SelectTrigger>
|
<Select.Content>
|
||||||
<SelectContent>
|
|
||||||
{ROUTE_TYPE_OPTIONS.map((item) => (
|
{ROUTE_TYPE_OPTIONS.map((item) => (
|
||||||
<SelectItem key={item} value={item}>
|
<Select.Item key={item} value={item}>
|
||||||
{item}
|
{item}
|
||||||
</SelectItem>
|
</Select.Item>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</Select.Content>
|
||||||
</Select>
|
</Select.Root>
|
||||||
</label>
|
</label>
|
||||||
<label className="space-y-2 text-sm">
|
<label className="space-y-2 text-sm">
|
||||||
<span>Route Key</span>
|
<span>Route Key</span>
|
||||||
<Input
|
<TextField.Root
|
||||||
value={routeForm.route_type === "GLOBAL" ? GLOBAL_ROUTE_KEY : routeForm.route_key}
|
value={routeForm.route_type === "GLOBAL" ? GLOBAL_ROUTE_KEY : routeForm.route_key}
|
||||||
disabled={routeForm.route_type === "GLOBAL"}
|
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"
|
placeholder="chat.default"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="space-y-2 text-sm">
|
<label className="space-y-2 text-sm">
|
||||||
<span>目标模型 Code</span>
|
<span>目标模型 Code</span>
|
||||||
<Input
|
<TextField.Root
|
||||||
value={routeForm.target_model_code}
|
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"
|
list="model-code-options"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
@@ -1018,19 +1015,20 @@ export default function AdminModelsPage() {
|
|||||||
</label>
|
</label>
|
||||||
<label className="space-y-2 text-sm">
|
<label className="space-y-2 text-sm">
|
||||||
<span>优先级(越小越高)</span>
|
<span>优先级(越小越高)</span>
|
||||||
<Input
|
<TextField.Root
|
||||||
type="number"
|
type="number"
|
||||||
value={routeForm.priority}
|
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"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="space-y-2 text-sm md:col-span-2">
|
<label className="space-y-2 text-sm md:col-span-2">
|
||||||
<span>备注</span>
|
<span>备注</span>
|
||||||
<Input
|
<TextArea
|
||||||
value={routeForm.note}
|
value={routeForm.note}
|
||||||
onChange={(event) => setRouteForm((prev) => ({ ...prev, note: event.target.value }))}
|
onChange={(event: ChangeEvent<HTMLTextAreaElement>) => setRouteForm((prev) => ({ ...prev, note: event.currentTarget.value }))}
|
||||||
placeholder="例如:客服场景优先使用"
|
placeholder="例如:客服场景优先使用"
|
||||||
|
rows={2}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
@@ -1038,7 +1036,7 @@ export default function AdminModelsPage() {
|
|||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={routeForm.enabled}
|
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>
|
<span>启用规则</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -1054,8 +1052,8 @@ export default function AdminModelsPage() {
|
|||||||
{saveRouteMutation.isPending ? "提交中..." : editingRouteId ? "保存规则" : "创建规则"}
|
{saveRouteMutation.isPending ? "提交中..." : editingRouteId ? "保存规则" : "创建规则"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</Dialog.Content>
|
||||||
</Dialog>
|
</Dialog.Root>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useMutation, useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useParams } from "next/navigation";
|
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 { useAuth } from "@/components/auth-provider";
|
||||||
import { Button, Select, TextArea, TextField } from "@radix-ui/themes";
|
import { Button, Select, TextArea, TextField } from "@radix-ui/themes";
|
||||||
@@ -119,7 +119,7 @@ function RequirementEditSection({
|
|||||||
<span>标题</span>
|
<span>标题</span>
|
||||||
<TextField.Root
|
<TextField.Root
|
||||||
value={title}
|
value={title}
|
||||||
onChange={(event) => setTitle(event.target.value)}
|
onChange={(event: ChangeEvent<HTMLInputElement>) => setTitle(event.currentTarget.value)}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
@@ -128,7 +128,7 @@ function RequirementEditSection({
|
|||||||
<span>描述</span>
|
<span>描述</span>
|
||||||
<TextArea
|
<TextArea
|
||||||
value={description}
|
value={description}
|
||||||
onChange={(event) => setDescription(event.target.value)}
|
onChange={(event: ChangeEvent<HTMLTextAreaElement>) => setDescription(event.currentTarget.value)}
|
||||||
rows={8}
|
rows={8}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
@@ -153,7 +153,7 @@ function RequirementEditSection({
|
|||||||
<TextField.Root
|
<TextField.Root
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
value={dueAt}
|
value={dueAt}
|
||||||
onChange={(event) => setDueAt(event.target.value)}
|
onChange={(event: ChangeEvent<HTMLInputElement>) => setDueAt(event.currentTarget.value)}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
@@ -162,7 +162,7 @@ function RequirementEditSection({
|
|||||||
<span>项目</span>
|
<span>项目</span>
|
||||||
<TextField.Root
|
<TextField.Root
|
||||||
value={projectName}
|
value={projectName}
|
||||||
onChange={(event) => setProjectName(event.target.value)}
|
onChange={(event: ChangeEvent<HTMLInputElement>) => setProjectName(event.currentTarget.value)}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
@@ -171,7 +171,7 @@ function RequirementEditSection({
|
|||||||
<span>模块</span>
|
<span>模块</span>
|
||||||
<TextField.Root
|
<TextField.Root
|
||||||
value={moduleName}
|
value={moduleName}
|
||||||
onChange={(event) => setModuleName(event.target.value)}
|
onChange={(event: ChangeEvent<HTMLInputElement>) => setModuleName(event.currentTarget.value)}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
@@ -180,7 +180,7 @@ function RequirementEditSection({
|
|||||||
<span>来源</span>
|
<span>来源</span>
|
||||||
<TextField.Root
|
<TextField.Root
|
||||||
value={source}
|
value={source}
|
||||||
onChange={(event) => setSource(event.target.value)}
|
onChange={(event: ChangeEvent<HTMLInputElement>) => setSource(event.currentTarget.value)}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
@@ -333,7 +333,7 @@ function RequirementActionsSection({
|
|||||||
</Select.Root>
|
</Select.Root>
|
||||||
<TextArea
|
<TextArea
|
||||||
value={transitionNote}
|
value={transitionNote}
|
||||||
onChange={(event) => setTransitionNote(event.target.value)}
|
onChange={(event: ChangeEvent<HTMLTextAreaElement>) => setTransitionNote(event.currentTarget.value)}
|
||||||
rows={3}
|
rows={3}
|
||||||
placeholder="流转备注(可选)"
|
placeholder="流转备注(可选)"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
@@ -417,7 +417,7 @@ function RequirementCommentSection({
|
|||||||
</Select.Root>
|
</Select.Root>
|
||||||
<TextArea
|
<TextArea
|
||||||
value={commentContent}
|
value={commentContent}
|
||||||
onChange={(event) => setCommentContent(event.target.value)}
|
onChange={(event: ChangeEvent<HTMLTextAreaElement>) => setCommentContent(event.currentTarget.value)}
|
||||||
rows={6}
|
rows={6}
|
||||||
placeholder="写点处理说明、分析结论或修订意见"
|
placeholder="写点处理说明、分析结论或修订意见"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useCallback, useState } from "react";
|
import { ChangeEvent, useCallback, useState } from "react";
|
||||||
|
|
||||||
import { useAuth } from "@/components/auth-provider";
|
import { useAuth } from "@/components/auth-provider";
|
||||||
import { Button, Select, TextArea, TextField } from "@radix-ui/themes";
|
import { Button, Select, TextArea, TextField } from "@radix-ui/themes";
|
||||||
@@ -123,7 +123,7 @@ export default function RequirementCreatePage() {
|
|||||||
<span>标题</span>
|
<span>标题</span>
|
||||||
<TextField.Root
|
<TextField.Root
|
||||||
value={title}
|
value={title}
|
||||||
onChange={(event) => setTitle(event.target.value)}
|
onChange={(event: ChangeEvent<HTMLInputElement>) => setTitle(event.currentTarget.value)}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
@@ -132,7 +132,7 @@ export default function RequirementCreatePage() {
|
|||||||
<span>描述</span>
|
<span>描述</span>
|
||||||
<TextArea
|
<TextArea
|
||||||
value={description}
|
value={description}
|
||||||
onChange={(event) => setDescription(event.target.value)}
|
onChange={(event: ChangeEvent<HTMLTextAreaElement>) => setDescription(event.currentTarget.value)}
|
||||||
rows={8}
|
rows={8}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
@@ -168,17 +168,29 @@ export default function RequirementCreatePage() {
|
|||||||
|
|
||||||
<label className="space-y-2 text-sm">
|
<label className="space-y-2 text-sm">
|
||||||
<span>项目</span>
|
<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>
|
||||||
|
|
||||||
<label className="space-y-2 text-sm">
|
<label className="space-y-2 text-sm">
|
||||||
<span>模块</span>
|
<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>
|
||||||
|
|
||||||
<label className="space-y-2 text-sm">
|
<label className="space-y-2 text-sm">
|
||||||
<span>来源</span>
|
<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>
|
||||||
|
|
||||||
<label className="space-y-2 text-sm">
|
<label className="space-y-2 text-sm">
|
||||||
@@ -202,7 +214,12 @@ export default function RequirementCreatePage() {
|
|||||||
|
|
||||||
<label className="space-y-2 text-sm">
|
<label className="space-y-2 text-sm">
|
||||||
<span>截止时间</span>
|
<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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import Link from "next/link";
|
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 { useAuth } from "@/components/auth-provider";
|
||||||
import { Select, TextField } from "@radix-ui/themes";
|
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">
|
<div className="mt-4 grid gap-3 md:grid-cols-4">
|
||||||
<TextField.Root
|
<TextField.Root
|
||||||
value={filters.keyword}
|
value={filters.keyword}
|
||||||
onChange={(event) => setFilters((prev) => ({ ...prev, keyword: event.target.value }))}
|
onChange={(event: ChangeEvent<HTMLInputElement>) =>
|
||||||
|
setFilters((prev) => ({ ...prev, keyword: event.currentTarget.value }))
|
||||||
|
}
|
||||||
placeholder="关键词 / 编号"
|
placeholder="关键词 / 编号"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useState, useCallback } from "react";
|
import { ChangeEvent, useEffect, useMemo, useState, useCallback } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
import { useAuth } from "@/components/auth-provider";
|
import { useAuth } from "@/components/auth-provider";
|
||||||
@@ -301,7 +301,9 @@ export default function AdminRolesPage() {
|
|||||||
<TextField.Root
|
<TextField.Root
|
||||||
value={form.code}
|
value={form.code}
|
||||||
disabled={editingRoleId !== null}
|
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"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
@@ -309,7 +311,9 @@ export default function AdminRolesPage() {
|
|||||||
<span>角色名称</span>
|
<span>角色名称</span>
|
||||||
<TextField.Root
|
<TextField.Root
|
||||||
value={form.name}
|
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"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
@@ -317,7 +321,9 @@ export default function AdminRolesPage() {
|
|||||||
<span>权限编码(逗号分隔)</span>
|
<span>权限编码(逗号分隔)</span>
|
||||||
<TextField.Root
|
<TextField.Root
|
||||||
value={form.permission_codes}
|
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(", ")}
|
placeholder={permissions.map((item) => item.code).join(", ")}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
@@ -332,10 +338,10 @@ export default function AdminRolesPage() {
|
|||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={checked}
|
checked={checked}
|
||||||
onChange={(event) => {
|
onChange={(event: ChangeEvent<HTMLInputElement>) => {
|
||||||
setForm((prev) => ({
|
setForm((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
menu_ids: event.target.checked
|
menu_ids: event.currentTarget.checked
|
||||||
? [...prev.menu_ids, item.value]
|
? [...prev.menu_ids, item.value]
|
||||||
: prev.menu_ids.filter((menuId) => menuId !== item.value),
|
: prev.menu_ids.filter((menuId) => menuId !== item.value),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import Link from "next/link";
|
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 { useAuth } from "@/components/auth-provider";
|
||||||
import { Button, Dialog, Select, Table, TextArea, TextField } from "@radix-ui/themes";
|
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">
|
<div className="mt-4 grid gap-3 md:grid-cols-4">
|
||||||
<TextField.Root
|
<TextField.Root
|
||||||
value={filters.keyword}
|
value={filters.keyword}
|
||||||
onChange={(event) => setFilters((prev) => ({ ...prev, keyword: event.target.value }))}
|
onChange={(event: ChangeEvent<HTMLInputElement>) =>
|
||||||
|
setFilters((prev) => ({ ...prev, keyword: event.currentTarget.value }))
|
||||||
|
}
|
||||||
placeholder="关键词"
|
placeholder="关键词"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
@@ -404,14 +406,18 @@ export default function TodoPage() {
|
|||||||
<TextField.Root
|
<TextField.Root
|
||||||
className="w-full"
|
className="w-full"
|
||||||
value={createDraft.title}
|
value={createDraft.title}
|
||||||
onChange={(event) => setCreateDraft((prev) => ({ ...prev, title: event.target.value }))}
|
onChange={(event: ChangeEvent<HTMLInputElement>) =>
|
||||||
|
setCreateDraft((prev) => ({ ...prev, title: event.currentTarget.value }))
|
||||||
|
}
|
||||||
placeholder="标题"
|
placeholder="标题"
|
||||||
/>
|
/>
|
||||||
<TextArea
|
<TextArea
|
||||||
className="w-full"
|
className="w-full"
|
||||||
rows={4}
|
rows={4}
|
||||||
value={createDraft.description}
|
value={createDraft.description}
|
||||||
onChange={(event) => setCreateDraft((prev) => ({ ...prev, description: event.target.value }))}
|
onChange={(event: ChangeEvent<HTMLTextAreaElement>) =>
|
||||||
|
setCreateDraft((prev) => ({ ...prev, description: event.currentTarget.value }))
|
||||||
|
}
|
||||||
placeholder="描述"
|
placeholder="描述"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -456,7 +462,9 @@ export default function TodoPage() {
|
|||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
value={createDraft.due_at}
|
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
|
<Select.Root
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import Link from "next/link";
|
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 { useAuth } from "@/components/auth-provider";
|
||||||
import { Button, TextField } from "@radix-ui/themes";
|
import { Button, TextField } from "@radix-ui/themes";
|
||||||
@@ -232,10 +232,38 @@ export default function AdminUsersPage() {
|
|||||||
<h2 className="text-lg font-semibold">新增用户</h2>
|
<h2 className="text-lg font-semibold">新增用户</h2>
|
||||||
<p className="mt-1 text-sm text-muted">用户 ID 由管理员手动填写,系统会校验重复。</p>
|
<p className="mt-1 text-sm text-muted">用户 ID 由管理员手动填写,系统会校验重复。</p>
|
||||||
<form className="mt-4 grid gap-3 md:grid-cols-2" onSubmit={handleCreateUser}>
|
<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
|
||||||
<TextField.Root placeholder="邮箱" type="email" value={newEmail} onChange={(e) => setNewEmail(e.target.value)} required />
|
placeholder="用户 ID(例如 ck001)"
|
||||||
<TextField.Root placeholder="用户名" value={newUsername} onChange={(e) => setNewUsername(e.target.value)} minLength={3} maxLength={64} required />
|
value={newUserId}
|
||||||
<TextField.Root placeholder="初始密码(至少8位)" type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} minLength={8} maxLength={128} required />
|
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">
|
<div className="md:col-span-2">
|
||||||
<Button type="submit" disabled={createUserMutation.isPending}>
|
<Button type="submit" disabled={createUserMutation.isPending}>
|
||||||
{createUserMutation.isPending ? "创建中..." : "创建用户"}
|
{createUserMutation.isPending ? "创建中..." : "创建用户"}
|
||||||
@@ -283,8 +311,8 @@ export default function AdminUsersPage() {
|
|||||||
checked={checked}
|
checked={checked}
|
||||||
disabled={savingUserId === item.id}
|
disabled={savingUserId === item.id}
|
||||||
className="accent-indigo-600"
|
className="accent-indigo-600"
|
||||||
onChange={(event) => {
|
onChange={(event: ChangeEvent<HTMLInputElement>) => {
|
||||||
const nextRoles = event.target.checked
|
const nextRoles = event.currentTarget.checked
|
||||||
? [...item.role_codes, roleCode]
|
? [...item.role_codes, roleCode]
|
||||||
: item.role_codes.filter((code) => code !== roleCode);
|
: item.role_codes.filter((code) => code !== roleCode);
|
||||||
updateRolesMutation.mutate({ userId: item.id, roleCodes: nextRoles });
|
updateRolesMutation.mutate({ userId: item.id, roleCodes: nextRoles });
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
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 { useAuth } from "@/components/auth-provider";
|
||||||
import { API_BASE_URL, getApiBaseUrl, readApiError } from "@/lib/api";
|
import { API_BASE_URL, getApiBaseUrl, readApiError } from "@/lib/api";
|
||||||
@@ -201,7 +201,7 @@ export default function Home() {
|
|||||||
type="email"
|
type="email"
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(event) => setEmail(event.target.value)}
|
onChange={(event: ChangeEvent<HTMLInputElement>) => setEmail(event.currentTarget.value)}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
{mode === "register" && (
|
{mode === "register" && (
|
||||||
@@ -210,7 +210,7 @@ export default function Home() {
|
|||||||
placeholder="Username"
|
placeholder="Username"
|
||||||
type="text"
|
type="text"
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(event) => setUsername(event.target.value)}
|
onChange={(event: ChangeEvent<HTMLInputElement>) => setUsername(event.currentTarget.value)}
|
||||||
minLength={3}
|
minLength={3}
|
||||||
maxLength={64}
|
maxLength={64}
|
||||||
required
|
required
|
||||||
@@ -222,7 +222,7 @@ export default function Home() {
|
|||||||
type="password"
|
type="password"
|
||||||
autoComplete={mode === "login" ? "current-password" : "new-password"}
|
autoComplete={mode === "login" ? "current-password" : "new-password"}
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(event) => setPassword(event.target.value)}
|
onChange={(event: ChangeEvent<HTMLInputElement>) => setPassword(event.currentTarget.value)}
|
||||||
minLength={8}
|
minLength={8}
|
||||||
maxLength={128}
|
maxLength={128}
|
||||||
required
|
required
|
||||||
@@ -232,7 +232,7 @@ export default function Home() {
|
|||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={rememberPassword}
|
checked={rememberPassword}
|
||||||
onChange={(event) => setRememberPassword(event.target.checked)}
|
onChange={(event: ChangeEvent<HTMLInputElement>) => setRememberPassword(event.currentTarget.checked)}
|
||||||
/>
|
/>
|
||||||
记住密码
|
记住密码
|
||||||
</label>
|
</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