feat: 增加项目管理功能
引入 SQLite 数据库(Drizzle ORM + bun:sqlite),实现项目 CRUD 与归档/恢复/删除 生命周期管理,新增项目管理前端页面,migration 嵌入单文件构建产物保持部署体验。 - src/server/db: schema、connection、migration 执行器、项目数据访问层 - src/server/routes/projects: 7 个 API 端点(列表/创建/详情/更新/归档/恢复/删除) - src/web: 项目管理页面(TDesign Table/Tabs/Dialog/Form),TanStack Query hooks - scripts: 构建时嵌入 migration SQL,开发期独立 generate-migrations-data 脚本 - tests: 60 个后端测试 + 27 个前端测试,覆盖 DB/migration/API/路由/页面 - docs: 更新架构、后端、发布、配置、部署、使用文档
This commit is contained in:
158
src/web/hooks/use-projects.ts
Normal file
158
src/web/hooks/use-projects.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import type {
|
||||
CreateProjectRequest,
|
||||
Project,
|
||||
ProjectListResponse,
|
||||
ProjectStatus,
|
||||
UpdateProjectRequest,
|
||||
} from "../../shared/api";
|
||||
|
||||
const PROJECTS_KEY = ["projects"] as const;
|
||||
|
||||
export function useArchiveProject() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: archiveProject,
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: PROJECTS_KEY });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateProject() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: createProject,
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: PROJECTS_KEY });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteProject() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: deleteProject,
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: PROJECTS_KEY });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useProject(id: string) {
|
||||
return useQuery({
|
||||
enabled: !!id,
|
||||
queryFn: () => fetchProject(id),
|
||||
queryKey: [...PROJECTS_KEY, "detail", id],
|
||||
});
|
||||
}
|
||||
|
||||
export function useProjectList(params: { keyword?: string; page?: number; pageSize?: number; status?: ProjectStatus }) {
|
||||
return useQuery({
|
||||
queryFn: () => fetchProjectList(params),
|
||||
queryKey: [...PROJECTS_KEY, "list", params],
|
||||
});
|
||||
}
|
||||
|
||||
export function useRestoreProject() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: restoreProject,
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: PROJECTS_KEY });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateProject() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (args: { data: UpdateProjectRequest; id: string }) => updateProject(args.id, args.data),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: PROJECTS_KEY });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function archiveProject(id: string): Promise<Project> {
|
||||
const response = await fetch(`/api/projects/${id}/archive`, { method: "POST" });
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => null)) as null | { error?: string };
|
||||
throw new Error(body?.error ?? `HTTP ${response.status}`);
|
||||
}
|
||||
return response.json() as Promise<Project>;
|
||||
}
|
||||
|
||||
async function createProject(data: CreateProjectRequest): Promise<Project> {
|
||||
const response = await fetch("/api/projects", {
|
||||
body: JSON.stringify(data),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => null)) as null | { error?: string };
|
||||
throw new Error(body?.error ?? `HTTP ${response.status}`);
|
||||
}
|
||||
return response.json() as Promise<Project>;
|
||||
}
|
||||
|
||||
async function deleteProject(id: string): Promise<void> {
|
||||
const response = await fetch(`/api/projects/${id}`, { method: "DELETE" });
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => null)) as null | { error?: string };
|
||||
throw new Error(body?.error ?? `HTTP ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchProject(id: string): Promise<Project> {
|
||||
const response = await fetch(`/api/projects/${id}`);
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => null)) as null | { error?: string };
|
||||
throw new Error(body?.error ?? `HTTP ${response.status}`);
|
||||
}
|
||||
return response.json() as Promise<Project>;
|
||||
}
|
||||
|
||||
async function fetchProjectList(params: {
|
||||
keyword?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
status?: ProjectStatus;
|
||||
}): Promise<ProjectListResponse> {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params.page) searchParams.set("page", String(params.page));
|
||||
if (params.pageSize) searchParams.set("pageSize", String(params.pageSize));
|
||||
if (params.keyword) searchParams.set("keyword", params.keyword);
|
||||
if (params.status) searchParams.set("status", params.status);
|
||||
const qs = searchParams.toString();
|
||||
const url = `/api/projects${qs ? `?${qs}` : ""}`;
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => null)) as null | { error?: string };
|
||||
throw new Error(body?.error ?? `HTTP ${response.status}`);
|
||||
}
|
||||
return response.json() as Promise<ProjectListResponse>;
|
||||
}
|
||||
|
||||
async function restoreProject(id: string): Promise<Project> {
|
||||
const response = await fetch(`/api/projects/${id}/restore`, { method: "POST" });
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => null)) as null | { error?: string };
|
||||
throw new Error(body?.error ?? `HTTP ${response.status}`);
|
||||
}
|
||||
return response.json() as Promise<Project>;
|
||||
}
|
||||
|
||||
async function updateProject(id: string, data: UpdateProjectRequest): Promise<Project> {
|
||||
const response = await fetch(`/api/projects/${id}`, {
|
||||
body: JSON.stringify(data),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "PATCH",
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => null)) as null | { error?: string };
|
||||
throw new Error(body?.error ?? `HTTP ${response.status}`);
|
||||
}
|
||||
return response.json() as Promise<Project>;
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import type { ReactElement } from "react";
|
||||
import type { MenuValue } from "tdesign-react";
|
||||
|
||||
import { createElement } from "react";
|
||||
import { DashboardIcon, SettingIcon, UserIcon } from "tdesign-icons-react";
|
||||
import { DashboardIcon, FolderIcon, SettingIcon, UserIcon } from "tdesign-icons-react";
|
||||
|
||||
export interface MenuItemConfig {
|
||||
icon: ReactElement;
|
||||
@@ -13,6 +13,7 @@ export interface MenuItemConfig {
|
||||
|
||||
export const MENU_ITEMS: readonly MenuItemConfig[] = [
|
||||
{ icon: createElement(DashboardIcon), label: "仪表盘", path: "/", value: "dashboard" },
|
||||
{ icon: createElement(FolderIcon), label: "项目管理", path: "/projects", value: "projects" },
|
||||
{ icon: createElement(UserIcon), label: "用户管理", path: "/users", value: "users" },
|
||||
{ icon: createElement(SettingIcon), label: "系统设置", path: "/settings", value: "settings" },
|
||||
] as const;
|
||||
|
||||
301
src/web/pages/projects/index.tsx
Normal file
301
src/web/pages/projects/index.tsx
Normal file
@@ -0,0 +1,301 @@
|
||||
import type { PrimaryTableCellParams, PrimaryTableCol } from "tdesign-react";
|
||||
|
||||
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";
|
||||
|
||||
import type { CreateProjectRequest, Project, ProjectStatus, UpdateProjectRequest } from "../../../shared/api";
|
||||
|
||||
import {
|
||||
useArchiveProject,
|
||||
useCreateProject,
|
||||
useDeleteProject,
|
||||
useProjectList,
|
||||
useRestoreProject,
|
||||
useUpdateProject,
|
||||
} from "../../hooks/use-projects";
|
||||
|
||||
const { useForm } = Form;
|
||||
|
||||
const STATUS_TABS = [
|
||||
{ label: "进行中", value: "active" },
|
||||
{ label: "已归档", value: "archived" },
|
||||
];
|
||||
|
||||
export function ProjectsPage() {
|
||||
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 [editingProject, setEditingProject] = useState<null | Project>(null);
|
||||
const [form] = useForm();
|
||||
|
||||
const { data, isLoading } = useProjectList({ keyword: keyword || undefined, page, pageSize, status: tabValue });
|
||||
const createMutation = useCreateProject();
|
||||
const updateMutation = useUpdateProject();
|
||||
const archiveMutation = useArchiveProject();
|
||||
const restoreMutation = useRestoreProject();
|
||||
const deleteMutation = useDeleteProject();
|
||||
|
||||
const handleSearch = () => {
|
||||
setKeyword(searchValue);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handleSearchKeydown = (_value: string, context: { e: React.KeyboardEvent<HTMLDivElement> }) => {
|
||||
if (context.e.key === "Enter") {
|
||||
handleSearch();
|
||||
}
|
||||
};
|
||||
|
||||
const handleTabChange = (value: number | string) => {
|
||||
setTabValue(value as ProjectStatus);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const openCreateDialog = () => {
|
||||
setEditingProject(null);
|
||||
setDialogVisible(true);
|
||||
};
|
||||
|
||||
const openEditDialog = (project: Project) => {
|
||||
setEditingProject(project);
|
||||
setDialogVisible(true);
|
||||
};
|
||||
|
||||
const handleDialogConfirm = async () => {
|
||||
const valid = await form?.validate?.();
|
||||
if (valid !== true) return;
|
||||
|
||||
const values = form?.getFieldsValue?.(true) as { description?: string; name: string };
|
||||
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 updateMutation.mutateAsync({ data: reqData, id: editingProject.id });
|
||||
void MessagePlugin.success("项目已更新");
|
||||
} else {
|
||||
const reqData: CreateProjectRequest = { description: values.description, name: values.name };
|
||||
await createMutation.mutateAsync(reqData);
|
||||
void MessagePlugin.success("项目已创建");
|
||||
}
|
||||
setDialogVisible(false);
|
||||
} catch (err) {
|
||||
void MessagePlugin.error((err as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleArchive = async (id: string) => {
|
||||
try {
|
||||
await archiveMutation.mutateAsync(id);
|
||||
void MessagePlugin.success("项目已归档");
|
||||
} catch (err) {
|
||||
void MessagePlugin.error((err as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestore = async (id: string) => {
|
||||
try {
|
||||
await restoreMutation.mutateAsync(id);
|
||||
void MessagePlugin.success("项目已恢复");
|
||||
} catch (err) {
|
||||
void MessagePlugin.error((err as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteMutation.mutateAsync(id);
|
||||
void MessagePlugin.success("项目已永久删除");
|
||||
} catch (err) {
|
||||
void MessagePlugin.error((err as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
const columns: Array<PrimaryTableCol<Project>> = [
|
||||
{ colKey: "name", ellipsis: true, title: "项目名称", width: 160 },
|
||||
{ colKey: "description", ellipsis: true, title: "项目描述" },
|
||||
{
|
||||
align: "center",
|
||||
cell: (params: PrimaryTableCellParams<Project>) => {
|
||||
const { row } = params;
|
||||
if (row.status === "archived") {
|
||||
return (
|
||||
<Tag theme="default" variant="light">
|
||||
已归档
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Tag theme="primary" variant="light">
|
||||
进行中
|
||||
</Tag>
|
||||
);
|
||||
},
|
||||
colKey: "status",
|
||||
title: "状态",
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
align: "center",
|
||||
cell: (params: PrimaryTableCellParams<Project>) => formatDatetime(params.row.createdAt),
|
||||
colKey: "createdAt",
|
||||
title: "创建时间",
|
||||
width: 185,
|
||||
},
|
||||
{
|
||||
align: "center",
|
||||
cell: (params: PrimaryTableCellParams<Project>) => formatDatetime(params.row.updatedAt),
|
||||
colKey: "updatedAt",
|
||||
title: "更新时间",
|
||||
width: 185,
|
||||
},
|
||||
{
|
||||
cell: (params: PrimaryTableCellParams<Project>) => {
|
||||
const { row } = params;
|
||||
if (row.status === "active") {
|
||||
return (
|
||||
<Space size="small">
|
||||
<Button
|
||||
icon={<EditIcon />}
|
||||
onClick={() => openEditDialog(row)}
|
||||
size="small"
|
||||
theme="primary"
|
||||
variant="text"
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm content="确认归档此项目?归档后项目将变为只读。" onConfirm={() => void handleArchive(row.id)}>
|
||||
<Button icon={<BrowseIcon />} size="small" theme="warning" variant="text">
|
||||
归档
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Space size="small">
|
||||
<Popconfirm content="确认恢复此项目?" onConfirm={() => void handleRestore(row.id)}>
|
||||
<Button icon={<BrowseIcon />} size="small" theme="success" variant="text">
|
||||
恢复
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
<Popconfirm content="确认永久删除此项目?此操作不可恢复。" onConfirm={() => void handleDelete(row.id)}>
|
||||
<Button icon={<DeleteIcon />} size="small" theme="danger" variant="text">
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
colKey: "op",
|
||||
fixed: "right",
|
||||
title: "操作",
|
||||
width: 180,
|
||||
},
|
||||
];
|
||||
|
||||
const isSubmitting = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
return (
|
||||
<Space className="full-width-space" direction="vertical" size="large">
|
||||
<div className="projects-header">
|
||||
<Tabs list={STATUS_TABS} onChange={handleTabChange} value={tabValue} />
|
||||
<Space>
|
||||
<Input
|
||||
clearable
|
||||
onChange={setSearchValue}
|
||||
onClear={() => {
|
||||
setKeyword("");
|
||||
setSearchValue("");
|
||||
setPage(1);
|
||||
}}
|
||||
onKeydown={handleSearchKeydown}
|
||||
placeholder="搜索项目名称或描述"
|
||||
value={searchValue}
|
||||
/>
|
||||
<Button icon={<SearchIcon />} onClick={handleSearch} theme="default">
|
||||
搜索
|
||||
</Button>
|
||||
{tabValue === "active" && (
|
||||
<Button icon={<AddIcon />} onClick={openCreateDialog} theme="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"
|
||||
/>
|
||||
)}
|
||||
|
||||
<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?.();
|
||||
}
|
||||
}}
|
||||
visible={dialogVisible}
|
||||
>
|
||||
<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>
|
||||
</Dialog>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
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())}`;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { Route, Routes } from "react-router";
|
||||
|
||||
import { NotFoundPage } from "./pages/404";
|
||||
import { DashboardPage } from "./pages/dashboard";
|
||||
import { ProjectsPage } from "./pages/projects";
|
||||
import { SettingsPage } from "./pages/settings";
|
||||
import { UsersPage } from "./pages/users";
|
||||
|
||||
@@ -11,6 +12,7 @@ export function AppRoutes() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<DashboardPage />} path="/" />
|
||||
<Route element={<ProjectsPage />} path="/projects" />
|
||||
<Route element={<UsersPage />} path="/users" />
|
||||
<Route element={<SettingsPage />} path="/settings" />
|
||||
<Route element={<NotFoundPage />} path="*" />
|
||||
|
||||
@@ -113,3 +113,11 @@
|
||||
.tabular-nums {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.projects-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--td-comp-margin-l);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user