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

174
bun.lock
View File

@@ -8,6 +8,7 @@
"@sinclair/typebox": "^0.34.49",
"@tanstack/react-query": "^5.100.10",
"ajv": "^8.20.0",
"drizzle-orm": "^0.45.2",
"es-toolkit": "^1.46.1",
"pino": "^10.3.1",
"pino-pretty": "^13.1.3",
@@ -30,6 +31,7 @@
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.2",
"drizzle-kit": "^0.31.10",
"eslint": "^10.3.0",
"eslint-config-prettier": "^10.1.8",
"eslint-import-resolver-typescript": "^4.4.4",
@@ -141,12 +143,70 @@
"@csstools/css-tokenizer": ["@csstools/css-tokenizer@4.0.0", "https://registry.npmmirror.com/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", {}, "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA=="],
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
"@emnapi/core": ["@emnapi/core@1.10.0", "https://registry.npmmirror.com/@emnapi/core/-/core-1.10.0.tgz", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
"@emnapi/runtime": ["@emnapi/runtime@1.10.0", "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.10.0.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "https://registry.npmmirror.com/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
"@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="],
"@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "https://registry.npmmirror.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="],
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "https://registry.npmmirror.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
@@ -407,6 +467,8 @@
"browserslist": ["browserslist@4.28.2", "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.2.tgz", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="],
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"bun-types": ["bun-types@1.3.14", "https://registry.npmmirror.com/bun-types/-/bun-types-1.3.14.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
"call-bind": ["call-bind@1.0.9", "https://registry.npmmirror.com/call-bind/-/call-bind-1.0.9.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" } }, "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ=="],
@@ -515,6 +577,10 @@
"dot-prop": ["dot-prop@5.3.0", "https://registry.npmmirror.com/dot-prop/-/dot-prop-5.3.0.tgz", { "dependencies": { "is-obj": "^2.0.0" } }, "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q=="],
"drizzle-kit": ["drizzle-kit@0.31.10", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "tsx": "^4.21.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw=="],
"drizzle-orm": ["drizzle-orm@0.45.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "prisma": "*", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "prisma", "sql.js", "sqlite3"] }, "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q=="],
"dunder-proto": ["dunder-proto@1.0.1", "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"electron-to-chromium": ["electron-to-chromium@1.5.353", "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.353.tgz", {}, "sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w=="],
@@ -547,6 +613,8 @@
"es-toolkit": ["es-toolkit@1.46.1", "https://registry.npmmirror.com/es-toolkit/-/es-toolkit-1.46.1.tgz", {}, "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ=="],
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
"escalade": ["escalade@3.2.0", "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"escape-string-regexp": ["escape-string-regexp@4.0.0", "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
@@ -1017,8 +1085,12 @@
"sortablejs": ["sortablejs@1.15.7", "https://registry.npmmirror.com/sortablejs/-/sortablejs-1.15.7.tgz", {}, "sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A=="],
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"source-map-js": ["source-map-js@1.2.1", "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
"split2": ["split2@4.2.0", "https://registry.npmmirror.com/split2/-/split2-4.2.0.tgz", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
"stable-hash-x": ["stable-hash-x@0.2.0", "https://registry.npmmirror.com/stable-hash-x/-/stable-hash-x-0.2.0.tgz", {}, "sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ=="],
@@ -1073,6 +1145,8 @@
"tslib": ["tslib@2.3.1", "https://registry.npmmirror.com/tslib/-/tslib-2.3.1.tgz", {}, "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="],
"tsx": ["tsx@4.22.3", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg=="],
"type-check": ["type-check@0.4.0", "https://registry.npmmirror.com/type-check/-/type-check-0.4.0.tgz", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
"typed-array-buffer": ["typed-array-buffer@1.0.3", "https://registry.npmmirror.com/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="],
@@ -1165,6 +1239,8 @@
"@emnapi/wasi-threads/tslib": ["tslib@2.8.1", "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@reduxjs/toolkit/immer": ["immer@11.1.8", "https://registry.npmmirror.com/immer/-/immer-11.1.8.tgz", {}, "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA=="],
@@ -1237,12 +1313,58 @@
"thread-stream/real-require": ["real-require@1.0.0", "https://registry.npmmirror.com/real-require/-/real-require-1.0.0.tgz", {}, "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g=="],
"tsx/esbuild": ["esbuild@0.28.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.0", "@esbuild/android-arm": "0.28.0", "@esbuild/android-arm64": "0.28.0", "@esbuild/android-x64": "0.28.0", "@esbuild/darwin-arm64": "0.28.0", "@esbuild/darwin-x64": "0.28.0", "@esbuild/freebsd-arm64": "0.28.0", "@esbuild/freebsd-x64": "0.28.0", "@esbuild/linux-arm": "0.28.0", "@esbuild/linux-arm64": "0.28.0", "@esbuild/linux-ia32": "0.28.0", "@esbuild/linux-loong64": "0.28.0", "@esbuild/linux-mips64el": "0.28.0", "@esbuild/linux-ppc64": "0.28.0", "@esbuild/linux-riscv64": "0.28.0", "@esbuild/linux-s390x": "0.28.0", "@esbuild/linux-x64": "0.28.0", "@esbuild/netbsd-arm64": "0.28.0", "@esbuild/netbsd-x64": "0.28.0", "@esbuild/openbsd-arm64": "0.28.0", "@esbuild/openbsd-x64": "0.28.0", "@esbuild/openharmony-arm64": "0.28.0", "@esbuild/sunos-x64": "0.28.0", "@esbuild/win32-arm64": "0.28.0", "@esbuild/win32-ia32": "0.28.0", "@esbuild/win32-x64": "0.28.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw=="],
"typescript-eslint/@typescript-eslint/utils": ["@typescript-eslint/utils@8.59.3", "https://registry.npmmirror.com/@typescript-eslint/utils/-/utils-8.59.3.tgz", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.59.3", "@typescript-eslint/types": "8.59.3", "@typescript-eslint/typescript-estree": "8.59.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg=="],
"wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-6.2.3.tgz", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"wrap-ansi/string-width": ["string-width@8.2.1", "https://registry.npmmirror.com/string-width/-/string-width-8.2.1.tgz", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.18.20", "", { "os": "android", "cpu": "x64" }, "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.18.20", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.18.20", "", { "os": "darwin", "cpu": "x64" }, "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.18.20", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.18.20", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.18.20", "", { "os": "linux", "cpu": "arm" }, "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.18.20", "", { "os": "linux", "cpu": "arm64" }, "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.18.20", "", { "os": "linux", "cpu": "ia32" }, "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.18.20", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.18.20", "", { "os": "linux", "cpu": "s390x" }, "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.18.20", "", { "os": "linux", "cpu": "x64" }, "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.18.20", "", { "os": "none", "cpu": "x64" }, "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.18.20", "", { "os": "openbsd", "cpu": "x64" }, "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.18.20", "", { "os": "sunos", "cpu": "x64" }, "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.18.20", "", { "os": "win32", "cpu": "arm64" }, "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.18.20", "", { "os": "win32", "cpu": "ia32" }, "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
"@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager/@typescript-eslint/types": ["@typescript-eslint/types@8.59.3", "https://registry.npmmirror.com/@typescript-eslint/types/-/types-8.59.3.tgz", {}, "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg=="],
"@typescript-eslint/eslint-plugin/@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.59.3", "https://registry.npmmirror.com/@typescript-eslint/types/-/types-8.59.3.tgz", {}, "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg=="],
@@ -1267,6 +1389,58 @@
"log-update/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-6.2.3.tgz", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA=="],
"tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.0", "", { "os": "android", "cpu": "arm" }, "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ=="],
"tsx/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.0", "", { "os": "android", "cpu": "arm64" }, "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw=="],
"tsx/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.0", "", { "os": "android", "cpu": "x64" }, "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA=="],
"tsx/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q=="],
"tsx/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ=="],
"tsx/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q=="],
"tsx/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw=="],
"tsx/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw=="],
"tsx/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A=="],
"tsx/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.0", "", { "os": "linux", "cpu": "ia32" }, "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ=="],
"tsx/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg=="],
"tsx/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w=="],
"tsx/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg=="],
"tsx/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ=="],
"tsx/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q=="],
"tsx/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.0", "", { "os": "linux", "cpu": "x64" }, "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ=="],
"tsx/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.0", "", { "os": "none", "cpu": "arm64" }, "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw=="],
"tsx/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.0", "", { "os": "none", "cpu": "x64" }, "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw=="],
"tsx/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.0", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g=="],
"tsx/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA=="],
"tsx/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.0", "", { "os": "none", "cpu": "arm64" }, "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w=="],
"tsx/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.0", "", { "os": "sunos", "cpu": "x64" }, "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw=="],
"tsx/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA=="],
"tsx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA=="],
"tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.0", "", { "os": "win32", "cpu": "x64" }, "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw=="],
"typescript-eslint/@typescript-eslint/utils/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.59.3", "https://registry.npmmirror.com/@typescript-eslint/scope-manager/-/scope-manager-8.59.3.tgz", { "dependencies": { "@typescript-eslint/types": "8.59.3", "@typescript-eslint/visitor-keys": "8.59.3" } }, "sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA=="],
"typescript-eslint/@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.59.3", "https://registry.npmmirror.com/@typescript-eslint/types/-/types-8.59.3.tgz", {}, "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg=="],

