From b5301ec7d1c1b40cb1064c84dd5de08d637d352a Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Thu, 28 May 2026 16:09:01 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E5=89=8D=E7=AB=AF=20antd=20?= =?UTF-8?q?=E7=BB=84=E4=BB=B6=E4=BD=BF=E7=94=A8=E6=9C=80=E4=BD=B3=E5=AE=9E?= =?UTF-8?q?=E8=B7=B5=E9=87=8D=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修正 API 响应类型,增加 ProjectResponse 包装类型 - ConfigProvider 配置中文 locale (zhCN) - 生产入口启用 ErrorBoundary,使用 Result 组件 - ReactQueryDevtools 仅开发环境渲染 - Sider 增加 collapsible 配置,使用 antd 默认折叠行为 - 项目页面拆分为 ProjectToolbar/ProjectTable/ProjectFormModal - 搜索改用 Input.Search,表单增加 whitespace 校验 - 404/ErrorBoundary/Dashboard 使用 antd Result/Typography/Card/Descriptions - 清理未使用的 ProtectedRoute 和冗余样式类 - styles.css 仅保留必要布局样式,无 antd 内部类覆盖 - 更新测试覆盖,避免依赖 antd 内部类名 - 更新 docs/development/frontend.md 开发规范 --- bun.lock | 16 +- docs/development/frontend.md | 4 +- skills-lock.json | 4 +- src/shared/api.ts | 4 + src/web/app.tsx | 30 +- src/web/components/ErrorBoundary.tsx | 18 +- src/web/components/Sidebar/index.tsx | 10 +- src/web/hooks/use-meta.ts | 18 ++ src/web/hooks/use-projects.ts | 16 +- src/web/hooks/use-sidebar-collapsed.ts | 6 +- src/web/main.tsx | 15 +- src/web/pages/404/index.tsx | 25 +- src/web/pages/dashboard/index.tsx | 41 +-- .../projects/components/ProjectFormModal.tsx | 86 +++++ .../projects/components/ProjectTable.tsx | 159 ++++++++++ .../projects/components/ProjectToolbar.tsx | 48 +++ src/web/pages/projects/index.tsx | 298 +++--------------- src/web/routes.tsx | 6 - src/web/styles.css | 77 +---- tests/web/App.test.tsx | 2 +- tests/web/routes/404.test.tsx | 5 +- tests/web/routes/projects.test.tsx | 2 +- 22 files changed, 458 insertions(+), 432 deletions(-) create mode 100644 src/web/hooks/use-meta.ts create mode 100644 src/web/pages/projects/components/ProjectFormModal.tsx create mode 100644 src/web/pages/projects/components/ProjectTable.tsx create mode 100644 src/web/pages/projects/components/ProjectToolbar.tsx diff --git a/bun.lock b/bun.lock index 227b350..9f154f0 100644 --- a/bun.lock +++ b/bun.lock @@ -7,11 +7,11 @@ "dependencies": { "@ant-design/icons": "^6.2.3", "@sinclair/typebox": "^0.34.49", - "@tanstack/react-query": "^5.100.10", + "@tanstack/react-query": "^5.100.14", "ajv": "^8.20.0", "antd": "^6.4.3", "drizzle-orm": "^0.45.2", - "es-toolkit": "^1.46.1", + "es-toolkit": "^1.47.0", "pino": "^10.3.1", "pino-pretty": "^13.1.3", "pino-roll": "^4.0.0", @@ -24,15 +24,15 @@ "@commitlint/cli": "^21.0.1", "@commitlint/config-conventional": "^21.0.1", "@eslint/js": "^10.0.1", - "@tanstack/react-query-devtools": "^5.100.10", + "@tanstack/react-query-devtools": "^5.100.14", "@testing-library/react": "^16.3.2", "@types/bun": "^1.3.14", "@types/jsdom": "^28.0.3", - "@types/react": "^19.2.14", + "@types/react": "^19.2.15", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.2", "drizzle-kit": "^0.31.10", - "eslint": "^10.3.0", + "eslint": "^10.4.0", "eslint-config-prettier": "^10.1.8", "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-import": "^2.32.0", @@ -42,11 +42,11 @@ "eslint-plugin-react-refresh": "^0.5.2", "husky": "^9.1.7", "jsdom": "^29.1.1", - "lint-staged": "^17.0.4", + "lint-staged": "^17.0.5", "prettier": "^3.8.3", "typescript": "^6.0.3", - "typescript-eslint": "^8.59.3", - "vite": "^8.0.13", + "typescript-eslint": "^8.60.0", + "vite": "^8.0.14", }, }, }, diff --git a/docs/development/frontend.md b/docs/development/frontend.md index 9ab4bc8..a6c4c72 100644 --- a/docs/development/frontend.md +++ b/docs/development/frontend.md @@ -22,8 +22,8 @@ - 每个 React 组件一个 .tsx 文件,文件名使用 PascalCase - 组件 props 定义为 interface XxxProps,紧邻组件函数声明 - 类型从 src/shared/api.ts 导入,使用 import type -- 展示组件放在 components/,通过 props 接收数据,通过回调返回事件 -- 容器逻辑放在 hooks 中,组件只做数据消费 +- 展示组件放在 components/,通过 props 接收数据,通过回调返回事件;页面专属展示组件可就近放在 pages/\*/components/ +- 容器逻辑放在 hooks 中,组件只做数据消费;全局共享查询可提取为独立 hook(如 use-meta) - 工具函数放在 utils/,保持纯函数无副作用 ## 样式开发规范 diff --git a/skills-lock.json b/skills-lock.json index 7ab1153..dd25367 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -5,13 +5,13 @@ "source": "ant-design/antd-skill", "sourceType": "github", "skillPath": "skills/ant-design/SKILL.md", - "computedHash": "4d0447d48fced080b2825ecc0fb4d7ca836c8015882899c643acca0b864d5179" + "computedHash": "096d4ac9513e43030f960aab49b50168a3d5eb35be86926ac6e96e5998ea9466" }, "antd": { "source": "ant-design/antd-skill", "sourceType": "github", "skillPath": "skills/antd/SKILL.md", - "computedHash": "4295010f09f85855cab9e9de9ec7f96c14541474b4f3f9d6ef89006430931b94" + "computedHash": "5e26c8042060bb811118927b5daf637af7929a00fa973dd8f5f804f3ba6e2bf2" } } } diff --git a/src/shared/api.ts b/src/shared/api.ts index b9fec82..3e75287 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -37,6 +37,10 @@ export interface ProjectListResponse { total: number; } +export interface ProjectResponse { + project: Project; +} + export type ProjectStatus = "active" | "archived"; export type RuntimeMode = "development" | "production" | "test"; diff --git a/src/web/app.tsx b/src/web/app.tsx index 244d999..3223cf1 100644 --- a/src/web/app.tsx +++ b/src/web/app.tsx @@ -1,16 +1,13 @@ import { MenuFoldOutlined, MenuUnfoldOutlined } from "@ant-design/icons"; -import { useQuery } from "@tanstack/react-query"; import { App as AntApp, ConfigProvider, Layout, Segmented, theme } from "antd"; +import zhCN from "antd/locale/zh_CN"; import { useEffect } from "react"; -import { useLocation } from "react-router"; - -import type { MetaResponse } from "../shared/api"; import { APP } from "../shared/app"; import { Sidebar } from "./components/Sidebar"; +import { useMeta } from "./hooks/use-meta"; import { useSidebarCollapsed } from "./hooks/use-sidebar-collapsed"; import { type ThemePreference, useThemePreference } from "./hooks/use-theme-preference"; -import { MENU_ITEMS } from "./menu"; import { AppRoutes } from "./routes"; const { Content, Header, Sider } = Layout; @@ -24,13 +21,7 @@ const THEME_OPTIONS = [ export function App() { const { effectiveTheme, preference: themePreference, setPreference: setThemePreference } = useThemePreference(); const { collapsed, setCollapsed } = useSidebarCollapsed(); - const location = useLocation(); - const { data: meta } = useQuery({ - queryFn: fetchMeta, - queryKey: ["meta"], - refetchInterval: 30000, - staleTime: 5000, - }); + const { data: meta } = useMeta(); useEffect(() => { document.title = APP.title; @@ -41,15 +32,12 @@ export function App() { setThemePreference(value as ThemePreference); }; - const currentPath = location.pathname; - const currentItem = MENU_ITEMS.find((item) => item.path === currentPath); - const pageTitle = currentItem?.label ?? APP.title; const versionDisplay = meta?.version ? `v${meta.version}` : null; const themeAlgorithm = effectiveTheme === "dark" ? theme.darkAlgorithm : theme.defaultAlgorithm; return ( - +
@@ -58,7 +46,6 @@ export function App() { {APP.title} {versionDisplay && {versionDisplay}} - {pageTitle}
setCollapsed(collapsed)} + theme="light" trigger={collapsed ? : } width={232} > @@ -90,9 +78,3 @@ export function App() { ); } - -async function fetchMeta(): Promise { - const response = await fetch("/api/meta"); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - return response.json() as Promise; -} diff --git a/src/web/components/ErrorBoundary.tsx b/src/web/components/ErrorBoundary.tsx index 03995a9..295a8aa 100644 --- a/src/web/components/ErrorBoundary.tsx +++ b/src/web/components/ErrorBoundary.tsx @@ -1,6 +1,6 @@ import type { ErrorInfo, ReactNode } from "react"; -import { Alert, Button, Space } from "antd"; +import { Button, Result } from "antd"; import { Component } from "react"; interface Props { @@ -25,12 +25,16 @@ export class ErrorBoundary extends Component { override render() { if (this.state.hasError) { return ( - - - - + window.location.reload()} type="primary"> + 刷新页面 + + } + status="500" + subTitle="页面渲染出现异常,请刷新重试" + title="渲染错误" + /> ); } return this.props.children; diff --git a/src/web/components/Sidebar/index.tsx b/src/web/components/Sidebar/index.tsx index 6f88a76..939f22c 100644 --- a/src/web/components/Sidebar/index.tsx +++ b/src/web/components/Sidebar/index.tsx @@ -28,13 +28,5 @@ export function Sidebar() { } }; - return ( - - ); + return ; } diff --git a/src/web/hooks/use-meta.ts b/src/web/hooks/use-meta.ts new file mode 100644 index 0000000..ab0d120 --- /dev/null +++ b/src/web/hooks/use-meta.ts @@ -0,0 +1,18 @@ +import { useQuery } from "@tanstack/react-query"; + +import type { MetaResponse } from "../../shared/api"; + +export function useMeta() { + return useQuery({ + queryFn: fetchMeta, + queryKey: ["meta"], + refetchInterval: 30000, + staleTime: 5000, + }); +} + +async function fetchMeta(): Promise { + const response = await fetch("/api/meta"); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json() as Promise; +} diff --git a/src/web/hooks/use-projects.ts b/src/web/hooks/use-projects.ts index 23fe92c..a07acd1 100644 --- a/src/web/hooks/use-projects.ts +++ b/src/web/hooks/use-projects.ts @@ -4,6 +4,7 @@ import type { CreateProjectRequest, Project, ProjectListResponse, + ProjectResponse, ProjectStatus, UpdateProjectRequest, } from "../../shared/api"; @@ -81,7 +82,8 @@ async function archiveProject(id: string): Promise { const body = (await response.json().catch(() => null)) as null | { error?: string }; throw new Error(body?.error ?? `HTTP ${response.status}`); } - return response.json() as Promise; + const data = (await response.json()) as ProjectResponse; + return data.project; } async function createProject(data: CreateProjectRequest): Promise { @@ -94,7 +96,8 @@ async function createProject(data: CreateProjectRequest): Promise { const body = (await response.json().catch(() => null)) as null | { error?: string }; throw new Error(body?.error ?? `HTTP ${response.status}`); } - return response.json() as Promise; + const result = (await response.json()) as ProjectResponse; + return result.project; } async function deleteProject(id: string): Promise { @@ -111,7 +114,8 @@ async function fetchProject(id: string): Promise { const body = (await response.json().catch(() => null)) as null | { error?: string }; throw new Error(body?.error ?? `HTTP ${response.status}`); } - return response.json() as Promise; + const data = (await response.json()) as ProjectResponse; + return data.project; } async function fetchProjectList(params: { @@ -141,7 +145,8 @@ async function restoreProject(id: string): Promise { const body = (await response.json().catch(() => null)) as null | { error?: string }; throw new Error(body?.error ?? `HTTP ${response.status}`); } - return response.json() as Promise; + const data = (await response.json()) as ProjectResponse; + return data.project; } async function updateProject(id: string, data: UpdateProjectRequest): Promise { @@ -154,5 +159,6 @@ async function updateProject(id: string, data: UpdateProjectRequest): Promise null)) as null | { error?: string }; throw new Error(body?.error ?? `HTTP ${response.status}`); } - return response.json() as Promise; + const result = (await response.json()) as ProjectResponse; + return result.project; } diff --git a/src/web/hooks/use-sidebar-collapsed.ts b/src/web/hooks/use-sidebar-collapsed.ts index b74bbab..87cf14d 100644 --- a/src/web/hooks/use-sidebar-collapsed.ts +++ b/src/web/hooks/use-sidebar-collapsed.ts @@ -22,11 +22,7 @@ export function useSidebarCollapsed() { writeSidebarCollapsed(nextCollapsed); }; - const toggleCollapsed = () => { - setCollapsed(!collapsed); - }; - - return { collapsed, setCollapsed, toggleCollapsed }; + return { collapsed, setCollapsed }; } export function writeSidebarCollapsed(collapsed: boolean, storage: Storage = window.localStorage) { diff --git a/src/web/main.tsx b/src/web/main.tsx index 10d1543..ebeebce 100644 --- a/src/web/main.tsx +++ b/src/web/main.tsx @@ -5,6 +5,7 @@ import { createRoot } from "react-dom/client"; import { BrowserRouter } from "react-router"; import { App } from "./app"; +import { ErrorBoundary } from "./components/ErrorBoundary"; import "./styles.css"; const queryClient = new QueryClient({ @@ -25,11 +26,13 @@ if (!rootElement) { createRoot(rootElement).render( - - - - - - + + + + + + {import.meta.env["DEV"] && } + + , ); diff --git a/src/web/pages/404/index.tsx b/src/web/pages/404/index.tsx index f5bc94f..2289342 100644 --- a/src/web/pages/404/index.tsx +++ b/src/web/pages/404/index.tsx @@ -1,22 +1,19 @@ -import { ExclamationCircleOutlined } from "@ant-design/icons"; -import { Button, Space } from "antd"; +import { Button, Result } from "antd"; import { useNavigate } from "react-router"; export function NotFoundPage() { const navigate = useNavigate(); - const handleGoHome = () => { - void navigate("/"); - }; - return ( - - -

404

-

您访问的页面不存在

- -
+ void navigate("/")} type="primary"> + 返回首页 + + } + status="404" + subTitle="您访问的页面不存在" + title="404" + /> ); } diff --git a/src/web/pages/dashboard/index.tsx b/src/web/pages/dashboard/index.tsx index 24b4fd6..50abf71 100644 --- a/src/web/pages/dashboard/index.tsx +++ b/src/web/pages/dashboard/index.tsx @@ -1,29 +1,32 @@ -import { useQuery } from "@tanstack/react-query"; -import { Space } from "antd"; +import type { DescriptionsProps } from "antd"; -import type { MetaResponse } from "../../../shared/api"; +import { Alert, Card, Descriptions, Space, Spin, Typography } from "antd"; import { APP } from "../../../shared/app"; +import { useMeta } from "../../hooks/use-meta"; export function DashboardPage() { - const { data: meta } = useQuery({ - queryFn: fetchMeta, - queryKey: ["meta"], - refetchInterval: 30000, - staleTime: 5000, - }); + const { data: meta, error, isLoading } = useMeta(); + + const descriptionItems: DescriptionsProps["items"] = meta + ? [ + { children: meta.service, key: "service", label: "服务" }, + { children: meta.version, key: "version", label: "版本" }, + { children: meta.timestamp, key: "timestamp", label: "时间戳" }, + ] + : []; return ( - -

欢迎使用 {APP.title}

-

在此构建你的应用。以下是 /api/meta 的返回数据(前后端联调示例):

- {meta &&
{JSON.stringify(meta, null, 2)}
} + + 欢迎使用 {APP.title} + 在此构建你的应用。以下是 /api/meta 的返回数据(前后端联调示例): + {isLoading && } + {error && } + {meta && ( + + + + )} ); } - -async function fetchMeta(): Promise { - const response = await fetch("/api/meta"); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - return response.json() as Promise; -} diff --git a/src/web/pages/projects/components/ProjectFormModal.tsx b/src/web/pages/projects/components/ProjectFormModal.tsx new file mode 100644 index 0000000..f00053b --- /dev/null +++ b/src/web/pages/projects/components/ProjectFormModal.tsx @@ -0,0 +1,86 @@ +import { App as AntApp, Form, Input, Modal } from "antd"; + +import type { CreateProjectRequest, Project, UpdateProjectRequest } from "../../../../shared/api"; + +interface FormValues { + description?: string; + name: string; +} + +interface ProjectFormModalProps { + editingProject: null | Project; + onCancel: () => void; + onCreate: (data: CreateProjectRequest) => Promise; + onOpenChange: (open: boolean) => void; + onUpdate: (args: { data: UpdateProjectRequest; id: string }) => Promise; + open: boolean; + submitting: boolean; +} + +export function ProjectFormModal({ + editingProject, + onCancel, + onCreate, + onOpenChange, + onUpdate, + open, + submitting, +}: ProjectFormModalProps) { + const { message } = AntApp.useApp(); + const [form] = Form.useForm(); + + const handleFinish = async (values: FormValues) => { + try { + if (editingProject) { + const reqData: UpdateProjectRequest = {}; + if (values.name !== editingProject.name) reqData.name = values.name; + if ((values.description ?? "") !== (editingProject.description ?? "")) reqData.description = values.description; + await onUpdate({ data: reqData, id: editingProject.id }); + message.success("项目已更新"); + } else { + const reqData: CreateProjectRequest = { description: values.description, name: values.name }; + await onCreate(reqData); + message.success("项目已创建"); + } + onOpenChange(false); + } catch (err) { + if (err instanceof Error) { + message.error(err.message); + } + } + }; + + return ( + { + if (visible) { + if (editingProject) { + form.setFieldsValue({ description: editingProject.description, name: editingProject.name }); + } else { + form.resetFields(); + } + } + }} + confirmLoading={submitting} + destroyOnHidden + okText="确定" + onCancel={onCancel} + onOk={() => void form.submit()} + open={open} + title={editingProject ? "编辑项目" : "新建项目"} + > +
void handleFinish(values)}> + + + + + + +
+
+ ); +} diff --git a/src/web/pages/projects/components/ProjectTable.tsx b/src/web/pages/projects/components/ProjectTable.tsx new file mode 100644 index 0000000..a9bc1b0 --- /dev/null +++ b/src/web/pages/projects/components/ProjectTable.tsx @@ -0,0 +1,159 @@ +import type { ColumnsType } from "antd/es/table"; + +import { DeleteOutlined, EditOutlined, InboxOutlined, RedoOutlined } from "@ant-design/icons"; +import { App as AntApp, Button, Popconfirm, Space, Table, Tag } from "antd"; + +import type { Project, ProjectListResponse } from "../../../../shared/api"; + +interface ProjectTableProps { + data: ProjectListResponse | undefined; + loading: boolean; + onArchive: (id: string) => Promise; + onDelete: (id: string) => Promise; + onEdit: (project: Project) => void; + onPageChange: (page: number, pageSize: number) => void; + onRestore: (id: string) => Promise; + page: number; + pageSize: number; +} + +const COLUMNS: ColumnsType = [ + { dataIndex: "name", ellipsis: true, title: "项目名称", width: 160 }, + { dataIndex: "description", ellipsis: true, title: "项目描述" }, + { + align: "center", + dataIndex: "status", + render: (_value, record: Project) => { + if (record.status === "archived") { + return 已归档; + } + return 进行中; + }, + title: "状态", + width: 100, + }, + { + align: "center", + dataIndex: "createdAt", + render: (_value, record: Project) => formatDatetime(record.createdAt), + title: "创建时间", + width: 185, + }, + { + align: "center", + dataIndex: "updatedAt", + render: (_value, record: Project) => formatDatetime(record.updatedAt), + title: "更新时间", + width: 185, + }, +]; + +export function ProjectTable({ + data, + loading, + onArchive, + onDelete, + onEdit, + onPageChange, + onRestore, + page, + pageSize, +}: ProjectTableProps) { + const { message } = AntApp.useApp(); + + const handleArchive = async (id: string) => { + try { + await onArchive(id); + message.success("项目已归档"); + } catch (err) { + message.error((err as Error).message); + } + }; + + const handleRestore = async (id: string) => { + try { + await onRestore(id); + message.success("项目已恢复"); + } catch (err) { + message.error((err as Error).message); + } + }; + + const handleDelete = async (id: string) => { + try { + await onDelete(id); + message.success("项目已永久删除"); + } catch (err) { + message.error((err as Error).message); + } + }; + + const operationColumn: ColumnsType[number] = { + dataIndex: "op", + fixed: "right", + render: (_value, record: Project) => { + if (record.status === "active") { + return ( + + + void handleArchive(record.id)} + title="确认归档此项目?" + > + + + + ); + } + return ( + + void handleRestore(record.id)} title="确认恢复此项目?"> + + + void handleDelete(record.id)} + title="确认永久删除此项目?" + > + + + + ); + }, + title: "操作", + width: 180, + }; + + return ( + + ); +} + +function formatDatetime(dateStr: string): string { + const d = new Date(dateStr); + const pad = (n: number) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; +} diff --git a/src/web/pages/projects/components/ProjectToolbar.tsx b/src/web/pages/projects/components/ProjectToolbar.tsx new file mode 100644 index 0000000..8a14ea2 --- /dev/null +++ b/src/web/pages/projects/components/ProjectToolbar.tsx @@ -0,0 +1,48 @@ +import { PlusOutlined } from "@ant-design/icons"; +import { Button, Flex, Input, Tabs } from "antd"; + +import type { ProjectStatus } from "../../../../shared/api"; + +interface ProjectToolbarProps { + activeTab: ProjectStatus; + keyword: string; + onSearch: (value: string) => void; + onSearchClear: () => void; + onTabChange: (key: string) => void; + openCreateDialog: () => void; +} + +const STATUS_TAB_ITEMS = [ + { key: "active", label: "进行中" }, + { key: "archived", label: "已归档" }, +]; + +export function ProjectToolbar({ + activeTab, + keyword, + onSearch, + onSearchClear, + onTabChange, + openCreateDialog, +}: ProjectToolbarProps) { + return ( + + + + + {activeTab === "active" && ( + + )} + + + ); +} diff --git a/src/web/pages/projects/index.tsx b/src/web/pages/projects/index.tsx index 94a0c34..ffbc630 100644 --- a/src/web/pages/projects/index.tsx +++ b/src/web/pages/projects/index.tsx @@ -1,17 +1,7 @@ -import type { ColumnsType } from "antd/es/table"; - -import { - DeleteOutlined, - EditOutlined, - InboxOutlined, - PlusOutlined, - RedoOutlined, - SearchOutlined, -} from "@ant-design/icons"; -import { App as AntApp, Button, Form, Input, Modal, Popconfirm, Space, Table, Tabs, Tag } from "antd"; +import { Flex } from "antd"; import { useState } from "react"; -import type { CreateProjectRequest, Project, ProjectStatus, UpdateProjectRequest } from "../../../shared/api"; +import type { Project, ProjectStatus } from "../../../shared/api"; import { useArchiveProject, @@ -21,29 +11,18 @@ import { useRestoreProject, useUpdateProject, } from "../../hooks/use-projects"; - -const STATUS_TAB_ITEMS = [ - { key: "active", label: "进行中" }, - { key: "archived", label: "已归档" }, -]; - -interface FormValues { - description?: string; - name: string; -} +import { ProjectFormModal } from "./components/ProjectFormModal"; +import { ProjectTable } from "./components/ProjectTable"; +import { ProjectToolbar } from "./components/ProjectToolbar"; export function ProjectsPage() { - const { message } = AntApp.useApp(); - const [tabValue, setTabValue] = useState("active"); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(20); const [keyword, setKeyword] = useState(""); - const [searchValue, setSearchValue] = useState(""); const [dialogOpen, setDialogOpen] = useState(false); const [editingProject, setEditingProject] = useState(null); - const [form] = Form.useForm(); const { data, isLoading } = useProjectList({ keyword: keyword || undefined, page, pageSize, status: tabValue }); const createMutation = useCreateProject(); @@ -52,236 +31,59 @@ export function ProjectsPage() { const restoreMutation = useRestoreProject(); const deleteMutation = useDeleteProject(); - const handleSearch = () => { - setKeyword(searchValue); - setPage(1); - }; - - const handleSearchKeydown = (e: React.KeyboardEvent) => { - if (e.key === "Enter") { - handleSearch(); - } - }; - - const handleTabChange = (key: string) => { - setTabValue(key as ProjectStatus); - setPage(1); - }; - - const openCreateDialog = () => { - setEditingProject(null); - setDialogOpen(true); - }; - - const openEditDialog = (project: Project) => { - setEditingProject(project); - setDialogOpen(true); - }; - - const handleDialogOk = async () => { - try { - const values = await form.validateFields(); - if (editingProject) { - const reqData: UpdateProjectRequest = {}; - if (values.name !== editingProject.name) reqData.name = values.name; - if ((values.description ?? "") !== (editingProject.description ?? "")) reqData.description = values.description; - await updateMutation.mutateAsync({ data: reqData, id: editingProject.id }); - message.success("项目已更新"); - } else { - const reqData: CreateProjectRequest = { description: values.description, name: values.name }; - await createMutation.mutateAsync(reqData); - message.success("项目已创建"); - } - setDialogOpen(false); - } catch (err) { - if (err instanceof Error) { - message.error(err.message); - } - } - }; - - const handleArchive = async (id: string) => { - try { - await archiveMutation.mutateAsync(id); - message.success("项目已归档"); - } catch (err) { - message.error((err as Error).message); - } - }; - - const handleRestore = async (id: string) => { - try { - await restoreMutation.mutateAsync(id); - message.success("项目已恢复"); - } catch (err) { - message.error((err as Error).message); - } - }; - - const handleDelete = async (id: string) => { - try { - await deleteMutation.mutateAsync(id); - message.success("项目已永久删除"); - } catch (err) { - message.error((err as Error).message); - } - }; - - const columns: ColumnsType = [ - { dataIndex: "name", ellipsis: true, title: "项目名称", width: 160 }, - { dataIndex: "description", ellipsis: true, title: "项目描述" }, - { - align: "center", - dataIndex: "status", - render: (_value, record: Project) => { - if (record.status === "archived") { - return 已归档; - } - return 进行中; - }, - title: "状态", - width: 100, - }, - { - align: "center", - dataIndex: "createdAt", - render: (_value, record: Project) => formatDatetime(record.createdAt), - title: "创建时间", - width: 185, - }, - { - align: "center", - dataIndex: "updatedAt", - render: (_value, record: Project) => formatDatetime(record.updatedAt), - title: "更新时间", - width: 185, - }, - { - dataIndex: "op", - fixed: "right", - render: (_value, record: Project) => { - if (record.status === "active") { - return ( - - - void handleArchive(record.id)} - title="确认归档此项目?" - > - - - - ); - } - return ( - - void handleRestore(record.id)} title="确认恢复此项目?"> - - - void handleDelete(record.id)} - title="确认永久删除此项目?" - > - - - - ); - }, - title: "操作", - width: 180, - }, - ]; - const isSubmitting = createMutation.isPending || updateMutation.isPending; + const isRowActionPending = archiveMutation.isPending || restoreMutation.isPending || deleteMutation.isPending; return ( - -
- - - setSearchValue(e.target.value)} - onClear={() => { - setKeyword(""); - setSearchValue(""); - setPage(1); - }} - onKeyDown={handleSearchKeydown} - placeholder="搜索项目名称或描述" - value={searchValue} - /> - - {tabValue === "active" && ( - - )} - -
- -
{ - setPage(p); - setPageSize(ps); - }, - pageSize, - total: data?.total ?? 0, + + { + setKeyword(value); + setPage(1); + }} + onSearchClear={() => { + setKeyword(""); + setPage(1); + }} + onTabChange={(key) => { + setTabValue(key as ProjectStatus); + setPage(1); + }} + openCreateDialog={() => { + setEditingProject(null); + setDialogOpen(true); }} - rowKey="id" /> - { - if (open) { - if (editingProject) { - form.setFieldsValue({ description: editingProject.description, name: editingProject.name }); - } else { - form.resetFields(); - } - } + archiveMutation.mutateAsync(id)} + onDelete={(id) => deleteMutation.mutateAsync(id)} + onEdit={(project) => { + setEditingProject(project); + setDialogOpen(true); }} - confirmLoading={isSubmitting} - destroyOnHidden - okText="确定" + onPageChange={(p, ps) => { + setPage(p); + setPageSize(ps); + }} + onRestore={(id) => restoreMutation.mutateAsync(id)} + page={page} + pageSize={pageSize} + /> + + setDialogOpen(false)} - // eslint-disable-next-line @typescript-eslint/no-misused-promises -- handleDialogOk 是 async 但最终返回 void,lint 规则误报 - onOk={handleDialogOk} + onCreate={(data) => createMutation.mutateAsync(data)} + onOpenChange={setDialogOpen} + onUpdate={(args) => updateMutation.mutateAsync(args)} open={dialogOpen} - title={editingProject ? "编辑项目" : "新建项目"} - > -
- - - - - - - -
- + submitting={isSubmitting} + /> +
); } - -function formatDatetime(dateStr: string): string { - const d = new Date(dateStr); - const pad = (n: number) => String(n).padStart(2, "0"); - return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; -} diff --git a/src/web/routes.tsx b/src/web/routes.tsx index 7f053e8..e51b2e5 100644 --- a/src/web/routes.tsx +++ b/src/web/routes.tsx @@ -1,5 +1,3 @@ -import type { ReactNode } from "react"; - import { Route, Routes } from "react-router"; import { NotFoundPage } from "./pages/404"; @@ -15,7 +13,3 @@ export function AppRoutes() { ); } - -export function ProtectedRoute({ children }: { children: ReactNode }) { - return children; -} diff --git a/src/web/styles.css b/src/web/styles.css index 6acaf4e..0c0c3a4 100644 --- a/src/web/styles.css +++ b/src/web/styles.css @@ -1,7 +1,10 @@ +html, +body { + margin: 0; +} + .app-layout { min-height: 100vh; - background: var(--ant-color-bg-layout); - width: 100%; } .app-header { @@ -11,7 +14,6 @@ padding: 0 var(--ant-padding-lg); background: var(--ant-color-bg-container); border-bottom: 1px solid var(--ant-color-border-secondary); - height: 64px; } .app-header-left { @@ -47,75 +49,6 @@ line-height: 1; } -.app-sidebar-collapse-btn { - width: 100%; - justify-content: center; - color: var(--ant-color-text-secondary); -} - -.app-page-title { - color: var(--ant-color-text-secondary); - font-size: var(--ant-font-size-heading-3); - font-weight: 500; -} - -.app-sidebar { - background: var(--ant-color-bg-container); - border-right: 1px solid var(--ant-color-border-secondary); - height: calc(100vh - 64px); - overflow: hidden; -} - -.app-sidebar-menu { - height: 100%; - overflow-y: auto; -} - .app-content { - box-sizing: border-box; padding: var(--ant-padding-xl) var(--ant-padding-xl); - min-height: calc(100vh - 64px); -} - -.meta-response { - background: var(--ant-color-fill-tertiary); - border-radius: var(--ant-border-radius); - padding: var(--ant-padding-lg) var(--ant-padding-lg); - font-size: var(--ant-font-size); - color: var(--ant-color-text); - overflow-x: auto; -} - -.error-boundary-fallback { - padding-top: 20vh; - width: 100%; -} - -.full-width { - width: 100%; -} - -.text-disabled { - color: var(--ant-color-text-disabled); -} - -.full-width-space { - width: 100%; -} - -.not-found-icon { - color: var(--ant-color-warning); - font-size: 64px; -} - -.tabular-nums { - font-variant-numeric: tabular-nums; -} - -.projects-header { - display: flex; - align-items: center; - justify-content: space-between; - flex-wrap: wrap; - gap: var(--ant-margin-lg); } diff --git a/tests/web/App.test.tsx b/tests/web/App.test.tsx index 45a1fda..bb6d2b8 100644 --- a/tests/web/App.test.tsx +++ b/tests/web/App.test.tsx @@ -59,7 +59,7 @@ describe("App", () => { const sider = document.querySelector(".ant-layout-sider"); expect(sider).not.toBeNull(); - const menu = document.querySelector(".app-sidebar-menu"); + const menu = document.querySelector(".ant-menu"); expect(menu).not.toBeNull(); }); }); diff --git a/tests/web/routes/404.test.tsx b/tests/web/routes/404.test.tsx index d962eae..2d119f5 100644 --- a/tests/web/routes/404.test.tsx +++ b/tests/web/routes/404.test.tsx @@ -11,14 +11,13 @@ describe("NotFoundPage", () => { expect(screen.getByText("404")).not.toBeNull(); expect(screen.getByText("您访问的页面不存在")).not.toBeNull(); - expect(screen.getByText("返回首页")).not.toBeNull(); + expect(screen.getByRole("button", { name: "返回首页" })).not.toBeNull(); }); test("返回首页按钮存在且可点击", () => { renderWithProviders(createElement(NotFoundPage)); - const button = screen.getByText("返回首页"); + const button = screen.getByRole("button", { name: "返回首页" }); expect(button).not.toBeNull(); - expect(button.closest("button")).not.toBeNull(); }); }); diff --git a/tests/web/routes/projects.test.tsx b/tests/web/routes/projects.test.tsx index e00e4ab..5ef10e2 100644 --- a/tests/web/routes/projects.test.tsx +++ b/tests/web/routes/projects.test.tsx @@ -11,8 +11,8 @@ describe("ProjectsPage", () => { expect(screen.getByText("进行中")).not.toBeNull(); expect(screen.getByText("已归档")).not.toBeNull(); - expect(screen.getByText("搜索")).not.toBeNull(); expect(screen.getByText("新建项目")).not.toBeNull(); + expect(screen.getByPlaceholderText("搜索项目名称或描述")).not.toBeNull(); await waitFor( () => {