refactor: 前端 UI 框架从 TDesign 迁移到 antd 6.x
- 移除 tdesign-react + tdesign-icons-react,新增 antd@6.4.3 + @ant-design/icons@6.2.3 - Layout/Header/Sider/Content 替换 TDesign Layout,Sider 内置折叠管理 - Segmented 替换 RadioGroup 主题切换,ConfigProvider 主题算法切换 - Menu items prop 模式,Sidebar 简化为无 props 纯组件 - Table/Modal/Form/Input.TextArea/Tabs/Tag/Popconfirm 全量迁移 - App.useApp().message 替换 MessagePlugin(hooks 模式) - --td-* CSS 变量替换为 --ant-* antd CSS 变量 - 测试适配:ConfigProvider+App wrapper,.ant-menu-item-selected,antd CSS-in-JS jsdom 兼容 - 文档更新:frontend.md, development/README.md, config.yaml, deploy.md - vendor-antd chunk 755KB gzipped 240KB
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import { MenuFoldOutlined, MenuUnfoldOutlined } from "@ant-design/icons";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { App as AntApp, ConfigProvider, Layout, Segmented, theme } from "antd";
|
||||
import { useEffect } from "react";
|
||||
import { useLocation } from "react-router";
|
||||
import { Layout, RadioGroup } from "tdesign-react";
|
||||
|
||||
import type { MetaResponse } from "../shared/api";
|
||||
|
||||
@@ -12,7 +13,7 @@ import { type ThemePreference, useThemePreference } from "./hooks/use-theme-pref
|
||||
import { MENU_ITEMS } from "./menu";
|
||||
import { AppRoutes } from "./routes";
|
||||
|
||||
const { Aside, Content, Header } = Layout;
|
||||
const { Content, Header, Sider } = Layout;
|
||||
|
||||
const THEME_OPTIONS = [
|
||||
{ label: "系统", value: "system" },
|
||||
@@ -21,8 +22,8 @@ const THEME_OPTIONS = [
|
||||
] as const;
|
||||
|
||||
export function App() {
|
||||
const { preference: themePreference, setPreference: setThemePreference } = useThemePreference();
|
||||
const { collapsed, toggleCollapsed } = useSidebarCollapsed();
|
||||
const { effectiveTheme, preference: themePreference, setPreference: setThemePreference } = useThemePreference();
|
||||
const { collapsed, setCollapsed } = useSidebarCollapsed();
|
||||
const location = useLocation();
|
||||
const { data: meta } = useQuery({
|
||||
queryFn: fetchMeta,
|
||||
@@ -36,8 +37,8 @@ export function App() {
|
||||
document.querySelector('meta[name="description"]')?.setAttribute("content", APP.description);
|
||||
}, []);
|
||||
|
||||
const handleThemeChange = (value: ThemePreference) => {
|
||||
setThemePreference(value);
|
||||
const handleThemeChange = (value: number | string) => {
|
||||
setThemePreference(value as ThemePreference);
|
||||
};
|
||||
|
||||
const currentPath = location.pathname;
|
||||
@@ -45,37 +46,48 @@ export function App() {
|
||||
const pageTitle = currentItem?.label ?? APP.title;
|
||||
const versionDisplay = meta?.version ? `v${meta.version}` : null;
|
||||
|
||||
const themeAlgorithm = effectiveTheme === "dark" ? theme.darkAlgorithm : theme.defaultAlgorithm;
|
||||
|
||||
return (
|
||||
<Layout className="app-layout">
|
||||
<Header className="app-header">
|
||||
<div className="app-header-left">
|
||||
<span className="app-brand-group">
|
||||
<span className="app-brand">{APP.title}</span>
|
||||
{versionDisplay && <span className="app-version">{versionDisplay}</span>}
|
||||
</span>
|
||||
<span className="app-page-title">{pageTitle}</span>
|
||||
</div>
|
||||
<div className="app-header-right">
|
||||
<RadioGroup
|
||||
onChange={handleThemeChange}
|
||||
options={THEME_OPTIONS.map((option) => ({ label: option.label, value: option.value }))}
|
||||
theme="button"
|
||||
value={themePreference}
|
||||
variant="default-filled"
|
||||
/>
|
||||
</div>
|
||||
</Header>
|
||||
<Layout>
|
||||
<Aside className="app-sidebar" width={collapsed ? "64px" : "232px"}>
|
||||
<Sidebar collapsed={collapsed} onToggleCollapsed={toggleCollapsed} />
|
||||
</Aside>
|
||||
<Layout>
|
||||
<Content className="app-content">
|
||||
<AppRoutes />
|
||||
</Content>
|
||||
<ConfigProvider theme={{ algorithm: themeAlgorithm }}>
|
||||
<AntApp>
|
||||
<Layout className="app-layout">
|
||||
<Header className="app-header">
|
||||
<div className="app-header-left">
|
||||
<span className="app-brand-group">
|
||||
<span className="app-brand">{APP.title}</span>
|
||||
{versionDisplay && <span className="app-version">{versionDisplay}</span>}
|
||||
</span>
|
||||
<span className="app-page-title">{pageTitle}</span>
|
||||
</div>
|
||||
<div className="app-header-right">
|
||||
<Segmented
|
||||
onChange={handleThemeChange}
|
||||
options={THEME_OPTIONS.map((option) => ({ label: option.label, value: option.value }))}
|
||||
value={themePreference}
|
||||
/>
|
||||
</div>
|
||||
</Header>
|
||||
<Layout>
|
||||
<Sider
|
||||
className="app-sidebar"
|
||||
collapsed={collapsed}
|
||||
collapsedWidth={64}
|
||||
onCollapse={(collapsed) => setCollapsed(collapsed)}
|
||||
trigger={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||
width={232}
|
||||
>
|
||||
<Sidebar />
|
||||
</Sider>
|
||||
<Layout>
|
||||
<Content className="app-content">
|
||||
<AppRoutes />
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
</Layout>
|
||||
</Layout>
|
||||
</Layout>
|
||||
</AntApp>
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ErrorInfo, ReactNode } from "react";
|
||||
|
||||
import { Alert, Button, Space } from "antd";
|
||||
import { Component } from "react";
|
||||
import { Alert, Button, Space } from "tdesign-react";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
@@ -25,9 +25,9 @@ export class ErrorBoundary extends Component<Props, State> {
|
||||
override render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<Space align="center" className="error-boundary-fallback" direction="vertical" size="large">
|
||||
<Alert message="页面渲染出现异常,请刷新重试" theme="error" title="页面出错" />
|
||||
<Button onClick={() => window.location.reload()} theme="primary">
|
||||
<Space align="center" className="error-boundary-fallback" size="large" vertical>
|
||||
<Alert showIcon title="页面渲染出现异常,请刷新重试" type="error" />
|
||||
<Button onClick={() => window.location.reload()} type="primary">
|
||||
刷新页面
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
@@ -1,26 +1,28 @@
|
||||
import type { MenuProps } from "antd";
|
||||
|
||||
import { Menu } from "antd";
|
||||
import { useLocation, useNavigate } from "react-router";
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "tdesign-icons-react";
|
||||
import { Button, Menu } from "tdesign-react";
|
||||
|
||||
import { MENU_ITEMS } from "../../menu";
|
||||
|
||||
const { MenuItem } = Menu;
|
||||
type MenuItem = Required<MenuProps>["items"][number];
|
||||
|
||||
interface SidebarProps {
|
||||
collapsed: boolean;
|
||||
onToggleCollapsed: () => void;
|
||||
}
|
||||
|
||||
export function Sidebar({ collapsed, onToggleCollapsed }: SidebarProps) {
|
||||
export function Sidebar() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const currentPath = location.pathname;
|
||||
const currentItem = MENU_ITEMS.find((item) => item.path === currentPath);
|
||||
const activeValue = currentItem?.value ?? "";
|
||||
const selectedKeys = currentItem ? [currentItem.value] : [];
|
||||
|
||||
const handleMenuChange = (value: number | string) => {
|
||||
const item = MENU_ITEMS.find((item) => item.value === value);
|
||||
const menuItems: MenuItem[] = MENU_ITEMS.map((item) => ({
|
||||
icon: item.icon,
|
||||
key: item.value,
|
||||
label: item.label,
|
||||
}));
|
||||
|
||||
const handleMenuClick: MenuProps["onClick"] = ({ key }) => {
|
||||
const item = MENU_ITEMS.find((i) => i.value === key);
|
||||
if (item) {
|
||||
void navigate(item.path);
|
||||
}
|
||||
@@ -29,25 +31,10 @@ export function Sidebar({ collapsed, onToggleCollapsed }: SidebarProps) {
|
||||
return (
|
||||
<Menu
|
||||
className="app-sidebar-menu"
|
||||
collapsed={collapsed}
|
||||
onChange={handleMenuChange}
|
||||
operations={
|
||||
<Button
|
||||
className="app-sidebar-collapse-btn"
|
||||
icon={collapsed ? <ChevronRightIcon /> : <ChevronLeftIcon />}
|
||||
onClick={onToggleCollapsed}
|
||||
shape="square"
|
||||
variant="text"
|
||||
/>
|
||||
}
|
||||
value={activeValue}
|
||||
width={collapsed ? "64px" : "232px"}
|
||||
>
|
||||
{MENU_ITEMS.map((item) => (
|
||||
<MenuItem icon={item.icon} key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
items={menuItems}
|
||||
mode="inline"
|
||||
onClick={handleMenuClick}
|
||||
selectedKeys={selectedKeys}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
export const SIDEBAR_COLLAPSED_STORAGE_KEY = "sidebar.collapsed";
|
||||
|
||||
export function applyInitialSidebarCollapsed() {
|
||||
const collapsed = readSidebarCollapsed();
|
||||
applySidebarCollapsed(collapsed);
|
||||
}
|
||||
|
||||
export function applySidebarCollapsed(collapsed: boolean, root: HTMLElement = document.documentElement) {
|
||||
root.setAttribute("data-sidebar-collapsed", String(collapsed));
|
||||
}
|
||||
|
||||
export function parseSidebarCollapsed(value: unknown): boolean {
|
||||
return value === "true";
|
||||
}
|
||||
@@ -26,10 +17,6 @@ export function readSidebarCollapsed(storage: Storage = window.localStorage): bo
|
||||
export function useSidebarCollapsed() {
|
||||
const [collapsed, setCollapsedState] = useState<boolean>(() => readSidebarCollapsed());
|
||||
|
||||
useEffect(() => {
|
||||
applySidebarCollapsed(collapsed);
|
||||
}, [collapsed]);
|
||||
|
||||
const setCollapsed = (nextCollapsed: boolean) => {
|
||||
setCollapsedState(nextCollapsed);
|
||||
writeSidebarCollapsed(nextCollapsed);
|
||||
|
||||
@@ -6,14 +6,6 @@ export type ThemePreference = "dark" | "light" | "system";
|
||||
export const THEME_PREFERENCE_STORAGE_KEY = "theme.preference";
|
||||
export const THEME_MEDIA_QUERY = "(prefers-color-scheme: dark)";
|
||||
|
||||
export function applyInitialThemePreference() {
|
||||
applyThemeMode(resolveEffectiveTheme(readThemePreference(), getSystemPrefersDark()));
|
||||
}
|
||||
|
||||
export function applyThemeMode(theme: EffectiveTheme, root: HTMLElement = document.documentElement) {
|
||||
root.setAttribute("theme-mode", theme);
|
||||
}
|
||||
|
||||
export function getSystemPrefersDark(matchMedia: Window["matchMedia"] = window.matchMedia): boolean {
|
||||
try {
|
||||
return matchMedia(THEME_MEDIA_QUERY).matches;
|
||||
@@ -44,10 +36,6 @@ export function useThemePreference() {
|
||||
const [systemPrefersDark, setSystemPrefersDark] = useState(() => getSystemPrefersDark());
|
||||
const effectiveTheme = resolveEffectiveTheme(preference, systemPrefersDark);
|
||||
|
||||
useEffect(() => {
|
||||
applyThemeMode(effectiveTheme);
|
||||
}, [effectiveTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
const mediaQueryList = window.matchMedia(THEME_MEDIA_QUERY);
|
||||
|
||||
|
||||
@@ -5,12 +5,6 @@ import { createRoot } from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router";
|
||||
|
||||
import { App } from "./app";
|
||||
import { ErrorBoundary } from "./components/ErrorBoundary";
|
||||
import { applyInitialSidebarCollapsed } from "./hooks/use-sidebar-collapsed";
|
||||
import { applyInitialThemePreference } from "./hooks/use-theme-preference";
|
||||
import "tdesign-react/dist/reset.css";
|
||||
import "tdesign-react/dist/tdesign.min.css";
|
||||
|
||||
import "./styles.css";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
@@ -29,18 +23,13 @@ if (!rootElement) {
|
||||
throw new Error("找不到前端挂载节点 #root");
|
||||
}
|
||||
|
||||
applyInitialThemePreference();
|
||||
applyInitialSidebarCollapsed();
|
||||
|
||||
createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
<ErrorBoundary>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</QueryClientProvider>
|
||||
</ErrorBoundary>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import type { ReactElement } from "react";
|
||||
import type { MenuValue } from "tdesign-react";
|
||||
|
||||
import { DashboardOutlined, FolderOutlined } from "@ant-design/icons";
|
||||
import { createElement } from "react";
|
||||
import { DashboardIcon, FolderIcon } from "tdesign-icons-react";
|
||||
|
||||
export interface MenuItemConfig {
|
||||
icon: ReactElement;
|
||||
label: string;
|
||||
path: string;
|
||||
value: MenuValue;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export const MENU_ITEMS: readonly MenuItemConfig[] = [
|
||||
{ icon: createElement(DashboardIcon), label: "仪表盘", path: "/", value: "dashboard" },
|
||||
{ icon: createElement(FolderIcon), label: "项目管理", path: "/projects", value: "projects" },
|
||||
{ icon: createElement(DashboardOutlined), label: "仪表盘", path: "/", value: "dashboard" },
|
||||
{ icon: createElement(FolderOutlined), label: "项目管理", path: "/projects", value: "projects" },
|
||||
] as const;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ExclamationCircleOutlined } from "@ant-design/icons";
|
||||
import { Button, Space } from "antd";
|
||||
import { useNavigate } from "react-router";
|
||||
import { ErrorCircleIcon } from "tdesign-icons-react";
|
||||
import { Button, Space } from "tdesign-react";
|
||||
|
||||
export function NotFoundPage() {
|
||||
const navigate = useNavigate();
|
||||
@@ -10,11 +10,11 @@ export function NotFoundPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<Space align="center" className="not-found-page" direction="vertical" size="large">
|
||||
<ErrorCircleIcon className="not-found-icon" size="64px" />
|
||||
<Space align="center" className="not-found-page" size="large" vertical>
|
||||
<ExclamationCircleOutlined className="not-found-icon" />
|
||||
<h1>404</h1>
|
||||
<p>您访问的页面不存在</p>
|
||||
<Button onClick={handleGoHome} theme="primary">
|
||||
<Button onClick={handleGoHome} type="primary">
|
||||
返回首页
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Space } from "tdesign-react";
|
||||
import { Space } from "antd";
|
||||
|
||||
import type { MetaResponse } from "../../../shared/api";
|
||||
|
||||
@@ -14,7 +14,7 @@ export function DashboardPage() {
|
||||
});
|
||||
|
||||
return (
|
||||
<Space className="full-width-space" direction="vertical" size="large">
|
||||
<Space className="full-width-space" size="large" vertical>
|
||||
<h2>欢迎使用 {APP.title}</h2>
|
||||
<p>在此构建你的应用。以下是 /api/meta 的返回数据(前后端联调示例):</p>
|
||||
{meta && <pre className="meta-response">{JSON.stringify(meta, null, 2)}</pre>}
|
||||
|
||||
@@ -1,21 +1,15 @@
|
||||
import type { PrimaryTableCellParams, PrimaryTableCol } from "tdesign-react";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
|
||||
import { useState } from "react";
|
||||
import { AddIcon, BrowseIcon, DeleteIcon, EditIcon, SearchIcon } from "tdesign-icons-react";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
Form,
|
||||
Input,
|
||||
Loading,
|
||||
MessagePlugin,
|
||||
Popconfirm,
|
||||
Space,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
Textarea,
|
||||
} from "tdesign-react";
|
||||
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 { useState } from "react";
|
||||
|
||||
import type { CreateProjectRequest, Project, ProjectStatus, UpdateProjectRequest } from "../../../shared/api";
|
||||
|
||||
@@ -28,23 +22,28 @@ import {
|
||||
useUpdateProject,
|
||||
} from "../../hooks/use-projects";
|
||||
|
||||
const { useForm } = Form;
|
||||
|
||||
const STATUS_TABS = [
|
||||
{ label: "进行中", value: "active" },
|
||||
{ label: "已归档", value: "archived" },
|
||||
const STATUS_TAB_ITEMS = [
|
||||
{ key: "active", label: "进行中" },
|
||||
{ key: "archived", label: "已归档" },
|
||||
];
|
||||
|
||||
interface FormValues {
|
||||
description?: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export function ProjectsPage() {
|
||||
const { message } = AntApp.useApp();
|
||||
|
||||
const [tabValue, setTabValue] = useState<ProjectStatus>("active");
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
|
||||
const [dialogVisible, setDialogVisible] = useState(false);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingProject, setEditingProject] = useState<null | Project>(null);
|
||||
const [form] = useForm();
|
||||
const [form] = Form.useForm<FormValues>();
|
||||
|
||||
const { data, isLoading } = useProjectList({ keyword: keyword || undefined, page, pageSize, status: tabValue });
|
||||
const createMutation = useCreateProject();
|
||||
@@ -58,132 +57,121 @@ export function ProjectsPage() {
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handleSearchKeydown = (_value: string, context: { e: React.KeyboardEvent<HTMLDivElement> }) => {
|
||||
if (context.e.key === "Enter") {
|
||||
const handleSearchKeydown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
handleSearch();
|
||||
}
|
||||
};
|
||||
|
||||
const handleTabChange = (value: number | string) => {
|
||||
setTabValue(value as ProjectStatus);
|
||||
const handleTabChange = (key: string) => {
|
||||
setTabValue(key as ProjectStatus);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const openCreateDialog = () => {
|
||||
setEditingProject(null);
|
||||
setDialogVisible(true);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const openEditDialog = (project: Project) => {
|
||||
setEditingProject(project);
|
||||
setDialogVisible(true);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDialogConfirm = async () => {
|
||||
const valid = await form?.validate?.();
|
||||
if (valid !== true) return;
|
||||
|
||||
const values = form?.getFieldsValue?.(true) as { description?: string; name: string };
|
||||
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 });
|
||||
void MessagePlugin.success("项目已更新");
|
||||
message.success("项目已更新");
|
||||
} else {
|
||||
const reqData: CreateProjectRequest = { description: values.description, name: values.name };
|
||||
await createMutation.mutateAsync(reqData);
|
||||
void MessagePlugin.success("项目已创建");
|
||||
message.success("项目已创建");
|
||||
}
|
||||
setDialogVisible(false);
|
||||
setDialogOpen(false);
|
||||
} catch (err) {
|
||||
void MessagePlugin.error((err as Error).message);
|
||||
if (err instanceof Error) {
|
||||
message.error(err.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleArchive = async (id: string) => {
|
||||
try {
|
||||
await archiveMutation.mutateAsync(id);
|
||||
void MessagePlugin.success("项目已归档");
|
||||
message.success("项目已归档");
|
||||
} catch (err) {
|
||||
void MessagePlugin.error((err as Error).message);
|
||||
message.error((err as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestore = async (id: string) => {
|
||||
try {
|
||||
await restoreMutation.mutateAsync(id);
|
||||
void MessagePlugin.success("项目已恢复");
|
||||
message.success("项目已恢复");
|
||||
} catch (err) {
|
||||
void MessagePlugin.error((err as Error).message);
|
||||
message.error((err as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteMutation.mutateAsync(id);
|
||||
void MessagePlugin.success("项目已永久删除");
|
||||
message.success("项目已永久删除");
|
||||
} catch (err) {
|
||||
void MessagePlugin.error((err as Error).message);
|
||||
message.error((err as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
const columns: Array<PrimaryTableCol<Project>> = [
|
||||
{ colKey: "name", ellipsis: true, title: "项目名称", width: 160 },
|
||||
{ colKey: "description", ellipsis: true, title: "项目描述" },
|
||||
const columns: ColumnsType<Project> = [
|
||||
{ dataIndex: "name", ellipsis: true, title: "项目名称", width: 160 },
|
||||
{ dataIndex: "description", ellipsis: true, title: "项目描述" },
|
||||
{
|
||||
align: "center",
|
||||
cell: (params: PrimaryTableCellParams<Project>) => {
|
||||
const { row } = params;
|
||||
if (row.status === "archived") {
|
||||
return (
|
||||
<Tag theme="default" variant="light">
|
||||
已归档
|
||||
</Tag>
|
||||
);
|
||||
dataIndex: "status",
|
||||
render: (_value, record: Project) => {
|
||||
if (record.status === "archived") {
|
||||
return <Tag>已归档</Tag>;
|
||||
}
|
||||
return (
|
||||
<Tag theme="primary" variant="light">
|
||||
进行中
|
||||
</Tag>
|
||||
);
|
||||
return <Tag color="blue">进行中</Tag>;
|
||||
},
|
||||
colKey: "status",
|
||||
title: "状态",
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
align: "center",
|
||||
cell: (params: PrimaryTableCellParams<Project>) => formatDatetime(params.row.createdAt),
|
||||
colKey: "createdAt",
|
||||
dataIndex: "createdAt",
|
||||
render: (_value, record: Project) => formatDatetime(record.createdAt),
|
||||
title: "创建时间",
|
||||
width: 185,
|
||||
},
|
||||
{
|
||||
align: "center",
|
||||
cell: (params: PrimaryTableCellParams<Project>) => formatDatetime(params.row.updatedAt),
|
||||
colKey: "updatedAt",
|
||||
dataIndex: "updatedAt",
|
||||
render: (_value, record: Project) => formatDatetime(record.updatedAt),
|
||||
title: "更新时间",
|
||||
width: 185,
|
||||
},
|
||||
{
|
||||
cell: (params: PrimaryTableCellParams<Project>) => {
|
||||
const { row } = params;
|
||||
if (row.status === "active") {
|
||||
dataIndex: "op",
|
||||
fixed: "right",
|
||||
render: (_value, record: Project) => {
|
||||
if (record.status === "active") {
|
||||
return (
|
||||
<Space size="small">
|
||||
<Button
|
||||
icon={<EditIcon />}
|
||||
onClick={() => openEditDialog(row)}
|
||||
size="small"
|
||||
theme="primary"
|
||||
variant="text"
|
||||
>
|
||||
<Button icon={<EditOutlined />} onClick={() => openEditDialog(record)} size="small" type="link">
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm content="确认归档此项目?归档后项目将变为只读。" onConfirm={() => void handleArchive(row.id)}>
|
||||
<Button icon={<BrowseIcon />} size="small" theme="warning" variant="text">
|
||||
<Popconfirm
|
||||
description="归档后项目将变为只读。"
|
||||
onConfirm={() => void handleArchive(record.id)}
|
||||
title="确认归档此项目?"
|
||||
>
|
||||
<Button icon={<InboxOutlined />} size="small" type="link">
|
||||
归档
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
@@ -192,21 +180,23 @@ export function ProjectsPage() {
|
||||
}
|
||||
return (
|
||||
<Space size="small">
|
||||
<Popconfirm content="确认恢复此项目?" onConfirm={() => void handleRestore(row.id)}>
|
||||
<Button icon={<BrowseIcon />} size="small" theme="success" variant="text">
|
||||
<Popconfirm onConfirm={() => void handleRestore(record.id)} title="确认恢复此项目?">
|
||||
<Button icon={<RedoOutlined />} size="small" type="link">
|
||||
恢复
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
<Popconfirm content="确认永久删除此项目?此操作不可恢复。" onConfirm={() => void handleDelete(row.id)}>
|
||||
<Button icon={<DeleteIcon />} size="small" theme="danger" variant="text">
|
||||
<Popconfirm
|
||||
description="此操作不可恢复。"
|
||||
onConfirm={() => void handleDelete(record.id)}
|
||||
title="确认永久删除此项目?"
|
||||
>
|
||||
<Button danger icon={<DeleteOutlined />} size="small" type="link">
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
colKey: "op",
|
||||
fixed: "right",
|
||||
title: "操作",
|
||||
width: 180,
|
||||
},
|
||||
@@ -215,81 +205,77 @@ export function ProjectsPage() {
|
||||
const isSubmitting = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
return (
|
||||
<Space className="full-width-space" direction="vertical" size="large">
|
||||
<Space className="full-width-space" size="large" vertical>
|
||||
<div className="projects-header">
|
||||
<Tabs list={STATUS_TABS} onChange={handleTabChange} value={tabValue} />
|
||||
<Tabs activeKey={tabValue} items={STATUS_TAB_ITEMS} onChange={handleTabChange} />
|
||||
<Space>
|
||||
<Input
|
||||
clearable
|
||||
onChange={setSearchValue}
|
||||
allowClear
|
||||
onChange={(e) => setSearchValue(e.target.value)}
|
||||
onClear={() => {
|
||||
setKeyword("");
|
||||
setSearchValue("");
|
||||
setPage(1);
|
||||
}}
|
||||
onKeydown={handleSearchKeydown}
|
||||
onKeyDown={handleSearchKeydown}
|
||||
placeholder="搜索项目名称或描述"
|
||||
value={searchValue}
|
||||
/>
|
||||
<Button icon={<SearchIcon />} onClick={handleSearch} theme="default">
|
||||
<Button icon={<SearchOutlined />} onClick={handleSearch}>
|
||||
搜索
|
||||
</Button>
|
||||
{tabValue === "active" && (
|
||||
<Button icon={<AddIcon />} onClick={openCreateDialog} theme="primary">
|
||||
<Button icon={<PlusOutlined />} onClick={openCreateDialog} type="primary">
|
||||
新建项目
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<Loading />
|
||||
) : (
|
||||
<Table
|
||||
columns={columns}
|
||||
data={data?.items ?? []}
|
||||
loading={archiveMutation.isPending || restoreMutation.isPending || deleteMutation.isPending}
|
||||
pagination={{
|
||||
current: page,
|
||||
onChange: (info: unknown) => {
|
||||
const p = info as { current: number; pageSize: number };
|
||||
setPage(p.current);
|
||||
setPageSize(p.pageSize);
|
||||
},
|
||||
pageSize,
|
||||
total: data?.total ?? 0,
|
||||
}}
|
||||
rowKey="id"
|
||||
/>
|
||||
)}
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
loading={isLoading || archiveMutation.isPending || restoreMutation.isPending || deleteMutation.isPending}
|
||||
pagination={{
|
||||
current: page,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
},
|
||||
pageSize,
|
||||
total: data?.total ?? 0,
|
||||
}}
|
||||
rowKey="id"
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
closeOnOverlayClick={false}
|
||||
confirmBtn={{ content: "确定", loading: isSubmitting, theme: "primary" }}
|
||||
destroyOnClose
|
||||
header={editingProject ? "编辑项目" : "新建项目"}
|
||||
onCancel={() => setDialogVisible(false)}
|
||||
onClose={() => setDialogVisible(false)}
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- handleDialogConfirm 是 async 但最终返回 void,lint 规则误报
|
||||
onConfirm={handleDialogConfirm}
|
||||
onOpened={() => {
|
||||
if (editingProject) {
|
||||
void form?.setFieldsValue?.({ description: editingProject.description, name: editingProject.name });
|
||||
} else {
|
||||
form?.reset?.();
|
||||
<Modal
|
||||
afterOpenChange={(open) => {
|
||||
if (open) {
|
||||
if (editingProject) {
|
||||
form.setFieldsValue({ description: editingProject.description, name: editingProject.name });
|
||||
} else {
|
||||
form.resetFields();
|
||||
}
|
||||
}
|
||||
}}
|
||||
visible={dialogVisible}
|
||||
confirmLoading={isSubmitting}
|
||||
destroyOnHidden
|
||||
okText="确定"
|
||||
onCancel={() => setDialogOpen(false)}
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- handleDialogOk 是 async 但最终返回 void,lint 规则误报
|
||||
onOk={handleDialogOk}
|
||||
open={dialogOpen}
|
||||
title={editingProject ? "编辑项目" : "新建项目"}
|
||||
>
|
||||
<Form form={form} labelAlign="top" resetType="initial">
|
||||
<Form.FormItem label="项目名称" name="name" rules={[{ message: "项目名称不能为空", required: true }]}>
|
||||
<Input maxlength={100} placeholder="请输入项目名称" />
|
||||
</Form.FormItem>
|
||||
<Form.FormItem label="项目描述" name="description">
|
||||
<Textarea autosize={{ minRows: 5 }} maxlength={500} placeholder="请输入项目描述" />
|
||||
</Form.FormItem>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item label="项目名称" name="name" rules={[{ message: "项目名称不能为空", required: true }]}>
|
||||
<Input maxLength={100} placeholder="请输入项目名称" />
|
||||
</Form.Item>
|
||||
<Form.Item label="项目描述" name="description">
|
||||
<Input.TextArea autoSize={{ minRows: 5 }} maxLength={500} placeholder="请输入项目描述" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Dialog>
|
||||
</Modal>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
:root {
|
||||
--td-brand-color: var(--td-brand-color-7);
|
||||
}
|
||||
|
||||
.app-layout {
|
||||
min-height: 100vh;
|
||||
background: var(--td-bg-color-page);
|
||||
background: var(--ant-color-bg-layout);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -12,41 +8,41 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 var(--td-comp-paddingLR-l);
|
||||
background: var(--td-bg-color-container);
|
||||
border-bottom: 1px solid var(--td-component-border);
|
||||
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 {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--td-comp-margin-l);
|
||||
gap: var(--ant-margin-lg);
|
||||
}
|
||||
|
||||
.app-header-right {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--td-comp-margin-s);
|
||||
gap: var(--ant-margin-sm);
|
||||
}
|
||||
|
||||
.app-brand-group {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: var(--td-comp-margin-s);
|
||||
gap: var(--ant-margin-sm);
|
||||
}
|
||||
|
||||
.app-brand {
|
||||
margin: 0;
|
||||
color: var(--td-text-color-primary);
|
||||
font-size: calc(var(--td-font-size-title-large) + 6px);
|
||||
color: var(--ant-color-text);
|
||||
font-size: calc(var(--ant-font-size-heading-1) - 6px);
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.app-version {
|
||||
color: var(--td-text-color-placeholder);
|
||||
font-size: var(--td-font-size-body-small);
|
||||
color: var(--ant-color-text-quaternary);
|
||||
font-size: var(--ant-font-size-sm);
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
}
|
||||
@@ -54,38 +50,39 @@
|
||||
.app-sidebar-collapse-btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
color: var(--td-text-color-secondary);
|
||||
color: var(--ant-color-text-secondary);
|
||||
}
|
||||
|
||||
.app-page-title {
|
||||
color: var(--td-text-color-secondary);
|
||||
font-size: var(--td-font-size-title-medium);
|
||||
color: var(--ant-color-text-secondary);
|
||||
font-size: var(--ant-font-size-heading-3);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.app-sidebar {
|
||||
background: var(--td-bg-color-container);
|
||||
border-right: 1px solid var(--td-component-border);
|
||||
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(--td-comp-paddingTB-xl) var(--td-comp-paddingLR-xl);
|
||||
padding: var(--ant-padding-xl) var(--ant-padding-xl);
|
||||
min-height: calc(100vh - 64px);
|
||||
}
|
||||
|
||||
.meta-response {
|
||||
background: var(--td-bg-color-component);
|
||||
border-radius: var(--td-radius-default);
|
||||
padding: var(--td-comp-paddingTB-l) var(--td-comp-paddingLR-l);
|
||||
font-size: var(--td-font-size-body-medium);
|
||||
color: var(--td-text-color-primary);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -99,7 +96,7 @@
|
||||
}
|
||||
|
||||
.text-disabled {
|
||||
color: var(--td-text-color-disabled);
|
||||
color: var(--ant-color-text-disabled);
|
||||
}
|
||||
|
||||
.full-width-space {
|
||||
@@ -107,7 +104,8 @@
|
||||
}
|
||||
|
||||
.not-found-icon {
|
||||
color: var(--td-warning-color);
|
||||
color: var(--ant-color-warning);
|
||||
font-size: 64px;
|
||||
}
|
||||
|
||||
.tabular-nums {
|
||||
@@ -119,5 +117,5 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--td-comp-margin-l);
|
||||
gap: var(--ant-margin-lg);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user