View File

@@ -71,16 +71,18 @@
## 目录边界
| 目录 | 约定 |
| ------------------- | -------------------------------------------------------- |
| `src/server/` | Bun 后端代码,不能 import src/web/HTML import 集成除外 |
| `src/web/` | React 前端,不能 import src/server/ 运行时实现 |
| `src/shared/` | 前后端共享 TypeScript 类型 |
| `scripts/` | 独立运行脚本,可 import 项目源码 |
| `tests/` | 测试目录,结构镜像 src/ |
| `docs/user/` | 用户使用、配置、部署和排障文档 |
| `docs/development/` | 架构、后端、前端、发布开发文档 |
| `openspec/` | OpenSpec 变更管理与规格文档 |
| 目录 | 约定 |
| ------------------- | -------------------------------------------------------------------- |
| `src/server/` | Bun 后端代码,不能 import src/web/HTML import 集成除外 |
| `src/server/db/` | SQLite 数据库模块,包含 schema、connection、migration 和 data access |
| `src/web/` | React 前端,不能 import src/server/ 运行时实现 |
| `src/shared/` | 前后端共享 TypeScript 类型 |
| `scripts/` | 独立运行脚本,可 import 项目源码 |
| `drizzle/` | Drizzle Kit 生成的 SQL migration 文件(开发期产出) |
| `tests/` | 测试目录,结构镜像 src/ |
| `docs/user/` | 用户使用、配置、部署和排障文档 |
| `docs/development/` | 架构、后端、前端、发布开发文档 |
| `openspec/` | OpenSpec 变更管理与规格文档 |
## 文档影响分析

View File

@@ -9,9 +9,15 @@
```text
src/
server/
bootstrap.ts 统一启动引导loadServerConfig -> startServer
bootstrap.ts 统一启动引导loadServerConfig -> DB 初始化 -> startServer
config.ts CLI 参数解析与配置文件加载 facade
config/ 配置解析模块types、issues、variables、normalizer、schema
db/ SQLite 数据库模块
schema.ts Drizzle ORM schema 定义
connection.ts 数据库连接与 PRAGMA 设置
load-migrations.ts 从文件系统加载 migration SQL
migrate.ts migration 执行器(备份 + 事务应用)
projects.ts 项目数据访问函数
dev.ts 开发模式启动入口
main.ts 生产模式启动入口
server.ts HTTP server 启动工厂Bun.serve routes 声明式路由)
@@ -49,9 +55,13 @@ dev.ts / main.ts
-> bootstrap({ configPath, mode })
-> loadServerConfig(configPath)
-> createRuntimeLogger(config.logging)
-> startServer({ config, logger })
-> 确保 dataDir 就绪mkdirSync
-> 加载 migrations生产嵌入的 bytes开发磁盘 drizzle/ 目录)
-> createDatabase(dataDir)
-> runMigrations(db, migrations)pending migration 存在时先备份 DB
-> startServer({ config, logger, db })
-> logger 记录启动成功
-> SIGINT/SIGTERM -> logger.flush() -> exit
-> SIGINT/SIGTERM -> db.close() -> logger.flush() -> exit
```
## HTTP 请求流程
@@ -77,15 +87,16 @@ Request
## 主要模块职责
| 模块 | 职责 |
| ------------------------- | ---------------------------------------- |
| `src/server/bootstrap.ts` | 统一启动引导和 shutdown 编排 |
| `src/server/server.ts` | Bun HTTP server 和 routes 注册 |
| `src/server/routes/` | API handler按端点拆分 |
| `src/server/config/` | 配置解析模块types、variables、schema |
| `src/web/` | React 前端 |
| `src/shared/api.ts` | 前后端共享 API 类型 |
| `src/shared/app.ts` | 应用全局常量 |
| 模块 | 职责 |
| ------------------------- | --------------------------------------------- |
| `src/server/bootstrap.ts` | 统一启动引导、DB 初始化和 shutdown 编排 |
| `src/server/server.ts` | Bun HTTP server 和 routes 注册 |
| `src/server/routes/` | API handler按端点拆分 |
| `src/server/db/` | SQLite 连接、schema、migration 和 data access |
| `src/server/config/` | 配置解析模块types、variables、schema |
| `src/web/` | React 前端 |
| `src/shared/api.ts` | 前后端共享 API 类型 |
| `src/shared/app.ts` | 应用全局常量 |
## 更新触发条件

