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:
2026-05-27 18:54:44 +08:00
parent 348b35ef8c
commit d5a0ba9e9e
44 changed files with 2458 additions and 43 deletions

View File

@@ -2,10 +2,12 @@ import { mkdirSync } from "node:fs";
import type { RuntimeMode } from "../shared/api";
import type { ResolvedConfig, ResolvedLoggingConfig } from "./config/types";
import type { MigrationRecord } from "./db/load-migrations";
import type { Logger } from "./logger";
import type { StartServerOptions } from "./server";
import { loadServerConfig } from "./config";
import { createDatabase, loadMigrationsFromDir, runMigrations } from "./db";
import { createConsoleFallback, createRuntimeLogger } from "./logger";
import { startServer } from "./server";
@@ -19,6 +21,7 @@ export interface BootstrapDependencies {
export interface BootstrapOptions {
configPath: string;
migrations?: MigrationRecord[];
mode: RuntimeMode;
staticAssets?: StartServerOptions["staticAssets"];
version?: string;
@@ -59,8 +62,13 @@ export async function bootstrap(options: BootstrapOptions, dependencies: Bootstr
mkdirSync(config.dataDir, { recursive: true });
logger!.info({ dataDir: config.dataDir }, "数据目录就绪");
const migrations = options.migrations ?? loadMigrationsFromDir();
const db = createDatabase(config.dataDir, logger!);
runMigrations(db, migrations, config.dataDir, logger!);
const shutdown = () => {
logger?.info("收到退出信号,开始优雅关闭");
db.close();
logger?.flush();
exit(0);
};
@@ -69,6 +77,7 @@ export async function bootstrap(options: BootstrapOptions, dependencies: Bootstr
serve({
config: { host: config.host, port: config.port },
db,
logger: logger!.child({ component: "server" }),
mode: options.mode,
staticAssets: options.staticAssets,

View File

@@ -0,0 +1,19 @@
import Database from "bun:sqlite";
import { join } from "node:path";
import type { Logger } from "../logger";
const DB_FILENAME = "alfred.db";
export function createDatabase(dataDir: string, logger: Logger): Database {
const dbPath = join(dataDir, DB_FILENAME);
const db = new Database(dbPath);
db.exec("PRAGMA foreign_keys = ON");
db.exec("PRAGMA journal_mode = WAL");
db.exec("PRAGMA busy_timeout = 5000");
logger.info({ dbPath }, "数据库连接初始化");
return db;
}

4
src/server/db/index.ts Normal file
View File

@@ -0,0 +1,4 @@
export { createDatabase } from "./connection";
export { loadMigrationsFromDir, type MigrationRecord } from "./load-migrations";
export { runMigrations } from "./migrate";
export { projects, schemaMigrations } from "./schema";

View File

@@ -0,0 +1,30 @@
import { createHash } from "node:crypto";
import { readdirSync, readFileSync } from "node:fs";
import { join, resolve } from "node:path";
export interface MigrationRecord {
checksum: string;
id: string;
sql: string;
}
export function loadMigrationsFromDir(migrationsDir?: string): MigrationRecord[] {
const dir = migrationsDir ?? resolve(import.meta.dir, "../../../drizzle");
let entries: string[];
try {
entries = readdirSync(dir)
.filter((f) => f.endsWith(".sql"))
.sort();
} catch {
return [];
}
return entries.map((filename) => {
const sql = readFileSync(join(dir, filename), "utf-8");
const id = filename.replace(/\.sql$/, "");
const checksum = createHash("sha256").update(sql).digest("hex").slice(0, 16);
return { checksum, id, sql };
});
}

67
src/server/db/migrate.ts Normal file
View File

@@ -0,0 +1,67 @@
import type Database from "bun:sqlite";
import { copyFileSync, existsSync, mkdirSync, statSync } from "node:fs";
import { join } from "node:path";
import type { Logger } from "../logger";
import type { MigrationRecord } from "./load-migrations";
export function runMigrations(db: Database, migrations: MigrationRecord[], dataDir: string, logger: Logger): void {
if (migrations.length === 0) {
logger.info("数据库 schema 已是最新");
return;
}
db.exec(
"CREATE TABLE IF NOT EXISTS schema_migrations (id TEXT PRIMARY KEY, checksum TEXT NOT NULL, applied_at TEXT NOT NULL)",
);
const applied = getAppliedMigrationIds(db);
const pending = migrations.filter((m) => !applied.has(m.id));
if (pending.length === 0) {
logger.info("数据库 schema 已是最新");
return;
}
logger.info({ count: pending.length }, "发现待执行 migration");
const dbPath = join(dataDir, "alfred.db");
if (needsBackup(dbPath)) {
backupDatabase(dbPath, dataDir, logger);
}
const insertApplied = db.prepare("INSERT INTO schema_migrations (id, checksum, applied_at) VALUES (?, ?, ?)");
db.transaction(() => {
for (const migration of pending) {
logger.info({ id: migration.id }, "执行 migration");
db.exec(migration.sql);
insertApplied.run(migration.id, migration.checksum, new Date().toISOString());
}
})();
logger.info({ count: pending.length }, "migration 全部执行完成");
}
function backupDatabase(dbPath: string, dataDir: string, logger: Logger): void {
const backupsDir = join(dataDir, "backups");
mkdirSync(backupsDir, { recursive: true });
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const backupPath = join(backupsDir, `alfred-${timestamp}.db`);
copyFileSync(dbPath, backupPath);
logger.info({ backupPath }, "数据库备份完成");
}
function getAppliedMigrationIds(db: Database): Set<string> {
const rows = db.query("SELECT id FROM schema_migrations").all() as Array<{ id: string }>;
return new Set(rows.map((r) => r.id));
}
function needsBackup(dbPath: string): boolean {
if (!existsSync(dbPath)) return false;
const stat = statSync(dbPath);
return stat.size > 0;
}

191
src/server/db/projects.ts Normal file
View File

@@ -0,0 +1,191 @@
import type Database from "bun:sqlite";
import { and, desc, eq, like, or, sql } from "drizzle-orm";
import { drizzle } from "drizzle-orm/bun-sqlite";
import type { CreateProjectRequest, Project, ProjectStatus, UpdateProjectRequest } from "../../shared/api";
import { projects } from "./schema";
export function archiveProject(raw: Database, id: string): { error: string; status: number } | { project: Project } {
const db = wrap(raw);
const existing = db.select().from(projects).where(eq(projects.id, id)).get();
if (!existing) return { error: "项目不存在", status: 404 };
if (existing.status === "archived") return { error: "项目已归档", status: 409 };
const now = new Date().toISOString();
db.update(projects).set({ archivedAt: now, status: "archived", updatedAt: now }).where(eq(projects.id, id)).run();
const updated = db.select().from(projects).where(eq(projects.id, id)).get();
return { project: toProject(updated!) };
}
export function createProject(
raw: Database,
request: CreateProjectRequest,
): { error: string; status: number } | { project: Project } {
const db = wrap(raw);
const name = request.name.trim();
if (!name) return { error: "项目名称不能为空", status: 400 };
const description = (request.description ?? "").trim();
const id = crypto.randomUUID();
const now = new Date().toISOString();
try {
db.insert(projects)
.values({
archivedAt: null,
createdAt: now,
description,
id,
name,
status: "active",
updatedAt: now,
})
.run();
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
if (msg.includes("UNIQUE constraint")) {
return { error: "项目名称已存在", status: 409 };
}
throw e;
}
const row = db.select().from(projects).where(eq(projects.id, id)).get();
return { project: toProject(row!) };
}
export function deleteProject(raw: Database, id: string): { error: string; status: number } | { success: true } {
const db = wrap(raw);
const existing = db.select().from(projects).where(eq(projects.id, id)).get();
if (!existing) return { error: "项目不存在", status: 404 };
if (existing.status === "active") return { error: "活跃项目不可删除,请先归档", status: 409 };
db.delete(projects).where(eq(projects.id, id)).run();
return { success: true };
}
export function getProject(raw: Database, id: string): { error: string; status: number } | { project: Project } {
const db = wrap(raw);
const row = db.select().from(projects).where(eq(projects.id, id)).get();
if (!row) return { error: "项目不存在", status: 404 };
return { project: toProject(row) };
}
export function listProjects(
raw: Database,
options: { keyword?: string; page: number; pageSize: number; status?: ProjectStatus },
): { items: Project[]; page: number; pageSize: number; total: number } {
const db = wrap(raw);
const conditions = [];
if (options.status) {
conditions.push(eq(projects.status, options.status));
}
if (options.keyword) {
const pattern = `%${options.keyword}%`;
conditions.push(or(like(projects.name, pattern), like(projects.description, pattern))!);
}
const where = conditions.length > 0 ? and(...conditions) : undefined;
const countResult = db
.select({ count: sql<number>`count(*)` })
.from(projects)
.where(where)
.get();
const total = Number(countResult?.count ?? 0);
const rows = db
.select()
.from(projects)
.where(where)
.orderBy(desc(projects.createdAt))
.limit(options.pageSize)
.offset((options.page - 1) * options.pageSize)
.all();
return {
items: rows.map(toProject),
page: options.page,
pageSize: options.pageSize,
total,
};
}
export function restoreProject(raw: Database, id: string): { error: string; status: number } | { project: Project } {
const db = wrap(raw);
const existing = db.select().from(projects).where(eq(projects.id, id)).get();
if (!existing) return { error: "项目不存在", status: 404 };
if (existing.status === "active") return { error: "项目已是活跃状态", status: 409 };
const now = new Date().toISOString();
db.update(projects).set({ archivedAt: null, status: "active", updatedAt: now }).where(eq(projects.id, id)).run();
const updated = db.select().from(projects).where(eq(projects.id, id)).get();
return { project: toProject(updated!) };
}
export function updateProject(
raw: Database,
id: string,
request: UpdateProjectRequest,
): { error: string; status: number } | { project: Project } {
const db = wrap(raw);
const existing = db.select().from(projects).where(eq(projects.id, id)).get();
if (!existing) return { error: "项目不存在", status: 404 };
if (existing.status === "archived") return { error: "已归档项目不可编辑", status: 409 };
const name = request.name?.trim();
if (name === "") return { error: "项目名称不能为空", status: 400 };
const updates: Partial<typeof projects.$inferInsert> = {
updatedAt: new Date().toISOString(),
};
if (name !== undefined && name !== existing.name) {
updates.name = name;
}
const description = request.description?.trim();
if (description !== undefined) {
updates.description = description;
}
if (Object.keys(updates).length === 1 && updates.updatedAt) {
return { project: toProject(existing) };
}
try {
db.update(projects).set(updates).where(eq(projects.id, id)).run();
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
if (msg.includes("UNIQUE constraint")) {
return { error: "项目名称已存在", status: 409 };
}
throw e;
}
const updated = db.select().from(projects).where(eq(projects.id, id)).get();
return { project: toProject(updated!) };
}
function toProject(row: typeof projects.$inferSelect): Project {
return {
archivedAt: row.archivedAt,
createdAt: row.createdAt,
description: row.description,
id: row.id,
name: row.name,
status: row.status,
updatedAt: row.updatedAt,
};
}
function wrap(raw: Database) {
return drizzle(raw);
}

19
src/server/db/schema.ts Normal file
View File

@@ -0,0 +1,19 @@
import { sqliteTable, text } from "drizzle-orm/sqlite-core";
export const projects = sqliteTable("projects", {
archivedAt: text("archived_at"),
createdAt: text("created_at").notNull(),
description: text("description").notNull().default(""),
id: text("id").primaryKey(),
name: text("name").notNull().unique(),
status: text("status", { enum: ["active", "archived"] })
.notNull()
.default("active"),
updatedAt: text("updated_at").notNull(),
});
export const schemaMigrations = sqliteTable("schema_migrations", {
appliedAt: text("applied_at").notNull(),
checksum: text("checksum").notNull(),
id: text("id").primaryKey(),
});

View File

@@ -0,0 +1,22 @@
import type Database from "bun:sqlite";
import type { RuntimeMode } from "../../../shared/api";
import { archiveProject } from "../../db/projects";
import { createApiError, jsonResponse } from "../../helpers";
import { validateIdParam } from "../../middleware";
export function handleArchiveProject(req: Request, db: Database, mode: RuntimeMode): Response {
const url = new URL(req.url);
const idStr = url.pathname.split("/")[3];
const validated = validateIdParam(idStr ?? "", mode);
if (validated instanceof Response) return validated;
const result = archiveProject(db, validated.id);
if ("error" in result) {
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
}
return jsonResponse(result, { mode });
}

View File

@@ -0,0 +1,26 @@
import type Database from "bun:sqlite";
import type { CreateProjectRequest, RuntimeMode } from "../../../shared/api";
import { createProject } from "../../db/projects";
import { createApiError, jsonResponse } from "../../helpers";
export async function handleCreateProject(req: Request, db: Database, mode: RuntimeMode): Promise<Response> {
let body: CreateProjectRequest;
try {
body = (await req.json()) as CreateProjectRequest;
} catch {
return jsonResponse(createApiError("Invalid JSON body", 400), { mode, status: 400 });
}
if (!body.name || typeof body.name !== "string") {
return jsonResponse(createApiError("name is required", 400), { mode, status: 400 });
}
const result = createProject(db, body);
if ("error" in result) {
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
}
return jsonResponse(result, { mode, status: 201 });
}

View File

@@ -0,0 +1,22 @@
import type Database from "bun:sqlite";
import type { RuntimeMode } from "../../../shared/api";
import { deleteProject } from "../../db/projects";
import { createApiError, jsonResponse } from "../../helpers";
import { validateIdParam } from "../../middleware";
export function handleDeleteProject(req: Request, db: Database, mode: RuntimeMode): Response {
const url = new URL(req.url);
const idStr = url.pathname.split("/")[3];
const validated = validateIdParam(idStr ?? "", mode);
if (validated instanceof Response) return validated;
const result = deleteProject(db, validated.id);
if ("error" in result) {
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
}
return new Response(null, { status: 204 });
}

View File

@@ -0,0 +1,22 @@
import type Database from "bun:sqlite";
import type { RuntimeMode } from "../../../shared/api";
import { getProject } from "../../db/projects";
import { jsonResponse } from "../../helpers";
import { validateIdParam } from "../../middleware";
export function handleGetProject(req: Request, db: Database, mode: RuntimeMode): Response {
const url = new URL(req.url);
const idStr = url.pathname.split("/")[3];
const validated = validateIdParam(idStr ?? "", mode);
if (validated instanceof Response) return validated;
const result = getProject(db, validated.id);
if ("error" in result) {
return jsonResponse({ error: result.error, status: result.status }, { mode, status: result.status });
}
return jsonResponse(result, { mode });
}

View File

@@ -0,0 +1,31 @@
import type Database from "bun:sqlite";
import type { RuntimeMode } from "../../../shared/api";
import { listProjects } from "../../db/projects";
import { createApiError, jsonResponse } from "../../helpers";
import { validatePagination } from "../../middleware";
export function handleListProjects(req: Request, db: Database, mode: RuntimeMode): Response {
const url = new URL(req.url);
const pageParam = url.searchParams.get("page");
const pageSizeParam = url.searchParams.get("pageSize");
const keyword = url.searchParams.get("keyword");
const statusParam = url.searchParams.get("status");
const pagination = validatePagination(pageParam, pageSizeParam, mode);
if (pagination instanceof Response) return pagination;
if (statusParam && statusParam !== "active" && statusParam !== "archived") {
return jsonResponse(createApiError("Invalid status parameter", 400), { mode, status: 400 });
}
const result = listProjects(db, {
keyword: keyword ?? undefined,
page: pagination.page,
pageSize: pagination.pageSize,
status: (statusParam as "active" | "archived") ?? undefined,
});
return jsonResponse(result, { mode });
}

View File

@@ -0,0 +1,22 @@
import type Database from "bun:sqlite";
import type { RuntimeMode } from "../../../shared/api";
import { restoreProject } from "../../db/projects";
import { createApiError, jsonResponse } from "../../helpers";
import { validateIdParam } from "../../middleware";
export function handleRestoreProject(req: Request, db: Database, mode: RuntimeMode): Response {
const url = new URL(req.url);
const idStr = url.pathname.split("/")[3];
const validated = validateIdParam(idStr ?? "", mode);
if (validated instanceof Response) return validated;
const result = restoreProject(db, validated.id);
if ("error" in result) {
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
}
return jsonResponse(result, { mode });
}

View File

@@ -0,0 +1,33 @@
import type Database from "bun:sqlite";
import type { RuntimeMode, UpdateProjectRequest } from "../../../shared/api";
import { updateProject } from "../../db/projects";
import { createApiError, jsonResponse } from "../../helpers";
import { validateIdParam } from "../../middleware";
export async function handleUpdateProject(req: Request, db: Database, mode: RuntimeMode): Promise<Response> {
const url = new URL(req.url);
const idStr = url.pathname.split("/")[3];
const validated = validateIdParam(idStr ?? "", mode);
if (validated instanceof Response) return validated;
let body: UpdateProjectRequest;
try {
body = (await req.json()) as UpdateProjectRequest;
} catch {
return jsonResponse(createApiError("Invalid JSON body", 400), { mode, status: 400 });
}
if (!body.name && !body.description && body.name !== "" && body.description !== "") {
return jsonResponse(createApiError("At least one of name or description is required", 400), { mode, status: 400 });
}
const result = updateProject(db, validated.id, body);
if ("error" in result) {
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
}
return jsonResponse(result, { mode });
}

View File

@@ -1,3 +1,5 @@
import type Database from "bun:sqlite";
import type { RuntimeMode } from "../shared/api";
import type { Logger } from "./logger";
import type { StaticAssets } from "./static";
@@ -9,6 +11,7 @@ import { readAppVersion } from "./version";
export interface StartServerOptions {
config: { host: string; port: number };
db: Database;
logger: Logger;
mode: RuntimeMode;
staticAssets?: StaticAssets;
@@ -16,7 +19,7 @@ export interface StartServerOptions {
}
export function startServer(options: StartServerOptions) {
const { config, logger, mode, staticAssets, version } = options;
const { config, db, logger, mode, staticAssets, version } = options;
const resolveVersion = (): Promise<string> => {
if (version) return Promise.resolve(version);
@@ -40,6 +43,42 @@ export function startServer(options: StartServerOptions) {
return handleMeta(mode, resolvedVersion);
},
},
"/api/projects": {
GET: async (req) => {
const { handleListProjects } = await import("./routes/projects/list");
return handleListProjects(req, db, mode);
},
POST: async (req) => {
const { handleCreateProject } = await import("./routes/projects/create");
return handleCreateProject(req, db, mode);
},
},
"/api/projects/:id": {
DELETE: async (req) => {
const { handleDeleteProject } = await import("./routes/projects/delete");
return handleDeleteProject(req, db, mode);
},
GET: async (req) => {
const { handleGetProject } = await import("./routes/projects/get");
return handleGetProject(req, db, mode);
},
PATCH: async (req) => {
const { handleUpdateProject } = await import("./routes/projects/update");
return handleUpdateProject(req, db, mode);
},
},
"/api/projects/:id/archive": {
POST: async (req) => {
const { handleArchiveProject } = await import("./routes/projects/archive");
return handleArchiveProject(req, db, mode);
},
},
"/api/projects/:id/restore": {
POST: async (req) => {
const { handleRestoreProject } = await import("./routes/projects/restore");
return handleRestoreProject(req, db, mode);
},
},
},
});

View File

@@ -3,6 +3,11 @@ export interface ApiErrorResponse {
status: number;
}
export interface CreateProjectRequest {
description?: string;
name: string;
}
export interface MetaResponse {
ok: true;
service: string;
@@ -10,9 +15,33 @@ export interface MetaResponse {
version: string;
}
export type RuntimeMode = "development" | "production" | "test";
// ==========================================
// 在此定义你的业务类型
// 前后端共享的类型都放在这个文件中
// ==========================================
export interface Project {
archivedAt: null | string;
createdAt: string;
description: string;
id: string;
name: string;
status: ProjectStatus;
updatedAt: string;
}
export interface ProjectListResponse {
items: Project[];
page: number;
pageSize: number;
total: number;
}
export type ProjectStatus = "active" | "archived";
export type RuntimeMode = "development" | "production" | "test";
export interface UpdateProjectRequest {
description?: string;
name?: string;
}

View 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>;
}

View File

@@ -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;

View 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 但最终返回 voidlint 规则误报
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())}`;
}

View File

@@ -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="*" />

View File

@@ -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);
}