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

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