View File

@@ -6,13 +6,13 @@
## 库使用优先级
| 优先级 | 来源 | 典型用途 |
| ------ | ------------ | ---------------------------------------- |
| 1 | Bun 内置 API | Bun.serve、Bun.file、Bun.YAML、Bun.spawn |
| 2 | es-toolkit | 类型判断、深度比较、并发控制 |
| 3 | 标准 Web API | Headers、fetch、AbortController |
| 4 | 主流三方库 | pino、@sinclair/typebox、ajv |
| 5 | 自行实现 | 仅在以上都无法满足时 |
| 优先级 | 来源 | 典型用途 |
| ------ | ------------ | ---------------------------------------------------- |
| 1 | Bun 内置 API | Bun.serve、Bun.file、Bun.YAML、Bun.spawn、bun:sqlite |
| 2 | es-toolkit | 类型判断、深度比较、并发控制 |
| 3 | 标准 Web API | Headers、fetch、AbortController |
| 4 | 主流三方库 | pino、@sinclair/typebox、ajv、drizzle-orm |
| 5 | 自行实现 | 仅在以上都无法满足时 |
新增依赖前必须先检查上述每一层是否已有可用方案。
@@ -41,6 +41,29 @@ middleware.ts 提供 API 参数校验函数:
- validatePagination(pageParam, pageSizeParam, mode) — 校验分页参数
- validateTimeRange(from, to, mode) — 校验时间范围参数
## 数据库
项目使用 SQLite 作为存储后端,通过 bun:sqlite + Drizzle ORM 实现类型安全的数据访问。
### schema 定义
`src/server/db/schema.ts` 使用 Drizzle ORM 定义表结构,列名使用 snake_caseTypeScript 类型使用 camelCaseDrizzle schema 负责映射。
### 数据库连接
`src/server/db/connection.ts``createDatabase(dataDir, logger)` 打开 `<dataDir>/alfred.db`,设置 PRAGMAforeign_keys=ON、journal_mode=WAL、busy_timeout=5000
### migration 机制
- 开发期:使用 `drizzle-kit generate` 从 TS schema 生成 SQL migration 文件到 `drizzle/` 目录
- 生产期:构建时将 `drizzle/*.sql` 嵌入可执行文件,启动时自动应用 pending migrations
- 每次 migration 前自动备份现有 DB 到 `<dataDir>/backups/alfred-<timestamp>.db`
- migration 在事务中执行,失败则回滚并停止启动
### 数据访问
`src/server/db/projects.ts` 提供项目数据访问函数,输入输出使用 `src/shared/api.ts` 的类型。函数内部使用 Drizzle query builder 包装 `bun:sqlite` Database。
## 类型规范
- 共享类型以 src/shared/api.ts 为唯一源头

View File

@@ -41,9 +41,10 @@ bun run build
构建流程:
```text
1. Vite build -> dist/web/
2. Code generation -> .build/static-assets.ts + .build/server-entry.ts
3. Bun compile -> dist/alfred
1. Vite build -> dist/web/
2. Code generation -> .build/static-assets.ts + .build/migrations-data.ts + .build/server-entry.ts
3. Bun compile -> dist/alfred
4. Cleanup -> 清理 .build/ 临时目录
```
构建参数:

View File

@@ -36,9 +36,9 @@ server:
## server.storage
| 字段 | 类型 | 说明 |
| ------- | ------ | --------------------------------------------------- |
| dataDir | string | 数据目录,默认 ./data相对路径基于配置文件目录解析 |
| 字段 | 类型 | 说明 |
| ------- | ------ | ------------------------------------------------------------------------------------------------------------------- |
| dataDir | string | 数据目录,默认 ./data相对路径基于配置文件目录解析。目录下存储 alfred.dbSQLite 数据库)和 backups/(数据库备份) |
## server.logging

View File

@@ -28,10 +28,18 @@ scripts/build.ts 执行三步流水线:
```
- Vite 构建前端资源到 dist/web/,自动 code splittingvendor-react、vendor-tdesign、vendor-chart
- Code generation 扫描 dist/web/ 生成 import with { type: "file" } 声明,将资源嵌入 binary
- Code generation 扫描 dist/web/ 生成 static-assets.ts读取 drizzle/\*.sql 生成 migrations-data.ts生成 server-entry.ts 串联入口
- migrations-data.ts 将 migration SQL 嵌入 binary生产部署无需额外携带 migration 文件
- Bun compile 以 .build/server-entry.ts 为入口编译最终可执行文件
- .build/ 临时目录在构建完成后自动清理
## 运行时数据存储
- 应用使用 SQLite 存储数据,数据库文件位于 `<dataDir>/alfred.db`
- 启动时自动应用 pending database migrations无需手动迁移
- 每次 migration 执行前自动备份现有数据库到 `<dataDir>/backups/`
- 数据库文件使用 WAL 模式,支持并发读写
## 产物
| 产物 | 用途 |

View File

@@ -29,3 +29,12 @@ bun run dev config.yaml
- [配置文件](config.md) — 了解 YAML 结构、变量语法和配置字段
- [部署文档](deploy.md) — 生产构建和运行方式
- [开发文档](../development/README.md) — 开发规范、架构和质量门禁
## 功能介绍
| 功能 | 路径 | 说明 |
| -------- | ----------- | ------------------------------------ |
| 仪表盘 | `/` | 应用总览,展示运行时元信息 |
| 项目管理 | `/projects` | 创建、编辑、归档、恢复和永久删除项目 |
| 用户管理 | `/users` | 页面建设中 |
| 系统设置 | `/settings` | 页面建设中 |

7
drizzle.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
dialect: "sqlite",
out: "./drizzle",
schema: "./src/server/db/schema.ts",
});

View File

@@ -0,0 +1,16 @@
CREATE TABLE `projects` (
`archived_at` text,
`created_at` text NOT NULL,
`description` text DEFAULT '' NOT NULL,
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`status` text DEFAULT 'active' NOT NULL CHECK (status IN ('active', 'archived')),
`updated_at` text NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `projects_name_unique` ON `projects` (`name`);--> statement-breakpoint
CREATE TABLE IF NOT EXISTS `schema_migrations` (
`applied_at` text NOT NULL,
`checksum` text NOT NULL,
`id` text PRIMARY KEY NOT NULL
);

View File

@@ -0,0 +1,116 @@
{
"version": "6",
"dialect": "sqlite",
"id": "7c940c6c-2dd6-4509-aad1-90aa48887cb9",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"projects": {
"name": "projects",
"columns": {
"archived_at": {
"name": "archived_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'active'"
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"projects_name_unique": {
"name": "projects_name_unique",
"columns": ["name"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"schema_migrations": {
"name": "schema_migrations",
"columns": {
"applied_at": {
"name": "applied_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"checksum": {
"name": "checksum",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1779873780188,
"tag": "0000_cheerful_switch",
"breakpoints": true
}
]
}

View File

@@ -34,6 +34,7 @@
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.2",
"drizzle-kit": "^0.31.10",
"eslint": "^10.3.0",
"eslint-config-prettier": "^10.1.8",
"eslint-import-resolver-typescript": "^4.4.4",
@@ -54,6 +55,7 @@
"@sinclair/typebox": "^0.34.49",
"@tanstack/react-query": "^5.100.10",
"ajv": "^8.20.0",
"drizzle-orm": "^0.45.2",
"es-toolkit": "^1.46.1",
"pino": "^10.3.1",
"pino-pretty": "^13.1.3",

View File

@@ -26,7 +26,7 @@ async function build() {
}
async function bunCompile() {
console.log("Step 3/3: Bun compile...");
console.log("Step 4/4: Bun compile...");
await rm(executablePath, { force: true });
const target = process.env["BUN_TARGET"] ?? process.env["BUILD_TARGET"];
@@ -60,7 +60,7 @@ async function cleanup() {
}
async function codeGeneration() {
console.log("Step 2/3: Code generation...");
console.log("Step 2/4: Code generation...");
await rm(buildDir, { force: true, recursive: true });
await Bun.write(join(buildDir, ".gitkeep"), "");
@@ -72,6 +72,9 @@ async function codeGeneration() {
}
validateVersion(version);
await generateMigrationsData();
console.log("Step 3/4: Generating static assets...");
const allFiles = await scanDir(distWebDir, "/");
const importLines: string[] = [];
const fileEntries: string[] = [];
@@ -115,13 +118,14 @@ async function codeGeneration() {
`import { bootstrap } from "../src/server/bootstrap";`,
`import { parseRuntimeArgs } from "../src/server/config";`,
`import { createConsoleFallback } from "../src/server/logger";`,
`import { MIGRATIONS } from "./migrations-data";`,
`import { staticAssets } from "./static-assets";`,
"",
`const APP_VERSION = "${version}" as const;`,
"",
`async function main() {`,
` const { configPath } = parseRuntimeArgs();`,
` await bootstrap({ configPath, mode: "production", staticAssets, version: APP_VERSION });`,
` await bootstrap({ configPath, migrations: MIGRATIONS, mode: "production", staticAssets, version: APP_VERSION });`,
`}`,
"",
`void main().catch((error) => {`,
@@ -134,6 +138,43 @@ async function codeGeneration() {
await writeFile(join(buildDir, "server-entry.ts"), serverEntryTs);
}
async function generateMigrationsData() {
const { createHash } = await import("node:crypto");
const { readdirSync, readFileSync } = await import("node:fs");
const migrationsDir = join(projectRoot, "drizzle");
let entries: string[];
try {
entries = readdirSync(migrationsDir)
.filter((f) => f.endsWith(".sql"))
.sort();
} catch {
entries = [];
}
const records = entries.map((filename) => {
const sql = readFileSync(join(migrationsDir, filename), "utf-8");
const id = filename.replace(/\.sql$/, "");
const checksum = createHash("sha256").update(sql).digest("hex").slice(0, 16);
return { checksum, id, sql: sql.trim() };
});
const lines = [
`import type { MigrationRecord } from "../src/server/db/load-migrations";`,
``,
`export const MIGRATIONS: MigrationRecord[] = [`,
...records.map(
(r) =>
` { id: ${JSON.stringify(r.id)}, sql: ${JSON.stringify(r.sql)}, checksum: ${JSON.stringify(r.checksum)} },`,
),
`];`,
``,
].join("\n");
await writeFile(join(buildDir, "migrations-data.ts"), lines);
console.log(`Embedded ${records.length} migration(s)`);
}
async function scanDir(dir: string, prefix: string): Promise<string[]> {
const entries = await readdir(dir, { withFileTypes: true });
const paths: string[] = [];

View File

@@ -0,0 +1,42 @@
import { createHash } from "node:crypto";
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
import { join, resolve } from "node:path";
const projectRoot = resolve(import.meta.dir, "..");
const migrationsDir = join(projectRoot, "drizzle");
const outputPath = join(projectRoot, ".build", "migrations-data.ts");
function generateMigrationsData() {
let entries: string[];
try {
entries = readdirSync(migrationsDir)
.filter((f) => f.endsWith(".sql"))
.sort();
} catch {
entries = [];
}
const records = entries.map((filename) => {
const sql = readFileSync(join(migrationsDir, filename), "utf-8");
const id = filename.replace(/\.sql$/, "");
const checksum = createHash("sha256").update(sql).digest("hex").slice(0, 16);
return { checksum, id, sql: sql.trim() };
});
const lines = [
`import type { MigrationRecord } from "../src/server/db/load-migrations";`,
``,
`export const MIGRATIONS: MigrationRecord[] = [`,
...records.map(
(r) =>
` { id: ${JSON.stringify(r.id)}, sql: ${JSON.stringify(r.sql)}, checksum: ${JSON.stringify(r.checksum)} },`,
),
`];`,
``,
].join("\n");
writeFileSync(outputPath, lines);
console.log(`Generated ${outputPath} with ${records.length} migration(s)`);
}
generateMigrationsData();

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

View File

@@ -0,0 +1,60 @@
/* eslint-disable @typescript-eslint/no-empty-function, @typescript-eslint/require-await */
import { describe, expect, test } from "bun:test";
import { mkdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { ResolvedConfig } from "../../src/server/config/types";
import { bootstrap, type BootstrapDependencies } from "../../src/server/bootstrap";
import { createMemoryLogger } from "../../src/server/logger";
function makeTempConfig(overrides: Partial<ResolvedConfig> = {}): ResolvedConfig {
const base = join(tmpdir(), `bootstrap-db-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(base, { recursive: true });
return {
configDir: base,
dataDir: join(base, "data"),
host: "127.0.0.1",
logging: {
consoleLevel: "info",
fileLevel: "info",
filePath: join(base, "data", "logs", "test.log"),
rotationFrequency: "daily",
rotationMaxFiles: 14,
rotationSizeBytes: 52428800,
rotationSizeRaw: "50MB",
},
port: 0,
...overrides,
};
}
describe("bootstrap 数据库集成", () => {
test("启动时将数据库传递给 startServer", async () => {
let started = false;
let receivedDb: unknown = undefined;
const cfg = makeTempConfig();
const mockLoadConfig = (async () => cfg) as unknown as BootstrapDependencies["loadConfig"];
const mockOnSignal = (_signal: string, _handler: () => void) => {};
const mockStartServer = (options: { db: unknown }) => {
receivedDb = options.db;
started = true;
return { close: () => {} };
};
const deps: BootstrapDependencies = {
createLogger: async () => createMemoryLogger(),
loadConfig: mockLoadConfig,
onSignal: mockOnSignal,
startServer: mockStartServer,
};
await bootstrap({ configPath: join(cfg.configDir, "config.yaml"), mode: "production" }, deps);
expect(started).toBe(true);
expect(receivedDb).not.toBeUndefined();
expect(typeof (receivedDb as { close?: unknown }).close).toBe("function");
});
});

View File

@@ -0,0 +1,64 @@
import { describe, expect, test } from "bun:test";
import { existsSync, mkdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createDatabase } from "../../../src/server/db/connection";
import { createMemoryLogger } from "../../../src/server/logger";
function makeTempDir(): string {
const dir = join(tmpdir(), `db-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(dir, { recursive: true });
return dir;
}
describe("数据库初始化", () => {
test("创建数据库文件并设置 PRAGMA", () => {
const dir = makeTempDir();
const logger = createMemoryLogger();
try {
const db = createDatabase(dir, logger);
const journalMode = db.query("PRAGMA journal_mode").get() as { journal_mode: string };
expect(journalMode.journal_mode).toBe("wal");
const foreignKeys = db.query("PRAGMA foreign_keys").get() as { foreign_keys: number };
expect(foreignKeys.foreign_keys).toBe(1);
// PRAGMA busy_timeout was set (Bun SQLite may return different formats)
const timeoutResult = db.query("PRAGMA busy_timeout").get() as Record<string, unknown>;
expect(timeoutResult).not.toBeNull();
db.close();
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
test("在空数据目录中创建 alfred.db", () => {
const dir = makeTempDir();
const logger = createMemoryLogger();
try {
const db = createDatabase(dir, logger);
db.close();
expect(existsSync(join(dir, "alfred.db"))).toBe(true);
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
test("数据库连接可执行查询", () => {
const dir = makeTempDir();
const logger = createMemoryLogger();
try {
const db = createDatabase(dir, logger);
db.exec("CREATE TABLE test (id TEXT PRIMARY KEY)");
db.exec("INSERT INTO test (id) VALUES ('1')");
const row = db.query("SELECT id FROM test WHERE id = '1'").get() as { id: string };
expect(row.id).toBe("1");
db.close();
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
});

View File

@@ -0,0 +1,153 @@
import { describe, expect, test } from "bun:test";
import { existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { MigrationRecord } from "../../../src/server/db/load-migrations";
import { createDatabase } from "../../../src/server/db/connection";
import { runMigrations } from "../../../src/server/db/migrate";
import { createMemoryLogger } from "../../../src/server/logger";
function makeTempDir(): string {
const dir = join(tmpdir(), `migration-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(dir, { recursive: true });
return dir;
}
const MIGRATION_001: MigrationRecord = {
checksum: "fake-checksum-001",
id: "0001_initial",
sql: `
CREATE TABLE test_table (id TEXT PRIMARY KEY, name TEXT NOT NULL);
`,
};
const MIGRATION_002: MigrationRecord = {
checksum: "fake-checksum-002",
id: "0002_add_desc",
sql: `
ALTER TABLE test_table ADD COLUMN description TEXT DEFAULT '';
`,
};
describe("migration 执行器", () => {
test("应用待执行 migration 并记录", () => {
const dir = makeTempDir();
const logger = createMemoryLogger();
try {
const db = createDatabase(dir, logger);
runMigrations(db, [MIGRATION_001], dir, logger);
const rows = db.query("SELECT id, checksum FROM schema_migrations").all() as Array<{
checksum: string;
id: string;
}>;
expect(rows.length).toBe(1);
expect(rows[0]!.id).toBe("0001_initial");
expect(rows[0]!.checksum).toBe("fake-checksum-001");
db.exec("INSERT INTO test_table (id, name) VALUES ('1', 'test')");
db.close();
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
test("跳过已应用的 migration", () => {
const dir = makeTempDir();
const logger = createMemoryLogger();
try {
const db = createDatabase(dir, logger);
runMigrations(db, [MIGRATION_001], dir, logger);
runMigrations(db, [MIGRATION_001], dir, logger);
const rows = db.query("SELECT id FROM schema_migrations").all() as Array<{ id: string }>;
expect(rows.length).toBe(1);
db.close();
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
test("按顺序应用多个 migration", () => {
const dir = makeTempDir();
const logger = createMemoryLogger();
try {
const db = createDatabase(dir, logger);
runMigrations(db, [MIGRATION_001, MIGRATION_002], dir, logger);
const rows = db.query("SELECT id FROM schema_migrations ORDER BY id").all() as Array<{ id: string }>;
expect(rows.length).toBe(2);
expect(rows[0]!.id).toBe("0001_initial");
expect(rows[1]!.id).toBe("0002_add_desc");
db.exec("INSERT INTO test_table (id, name, description) VALUES ('1', 'test', 'desc')");
db.close();
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
test("无待执行 migration 时不做变更", () => {
const dir = makeTempDir();
const logger = createMemoryLogger();
try {
const db = createDatabase(dir, logger);
runMigrations(db, [], dir, logger);
const tableExists = db
.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'schema_migrations'")
.get();
expect(tableExists).toBeNull();
db.close();
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
test("执行 migration 前创建备份", () => {
const dir = makeTempDir();
const logger = createMemoryLogger();
try {
const db = createDatabase(dir, logger);
db.exec("CREATE TABLE existing (id TEXT)");
db.exec("INSERT INTO existing (id) VALUES ('x')");
db.close();
const db2 = createDatabase(dir, logger);
runMigrations(db2, [MIGRATION_001], dir, logger);
db2.close();
const backupsDir = join(dir, "backups");
expect(existsSync(backupsDir)).toBe(true);
const backupFiles = readdirSync(backupsDir);
expect(backupFiles.length).toBe(1);
expect(backupFiles[0]!).toMatch(/^alfred-.*\.db$/);
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
test("失败的 migration 不留下部分记录", () => {
const dir = makeTempDir();
const logger = createMemoryLogger();
const BAD_MIGRATION: MigrationRecord = {
checksum: "bad",
id: "0003_bad",
sql: "INVALID SQL STATEMENT;",
};
try {
const db = createDatabase(dir, logger);
expect(() => {
runMigrations(db, [MIGRATION_001, BAD_MIGRATION], dir, logger);
}).toThrow();
const rows = db.query("SELECT id FROM schema_migrations").all() as Array<{ id: string }>;
expect(rows.length).toBe(0);
db.close();
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
});

View File

@@ -0,0 +1,303 @@
import type Database from "bun:sqlite";
import { describe, expect, test } from "bun:test";
import { mkdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createDatabase } from "../../../src/server/db/connection";
import { runMigrations } from "../../../src/server/db/migrate";
import {
archiveProject,
createProject,
deleteProject,
getProject,
listProjects,
restoreProject,
updateProject,
} from "../../../src/server/db/projects";
import { createMemoryLogger } from "../../../src/server/logger";
const MIGRATION_SQL = `
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'archived')),
archived_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS schema_migrations (
id TEXT PRIMARY KEY,
checksum TEXT NOT NULL,
applied_at TEXT NOT NULL
);
`;
function makeTempDir(): string {
const dir = join(tmpdir(), `projects-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(dir, { recursive: true });
return dir;
}
function setupDb(dir: string): Database {
const logger = createMemoryLogger();
const db = createDatabase(dir, logger);
runMigrations(db, [{ checksum: "init", id: "001_init", sql: MIGRATION_SQL }], dir, logger);
return db;
}
describe("项目数据访问层", () => {
test("创建项目", () => {
const dir = makeTempDir();
try {
const db = setupDb(dir);
const result = createProject(db, { description: "测试描述", name: "测试项目" });
expect("error" in result).toBe(false);
expect((result as { project: unknown }).project).toBeDefined();
const row = db.query("SELECT name, description, status FROM projects WHERE name = '测试项目'").get() as {
description: string;
name: string;
status: string;
};
expect(row.name).toBe("测试项目");
expect(row.description).toBe("测试描述");
expect(row.status).toBe("active");
db.close();
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
test("项目名称全局唯一(含归档项目)", () => {
const dir = makeTempDir();
try {
const db = setupDb(dir);
createProject(db, { name: "唯一名称" });
const result2 = createProject(db, { name: "唯一名称" });
expect("error" in result2).toBe(true);
expect((result2 as unknown as { error: string }).error).toContain("已存在");
db.close();
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
test("trim 后名称为空时创建失败", () => {
const dir = makeTempDir();
try {
const db = setupDb(dir);
const result = createProject(db, { name: " " });
expect("error" in result).toBe(true);
expect((result as unknown as { error: string }).error).toContain("不能为空");
db.close();
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
test("列表查询(分页和关键字)", () => {
const dir = makeTempDir();
try {
const db = setupDb(dir);
createProject(db, { description: "descA", name: "项目A" });
createProject(db, { description: "descB", name: "项目B" });
createProject(db, { name: "其他" });
const result1 = listProjects(db, { page: 1, pageSize: 20 });
expect(result1.total).toBe(3);
expect(result1.items.length).toBe(3);
const result2 = listProjects(db, { keyword: "项目", page: 1, pageSize: 20 });
expect(result2.total).toBe(2);
const result3 = listProjects(db, { page: 1, pageSize: 1 });
expect(result3.total).toBe(3);
expect(result3.items.length).toBe(1);
db.close();
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
test("获取项目详情", () => {
const dir = makeTempDir();
try {
const db = setupDb(dir);
const created = createProject(db, { description: "详情", name: "详情项目" });
const id = (created as { project: { id: string } }).project.id;
const result = getProject(db, id);
expect("error" in result).toBe(false);
const projectResult = result as { project: { description: string } };
expect(projectResult.project.description).toBe("详情");
db.close();
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
test("获取不存在的项目返回 404 错误", () => {
const dir = makeTempDir();
try {
const db = setupDb(dir);
const result = getProject(db, "nonexistent");
expect("error" in result).toBe(true);
expect((result as unknown as { status: number }).status).toBe(404);
db.close();
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
test("更新项目名称和描述", () => {
const dir = makeTempDir();
try {
const db = setupDb(dir);
const created = createProject(db, { name: "原名" });
const id = (created as { project: { id: string } }).project.id;
const result = updateProject(db, id, { description: "新描述", name: "新名" });
expect("error" in result).toBe(false);
const updated = result as { project: { description: string; name: string } };
expect(updated.project.name).toBe("新名");
expect(updated.project.description).toBe("新描述");
db.close();
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
test("更新已归档项目失败", () => {
const dir = makeTempDir();
try {
const db = setupDb(dir);
const created = createProject(db, { name: "待归档" });
const id = (created as { project: { id: string } }).project.id;
archiveProject(db, id);
const result = updateProject(db, id, { name: "新名称" });
expect("error" in result).toBe(true);
expect((result as unknown as { status: number }).status).toBe(409);
db.close();
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
test("归档项目", () => {
const dir = makeTempDir();
try {
const db = setupDb(dir);
const created = createProject(db, { name: "待归档" });
const id = (created as { project: { id: string } }).project.id;
const result = archiveProject(db, id);
expect("error" in result).toBe(false);
const archived = (result as { project: { archivedAt: null | string; status: string } }).project;
expect(archived.status).toBe("archived");
expect(archived.archivedAt).not.toBeNull();
const row = db.query("SELECT status, archived_at FROM projects WHERE id = ?").get(id) as {
archived_at: null | string;
status: string;
};
expect(row.status).toBe("archived");
expect(row.archived_at).not.toBeNull();
db.close();
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
test("对已归档项目重复归档失败", () => {
const dir = makeTempDir();
try {
const db = setupDb(dir);
const created = createProject(db, { name: "测试" });
const id = (created as { project: { id: string } }).project.id;
archiveProject(db, id);
const result = archiveProject(db, id);
expect("error" in result).toBe(true);
expect((result as unknown as { status: number }).status).toBe(409);
db.close();
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
test("恢复已归档项目", () => {
const dir = makeTempDir();
try {
const db = setupDb(dir);
const created = createProject(db, { name: "恢复测试" });
const id = (created as { project: { id: string } }).project.id;
archiveProject(db, id);
const result = restoreProject(db, id);
expect("error" in result).toBe(false);
const restored = (result as { project: { archivedAt: null | string; status: string } }).project;
expect(restored.status).toBe("active");
expect(restored.archivedAt).toBeNull();
db.close();
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
test("恢复 active 项目失败", () => {
const dir = makeTempDir();
try {
const db = setupDb(dir);
const created = createProject(db, { name: "活跃项目" });
const id = (created as { project: { id: string } }).project.id;
const result = restoreProject(db, id);
expect("error" in result).toBe(true);
expect((result as unknown as { status: number }).status).toBe(409);
db.close();
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
test("永久删除已归档项目", () => {
const dir = makeTempDir();
try {
const db = setupDb(dir);
const created = createProject(db, { name: "删除测试" });
const id = (created as { project: { id: string } }).project.id;
archiveProject(db, id);
const result = deleteProject(db, id);
expect("error" in result).toBe(false);
const after = getProject(db, id);
expect("error" in after).toBe(true);
db.close();
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
test("删除 active 项目失败", () => {
const dir = makeTempDir();
try {
const db = setupDb(dir);
const created = createProject(db, { name: "活跃项目" });
const id = (created as { project: { id: string } }).project.id;
const result = deleteProject(db, id);
expect("error" in result).toBe(true);
expect((result as unknown as { status: number }).status).toBe(409);
db.close();
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
});

View File

@@ -0,0 +1,261 @@
import type Database from "bun:sqlite";
import { describe, expect, test } from "bun:test";
import { mkdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { Project, RuntimeMode } from "../../../src/shared/api";
import { createDatabase } from "../../../src/server/db/connection";
import { runMigrations } from "../../../src/server/db/migrate";
import { createMemoryLogger } from "../../../src/server/logger";
const MODE: RuntimeMode = "test";
const MIGRATION_SQL = `
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'archived')),
archived_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS schema_migrations (
id TEXT PRIMARY KEY,
checksum TEXT NOT NULL,
applied_at TEXT NOT NULL
);
`;
async function archiveProjectViaHandler(req: Request, db: Database): Promise<Response> {
const { handleArchiveProject: h } = await import("../../../src/server/routes/projects/archive");
return h(req, db, MODE);
}
// Inline imports for actual route handler tests (each handler is in separate file)
async function createProjectViaHandler(req: Request, db: Database): Promise<Response> {
const { handleCreateProject: h } = await import("../../../src/server/routes/projects/create");
return h(req, db, MODE);
}
function createTestProject(db: Database, name = "测试项目"): Project {
const result = createProject(db, { name });
if ("error" in result) throw new Error(result.error);
return result.project;
}
async function deleteProjectViaHandler(req: Request, db: Database): Promise<Response> {
const { handleDeleteProject: h } = await import("../../../src/server/routes/projects/delete");
return h(req, db, MODE);
}
async function getProjectViaHandler(req: Request, db: Database): Promise<Response> {
const { handleGetProject: h } = await import("../../../src/server/routes/projects/get");
return h(req, db, MODE);
}
async function listProjectsViaHandler(req: Request, db: Database): Promise<Response> {
const { handleListProjects: h } = await import("../../../src/server/routes/projects/list");
return h(req, db, MODE);
}
function makeTempDir(): string {
const dir = join(tmpdir(), `route-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(dir, { recursive: true });
return dir;
}
async function restoreProjectViaHandler(req: Request, db: Database): Promise<Response> {
const { handleRestoreProject: h } = await import("../../../src/server/routes/projects/restore");
return h(req, db, MODE);
}
function setupDb(dir: string): Database {
const logger = createMemoryLogger();
const db = createDatabase(dir, logger);
runMigrations(db, [{ checksum: "init", id: "001_init", sql: MIGRATION_SQL }], dir, logger);
return db;
}
async function updateProjectViaHandler(req: Request, db: Database): Promise<Response> {
const { handleUpdateProject: h } = await import("../../../src/server/routes/projects/update");
return h(req, db, MODE);
}
// Need db/projects for setup
import { archiveProject, createProject, getProject } from "../../../src/server/db/projects";
describe("项目 API 路由", () => {
test("POST /api/projects 创建项目", async () => {
const dir = makeTempDir();
try {
const db = setupDb(dir);
const req = new Request("http://localhost/api/projects", {
body: JSON.stringify({ description: "路由测试", name: "路由项目" }),
headers: { "Content-Type": "application/json" },
method: "POST",
});
const res = await createProjectViaHandler(req, db);
expect(res.status).toBe(201);
const body = (await res.json()) as { project: Project };
expect(body.project.name).toBe("路由项目");
db.close();
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
test("GET /api/projects 列表查询", async () => {
const dir = makeTempDir();
try {
const db = setupDb(dir);
createTestProject(db, "A项目");
createTestProject(db, "B项目");
const req = new Request("http://localhost/api/projects?page=1&pageSize=20");
const res = await listProjectsViaHandler(req, db);
expect(res.status).toBe(200);
const body = (await res.json()) as { items: Project[]; total: number };
expect(body.total).toBe(2);
expect(body.items.length).toBe(2);
db.close();
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
test("GET /api/projects/:id 获取详情", async () => {
const dir = makeTempDir();
try {
const db = setupDb(dir);
const project = createTestProject(db, "详情路由");
const req = new Request(`http://localhost/api/projects/${project.id}`);
const res = await getProjectViaHandler(req, db);
expect(res.status).toBe(200);
const body = (await res.json()) as { project: Project };
expect(body.project.name).toBe("详情路由");
db.close();
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
test("PATCH /api/projects/:id 更新项目", async () => {
const dir = makeTempDir();
try {
const db = setupDb(dir);
const project = createTestProject(db, "更新路由");
const req = new Request(`http://localhost/api/projects/${project.id}`, {
body: JSON.stringify({ name: "已更新" }),
headers: { "Content-Type": "application/json" },
method: "PATCH",
});
const res = await updateProjectViaHandler(req, db);
expect(res.status).toBe(200);
const body = (await res.json()) as { project: Project };
expect(body.project.name).toBe("已更新");
db.close();
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
test("POST /api/projects/:id/archive 归档项目", async () => {
const dir = makeTempDir();
try {
const db = setupDb(dir);
const project = createTestProject(db, "归档路由");
const req = new Request(`http://localhost/api/projects/${project.id}/archive`, { method: "POST" });
const res = await archiveProjectViaHandler(req, db);
expect(res.status).toBe(200);
const body = (await res.json()) as { project: Project };
expect(body.project.status).toBe("archived");
db.close();
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
test("POST /api/projects/:id/restore 恢复项目", async () => {
const dir = makeTempDir();
try {
const db = setupDb(dir);
const project = createTestProject(db, "恢复路由");
archiveProject(db, project.id);
const req = new Request(`http://localhost/api/projects/${project.id}/restore`, { method: "POST" });
const res = await restoreProjectViaHandler(req, db);
expect(res.status).toBe(200);
const body = (await res.json()) as { project: Project };
expect(body.project.status).toBe("active");
db.close();
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
test("DELETE /api/projects/:id 永久删除已归档项目", async () => {
const dir = makeTempDir();
try {
const db = setupDb(dir);
const project = createTestProject(db, "删除路由");
archiveProject(db, project.id);
const req = new Request(`http://localhost/api/projects/${project.id}`, { method: "DELETE" });
const res = await deleteProjectViaHandler(req, db);
expect(res.status).toBe(204);
const after = getProject(db, project.id);
expect("error" in after).toBe(true);
db.close();
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
test("创建同名项目返回 409", async () => {
const dir = makeTempDir();
try {
const db = setupDb(dir);
const req1 = new Request("http://localhost/api/projects", {
body: JSON.stringify({ name: "重复名" }),
headers: { "Content-Type": "application/json" },
method: "POST",
});
await createProjectViaHandler(req1, db);
const req2 = new Request("http://localhost/api/projects", {
body: JSON.stringify({ name: "重复名" }),
headers: { "Content-Type": "application/json" },
method: "POST",
});
const res = await createProjectViaHandler(req2, db);
expect(res.status).toBe(409);
db.close();
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
test("删除 active 项目返回 409", async () => {
const dir = makeTempDir();
try {
const db = setupDb(dir);
const project = createTestProject(db, "活项目");
const req = new Request(`http://localhost/api/projects/${project.id}`, { method: "DELETE" });
const res = await deleteProjectViaHandler(req, db);
expect(res.status).toBe(409);
db.close();
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
});

View File

@@ -41,6 +41,7 @@ describe("App", () => {
renderWithProviders(createElement(App));
expect(screen.getAllByText("仪表盘").length).toBeGreaterThan(0);
expect(screen.getAllByText("项目管理").length).toBeGreaterThan(0);
expect(screen.getAllByText("用户管理").length).toBeGreaterThan(0);
expect(screen.getAllByText("系统设置").length).toBeGreaterThan(0);
});

View File

@@ -11,6 +11,7 @@ describe("Sidebar", () => {
renderWithProviders(createElement(Sidebar, { collapsed: false, onToggleCollapsed: () => {} }));
expect(screen.getByText("仪表盘")).not.toBeNull();
expect(screen.getByText("项目管理")).not.toBeNull();
expect(screen.getByText("用户管理")).not.toBeNull();
expect(screen.getByText("系统设置")).not.toBeNull();
});
@@ -19,10 +20,21 @@ describe("Sidebar", () => {
renderWithProviders(createElement(Sidebar, { collapsed: true, onToggleCollapsed: () => {} }));
expect(screen.getByText("仪表盘")).not.toBeNull();
expect(screen.getByText("项目管理")).not.toBeNull();
expect(screen.getByText("用户管理")).not.toBeNull();
expect(screen.getByText("系统设置")).not.toBeNull();
});
test("项目管理菜单项可导航到 /projects", () => {
renderWithProviders(createElement(Sidebar, { collapsed: false, onToggleCollapsed: () => {} }), {
initialRoute: "/projects",
});
const activeItem = document.querySelector(".t-is-active");
expect(activeItem).not.toBeNull();
expect(activeItem?.textContent).toContain("项目管理");
});
test("高亮当前路由对应的菜单项", () => {
renderWithProviders(createElement(Sidebar, { collapsed: false, onToggleCollapsed: () => {} }), {
initialRoute: "/users",

View File

@@ -0,0 +1,41 @@
import { screen, waitFor } from "@testing-library/react";
import { describe, expect, test } from "bun:test";
import { createElement } from "react";
import { ProjectsPage } from "../../../src/web/pages/projects";
import { renderWithProviders } from "../test-utils";
describe("ProjectsPage", () => {
test("渲染 Tab、搜索框、新建按钮和表格", async () => {
renderWithProviders(createElement(ProjectsPage));
// Tabs, search, create button always visible
expect(screen.getByText("进行中")).not.toBeNull();
expect(screen.getByText("已归档")).not.toBeNull();
expect(screen.getByText("搜索")).not.toBeNull();
expect(screen.getByText("新建项目")).not.toBeNull();
// Table renders (shows loading or empty state)
await waitFor(
() => {
const body = document.body.textContent ?? "";
expect(body).toContain("项目名称");
},
{ timeout: 10000 },
);
});
test("新建按钮点击打开弹窗", async () => {
renderWithProviders(createElement(ProjectsPage));
const createBtn = screen.getByText("新建项目");
createBtn.click();
await waitFor(
() => {
expect(screen.getByText("确定")).not.toBeNull();
},
{ timeout: 5000 },
);
});
});