feat: DevPerf Dashboard 研发人效看板 v1.0
- 后端:Bun + Hono + Drizzle ORM + SQLite - 前端:Vue 3 + Naive UI + ECharts - 项目管理:创建项目 + 绑定 Git 仓库 - OKR 系统:目标/关键结果 CRUD + 进度追踪 - Git 同步:Gitea API 自动同步 commit/PR + 作者关联 - 数据看板:项目 OKR 进度 + KR 状态分布 + 代码活动 - 权限体系:admin/manager/developer/viewer 四级 - Docker 部署:docker-compose + nginx Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
44464dd334
23
.env.example
Normal file
23
.env.example
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
# DevPerf Dashboard - Environment Configuration
|
||||||
|
# Copy this file to .env and fill in the required values
|
||||||
|
|
||||||
|
# ---- Required ----
|
||||||
|
JWT_SECRET=your-jwt-secret-here-must-be-32-chars-minimum
|
||||||
|
|
||||||
|
# ---- Plane Connection ----
|
||||||
|
PLANE_BASE_URL=http://plane-api:8000
|
||||||
|
PLANE_API_TOKEN= # Generate in Plane Settings > API Tokens
|
||||||
|
PLANE_WORKSPACE_SLUG=jasonqiyuan
|
||||||
|
|
||||||
|
# ---- Gitea Connection ----
|
||||||
|
GITEA_BASE_URL=http://gitea:3000
|
||||||
|
GITEA_API_TOKEN= # Generate in Gitea Settings > Applications
|
||||||
|
GITEA_ORG=jasonqiyuan
|
||||||
|
|
||||||
|
# ---- Sync Intervals (minutes) ----
|
||||||
|
SYNC_PLANE_INTERVAL=15
|
||||||
|
SYNC_GITEA_INTERVAL=30
|
||||||
|
|
||||||
|
# ---- Initial Admin Account ----
|
||||||
|
ADMIN_EMAIL=admin@jasonqiyuan.com
|
||||||
|
ADMIN_PASSWORD=Admin123!
|
||||||
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
*.db
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
data/
|
||||||
|
.DS_Store
|
||||||
22
backend/.env.example
Normal file
22
backend/.env.example
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
# ---- Required ----
|
||||||
|
DATABASE_PATH=./data/devperf.db
|
||||||
|
JWT_SECRET=your-jwt-secret-here-change-in-production
|
||||||
|
PORT=3200
|
||||||
|
|
||||||
|
# ---- Plane Connection ----
|
||||||
|
PLANE_BASE_URL=http://plane-api:8000
|
||||||
|
PLANE_API_TOKEN= # Generate in Plane Settings > API Tokens
|
||||||
|
PLANE_WORKSPACE_SLUG=jasonqiyuan
|
||||||
|
|
||||||
|
# ---- Gitea Connection ----
|
||||||
|
GITEA_BASE_URL=http://gitea:3000
|
||||||
|
GITEA_API_TOKEN= # Generate in Gitea Settings > Applications
|
||||||
|
GITEA_ORG=jasonqiyuan
|
||||||
|
|
||||||
|
# ---- Sync Intervals (minutes) ----
|
||||||
|
SYNC_PLANE_INTERVAL=15
|
||||||
|
SYNC_GITEA_INTERVAL=30
|
||||||
|
|
||||||
|
# ---- Initial Admin ----
|
||||||
|
ADMIN_EMAIL=admin@jasonqiyuan.com
|
||||||
|
ADMIN_PASSWORD=Admin123!
|
||||||
16
backend/Dockerfile
Normal file
16
backend/Dockerfile
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
FROM oven/bun:1 AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json bun.lockb* ./
|
||||||
|
RUN bun install --frozen-lockfile || bun install
|
||||||
|
COPY src/ src/
|
||||||
|
COPY drizzle.config.ts tsconfig.json ./
|
||||||
|
RUN bun build src/index.ts --outdir=dist --target=bun
|
||||||
|
|
||||||
|
FROM oven/bun:1-slim
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=builder /app/dist/ ./dist/
|
||||||
|
COPY --from=builder /app/node_modules/ ./node_modules/
|
||||||
|
COPY --from=builder /app/package.json ./
|
||||||
|
RUN mkdir -p /data
|
||||||
|
EXPOSE 3200
|
||||||
|
CMD ["bun", "run", "dist/index.js"]
|
||||||
350
backend/bun.lock
Normal file
350
backend/bun.lock
Normal file
@ -0,0 +1,350 @@
|
|||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"configVersion": 1,
|
||||||
|
"workspaces": {
|
||||||
|
"": {
|
||||||
|
"name": "devperf-dashboard-backend",
|
||||||
|
"dependencies": {
|
||||||
|
"@hono/zod-validator": "^0.4.0",
|
||||||
|
"bcrypt": "^5.1.1",
|
||||||
|
"better-sqlite3": "^11.7.0",
|
||||||
|
"croner": "^9.0.0",
|
||||||
|
"dayjs": "^1.11.13",
|
||||||
|
"drizzle-orm": "^0.36.0",
|
||||||
|
"hono": "^4.7.0",
|
||||||
|
"jose": "^5.9.0",
|
||||||
|
"uuid": "^11.0.0",
|
||||||
|
"zod": "^3.24.0",
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bcrypt": "^5.0.2",
|
||||||
|
"@types/better-sqlite3": "^7.6.12",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
|
"bun-types": "^1.3.11",
|
||||||
|
"drizzle-kit": "^0.28.0",
|
||||||
|
"typescript": "^5.7.0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
|
||||||
|
|
||||||
|
"@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.19.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA=="],
|
||||||
|
|
||||||
|
"@esbuild/android-arm": ["@esbuild/android-arm@0.19.12", "", { "os": "android", "cpu": "arm" }, "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w=="],
|
||||||
|
|
||||||
|
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.19.12", "", { "os": "android", "cpu": "arm64" }, "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA=="],
|
||||||
|
|
||||||
|
"@esbuild/android-x64": ["@esbuild/android-x64@0.19.12", "", { "os": "android", "cpu": "x64" }, "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew=="],
|
||||||
|
|
||||||
|
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.19.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g=="],
|
||||||
|
|
||||||
|
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.19.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A=="],
|
||||||
|
|
||||||
|
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.19.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA=="],
|
||||||
|
|
||||||
|
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.19.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.19.12", "", { "os": "linux", "cpu": "arm" }, "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.19.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.19.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.19.12", "", { "os": "linux", "cpu": "none" }, "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.19.12", "", { "os": "linux", "cpu": "none" }, "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.19.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.19.12", "", { "os": "linux", "cpu": "none" }, "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.19.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.19.12", "", { "os": "linux", "cpu": "x64" }, "sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg=="],
|
||||||
|
|
||||||
|
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.19.12", "", { "os": "none", "cpu": "x64" }, "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA=="],
|
||||||
|
|
||||||
|
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.19.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw=="],
|
||||||
|
|
||||||
|
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.19.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.19.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.19.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.19.12", "", { "os": "win32", "cpu": "x64" }, "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA=="],
|
||||||
|
|
||||||
|
"@hono/zod-validator": ["@hono/zod-validator@0.4.3", "", { "peerDependencies": { "hono": ">=3.9.0", "zod": "^3.19.1" } }, "sha512-xIgMYXDyJ4Hj6ekm9T9Y27s080Nl9NXHcJkOvkXPhubOLj8hZkOL8pDnnXfvCf5xEE8Q4oMFenQUZZREUY2gqQ=="],
|
||||||
|
|
||||||
|
"@mapbox/node-pre-gyp": ["@mapbox/node-pre-gyp@1.0.11", "", { "dependencies": { "detect-libc": "^2.0.0", "https-proxy-agent": "^5.0.0", "make-dir": "^3.1.0", "node-fetch": "^2.6.7", "nopt": "^5.0.0", "npmlog": "^5.0.1", "rimraf": "^3.0.2", "semver": "^7.3.5", "tar": "^6.1.11" }, "bin": { "node-pre-gyp": "bin/node-pre-gyp" } }, "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ=="],
|
||||||
|
|
||||||
|
"@types/bcrypt": ["@types/bcrypt@5.0.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-6atioO8Y75fNcbmj0G7UjI9lXN2pQ/IGJ2FWT4a/btd0Lk9lQalHLKhkgKVZ3r+spnmWUKfbMi1GEe9wyHQfNQ=="],
|
||||||
|
|
||||||
|
"@types/better-sqlite3": ["@types/better-sqlite3@7.6.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA=="],
|
||||||
|
|
||||||
|
"@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="],
|
||||||
|
|
||||||
|
"@types/uuid": ["@types/uuid@10.0.0", "", {}, "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ=="],
|
||||||
|
|
||||||
|
"abbrev": ["abbrev@1.1.1", "", {}, "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q=="],
|
||||||
|
|
||||||
|
"agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="],
|
||||||
|
|
||||||
|
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||||
|
|
||||||
|
"aproba": ["aproba@2.1.0", "", {}, "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew=="],
|
||||||
|
|
||||||
|
"are-we-there-yet": ["are-we-there-yet@2.0.0", "", { "dependencies": { "delegates": "^1.0.0", "readable-stream": "^3.6.0" } }, "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw=="],
|
||||||
|
|
||||||
|
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||||
|
|
||||||
|
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
||||||
|
|
||||||
|
"bcrypt": ["bcrypt@5.1.1", "", { "dependencies": { "@mapbox/node-pre-gyp": "^1.0.11", "node-addon-api": "^5.0.0" } }, "sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww=="],
|
||||||
|
|
||||||
|
"better-sqlite3": ["better-sqlite3@11.10.0", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ=="],
|
||||||
|
|
||||||
|
"bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="],
|
||||||
|
|
||||||
|
"bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="],
|
||||||
|
|
||||||
|
"brace-expansion": ["brace-expansion@1.1.13", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w=="],
|
||||||
|
|
||||||
|
"buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
|
||||||
|
|
||||||
|
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||||
|
|
||||||
|
"bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
|
||||||
|
|
||||||
|
"chownr": ["chownr@2.0.0", "", {}, "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ=="],
|
||||||
|
|
||||||
|
"color-support": ["color-support@1.1.3", "", { "bin": { "color-support": "bin.js" } }, "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg=="],
|
||||||
|
|
||||||
|
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
|
||||||
|
|
||||||
|
"console-control-strings": ["console-control-strings@1.1.0", "", {}, "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ=="],
|
||||||
|
|
||||||
|
"croner": ["croner@9.1.0", "", {}, "sha512-p9nwwR4qyT5W996vBZhdvBCnMhicY5ytZkR4D1Xj0wuTDEiMnjwR57Q3RXYY/s0EpX6Ay3vgIcfaR+ewGHsi+g=="],
|
||||||
|
|
||||||
|
"dayjs": ["dayjs@1.11.20", "", {}, "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ=="],
|
||||||
|
|
||||||
|
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||||
|
|
||||||
|
"decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="],
|
||||||
|
|
||||||
|
"deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="],
|
||||||
|
|
||||||
|
"delegates": ["delegates@1.0.0", "", {}, "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ=="],
|
||||||
|
|
||||||
|
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||||
|
|
||||||
|
"drizzle-kit": ["drizzle-kit@0.28.1", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.19.7", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-JimOV+ystXTWMgZkLHYHf2w3oS28hxiH1FR0dkmJLc7GHzdGJoJAQtQS5DRppnabsRZwE2U1F6CuezVBgmsBBQ=="],
|
||||||
|
|
||||||
|
"drizzle-orm": ["drizzle-orm@0.36.4", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=3", "@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", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/react": ">=18", "@types/sql.js": "*", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "react": ">=18", "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/react", "@types/sql.js", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "knex", "kysely", "mysql2", "pg", "postgres", "react", "sql.js", "sqlite3"] }, "sha512-1OZY3PXD7BR00Gl61UUOFihslDldfH4NFRH2MbP54Yxi0G/PKn4HfO65JYZ7c16DeP3SpM3Aw+VXVG9j6CRSXA=="],
|
||||||
|
|
||||||
|
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||||
|
|
||||||
|
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
|
||||||
|
|
||||||
|
"esbuild": ["esbuild@0.19.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.19.12", "@esbuild/android-arm": "0.19.12", "@esbuild/android-arm64": "0.19.12", "@esbuild/android-x64": "0.19.12", "@esbuild/darwin-arm64": "0.19.12", "@esbuild/darwin-x64": "0.19.12", "@esbuild/freebsd-arm64": "0.19.12", "@esbuild/freebsd-x64": "0.19.12", "@esbuild/linux-arm": "0.19.12", "@esbuild/linux-arm64": "0.19.12", "@esbuild/linux-ia32": "0.19.12", "@esbuild/linux-loong64": "0.19.12", "@esbuild/linux-mips64el": "0.19.12", "@esbuild/linux-ppc64": "0.19.12", "@esbuild/linux-riscv64": "0.19.12", "@esbuild/linux-s390x": "0.19.12", "@esbuild/linux-x64": "0.19.12", "@esbuild/netbsd-x64": "0.19.12", "@esbuild/openbsd-x64": "0.19.12", "@esbuild/sunos-x64": "0.19.12", "@esbuild/win32-arm64": "0.19.12", "@esbuild/win32-ia32": "0.19.12", "@esbuild/win32-x64": "0.19.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg=="],
|
||||||
|
|
||||||
|
"esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="],
|
||||||
|
|
||||||
|
"expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="],
|
||||||
|
|
||||||
|
"file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="],
|
||||||
|
|
||||||
|
"fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="],
|
||||||
|
|
||||||
|
"fs-minipass": ["fs-minipass@2.1.0", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg=="],
|
||||||
|
|
||||||
|
"fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="],
|
||||||
|
|
||||||
|
"gauge": ["gauge@3.0.2", "", { "dependencies": { "aproba": "^1.0.3 || ^2.0.0", "color-support": "^1.1.2", "console-control-strings": "^1.0.0", "has-unicode": "^2.0.1", "object-assign": "^4.1.1", "signal-exit": "^3.0.0", "string-width": "^4.2.3", "strip-ansi": "^6.0.1", "wide-align": "^1.1.2" } }, "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q=="],
|
||||||
|
|
||||||
|
"get-tsconfig": ["get-tsconfig@4.13.7", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q=="],
|
||||||
|
|
||||||
|
"github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="],
|
||||||
|
|
||||||
|
"glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
|
||||||
|
|
||||||
|
"has-unicode": ["has-unicode@2.0.1", "", {}, "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ=="],
|
||||||
|
|
||||||
|
"hono": ["hono@4.12.12", "", {}, "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q=="],
|
||||||
|
|
||||||
|
"https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="],
|
||||||
|
|
||||||
|
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
||||||
|
|
||||||
|
"inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="],
|
||||||
|
|
||||||
|
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||||
|
|
||||||
|
"ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="],
|
||||||
|
|
||||||
|
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
|
||||||
|
|
||||||
|
"jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="],
|
||||||
|
|
||||||
|
"make-dir": ["make-dir@3.1.0", "", { "dependencies": { "semver": "^6.0.0" } }, "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw=="],
|
||||||
|
|
||||||
|
"mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="],
|
||||||
|
|
||||||
|
"minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
|
||||||
|
|
||||||
|
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
||||||
|
|
||||||
|
"minipass": ["minipass@5.0.0", "", {}, "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ=="],
|
||||||
|
|
||||||
|
"minizlib": ["minizlib@2.1.2", "", { "dependencies": { "minipass": "^3.0.0", "yallist": "^4.0.0" } }, "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg=="],
|
||||||
|
|
||||||
|
"mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="],
|
||||||
|
|
||||||
|
"mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="],
|
||||||
|
|
||||||
|
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||||
|
|
||||||
|
"napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="],
|
||||||
|
|
||||||
|
"node-abi": ["node-abi@3.89.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA=="],
|
||||||
|
|
||||||
|
"node-addon-api": ["node-addon-api@5.1.0", "", {}, "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA=="],
|
||||||
|
|
||||||
|
"node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
|
||||||
|
|
||||||
|
"nopt": ["nopt@5.0.0", "", { "dependencies": { "abbrev": "1" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ=="],
|
||||||
|
|
||||||
|
"npmlog": ["npmlog@5.0.1", "", { "dependencies": { "are-we-there-yet": "^2.0.0", "console-control-strings": "^1.1.0", "gauge": "^3.0.0", "set-blocking": "^2.0.0" } }, "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw=="],
|
||||||
|
|
||||||
|
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||||
|
|
||||||
|
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||||
|
|
||||||
|
"path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="],
|
||||||
|
|
||||||
|
"prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="],
|
||||||
|
|
||||||
|
"pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="],
|
||||||
|
|
||||||
|
"rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="],
|
||||||
|
|
||||||
|
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
|
||||||
|
|
||||||
|
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
||||||
|
|
||||||
|
"rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="],
|
||||||
|
|
||||||
|
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||||
|
|
||||||
|
"semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||||
|
|
||||||
|
"set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="],
|
||||||
|
|
||||||
|
"signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
|
||||||
|
|
||||||
|
"simple-concat": ["simple-concat@1.0.1", "", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="],
|
||||||
|
|
||||||
|
"simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="],
|
||||||
|
|
||||||
|
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||||
|
|
||||||
|
"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=="],
|
||||||
|
|
||||||
|
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||||
|
|
||||||
|
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
|
||||||
|
|
||||||
|
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||||
|
|
||||||
|
"strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="],
|
||||||
|
|
||||||
|
"tar": ["tar@6.2.1", "", { "dependencies": { "chownr": "^2.0.0", "fs-minipass": "^2.0.0", "minipass": "^5.0.0", "minizlib": "^2.1.1", "mkdirp": "^1.0.3", "yallist": "^4.0.0" } }, "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A=="],
|
||||||
|
|
||||||
|
"tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="],
|
||||||
|
|
||||||
|
"tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="],
|
||||||
|
|
||||||
|
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
|
||||||
|
|
||||||
|
"tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="],
|
||||||
|
|
||||||
|
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||||
|
|
||||||
|
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||||
|
|
||||||
|
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||||
|
|
||||||
|
"uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="],
|
||||||
|
|
||||||
|
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
|
||||||
|
|
||||||
|
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
|
||||||
|
|
||||||
|
"wide-align": ["wide-align@1.1.5", "", { "dependencies": { "string-width": "^1.0.2 || 2 || 3 || 4" } }, "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg=="],
|
||||||
|
|
||||||
|
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||||
|
|
||||||
|
"yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
||||||
|
|
||||||
|
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||||
|
|
||||||
|
"@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=="],
|
||||||
|
|
||||||
|
"fs-minipass/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
|
||||||
|
|
||||||
|
"make-dir/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||||
|
|
||||||
|
"minizlib/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
|
||||||
|
|
||||||
|
"tar-fs/chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="],
|
||||||
|
|
||||||
|
"@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=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
10
backend/drizzle.config.ts
Normal file
10
backend/drizzle.config.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from 'drizzle-kit';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: './src/db/schema.ts',
|
||||||
|
out: './drizzle',
|
||||||
|
dialect: 'sqlite',
|
||||||
|
dbCredentials: {
|
||||||
|
url: process.env.DATABASE_PATH || './data/devperf.db',
|
||||||
|
},
|
||||||
|
});
|
||||||
180
backend/drizzle/0000_grey_anita_blake.sql
Normal file
180
backend/drizzle/0000_grey_anita_blake.sql
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
CREATE TABLE `author_mappings` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`git_email` text,
|
||||||
|
`git_username` text,
|
||||||
|
`user_id` text,
|
||||||
|
`created_at` integer NOT NULL,
|
||||||
|
`updated_at` integer NOT NULL,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `uniq_mapping_email` ON `author_mappings` (`git_email`);--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `uniq_mapping_username` ON `author_mappings` (`git_username`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `git_commits` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`repo_name` text NOT NULL,
|
||||||
|
`sha` text NOT NULL,
|
||||||
|
`author_email` text,
|
||||||
|
`author_name` text,
|
||||||
|
`user_id` text,
|
||||||
|
`message` text,
|
||||||
|
`additions` integer DEFAULT 0,
|
||||||
|
`deletions` integer DEFAULT 0,
|
||||||
|
`committed_at` integer NOT NULL,
|
||||||
|
`created_at` integer NOT NULL,
|
||||||
|
`updated_at` integer NOT NULL,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `git_commits_sha_unique` ON `git_commits` (`sha`);--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `uniq_commits_sha` ON `git_commits` (`sha`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_commits_user` ON `git_commits` (`user_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_commits_repo` ON `git_commits` (`repo_name`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_commits_committed_at` ON `git_commits` (`committed_at`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `git_prs` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`repo_name` text NOT NULL,
|
||||||
|
`external_id` integer NOT NULL,
|
||||||
|
`title` text,
|
||||||
|
`user_id` text,
|
||||||
|
`author_username` text,
|
||||||
|
`state` text,
|
||||||
|
`additions` integer DEFAULT 0,
|
||||||
|
`deletions` integer DEFAULT 0,
|
||||||
|
`review_comments` integer DEFAULT 0,
|
||||||
|
`created_at` integer,
|
||||||
|
`merged_at` integer,
|
||||||
|
`merge_time_hours` real,
|
||||||
|
`updated_at` integer NOT NULL,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_prs_user` ON `git_prs` (`user_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_prs_repo` ON `git_prs` (`repo_name`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_prs_state` ON `git_prs` (`state`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `key_results` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`objective_id` text NOT NULL,
|
||||||
|
`title` text NOT NULL,
|
||||||
|
`target_value` real NOT NULL,
|
||||||
|
`current_value` real DEFAULT 0,
|
||||||
|
`unit` text,
|
||||||
|
`weight` real DEFAULT 1,
|
||||||
|
`linked_plane_cycle_id` text,
|
||||||
|
`linked_plane_module_id` text,
|
||||||
|
`created_at` integer NOT NULL,
|
||||||
|
`updated_at` integer NOT NULL,
|
||||||
|
FOREIGN KEY (`objective_id`) REFERENCES `objectives`(`id`) ON UPDATE no action ON DELETE no action
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_kr_objective` ON `key_results` (`objective_id`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `milestones` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`plane_module_id` text NOT NULL,
|
||||||
|
`project_id` text,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`status` text,
|
||||||
|
`target_date` text,
|
||||||
|
`total_issues` integer DEFAULT 0,
|
||||||
|
`completed_issues` integer DEFAULT 0,
|
||||||
|
`created_at` integer NOT NULL,
|
||||||
|
`updated_at` integer NOT NULL,
|
||||||
|
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE no action
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_milestone_project` ON `milestones` (`project_id`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `objectives` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`title` text NOT NULL,
|
||||||
|
`owner_id` text,
|
||||||
|
`project_id` text,
|
||||||
|
`period` text NOT NULL,
|
||||||
|
`progress` real DEFAULT 0,
|
||||||
|
`created_at` integer NOT NULL,
|
||||||
|
`updated_at` integer NOT NULL,
|
||||||
|
FOREIGN KEY (`owner_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
|
||||||
|
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE no action
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_obj_period` ON `objectives` (`period`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_obj_owner` ON `objectives` (`owner_id`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `projects` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`plane_project_id` text NOT NULL,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`identifier` text,
|
||||||
|
`last_synced_at` integer,
|
||||||
|
`created_at` integer NOT NULL,
|
||||||
|
`updated_at` integer NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `uniq_projects_plane_id` ON `projects` (`plane_project_id`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `sprint_snapshots` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`project_id` text,
|
||||||
|
`plane_cycle_id` text NOT NULL,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`start_date` text,
|
||||||
|
`end_date` text,
|
||||||
|
`total_points` integer DEFAULT 0,
|
||||||
|
`completed_points` integer DEFAULT 0,
|
||||||
|
`total_issues` integer DEFAULT 0,
|
||||||
|
`completed_issues` integer DEFAULT 0,
|
||||||
|
`burndown_data` text,
|
||||||
|
`status` text,
|
||||||
|
`created_at` integer NOT NULL,
|
||||||
|
`updated_at` integer NOT NULL,
|
||||||
|
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE no action
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_sprint_project` ON `sprint_snapshots` (`project_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_sprint_status` ON `sprint_snapshots` (`status`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `sync_logs` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`source` text NOT NULL,
|
||||||
|
`status` text NOT NULL,
|
||||||
|
`message` text,
|
||||||
|
`records_processed` integer DEFAULT 0,
|
||||||
|
`synced_at` integer NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `task_snapshots` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`plane_issue_id` text NOT NULL,
|
||||||
|
`project_id` text,
|
||||||
|
`sprint_id` text,
|
||||||
|
`title` text NOT NULL,
|
||||||
|
`status` text,
|
||||||
|
`priority` text,
|
||||||
|
`assignee_id` text,
|
||||||
|
`story_points` integer,
|
||||||
|
`created_at` integer,
|
||||||
|
`completed_at` integer,
|
||||||
|
`due_date` text,
|
||||||
|
`labels` text,
|
||||||
|
`updated_at` integer NOT NULL,
|
||||||
|
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE no action,
|
||||||
|
FOREIGN KEY (`sprint_id`) REFERENCES `sprint_snapshots`(`id`) ON UPDATE no action ON DELETE no action,
|
||||||
|
FOREIGN KEY (`assignee_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_task_project` ON `task_snapshots` (`project_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_task_sprint` ON `task_snapshots` (`sprint_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_task_assignee` ON `task_snapshots` (`assignee_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_task_status` ON `task_snapshots` (`status`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `users` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`plane_user_id` text,
|
||||||
|
`display_name` text NOT NULL,
|
||||||
|
`email` text NOT NULL,
|
||||||
|
`git_username` text,
|
||||||
|
`role` text NOT NULL,
|
||||||
|
`password_hash` text NOT NULL,
|
||||||
|
`login_attempts` integer DEFAULT 0,
|
||||||
|
`locked_until` integer,
|
||||||
|
`created_at` integer NOT NULL,
|
||||||
|
`updated_at` integer NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `users_email_unique` ON `users` (`email`);--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `uniq_users_email` ON `users` (`email`);
|
||||||
1257
backend/drizzle/meta/0000_snapshot.json
Normal file
1257
backend/drizzle/meta/0000_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
13
backend/drizzle/meta/_journal.json
Normal file
13
backend/drizzle/meta/_journal.json
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1775707049155,
|
||||||
|
"tag": "0000_grey_anita_blake",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
35
backend/package.json
Normal file
35
backend/package.json
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "devperf-dashboard-backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "bun run --watch src/index.ts",
|
||||||
|
"build": "bun build src/index.ts --outdir=dist --target=bun",
|
||||||
|
"start": "bun run dist/index.js",
|
||||||
|
"db:generate": "drizzle-kit generate",
|
||||||
|
"db:migrate": "bun run src/db/migrate.ts",
|
||||||
|
"db:seed": "bun run src/db/seed.ts",
|
||||||
|
"test": "bun test",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"hono": "^4.7.0",
|
||||||
|
"drizzle-orm": "^0.36.0",
|
||||||
|
"better-sqlite3": "^11.7.0",
|
||||||
|
"jose": "^5.9.0",
|
||||||
|
"bcrypt": "^5.1.1",
|
||||||
|
"zod": "^3.24.0",
|
||||||
|
"@hono/zod-validator": "^0.4.0",
|
||||||
|
"croner": "^9.0.0",
|
||||||
|
"dayjs": "^1.11.13",
|
||||||
|
"uuid": "^11.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bcrypt": "^5.0.2",
|
||||||
|
"@types/better-sqlite3": "^7.6.12",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
|
"bun-types": "^1.3.11",
|
||||||
|
"drizzle-kit": "^0.28.0",
|
||||||
|
"typescript": "^5.7.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
78
backend/src/api/gitea-client.ts
Normal file
78
backend/src/api/gitea-client.ts
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import { config } from '../config';
|
||||||
|
|
||||||
|
const BASE_URL = config.GITEA_BASE_URL;
|
||||||
|
const TOKEN = config.GITEA_API_TOKEN;
|
||||||
|
const ORG = config.GITEA_ORG;
|
||||||
|
|
||||||
|
async function giteaGet<T>(path: string): Promise<T> {
|
||||||
|
const url = `${BASE_URL}/api/v1${path}`;
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `token ${TOKEN}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Gitea API error: ${res.status} ${res.statusText} at ${path}`);
|
||||||
|
}
|
||||||
|
return res.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GiteaRepo {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
full_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GiteaCommit {
|
||||||
|
sha: string;
|
||||||
|
commit: {
|
||||||
|
author: {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
date: string;
|
||||||
|
};
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
stats?: {
|
||||||
|
additions: number;
|
||||||
|
deletions: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GiteaPR {
|
||||||
|
id: number;
|
||||||
|
number: number;
|
||||||
|
title: string;
|
||||||
|
state: string;
|
||||||
|
user: { login: string };
|
||||||
|
created_at: string;
|
||||||
|
merged_at: string | null;
|
||||||
|
additions: number;
|
||||||
|
deletions: number;
|
||||||
|
comments: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRepos(): Promise<GiteaRepo[]> {
|
||||||
|
const data = await giteaGet<GiteaRepo[]>(`/orgs/${ORG}/repos?limit=50`);
|
||||||
|
return Array.isArray(data) ? data : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCommits(owner: string, repo: string, since?: string): Promise<GiteaCommit[]> {
|
||||||
|
// 分页拉取所有 commit(每页 50,最多 10 页 = 500 条)
|
||||||
|
const all: GiteaCommit[] = [];
|
||||||
|
for (let page = 1; page <= 10; page++) {
|
||||||
|
let path = `/repos/${owner}/${repo}/commits?limit=50&page=${page}`;
|
||||||
|
if (since) path += `&since=${since}`;
|
||||||
|
const data = await giteaGet<GiteaCommit[]>(path);
|
||||||
|
if (!Array.isArray(data) || data.length === 0) break;
|
||||||
|
all.push(...data);
|
||||||
|
if (data.length < 50) break; // 最后一页
|
||||||
|
}
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPullRequests(owner: string, repo: string): Promise<GiteaPR[]> {
|
||||||
|
const data = await giteaGet<GiteaPR[]>(`/repos/${owner}/${repo}/pulls?state=all&sort=updated&limit=50`);
|
||||||
|
return Array.isArray(data) ? data : [];
|
||||||
|
}
|
||||||
79
backend/src/api/plane-client.ts
Normal file
79
backend/src/api/plane-client.ts
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
import { config } from '../config';
|
||||||
|
|
||||||
|
const BASE_URL = config.PLANE_BASE_URL;
|
||||||
|
const TOKEN = config.PLANE_API_TOKEN;
|
||||||
|
const WORKSPACE = config.PLANE_WORKSPACE_SLUG;
|
||||||
|
|
||||||
|
async function planeGet<T>(path: string): Promise<T> {
|
||||||
|
const url = `${BASE_URL}/api/v1/workspaces/${WORKSPACE}${path}`;
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
'X-API-Key': TOKEN,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Plane API error: ${res.status} ${res.statusText} at ${path}`);
|
||||||
|
}
|
||||||
|
return res.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlaneProject {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
identifier: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlaneCycle {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
start_date: string | null;
|
||||||
|
end_date: string | null;
|
||||||
|
total_estimates: number;
|
||||||
|
completed_estimates: number;
|
||||||
|
total_issues: number;
|
||||||
|
completed_issues: number;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlaneIssue {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
state_detail?: { name: string; group: string };
|
||||||
|
priority: string;
|
||||||
|
assignees: string[];
|
||||||
|
estimate_point: number | null;
|
||||||
|
created_at: string;
|
||||||
|
completed_at: string | null;
|
||||||
|
target_date: string | null;
|
||||||
|
labels: { name: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlaneModule {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
target_date: string | null;
|
||||||
|
total_issues: number;
|
||||||
|
completed_issues: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getProjects(): Promise<PlaneProject[]> {
|
||||||
|
const data = await planeGet<{ results: PlaneProject[] }>('/projects/');
|
||||||
|
return data.results || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCycles(projectId: string): Promise<PlaneCycle[]> {
|
||||||
|
const data = await planeGet<PlaneCycle[]>(`/projects/${projectId}/cycles/`);
|
||||||
|
return Array.isArray(data) ? data : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getIssues(projectId: string): Promise<PlaneIssue[]> {
|
||||||
|
const data = await planeGet<{ results: PlaneIssue[] }>(`/projects/${projectId}/issues/`);
|
||||||
|
return data.results || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getModules(projectId: string): Promise<PlaneModule[]> {
|
||||||
|
const data = await planeGet<PlaneModule[]>(`/projects/${projectId}/modules/`);
|
||||||
|
return Array.isArray(data) ? data : [];
|
||||||
|
}
|
||||||
36
backend/src/config.ts
Normal file
36
backend/src/config.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const envSchema = z.object({
|
||||||
|
DATABASE_PATH: z.string().default('./data/devperf.db'),
|
||||||
|
JWT_SECRET: z.string().min(16, 'JWT_SECRET must be at least 16 characters'),
|
||||||
|
PORT: z.coerce.number().default(3200),
|
||||||
|
|
||||||
|
PLANE_BASE_URL: z.string().url().default('http://plane-api:8000'),
|
||||||
|
PLANE_API_TOKEN: z.string().default(''),
|
||||||
|
PLANE_WORKSPACE_SLUG: z.string().default('jasonqiyuan'),
|
||||||
|
|
||||||
|
GITEA_BASE_URL: z.string().url().default('http://gitea:3000'),
|
||||||
|
GITEA_API_TOKEN: z.string().default(''),
|
||||||
|
GITEA_ORG: z.string().default('jasonqiyuan'),
|
||||||
|
|
||||||
|
SYNC_PLANE_INTERVAL: z.coerce.number().default(15),
|
||||||
|
SYNC_GITEA_INTERVAL: z.coerce.number().default(30),
|
||||||
|
|
||||||
|
ADMIN_EMAIL: z.string().email().default('admin@jasonqiyuan.com'),
|
||||||
|
ADMIN_PASSWORD: z.string().min(6).default('Admin123!'),
|
||||||
|
});
|
||||||
|
|
||||||
|
function loadConfig() {
|
||||||
|
const result = envSchema.safeParse(process.env);
|
||||||
|
if (!result.success) {
|
||||||
|
const missing = result.error.issues.map(
|
||||||
|
(i) => ` ${i.path.join('.')}: ${i.message}`
|
||||||
|
);
|
||||||
|
console.error('Environment validation failed:\n' + missing.join('\n'));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = loadConfig();
|
||||||
|
export type Config = z.infer<typeof envSchema>;
|
||||||
213
backend/src/db/index.ts
Normal file
213
backend/src/db/index.ts
Normal file
@ -0,0 +1,213 @@
|
|||||||
|
import { drizzle } from 'drizzle-orm/bun-sqlite';
|
||||||
|
import { Database } from 'bun:sqlite';
|
||||||
|
import { migrate } from 'drizzle-orm/bun-sqlite/migrator';
|
||||||
|
import * as schema from './schema';
|
||||||
|
import { config } from '../config';
|
||||||
|
import { mkdirSync, existsSync } from 'fs';
|
||||||
|
import { dirname, resolve } from 'path';
|
||||||
|
|
||||||
|
// Ensure data directory exists
|
||||||
|
const dbDir = dirname(config.DATABASE_PATH);
|
||||||
|
if (!existsSync(dbDir)) {
|
||||||
|
mkdirSync(dbDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sqlite = new Database(config.DATABASE_PATH, { create: true });
|
||||||
|
sqlite.exec('PRAGMA journal_mode = WAL');
|
||||||
|
sqlite.exec('PRAGMA foreign_keys = ON');
|
||||||
|
|
||||||
|
export const db = drizzle(sqlite, { schema });
|
||||||
|
export { sqlite };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run database migrations automatically on startup.
|
||||||
|
* Uses CREATE TABLE IF NOT EXISTS semantics (via drizzle migrate)
|
||||||
|
* so it is safe to call on every boot -- already-applied migrations
|
||||||
|
* are tracked in the drizzle __drizzle_migrations journal table.
|
||||||
|
*
|
||||||
|
* The migrationsFolder path is resolved relative to the project root
|
||||||
|
* (where package.json lives) so it works regardless of cwd.
|
||||||
|
*/
|
||||||
|
function autoMigrate() {
|
||||||
|
try {
|
||||||
|
// Resolve the drizzle folder relative to this file's location:
|
||||||
|
// src/db/index.ts -> ../../drizzle
|
||||||
|
const migrationsFolder = resolve(import.meta.dir, '../../drizzle');
|
||||||
|
migrate(db, { migrationsFolder });
|
||||||
|
console.info('[DB] Auto-migration completed successfully.');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[DB] Auto-migration failed:', err);
|
||||||
|
// Do not crash the process -- tables may already exist from a prior run.
|
||||||
|
// Fallback: create core tables with raw SQL if drizzle migrations folder is missing.
|
||||||
|
try {
|
||||||
|
createTablesIfNotExist();
|
||||||
|
console.info('[DB] Fallback table creation completed.');
|
||||||
|
} catch (fallbackErr) {
|
||||||
|
console.error('[DB] Fallback table creation also failed:', fallbackErr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fallback: create all required tables using raw SQL CREATE TABLE IF NOT EXISTS.
|
||||||
|
* This is only used when the drizzle migrations folder cannot be found.
|
||||||
|
*/
|
||||||
|
function createTablesIfNotExist() {
|
||||||
|
sqlite.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
plane_user_id TEXT,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
git_username TEXT,
|
||||||
|
role TEXT NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
login_attempts INTEGER DEFAULT 0,
|
||||||
|
locked_until INTEGER,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS projects (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
plane_project_id TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
identifier TEXT,
|
||||||
|
last_synced_at INTEGER,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sprint_snapshots (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
project_id TEXT REFERENCES projects(id),
|
||||||
|
plane_cycle_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
start_date TEXT,
|
||||||
|
end_date TEXT,
|
||||||
|
total_points INTEGER DEFAULT 0,
|
||||||
|
completed_points INTEGER DEFAULT 0,
|
||||||
|
total_issues INTEGER DEFAULT 0,
|
||||||
|
completed_issues INTEGER DEFAULT 0,
|
||||||
|
burndown_data TEXT,
|
||||||
|
status TEXT,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS task_snapshots (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
plane_issue_id TEXT NOT NULL,
|
||||||
|
project_id TEXT REFERENCES projects(id),
|
||||||
|
sprint_id TEXT REFERENCES sprint_snapshots(id),
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
status TEXT,
|
||||||
|
priority TEXT,
|
||||||
|
assignee_id TEXT REFERENCES users(id),
|
||||||
|
story_points INTEGER,
|
||||||
|
created_at INTEGER,
|
||||||
|
completed_at INTEGER,
|
||||||
|
due_date TEXT,
|
||||||
|
labels TEXT,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS milestones (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
plane_module_id TEXT NOT NULL,
|
||||||
|
project_id TEXT REFERENCES projects(id),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
status TEXT,
|
||||||
|
target_date TEXT,
|
||||||
|
total_issues INTEGER DEFAULT 0,
|
||||||
|
completed_issues INTEGER DEFAULT 0,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS git_commits (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
repo_name TEXT NOT NULL,
|
||||||
|
sha TEXT NOT NULL UNIQUE,
|
||||||
|
author_email TEXT,
|
||||||
|
author_name TEXT,
|
||||||
|
user_id TEXT REFERENCES users(id),
|
||||||
|
message TEXT,
|
||||||
|
additions INTEGER DEFAULT 0,
|
||||||
|
deletions INTEGER DEFAULT 0,
|
||||||
|
committed_at INTEGER NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS git_prs (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
repo_name TEXT NOT NULL,
|
||||||
|
external_id INTEGER NOT NULL,
|
||||||
|
title TEXT,
|
||||||
|
user_id TEXT REFERENCES users(id),
|
||||||
|
author_username TEXT,
|
||||||
|
state TEXT,
|
||||||
|
additions INTEGER DEFAULT 0,
|
||||||
|
deletions INTEGER DEFAULT 0,
|
||||||
|
review_comments INTEGER DEFAULT 0,
|
||||||
|
created_at INTEGER,
|
||||||
|
merged_at INTEGER,
|
||||||
|
merge_time_hours REAL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS objectives (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
owner_id TEXT REFERENCES users(id),
|
||||||
|
project_id TEXT REFERENCES projects(id),
|
||||||
|
period TEXT NOT NULL,
|
||||||
|
progress REAL DEFAULT 0,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS key_results (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
objective_id TEXT NOT NULL REFERENCES objectives(id),
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
target_value REAL NOT NULL,
|
||||||
|
current_value REAL DEFAULT 0,
|
||||||
|
unit TEXT,
|
||||||
|
weight REAL DEFAULT 1,
|
||||||
|
linked_plane_cycle_id TEXT,
|
||||||
|
linked_plane_module_id TEXT,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS author_mappings (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
git_email TEXT,
|
||||||
|
git_username TEXT,
|
||||||
|
user_id TEXT REFERENCES users(id),
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS project_repos (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||||
|
repo_name TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sync_logs (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
message TEXT,
|
||||||
|
records_processed INTEGER DEFAULT 0,
|
||||||
|
synced_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run auto-migration on module load (i.e. on server startup)
|
||||||
|
autoMigrate();
|
||||||
6
backend/src/db/migrate.ts
Normal file
6
backend/src/db/migrate.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { migrate } from 'drizzle-orm/bun-sqlite/migrator';
|
||||||
|
import { db } from './index';
|
||||||
|
|
||||||
|
console.log('Running migrations...');
|
||||||
|
migrate(db, { migrationsFolder: './drizzle' });
|
||||||
|
console.log('Migrations complete.');
|
||||||
204
backend/src/db/schema.ts
Normal file
204
backend/src/db/schema.ts
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
import { sqliteTable, text, integer, real, index, uniqueIndex } from 'drizzle-orm/sqlite-core';
|
||||||
|
|
||||||
|
// ── Users ──
|
||||||
|
export const users = sqliteTable('users', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
planeUserId: text('plane_user_id'),
|
||||||
|
displayName: text('display_name').notNull(),
|
||||||
|
email: text('email').notNull().unique(),
|
||||||
|
gitUsername: text('git_username'),
|
||||||
|
role: text('role', { enum: ['admin', 'manager', 'developer', 'viewer'] }).notNull(),
|
||||||
|
passwordHash: text('password_hash').notNull(),
|
||||||
|
loginAttempts: integer('login_attempts').default(0),
|
||||||
|
lockedUntil: integer('locked_until', { mode: 'timestamp' }),
|
||||||
|
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||||
|
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||||
|
}, (table) => ({
|
||||||
|
emailIdx: uniqueIndex('uniq_users_email').on(table.email),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ── Projects ──
|
||||||
|
export const projects = sqliteTable('projects', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
planeProjectId: text('plane_project_id').notNull(),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
identifier: text('identifier'),
|
||||||
|
lastSyncedAt: integer('last_synced_at', { mode: 'timestamp' }),
|
||||||
|
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||||
|
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||||
|
}, (table) => ({
|
||||||
|
planeProjectIdx: uniqueIndex('uniq_projects_plane_id').on(table.planeProjectId),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ── Sprint Snapshots ──
|
||||||
|
export const sprintSnapshots = sqliteTable('sprint_snapshots', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
projectId: text('project_id').references(() => projects.id),
|
||||||
|
planeCycleId: text('plane_cycle_id').notNull(),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
startDate: text('start_date'),
|
||||||
|
endDate: text('end_date'),
|
||||||
|
totalPoints: integer('total_points').default(0),
|
||||||
|
completedPoints: integer('completed_points').default(0),
|
||||||
|
totalIssues: integer('total_issues').default(0),
|
||||||
|
completedIssues: integer('completed_issues').default(0),
|
||||||
|
burndownData: text('burndown_data', { mode: 'json' }),
|
||||||
|
status: text('status', { enum: ['upcoming', 'active', 'completed'] }),
|
||||||
|
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||||
|
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||||
|
}, (table) => ({
|
||||||
|
projectIdx: index('idx_sprint_project').on(table.projectId),
|
||||||
|
statusIdx: index('idx_sprint_status').on(table.status),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ── Task Snapshots ──
|
||||||
|
export const taskSnapshots = sqliteTable('task_snapshots', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
planeIssueId: text('plane_issue_id').notNull(),
|
||||||
|
projectId: text('project_id').references(() => projects.id),
|
||||||
|
sprintId: text('sprint_id').references(() => sprintSnapshots.id),
|
||||||
|
title: text('title').notNull(),
|
||||||
|
status: text('status'),
|
||||||
|
priority: text('priority'),
|
||||||
|
assigneeId: text('assignee_id').references(() => users.id),
|
||||||
|
storyPoints: integer('story_points'),
|
||||||
|
createdAt: integer('created_at', { mode: 'timestamp' }),
|
||||||
|
completedAt: integer('completed_at', { mode: 'timestamp' }),
|
||||||
|
dueDate: text('due_date'),
|
||||||
|
labels: text('labels', { mode: 'json' }),
|
||||||
|
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||||
|
}, (table) => ({
|
||||||
|
projectIdx: index('idx_task_project').on(table.projectId),
|
||||||
|
sprintIdx: index('idx_task_sprint').on(table.sprintId),
|
||||||
|
assigneeIdx: index('idx_task_assignee').on(table.assigneeId),
|
||||||
|
statusIdx: index('idx_task_status').on(table.status),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ── Milestones ──
|
||||||
|
export const milestones = sqliteTable('milestones', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
planeModuleId: text('plane_module_id').notNull(),
|
||||||
|
projectId: text('project_id').references(() => projects.id),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
status: text('status'),
|
||||||
|
targetDate: text('target_date'),
|
||||||
|
totalIssues: integer('total_issues').default(0),
|
||||||
|
completedIssues: integer('completed_issues').default(0),
|
||||||
|
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||||
|
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||||
|
}, (table) => ({
|
||||||
|
projectIdx: index('idx_milestone_project').on(table.projectId),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ── Git Commits ──
|
||||||
|
export const gitCommits = sqliteTable('git_commits', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
repoName: text('repo_name').notNull(),
|
||||||
|
sha: text('sha').notNull().unique(),
|
||||||
|
authorEmail: text('author_email'),
|
||||||
|
authorName: text('author_name'),
|
||||||
|
userId: text('user_id').references(() => users.id),
|
||||||
|
message: text('message'),
|
||||||
|
additions: integer('additions').default(0),
|
||||||
|
deletions: integer('deletions').default(0),
|
||||||
|
committedAt: integer('committed_at', { mode: 'timestamp' }).notNull(),
|
||||||
|
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||||
|
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||||
|
}, (table) => ({
|
||||||
|
shaIdx: uniqueIndex('uniq_commits_sha').on(table.sha),
|
||||||
|
userIdx: index('idx_commits_user').on(table.userId),
|
||||||
|
repoIdx: index('idx_commits_repo').on(table.repoName),
|
||||||
|
committedAtIdx: index('idx_commits_committed_at').on(table.committedAt),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ── Git PRs ──
|
||||||
|
export const gitPRs = sqliteTable('git_prs', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
repoName: text('repo_name').notNull(),
|
||||||
|
externalId: integer('external_id').notNull(),
|
||||||
|
title: text('title'),
|
||||||
|
userId: text('user_id').references(() => users.id),
|
||||||
|
authorUsername: text('author_username'),
|
||||||
|
state: text('state'),
|
||||||
|
additions: integer('additions').default(0),
|
||||||
|
deletions: integer('deletions').default(0),
|
||||||
|
reviewComments: integer('review_comments').default(0),
|
||||||
|
createdAt: integer('created_at', { mode: 'timestamp' }),
|
||||||
|
mergedAt: integer('merged_at', { mode: 'timestamp' }),
|
||||||
|
mergeTimeHours: real('merge_time_hours'),
|
||||||
|
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||||
|
}, (table) => ({
|
||||||
|
userIdx: index('idx_prs_user').on(table.userId),
|
||||||
|
repoIdx: index('idx_prs_repo').on(table.repoName),
|
||||||
|
stateIdx: index('idx_prs_state').on(table.state),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ── Objectives (OKR) ──
|
||||||
|
export const objectives = sqliteTable('objectives', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
title: text('title').notNull(),
|
||||||
|
ownerId: text('owner_id').references(() => users.id),
|
||||||
|
projectId: text('project_id').references(() => projects.id),
|
||||||
|
period: text('period').notNull(),
|
||||||
|
startDate: text('start_date'),
|
||||||
|
endDate: text('end_date'),
|
||||||
|
progress: real('progress').default(0),
|
||||||
|
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||||
|
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||||
|
}, (table) => ({
|
||||||
|
periodIdx: index('idx_obj_period').on(table.period),
|
||||||
|
ownerIdx: index('idx_obj_owner').on(table.ownerId),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ── Key Results (OKR) ──
|
||||||
|
export const keyResults = sqliteTable('key_results', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
objectiveId: text('objective_id').references(() => objectives.id).notNull(),
|
||||||
|
title: text('title').notNull(),
|
||||||
|
targetValue: real('target_value').notNull(),
|
||||||
|
currentValue: real('current_value').default(0),
|
||||||
|
unit: text('unit'),
|
||||||
|
weight: real('weight').default(1),
|
||||||
|
startDate: text('start_date'),
|
||||||
|
endDate: text('end_date'),
|
||||||
|
linkedPlaneCycleId: text('linked_plane_cycle_id'),
|
||||||
|
linkedPlaneModuleId: text('linked_plane_module_id'),
|
||||||
|
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||||
|
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||||
|
}, (table) => ({
|
||||||
|
objectiveIdx: index('idx_kr_objective').on(table.objectiveId),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ── Author Mappings ──
|
||||||
|
export const authorMappings = sqliteTable('author_mappings', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
gitEmail: text('git_email'),
|
||||||
|
gitUsername: text('git_username'),
|
||||||
|
userId: text('user_id').references(() => users.id),
|
||||||
|
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||||
|
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||||
|
}, (table) => ({
|
||||||
|
emailIdx: uniqueIndex('uniq_mapping_email').on(table.gitEmail),
|
||||||
|
usernameIdx: uniqueIndex('uniq_mapping_username').on(table.gitUsername),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ── Project ↔ Repo Mapping ──
|
||||||
|
export const projectRepos = sqliteTable('project_repos', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
projectId: text('project_id').references(() => projects.id).notNull(),
|
||||||
|
repoName: text('repo_name').notNull(),
|
||||||
|
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||||
|
}, (table) => ({
|
||||||
|
projectIdx: index('idx_project_repos_project').on(table.projectId),
|
||||||
|
repoIdx: index('idx_project_repos_repo').on(table.repoName),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ── Sync Logs ──
|
||||||
|
export const syncLogs = sqliteTable('sync_logs', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
source: text('source', { enum: ['plane', 'gitea'] }).notNull(),
|
||||||
|
status: text('status', { enum: ['success', 'error'] }).notNull(),
|
||||||
|
message: text('message'),
|
||||||
|
recordsProcessed: integer('records_processed').default(0),
|
||||||
|
syncedAt: integer('synced_at', { mode: 'timestamp' }).notNull(),
|
||||||
|
});
|
||||||
37
backend/src/db/seed-auto.ts
Normal file
37
backend/src/db/seed-auto.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* Auto-seed module: creates the default admin user on first startup.
|
||||||
|
* Unlike seed.ts (which is a standalone CLI script), this module
|
||||||
|
* exports an async function that is safe to call on every boot.
|
||||||
|
*/
|
||||||
|
import { db } from './index';
|
||||||
|
import { users } from './schema';
|
||||||
|
import { config } from '../config';
|
||||||
|
import bcrypt from 'bcrypt';
|
||||||
|
import { v4 as uuid } from 'uuid';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export async function seedAdminUser(): Promise<void> {
|
||||||
|
// Check if admin user already exists
|
||||||
|
const existingAdmin = await db.query.users.findFirst({
|
||||||
|
where: eq(users.email, config.ADMIN_EMAIL),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingAdmin) {
|
||||||
|
return; // Admin already exists, nothing to do
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(config.ADMIN_PASSWORD, 12);
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
await db.insert(users).values({
|
||||||
|
id: uuid(),
|
||||||
|
displayName: 'Admin',
|
||||||
|
email: config.ADMIN_EMAIL,
|
||||||
|
role: 'admin',
|
||||||
|
passwordHash,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.info(`[Seed] Admin user created: ${config.ADMIN_EMAIL}`);
|
||||||
|
}
|
||||||
417
backend/src/db/seed-testdata.ts
Normal file
417
backend/src/db/seed-testdata.ts
Normal file
@ -0,0 +1,417 @@
|
|||||||
|
/**
|
||||||
|
* 完整测试数据 seed 脚本
|
||||||
|
* 往所有表灌入贴近真实的测试数据,用于 Playwright E2E 测试验证页面展示
|
||||||
|
*
|
||||||
|
* 运行: bun run src/db/seed-testdata.ts
|
||||||
|
*
|
||||||
|
* NOTE: Drizzle schema uses `integer('...', { mode: 'timestamp' })` which
|
||||||
|
* expects Date objects in the application layer. Drizzle internally converts:
|
||||||
|
* - Write: Math.floor(date.getTime() / 1000) -> stores Unix seconds
|
||||||
|
* - Read: new Date(storedSeconds * 1000) -> returns Date object
|
||||||
|
* All timestamp fields in this seed MUST pass Date objects, NOT raw numbers.
|
||||||
|
* Passing millisecond numbers directly would cause year-58239 display bugs
|
||||||
|
* because Drizzle would store the millisecond value as-is, then multiply
|
||||||
|
* by 1000 again on read.
|
||||||
|
*/
|
||||||
|
import { db, sqlite } from './index';
|
||||||
|
import {
|
||||||
|
users, projects, sprintSnapshots, taskSnapshots, milestones,
|
||||||
|
gitCommits, gitPRs, objectives, keyResults, authorMappings, syncLogs,
|
||||||
|
} from './schema';
|
||||||
|
import bcrypt from 'bcrypt';
|
||||||
|
import { v4 as uuid } from 'uuid';
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a Date object representing `daysAgo` days before now.
|
||||||
|
* Always returns a proper Date object for Drizzle `mode: 'timestamp'` columns.
|
||||||
|
*/
|
||||||
|
const ts = (daysAgo: number): Date => new Date(now.getTime() - daysAgo * 86400000);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a Date for the Monday of `weeksAgo` weeks before current week.
|
||||||
|
*/
|
||||||
|
const weekStart = (weeksAgo: number): Date => {
|
||||||
|
const d = new Date(now);
|
||||||
|
d.setDate(d.getDate() - d.getDay() + 1 - weeksAgo * 7);
|
||||||
|
d.setHours(0, 0, 0, 0);
|
||||||
|
return d;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fmtDate = (d: Date) => d.toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
async function seed() {
|
||||||
|
console.log('=== Seeding comprehensive test data ===');
|
||||||
|
|
||||||
|
// ── 0. Clean existing data to avoid stale / corrupt timestamps ──
|
||||||
|
// Delete in reverse dependency order to respect foreign key constraints.
|
||||||
|
console.log(' Cleaning existing data...');
|
||||||
|
sqlite.exec('DELETE FROM sync_logs');
|
||||||
|
sqlite.exec('DELETE FROM author_mappings');
|
||||||
|
sqlite.exec('DELETE FROM key_results');
|
||||||
|
sqlite.exec('DELETE FROM objectives');
|
||||||
|
sqlite.exec('DELETE FROM git_prs');
|
||||||
|
sqlite.exec('DELETE FROM git_commits');
|
||||||
|
sqlite.exec('DELETE FROM milestones');
|
||||||
|
sqlite.exec('DELETE FROM task_snapshots');
|
||||||
|
sqlite.exec('DELETE FROM sprint_snapshots');
|
||||||
|
sqlite.exec('DELETE FROM projects');
|
||||||
|
sqlite.exec('DELETE FROM users');
|
||||||
|
console.log(' Done.');
|
||||||
|
|
||||||
|
// ── 1. Users (8 人: 1 admin, 2 manager, 4 developer, 1 viewer) ──
|
||||||
|
const passwordHash = await bcrypt.hash('Test1234!', 10);
|
||||||
|
|
||||||
|
const userList = [
|
||||||
|
{ id: 'u-admin', displayName: '张伟(Admin)', email: 'admin@jasonqiyuan.com', role: 'admin' as const, gitUsername: 'zhangwei' },
|
||||||
|
{ id: 'u-mgr-1', displayName: '李明', email: 'liming@jasonqiyuan.com', role: 'manager' as const, gitUsername: 'liming' },
|
||||||
|
{ id: 'u-mgr-2', displayName: '王芳', email: 'wangfang@jasonqiyuan.com', role: 'manager' as const, gitUsername: 'wangfang' },
|
||||||
|
{ id: 'u-dev-1', displayName: '陈强', email: 'chenqiang@jasonqiyuan.com', role: 'developer' as const, gitUsername: 'chenqiang' },
|
||||||
|
{ id: 'u-dev-2', displayName: '刘洋', email: 'liuyang@jasonqiyuan.com', role: 'developer' as const, gitUsername: 'liuyang' },
|
||||||
|
{ id: 'u-dev-3', displayName: '赵雪', email: 'zhaoxue@jasonqiyuan.com', role: 'developer' as const, gitUsername: 'zhaoxue' },
|
||||||
|
{ id: 'u-dev-4', displayName: '孙磊', email: 'sunlei@jasonqiyuan.com', role: 'developer' as const, gitUsername: 'sunlei' },
|
||||||
|
{ id: 'u-viewer', displayName: '杰森集团-周总', email: 'zhou@jason.com', role: 'viewer' as const, gitUsername: null },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const u of userList) {
|
||||||
|
await db.insert(users).values({
|
||||||
|
...u,
|
||||||
|
planeUserId: `plane-${u.id}`,
|
||||||
|
passwordHash: u.email === 'admin@jasonqiyuan.com'
|
||||||
|
? await bcrypt.hash('Admin123!', 10)
|
||||||
|
: passwordHash,
|
||||||
|
createdAt: ts(90), // Date object -- Drizzle stores as Unix seconds
|
||||||
|
updatedAt: now, // Date object
|
||||||
|
}).onConflictDoNothing();
|
||||||
|
}
|
||||||
|
console.log(` Users: ${userList.length}`);
|
||||||
|
|
||||||
|
// ── 2. Projects (4 个产品线) ──
|
||||||
|
const projectList = [
|
||||||
|
{ id: 'p-avatar', planeProjectId: 'pp-avatar', name: 'Avatar 数字人平台', identifier: 'AVATAR' },
|
||||||
|
{ id: 'p-airflow', planeProjectId: 'pp-airflow', name: 'AirFlow 工作流引擎', identifier: 'AIRFLOW' },
|
||||||
|
{ id: 'p-datahub', planeProjectId: 'pp-datahub', name: 'DataHub 数据中台', identifier: 'DATAHUB' },
|
||||||
|
{ id: 'p-admin', planeProjectId: 'pp-admin', name: '运营管理后台', identifier: 'OPS' },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const p of projectList) {
|
||||||
|
await db.insert(projects).values({
|
||||||
|
...p,
|
||||||
|
lastSyncedAt: ts(0), // Date object
|
||||||
|
createdAt: ts(120), // Date object
|
||||||
|
updatedAt: now, // Date object
|
||||||
|
}).onConflictDoNothing();
|
||||||
|
}
|
||||||
|
console.log(` Projects: ${projectList.length}`);
|
||||||
|
|
||||||
|
// ── 3. Sprint Snapshots (每个项目 6 个 Sprint) ──
|
||||||
|
const sprintStatuses: Array<'completed' | 'active' | 'upcoming'> = [
|
||||||
|
'completed', 'completed', 'completed', 'completed', 'active', 'upcoming'
|
||||||
|
];
|
||||||
|
const allSprints: Array<{ id: string; projectId: string; name: string }> = [];
|
||||||
|
|
||||||
|
for (const proj of projectList) {
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
const sprintId = `sp-${proj.identifier.toLowerCase()}-${i + 1}`;
|
||||||
|
const totalPts = 30 + Math.floor(Math.random() * 20);
|
||||||
|
const completedPts = sprintStatuses[i] === 'completed'
|
||||||
|
? Math.floor(totalPts * (0.65 + Math.random() * 0.3))
|
||||||
|
: sprintStatuses[i] === 'active'
|
||||||
|
? Math.floor(totalPts * (0.3 + Math.random() * 0.4))
|
||||||
|
: 0;
|
||||||
|
const startDay = 14 * (5 - i) + 14;
|
||||||
|
const endDay = 14 * (5 - i);
|
||||||
|
const sprintName = `${proj.identifier} Sprint ${i + 1}`;
|
||||||
|
|
||||||
|
// Burndown data for active/completed sprints
|
||||||
|
const burndown: Array<{ date: string; ideal: number; actual: number }> = [];
|
||||||
|
if (sprintStatuses[i] !== 'upcoming') {
|
||||||
|
for (let d = 0; d <= 14; d++) {
|
||||||
|
const ideal = totalPts - (totalPts / 14) * d;
|
||||||
|
const actual = totalPts - (completedPts / 14) * d + (Math.random() * 3 - 1.5);
|
||||||
|
burndown.push({
|
||||||
|
date: fmtDate(ts(startDay - d)),
|
||||||
|
ideal: Math.round(ideal * 10) / 10,
|
||||||
|
actual: Math.max(0, Math.round(actual * 10) / 10),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.insert(sprintSnapshots).values({
|
||||||
|
id: sprintId,
|
||||||
|
projectId: proj.id,
|
||||||
|
planeCycleId: `cycle-${sprintId}`,
|
||||||
|
name: sprintName,
|
||||||
|
startDate: fmtDate(ts(startDay)),
|
||||||
|
endDate: fmtDate(ts(endDay)),
|
||||||
|
totalPoints: totalPts,
|
||||||
|
completedPoints: completedPts,
|
||||||
|
totalIssues: totalPts + Math.floor(Math.random() * 5),
|
||||||
|
completedIssues: completedPts,
|
||||||
|
burndownData: burndown,
|
||||||
|
status: sprintStatuses[i],
|
||||||
|
createdAt: ts(startDay), // Date object
|
||||||
|
updatedAt: now, // Date object
|
||||||
|
}).onConflictDoNothing();
|
||||||
|
|
||||||
|
allSprints.push({ id: sprintId, projectId: proj.id, name: sprintName });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(` Sprints: ${allSprints.length}`);
|
||||||
|
|
||||||
|
// ── 4. Task Snapshots (每个 Sprint 8-12 个任务) ──
|
||||||
|
const taskStatuses = ['todo', 'in_progress', 'review', 'done'];
|
||||||
|
const priorities = ['urgent', 'high', 'medium', 'low', 'none'];
|
||||||
|
const devUsers = ['u-dev-1', 'u-dev-2', 'u-dev-3', 'u-dev-4', 'u-mgr-1', 'u-mgr-2'];
|
||||||
|
const taskTitles = [
|
||||||
|
'实现用户登录接口', '优化数据库查询性能', '修复移动端布局问题',
|
||||||
|
'添加数据导出功能', '编写单元测试', '重构权限校验模块',
|
||||||
|
'实现实时消息推送', '优化前端打包体积', '添加操作日志记录',
|
||||||
|
'修复文件上传超时', '设计新版首页UI', '实现多语言支持',
|
||||||
|
'添加数据缓存层', '优化API响应速度', '修复跨域请求问题',
|
||||||
|
'实现自动化部署流程', '添加健康检查接口', '优化图表渲染性能',
|
||||||
|
'实现批量数据导入', '修复Safari兼容问题', '添加用户行为追踪',
|
||||||
|
'重构状态管理逻辑', '实现SSE实时推送', '优化Docker镜像大小',
|
||||||
|
];
|
||||||
|
let taskCount = 0;
|
||||||
|
|
||||||
|
for (const sprint of allSprints) {
|
||||||
|
const taskNum = 8 + Math.floor(Math.random() * 5);
|
||||||
|
for (let i = 0; i < taskNum; i++) {
|
||||||
|
const status = taskStatuses[Math.floor(Math.random() * taskStatuses.length)];
|
||||||
|
const pts = [1, 2, 3, 5, 8][Math.floor(Math.random() * 5)];
|
||||||
|
const createdDaysAgo = 14 + Math.floor(Math.random() * 30);
|
||||||
|
|
||||||
|
await db.insert(taskSnapshots).values({
|
||||||
|
id: `task-${sprint.id}-${i}`,
|
||||||
|
planeIssueId: `issue-${sprint.id}-${i}`,
|
||||||
|
projectId: sprint.projectId,
|
||||||
|
sprintId: sprint.id,
|
||||||
|
title: taskTitles[(taskCount + i) % taskTitles.length],
|
||||||
|
status,
|
||||||
|
priority: priorities[Math.floor(Math.random() * priorities.length)],
|
||||||
|
assigneeId: devUsers[Math.floor(Math.random() * devUsers.length)],
|
||||||
|
storyPoints: pts,
|
||||||
|
createdAt: ts(createdDaysAgo), // Date object
|
||||||
|
completedAt: status === 'done' ? ts(Math.floor(Math.random() * 7)) : null, // Date or null
|
||||||
|
dueDate: fmtDate(ts(Math.floor(Math.random() * 14))),
|
||||||
|
labels: status === 'done' && Math.random() > 0.7 ? ['bug'] : [],
|
||||||
|
updatedAt: now, // Date object
|
||||||
|
}).onConflictDoNothing();
|
||||||
|
taskCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(` Tasks: ${taskCount}`);
|
||||||
|
|
||||||
|
// ── 5. Milestones (每个项目 3 个) ──
|
||||||
|
const milestoneStatuses: Array<'completed' | 'active' | 'backlog'> = ['completed', 'active', 'backlog'];
|
||||||
|
const milestoneNames = ['MVP 发布', '2.0 版本', '3.0 规划', '性能优化', '安全加固'];
|
||||||
|
let msCount = 0;
|
||||||
|
|
||||||
|
for (const proj of projectList) {
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const total = 10 + Math.floor(Math.random() * 15);
|
||||||
|
const completed = milestoneStatuses[i] === 'completed'
|
||||||
|
? total
|
||||||
|
: milestoneStatuses[i] === 'active'
|
||||||
|
? Math.floor(total * 0.6)
|
||||||
|
: 0;
|
||||||
|
await db.insert(milestones).values({
|
||||||
|
id: `ms-${proj.identifier.toLowerCase()}-${i}`,
|
||||||
|
planeModuleId: `mod-${proj.identifier.toLowerCase()}-${i}`,
|
||||||
|
projectId: proj.id,
|
||||||
|
name: `${proj.identifier} ${milestoneNames[i]}`,
|
||||||
|
status: milestoneStatuses[i],
|
||||||
|
targetDate: fmtDate(ts(-30 * (i + 1))),
|
||||||
|
totalIssues: total,
|
||||||
|
completedIssues: completed,
|
||||||
|
createdAt: ts(90), // Date object
|
||||||
|
updatedAt: now, // Date object
|
||||||
|
}).onConflictDoNothing();
|
||||||
|
msCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(` Milestones: ${msCount}`);
|
||||||
|
|
||||||
|
// ── 6. Git Commits (近 12 周,每个开发者每周 5-15 条) ──
|
||||||
|
const repos = ['avatar-frontend', 'avatar-backend', 'airflow-engine', 'datahub-api', 'ops-admin'];
|
||||||
|
const commitMessages = [
|
||||||
|
'feat: 添加用户认证模块', 'fix: 修复数据同步延迟', 'refactor: 重构API路由层',
|
||||||
|
'perf: 优化数据库查询', 'style: 统一代码格式', 'docs: 更新API文档',
|
||||||
|
'test: 添加集成测试', 'chore: 升级依赖版本', 'feat: 实现文件上传',
|
||||||
|
'fix: 修复权限检查逻辑', 'feat: 添加数据导出', 'perf: 前端包体积优化',
|
||||||
|
'fix: 修复时区转换错误', 'feat: 实现WebSocket推送', 'refactor: 状态管理迁移Pinia',
|
||||||
|
];
|
||||||
|
let commitCount = 0;
|
||||||
|
|
||||||
|
for (let week = 0; week < 12; week++) {
|
||||||
|
for (const userId of ['u-dev-1', 'u-dev-2', 'u-dev-3', 'u-dev-4']) {
|
||||||
|
const commitNum = 5 + Math.floor(Math.random() * 11);
|
||||||
|
for (let i = 0; i < commitNum; i++) {
|
||||||
|
const dayOffset = Math.floor(Math.random() * 7);
|
||||||
|
// Construct a proper Date object for committedAt
|
||||||
|
const commitDate = new Date(weekStart(week).getTime() + dayOffset * 86400000 + Math.random() * 86400000);
|
||||||
|
const additions = 10 + Math.floor(Math.random() * 200);
|
||||||
|
const deletions = Math.floor(Math.random() * 80);
|
||||||
|
|
||||||
|
await db.insert(gitCommits).values({
|
||||||
|
id: `gc-${week}-${userId}-${i}`,
|
||||||
|
repoName: repos[Math.floor(Math.random() * repos.length)],
|
||||||
|
sha: `${uuid().replace(/-/g, '')}${week}${i}`.slice(0, 40),
|
||||||
|
authorEmail: `${userId.replace('u-', '')}@jasonqiyuan.com`,
|
||||||
|
authorName: userList.find(u => u.id === userId)?.displayName || userId,
|
||||||
|
userId,
|
||||||
|
message: commitMessages[Math.floor(Math.random() * commitMessages.length)],
|
||||||
|
additions,
|
||||||
|
deletions,
|
||||||
|
committedAt: commitDate, // Date object
|
||||||
|
createdAt: commitDate, // Date object
|
||||||
|
updatedAt: now, // Date object
|
||||||
|
}).onConflictDoNothing();
|
||||||
|
commitCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(` Git commits: ${commitCount}`);
|
||||||
|
|
||||||
|
// ── 7. Git PRs (近 12 周,每个开发者每周 1-4 个 PR) ──
|
||||||
|
let prCount = 0;
|
||||||
|
const prTitles = [
|
||||||
|
'feat: 用户认证模块', 'fix: 数据同步延迟', 'refactor: API路由层重构',
|
||||||
|
'feat: 文件上传功能', 'fix: 权限检查修复', 'feat: 数据导出功能',
|
||||||
|
'perf: 查询性能优化', 'feat: WebSocket推送', 'fix: 时区转换问题',
|
||||||
|
'chore: 依赖升级', 'feat: 批量导入', 'style: UI样式统一',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (let week = 0; week < 12; week++) {
|
||||||
|
for (const userId of ['u-dev-1', 'u-dev-2', 'u-dev-3', 'u-dev-4']) {
|
||||||
|
const prNum = 1 + Math.floor(Math.random() * 4);
|
||||||
|
for (let i = 0; i < prNum; i++) {
|
||||||
|
const dayOffset = Math.floor(Math.random() * 7);
|
||||||
|
const createdDate = new Date(weekStart(week).getTime() + dayOffset * 86400000);
|
||||||
|
const mergeHours = 2 + Math.random() * 72;
|
||||||
|
const mergedDate = new Date(createdDate.getTime() + mergeHours * 3600000);
|
||||||
|
const isMerged = week > 0 || Math.random() > 0.3;
|
||||||
|
const state = isMerged ? 'merged' : (Math.random() > 0.5 ? 'open' : 'closed');
|
||||||
|
|
||||||
|
await db.insert(gitPRs).values({
|
||||||
|
id: `pr-${week}-${userId}-${i}`,
|
||||||
|
repoName: repos[Math.floor(Math.random() * repos.length)],
|
||||||
|
externalId: 100 + prCount,
|
||||||
|
title: prTitles[Math.floor(Math.random() * prTitles.length)],
|
||||||
|
userId,
|
||||||
|
authorUsername: userList.find(u => u.id === userId)?.gitUsername || userId,
|
||||||
|
state,
|
||||||
|
additions: 20 + Math.floor(Math.random() * 300),
|
||||||
|
deletions: Math.floor(Math.random() * 100),
|
||||||
|
reviewComments: Math.floor(Math.random() * 8),
|
||||||
|
createdAt: createdDate, // Date object
|
||||||
|
mergedAt: state === 'merged' ? mergedDate : null, // Date or null
|
||||||
|
mergeTimeHours: state === 'merged' ? Math.round(mergeHours * 10) / 10 : null,
|
||||||
|
updatedAt: now, // Date object
|
||||||
|
}).onConflictDoNothing();
|
||||||
|
prCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(` Git PRs: ${prCount}`);
|
||||||
|
|
||||||
|
// ── 8. OKR (3 个 Objective,每个 2-3 个 KR) ──
|
||||||
|
const okrData = [
|
||||||
|
{
|
||||||
|
id: 'obj-1', title: '提升研发交付效率', ownerId: 'u-mgr-1', projectId: 'p-avatar',
|
||||||
|
period: '2026-Q2', progress: 68,
|
||||||
|
krs: [
|
||||||
|
{ title: 'Sprint 交付率提升至 85%', target: 85, current: 72, unit: '%', weight: 2 },
|
||||||
|
{ title: '平均交付周期缩短至 5 天', target: 5, current: 6.2, unit: '天', weight: 1.5 },
|
||||||
|
{ title: 'Bug 密度降至 0.3 以下', target: 0.3, current: 0.45, unit: 'Bug/点', weight: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'obj-2', title: '建设代码质量体系', ownerId: 'u-mgr-2', projectId: 'p-airflow',
|
||||||
|
period: '2026-Q2', progress: 55,
|
||||||
|
krs: [
|
||||||
|
{ title: 'PR Review 覆盖率达到 90%', target: 90, current: 65, unit: '%', weight: 2 },
|
||||||
|
{ title: 'PR 平均合入时间 < 24h', target: 24, current: 31, unit: '小时', weight: 1.5 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'obj-3', title: '完成 DataHub 2.0 发布', ownerId: 'u-dev-1', projectId: 'p-datahub',
|
||||||
|
period: '2026-Q2', progress: 42,
|
||||||
|
krs: [
|
||||||
|
{ title: '核心功能开发完成度', target: 100, current: 60, unit: '%', weight: 3 },
|
||||||
|
{ title: '性能基准测试通过', target: 100, current: 30, unit: '%', weight: 2 },
|
||||||
|
{ title: '文档完善度', target: 100, current: 25, unit: '%', weight: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const obj of okrData) {
|
||||||
|
await db.insert(objectives).values({
|
||||||
|
id: obj.id,
|
||||||
|
title: obj.title,
|
||||||
|
ownerId: obj.ownerId,
|
||||||
|
projectId: obj.projectId,
|
||||||
|
period: obj.period,
|
||||||
|
progress: obj.progress,
|
||||||
|
createdAt: ts(60), // Date object
|
||||||
|
updatedAt: now, // Date object
|
||||||
|
}).onConflictDoNothing();
|
||||||
|
|
||||||
|
for (let i = 0; i < obj.krs.length; i++) {
|
||||||
|
const kr = obj.krs[i];
|
||||||
|
await db.insert(keyResults).values({
|
||||||
|
id: `kr-${obj.id}-${i}`,
|
||||||
|
objectiveId: obj.id,
|
||||||
|
title: kr.title,
|
||||||
|
targetValue: kr.target,
|
||||||
|
currentValue: kr.current,
|
||||||
|
unit: kr.unit,
|
||||||
|
weight: kr.weight,
|
||||||
|
createdAt: ts(60), // Date object
|
||||||
|
updatedAt: now, // Date object
|
||||||
|
}).onConflictDoNothing();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(` OKR Objectives: ${okrData.length}, Key Results: ${okrData.reduce((s, o) => s + o.krs.length, 0)}`);
|
||||||
|
|
||||||
|
// ── 9. Author Mappings ──
|
||||||
|
for (const u of userList.filter(u => u.gitUsername)) {
|
||||||
|
await db.insert(authorMappings).values({
|
||||||
|
id: `am-${u.id}`,
|
||||||
|
gitEmail: u.email,
|
||||||
|
gitUsername: u.gitUsername,
|
||||||
|
userId: u.id,
|
||||||
|
createdAt: ts(90), // Date object
|
||||||
|
updatedAt: now, // Date object
|
||||||
|
}).onConflictDoNothing();
|
||||||
|
}
|
||||||
|
console.log(` Author mappings: ${userList.filter(u => u.gitUsername).length}`);
|
||||||
|
|
||||||
|
// ── 10. Sync Logs (最近 10 条) ──
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
const source = i % 2 === 0 ? 'plane' as const : 'gitea' as const;
|
||||||
|
await db.insert(syncLogs).values({
|
||||||
|
id: `sl-${i}`,
|
||||||
|
source,
|
||||||
|
status: i === 3 ? 'error' as const : 'success' as const,
|
||||||
|
message: i === 3 ? 'Connection timeout to Gitea API' : `Synced ${20 + Math.floor(Math.random() * 50)} records`,
|
||||||
|
recordsProcessed: i === 3 ? 0 : 20 + Math.floor(Math.random() * 50),
|
||||||
|
syncedAt: ts(i * 0.5), // Date object -- spread logs over ~5 days
|
||||||
|
}).onConflictDoNothing();
|
||||||
|
}
|
||||||
|
console.log(` Sync logs: 10`);
|
||||||
|
|
||||||
|
console.log('\n=== Test data seeding complete! ===');
|
||||||
|
console.log(` Users: ${userList.length}`);
|
||||||
|
console.log(` Projects: ${projectList.length}`);
|
||||||
|
console.log(` Sprints: ${allSprints.length}`);
|
||||||
|
console.log(` Tasks: ${taskCount}`);
|
||||||
|
console.log(` Milestones: ${msCount}`);
|
||||||
|
console.log(` Git Commits: ${commitCount}`);
|
||||||
|
console.log(` Git PRs: ${prCount}`);
|
||||||
|
console.log(` OKR Objectives: ${okrData.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
seed().catch(console.error);
|
||||||
38
backend/src/db/seed.ts
Normal file
38
backend/src/db/seed.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { db } from './index';
|
||||||
|
import { users } from './schema';
|
||||||
|
import { config } from '../config';
|
||||||
|
import bcrypt from 'bcrypt';
|
||||||
|
import { v4 as uuid } from 'uuid';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
async function seed() {
|
||||||
|
console.log('Seeding database...');
|
||||||
|
|
||||||
|
// Create admin user if not exists
|
||||||
|
const existingAdmin = await db.query.users.findFirst({
|
||||||
|
where: eq(users.email, config.ADMIN_EMAIL),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingAdmin) {
|
||||||
|
const passwordHash = await bcrypt.hash(config.ADMIN_PASSWORD, 12);
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
await db.insert(users).values({
|
||||||
|
id: uuid(),
|
||||||
|
displayName: 'Admin',
|
||||||
|
email: config.ADMIN_EMAIL,
|
||||||
|
role: 'admin',
|
||||||
|
passwordHash,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Admin user created: ${config.ADMIN_EMAIL}`);
|
||||||
|
} else {
|
||||||
|
console.log('Admin user already exists, skipping seed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Seed complete.');
|
||||||
|
}
|
||||||
|
|
||||||
|
seed().catch(console.error);
|
||||||
70
backend/src/index.ts
Normal file
70
backend/src/index.ts
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { cors } from 'hono/cors';
|
||||||
|
import { config } from './config';
|
||||||
|
import { requestLogger } from './middleware/logger';
|
||||||
|
import { errorHandler } from './middleware/error-handler';
|
||||||
|
import { authMiddleware } from './middleware/auth';
|
||||||
|
import { authRoutes } from './routes/auth';
|
||||||
|
import { overviewRoutes } from './routes/overview';
|
||||||
|
import { projectRoutes } from './routes/projects';
|
||||||
|
import { memberRoutes } from './routes/members';
|
||||||
|
import { okrRoutes } from './routes/okr';
|
||||||
|
import { gitRoutes } from './routes/git';
|
||||||
|
import { adminRoutes } from './routes/admin';
|
||||||
|
// Importing db triggers auto-migration on first load (B-07 fix)
|
||||||
|
import { db } from './db/index';
|
||||||
|
import { seedAdminUser } from './db/seed-auto';
|
||||||
|
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
// Global middleware
|
||||||
|
app.use('*', cors({
|
||||||
|
origin: ['http://localhost:5173', 'http://localhost:3201'],
|
||||||
|
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||||
|
allowHeaders: ['Content-Type', 'Authorization'],
|
||||||
|
}));
|
||||||
|
app.use('*', requestLogger);
|
||||||
|
|
||||||
|
// Health check (public)
|
||||||
|
app.get('/api/health', (c) => {
|
||||||
|
const startTime = process.uptime();
|
||||||
|
return c.json({
|
||||||
|
code: 0,
|
||||||
|
data: {
|
||||||
|
status: 'ok',
|
||||||
|
version: '1.0.0',
|
||||||
|
uptime: Math.floor(startTime),
|
||||||
|
dbConnected: true,
|
||||||
|
},
|
||||||
|
message: 'success',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auth routes (public)
|
||||||
|
app.route('/api/auth', authRoutes);
|
||||||
|
|
||||||
|
// Protected routes
|
||||||
|
app.use('/api/*', authMiddleware);
|
||||||
|
app.route('/api', overviewRoutes);
|
||||||
|
app.route('/api', projectRoutes);
|
||||||
|
app.route('/api', memberRoutes);
|
||||||
|
app.route('/api', okrRoutes);
|
||||||
|
app.route('/api', gitRoutes);
|
||||||
|
app.route('/api', adminRoutes);
|
||||||
|
|
||||||
|
// Error handler
|
||||||
|
app.onError(errorHandler);
|
||||||
|
|
||||||
|
// Auto-seed admin user on startup (safe to call repeatedly)
|
||||||
|
seedAdminUser().catch((err) => {
|
||||||
|
console.error('[Seed] Failed to seed admin user:', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start server
|
||||||
|
const port = config.PORT;
|
||||||
|
console.info(`DevPerf Dashboard API starting on port ${port}`);
|
||||||
|
|
||||||
|
export default {
|
||||||
|
port,
|
||||||
|
fetch: app.fetch,
|
||||||
|
};
|
||||||
40
backend/src/middleware/auth.ts
Normal file
40
backend/src/middleware/auth.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { MiddlewareHandler } from 'hono';
|
||||||
|
import { jwtVerify } from 'jose';
|
||||||
|
import { config } from '../config';
|
||||||
|
import { AppError } from './error-handler';
|
||||||
|
|
||||||
|
export interface JWTPayload {
|
||||||
|
sub: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
displayName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module 'hono' {
|
||||||
|
interface ContextVariableMap {
|
||||||
|
user: JWTPayload;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const secret = new TextEncoder().encode(config.JWT_SECRET);
|
||||||
|
|
||||||
|
export const authMiddleware: MiddlewareHandler = async (c, next) => {
|
||||||
|
const authHeader = c.req.header('Authorization');
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
throw new AppError(40101, 'Authentication required', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authHeader.slice(7);
|
||||||
|
try {
|
||||||
|
const { payload } = await jwtVerify(token, secret);
|
||||||
|
c.set('user', {
|
||||||
|
sub: payload.sub as string,
|
||||||
|
email: payload.email as string,
|
||||||
|
role: payload.role as string,
|
||||||
|
displayName: payload.displayName as string,
|
||||||
|
});
|
||||||
|
await next();
|
||||||
|
} catch {
|
||||||
|
throw new AppError(40102, 'Token expired or invalid', 401);
|
||||||
|
}
|
||||||
|
};
|
||||||
35
backend/src/middleware/error-handler.ts
Normal file
35
backend/src/middleware/error-handler.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { Context } from 'hono';
|
||||||
|
import { HTTPException } from 'hono/http-exception';
|
||||||
|
|
||||||
|
export class AppError extends Error {
|
||||||
|
constructor(
|
||||||
|
public code: number,
|
||||||
|
message: string,
|
||||||
|
public status: number = 400
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'AppError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function errorHandler(err: Error, c: Context) {
|
||||||
|
if (err instanceof AppError) {
|
||||||
|
return c.json(
|
||||||
|
{ code: err.code, data: null, message: err.message },
|
||||||
|
err.status as any
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err instanceof HTTPException) {
|
||||||
|
return c.json(
|
||||||
|
{ code: err.status * 100, data: null, message: err.message },
|
||||||
|
err.status
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('Unhandled error:', err);
|
||||||
|
return c.json(
|
||||||
|
{ code: 50001, data: null, message: 'Internal server error' },
|
||||||
|
500
|
||||||
|
);
|
||||||
|
}
|
||||||
15
backend/src/middleware/logger.ts
Normal file
15
backend/src/middleware/logger.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { MiddlewareHandler } from 'hono';
|
||||||
|
|
||||||
|
export const requestLogger: MiddlewareHandler = async (c, next) => {
|
||||||
|
const start = Date.now();
|
||||||
|
const method = c.req.method;
|
||||||
|
const path = c.req.path;
|
||||||
|
|
||||||
|
console.info(`[REQ] ${method} ${path}`);
|
||||||
|
|
||||||
|
await next();
|
||||||
|
|
||||||
|
const duration = Date.now() - start;
|
||||||
|
const status = c.res.status;
|
||||||
|
console.info(`[RES] ${method} ${path} ${status} ${duration}ms`);
|
||||||
|
};
|
||||||
17
backend/src/middleware/role.ts
Normal file
17
backend/src/middleware/role.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { MiddlewareHandler } from 'hono';
|
||||||
|
import { AppError } from './error-handler';
|
||||||
|
|
||||||
|
type UserRole = 'admin' | 'manager' | 'developer' | 'viewer';
|
||||||
|
|
||||||
|
export function requireRole(...roles: UserRole[]): MiddlewareHandler {
|
||||||
|
return async (c, next) => {
|
||||||
|
const user = c.get('user');
|
||||||
|
if (!user) {
|
||||||
|
throw new AppError(40101, 'Authentication required', 401);
|
||||||
|
}
|
||||||
|
if (!roles.includes(user.role as UserRole)) {
|
||||||
|
throw new AppError(40103, 'Insufficient permissions', 403);
|
||||||
|
}
|
||||||
|
await next();
|
||||||
|
};
|
||||||
|
}
|
||||||
290
backend/src/routes/admin.ts
Normal file
290
backend/src/routes/admin.ts
Normal file
@ -0,0 +1,290 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { zValidator } from '@hono/zod-validator';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { v4 as uuid } from 'uuid';
|
||||||
|
import bcrypt from 'bcrypt';
|
||||||
|
import { db } from '../db/index';
|
||||||
|
import { users, authorMappings, syncLogs, projects, projectRepos, gitCommits, gitPRs } from '../db/schema';
|
||||||
|
import { requireRole } from '../middleware/role';
|
||||||
|
import { AppError } from '../middleware/error-handler';
|
||||||
|
|
||||||
|
export const adminRoutes = new Hono();
|
||||||
|
|
||||||
|
// All admin routes require admin role
|
||||||
|
adminRoutes.use('/admin/*', requireRole('admin'));
|
||||||
|
|
||||||
|
// ── Users CRUD ──
|
||||||
|
|
||||||
|
adminRoutes.get('/admin/users', async (c) => {
|
||||||
|
const allUsers = await db.select().from(users);
|
||||||
|
return c.json({
|
||||||
|
code: 0,
|
||||||
|
data: allUsers.map(u => ({
|
||||||
|
id: u.id,
|
||||||
|
displayName: u.displayName,
|
||||||
|
email: u.email,
|
||||||
|
role: u.role,
|
||||||
|
planeUserId: u.planeUserId,
|
||||||
|
gitUsername: u.gitUsername,
|
||||||
|
createdAt: u.createdAt.toISOString(),
|
||||||
|
})),
|
||||||
|
message: 'success',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const createUserSchema = z.object({
|
||||||
|
displayName: z.string().min(1).max(100),
|
||||||
|
email: z.string().email(),
|
||||||
|
password: z.string().min(6),
|
||||||
|
role: z.enum(['admin', 'manager', 'developer', 'viewer']),
|
||||||
|
planeUserId: z.string().optional(),
|
||||||
|
gitUsername: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
adminRoutes.post('/admin/users', zValidator('json', createUserSchema), async (c) => {
|
||||||
|
const data = c.req.valid('json');
|
||||||
|
|
||||||
|
// Check email uniqueness
|
||||||
|
const existing = await db.query.users.findFirst({
|
||||||
|
where: eq(users.email, data.email),
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
throw new AppError(40901, 'Email already exists', 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = uuid();
|
||||||
|
const now = new Date();
|
||||||
|
const passwordHash = await bcrypt.hash(data.password, 12);
|
||||||
|
|
||||||
|
await db.insert(users).values({
|
||||||
|
id,
|
||||||
|
displayName: data.displayName,
|
||||||
|
email: data.email,
|
||||||
|
passwordHash,
|
||||||
|
role: data.role,
|
||||||
|
planeUserId: data.planeUserId || null,
|
||||||
|
gitUsername: data.gitUsername || null,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.json({ code: 0, data: { id }, message: 'success' }, 201);
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateUserSchema = z.object({
|
||||||
|
displayName: z.string().min(1).max(100).optional(),
|
||||||
|
email: z.string().email().optional(),
|
||||||
|
password: z.string().min(6).optional(),
|
||||||
|
role: z.enum(['admin', 'manager', 'developer', 'viewer']).optional(),
|
||||||
|
planeUserId: z.string().optional(),
|
||||||
|
gitUsername: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
adminRoutes.patch('/admin/users/:id', zValidator('json', updateUserSchema), async (c) => {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
const data = c.req.valid('json');
|
||||||
|
|
||||||
|
const user = await db.query.users.findFirst({ where: eq(users.id, id) });
|
||||||
|
if (!user) {
|
||||||
|
throw new AppError(40401, 'User not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData: Record<string, any> = { updatedAt: new Date() };
|
||||||
|
if (data.displayName) updateData.displayName = data.displayName;
|
||||||
|
if (data.email) updateData.email = data.email;
|
||||||
|
if (data.role) updateData.role = data.role;
|
||||||
|
if (data.planeUserId !== undefined) updateData.planeUserId = data.planeUserId;
|
||||||
|
if (data.gitUsername !== undefined) updateData.gitUsername = data.gitUsername;
|
||||||
|
if (data.password) updateData.passwordHash = await bcrypt.hash(data.password, 12);
|
||||||
|
|
||||||
|
await db.update(users).set(updateData).where(eq(users.id, id));
|
||||||
|
return c.json({ code: 0, data: { id }, message: 'success' });
|
||||||
|
});
|
||||||
|
|
||||||
|
adminRoutes.delete('/admin/users/:id', async (c) => {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
await db.delete(users).where(eq(users.id, id));
|
||||||
|
return c.json({ code: 0, data: null, message: 'success' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Projects CRUD ──
|
||||||
|
|
||||||
|
adminRoutes.get('/admin/projects', async (c) => {
|
||||||
|
const allProjects = await db.select().from(projects);
|
||||||
|
return c.json({
|
||||||
|
code: 0,
|
||||||
|
data: allProjects.map(p => ({
|
||||||
|
id: p.id,
|
||||||
|
name: p.name,
|
||||||
|
identifier: p.identifier,
|
||||||
|
planeProjectId: p.planeProjectId,
|
||||||
|
createdAt: p.createdAt instanceof Date ? p.createdAt.toISOString() : p.createdAt,
|
||||||
|
})),
|
||||||
|
message: 'success',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const createProjectSchema = z.object({
|
||||||
|
name: z.string().min(1).max(200),
|
||||||
|
identifier: z.string().min(1).max(20).toUpperCase(),
|
||||||
|
});
|
||||||
|
|
||||||
|
adminRoutes.post('/admin/projects', zValidator('json', createProjectSchema), async (c) => {
|
||||||
|
const data = c.req.valid('json');
|
||||||
|
const id = uuid();
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
await db.insert(projects).values({
|
||||||
|
id,
|
||||||
|
planeProjectId: `local-${id}`,
|
||||||
|
name: data.name,
|
||||||
|
identifier: data.identifier,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.json({ code: 0, data: { id }, message: 'success' }, 201);
|
||||||
|
});
|
||||||
|
|
||||||
|
adminRoutes.delete('/admin/projects/:id', async (c) => {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
await db.delete(projects).where(eq(projects.id, id));
|
||||||
|
return c.json({ code: 0, data: null, message: 'success' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Project Repo Bindings ──
|
||||||
|
|
||||||
|
adminRoutes.get('/admin/projects/:id/repos', async (c) => {
|
||||||
|
const projectId = c.req.param('id');
|
||||||
|
const bindings = await db.select().from(projectRepos)
|
||||||
|
.where(eq(projectRepos.projectId, projectId));
|
||||||
|
return c.json({ code: 0, data: bindings, message: 'success' });
|
||||||
|
});
|
||||||
|
|
||||||
|
const bindRepoSchema = z.object({
|
||||||
|
repoName: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
adminRoutes.post('/admin/projects/:id/repos', zValidator('json', bindRepoSchema), async (c) => {
|
||||||
|
const projectId = c.req.param('id');
|
||||||
|
const { repoName } = c.req.valid('json');
|
||||||
|
const id = uuid();
|
||||||
|
await db.insert(projectRepos).values({
|
||||||
|
id,
|
||||||
|
projectId,
|
||||||
|
repoName,
|
||||||
|
createdAt: new Date(),
|
||||||
|
});
|
||||||
|
return c.json({ code: 0, data: { id }, message: 'success' }, 201);
|
||||||
|
});
|
||||||
|
|
||||||
|
adminRoutes.delete('/admin/project-repos/:id', async (c) => {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
await db.delete(projectRepos).where(eq(projectRepos.id, id));
|
||||||
|
return c.json({ code: 0, data: null, message: 'success' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 查询已同步的所有仓库名(供下拉选择)
|
||||||
|
adminRoutes.get('/admin/available-repos', async (c) => {
|
||||||
|
const commits = await db.selectDistinct({ repoName: gitCommits.repoName }).from(gitCommits);
|
||||||
|
const prs = await db.selectDistinct({ repoName: gitPRs.repoName }).from(gitPRs);
|
||||||
|
const repoSet = new Set([
|
||||||
|
...commits.map(c => c.repoName),
|
||||||
|
...prs.map(p => p.repoName),
|
||||||
|
]);
|
||||||
|
return c.json({ code: 0, data: Array.from(repoSet).sort(), message: 'success' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Author Mappings ──
|
||||||
|
|
||||||
|
adminRoutes.get('/admin/author-mappings', async (c) => {
|
||||||
|
const mappings = await db.select().from(authorMappings);
|
||||||
|
const allUsers = await db.select().from(users);
|
||||||
|
const userMap = new Map(allUsers.map(u => [u.id, u.displayName]));
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
code: 0,
|
||||||
|
data: mappings.map(m => ({
|
||||||
|
id: m.id,
|
||||||
|
gitEmail: m.gitEmail,
|
||||||
|
gitUsername: m.gitUsername,
|
||||||
|
userId: m.userId,
|
||||||
|
userName: m.userId ? userMap.get(m.userId) || null : null,
|
||||||
|
})),
|
||||||
|
message: 'success',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const createMappingSchema = z.object({
|
||||||
|
gitEmail: z.string().email().optional(),
|
||||||
|
gitUsername: z.string().optional(),
|
||||||
|
userId: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
adminRoutes.post('/admin/author-mappings', zValidator('json', createMappingSchema), async (c) => {
|
||||||
|
const data = c.req.valid('json');
|
||||||
|
|
||||||
|
if (!data.gitEmail && !data.gitUsername) {
|
||||||
|
throw new AppError(40001, 'Either gitEmail or gitUsername is required', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = uuid();
|
||||||
|
const now = new Date();
|
||||||
|
await db.insert(authorMappings).values({
|
||||||
|
id,
|
||||||
|
gitEmail: data.gitEmail || null,
|
||||||
|
gitUsername: data.gitUsername || null,
|
||||||
|
userId: data.userId,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.json({ code: 0, data: { id }, message: 'success' }, 201);
|
||||||
|
});
|
||||||
|
|
||||||
|
adminRoutes.delete('/admin/author-mappings/:id', async (c) => {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
await db.delete(authorMappings).where(eq(authorMappings.id, id));
|
||||||
|
return c.json({ code: 0, data: null, message: 'success' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Sync ──
|
||||||
|
|
||||||
|
adminRoutes.post('/admin/sync/trigger', async (c) => {
|
||||||
|
const { syncGitea } = await import('../sync/sync-gitea');
|
||||||
|
// 异步执行,不阻塞响应
|
||||||
|
syncGitea().catch(err => console.error('[SYNC] Manual trigger failed:', err));
|
||||||
|
return c.json({
|
||||||
|
code: 0,
|
||||||
|
data: { message: '同步已触发,请稍后刷新查看结果' },
|
||||||
|
message: 'success',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
adminRoutes.get('/admin/sync/logs', async (c) => {
|
||||||
|
const page = parseInt(c.req.query('page') || '1');
|
||||||
|
const pageSize = parseInt(c.req.query('pageSize') || '20');
|
||||||
|
|
||||||
|
const allLogs = await db.select().from(syncLogs).orderBy(syncLogs.syncedAt);
|
||||||
|
const total = allLogs.length;
|
||||||
|
const items = allLogs.slice((page - 1) * pageSize, page * pageSize);
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
code: 0,
|
||||||
|
data: {
|
||||||
|
items: items.map(l => ({
|
||||||
|
id: l.id,
|
||||||
|
source: l.source,
|
||||||
|
status: l.status,
|
||||||
|
message: l.message,
|
||||||
|
recordsProcessed: l.recordsProcessed,
|
||||||
|
syncedAt: l.syncedAt.toISOString(),
|
||||||
|
})),
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
},
|
||||||
|
message: 'success',
|
||||||
|
});
|
||||||
|
});
|
||||||
24
backend/src/routes/auth.ts
Normal file
24
backend/src/routes/auth.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { zValidator } from '@hono/zod-validator';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { login, getUserById } from '../services/auth';
|
||||||
|
import { authMiddleware } from '../middleware/auth';
|
||||||
|
|
||||||
|
export const authRoutes = new Hono();
|
||||||
|
|
||||||
|
const loginSchema = z.object({
|
||||||
|
email: z.string().email('Invalid email format'),
|
||||||
|
password: z.string().min(6, 'Password must be at least 6 characters'),
|
||||||
|
});
|
||||||
|
|
||||||
|
authRoutes.post('/login', zValidator('json', loginSchema), async (c) => {
|
||||||
|
const { email, password } = c.req.valid('json');
|
||||||
|
const result = await login(email, password);
|
||||||
|
return c.json({ code: 0, data: result, message: 'success' });
|
||||||
|
});
|
||||||
|
|
||||||
|
authRoutes.get('/me', authMiddleware, async (c) => {
|
||||||
|
const user = c.get('user');
|
||||||
|
const userData = await getUserById(user.sub);
|
||||||
|
return c.json({ code: 0, data: userData, message: 'success' });
|
||||||
|
});
|
||||||
103
backend/src/routes/git.ts
Normal file
103
backend/src/routes/git.ts
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { db } from '../db/index';
|
||||||
|
import { gitCommits, gitPRs, users } from '../db/schema';
|
||||||
|
import { eq, and, gte, desc } from 'drizzle-orm';
|
||||||
|
import { AppError } from '../middleware/error-handler';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
export const gitRoutes = new Hono();
|
||||||
|
|
||||||
|
// GET /api/git/activity
|
||||||
|
gitRoutes.get('/git/activity', async (c) => {
|
||||||
|
const user = c.get('user');
|
||||||
|
const queryUserId = c.req.query('userId');
|
||||||
|
const weeks = parseInt(c.req.query('weeks') || '12');
|
||||||
|
|
||||||
|
if (user.role === 'viewer') {
|
||||||
|
throw new AppError(40103, 'Insufficient permissions', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
let targetUserId: string | undefined;
|
||||||
|
if (user.role === 'developer') {
|
||||||
|
targetUserId = user.sub;
|
||||||
|
} else if (queryUserId) {
|
||||||
|
targetUserId = queryUserId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDate = dayjs().subtract(weeks, 'week').startOf('week').toDate();
|
||||||
|
|
||||||
|
// 所有 commits
|
||||||
|
const commitQuery = targetUserId
|
||||||
|
? db.select().from(gitCommits).where(and(eq(gitCommits.userId, targetUserId), gte(gitCommits.committedAt, startDate)))
|
||||||
|
: db.select().from(gitCommits).where(gte(gitCommits.committedAt, startDate));
|
||||||
|
const commits = await commitQuery;
|
||||||
|
|
||||||
|
// Heatmap(按天)
|
||||||
|
const dayMap: Record<string, { commits: number; additions: number; deletions: number }> = {};
|
||||||
|
const today = dayjs();
|
||||||
|
for (let d = dayjs(startDate); d.isBefore(today) || d.isSame(today, 'day'); d = d.add(1, 'day')) {
|
||||||
|
dayMap[d.format('YYYY-MM-DD')] = { commits: 0, additions: 0, deletions: 0 };
|
||||||
|
}
|
||||||
|
for (const commit of commits) {
|
||||||
|
const day = dayjs(commit.committedAt).format('YYYY-MM-DD');
|
||||||
|
if (dayMap[day]) {
|
||||||
|
dayMap[day].commits++;
|
||||||
|
dayMap[day].additions += commit.additions || 0;
|
||||||
|
dayMap[day].deletions += commit.deletions || 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const heatmap = Object.entries(dayMap).map(([date, data]) => ({ date, ...data }));
|
||||||
|
|
||||||
|
// 统计指标(替代原来的 PR 指标)
|
||||||
|
const allCommits = await db.select().from(gitCommits);
|
||||||
|
const thisMonthStart = dayjs().startOf('month').toDate();
|
||||||
|
const thisMonthCommits = allCommits.filter(c => dayjs(c.committedAt).isAfter(thisMonthStart));
|
||||||
|
const activeContributors = new Set(allCommits.filter(c => c.userId).map(c => c.userId)).size;
|
||||||
|
const activeRepos = new Set(allCommits.map(c => c.repoName)).size;
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
totalCommits: allCommits.length,
|
||||||
|
activeContributors,
|
||||||
|
thisMonthCommits: thisMonthCommits.length,
|
||||||
|
activeRepos,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 每周趋势(按人堆叠)
|
||||||
|
const allUsers = await db.select().from(users);
|
||||||
|
const userMap = new Map(allUsers.map(u => [u.id, u.displayName]));
|
||||||
|
|
||||||
|
const weeklyTrend = [];
|
||||||
|
for (let i = 0; i < weeks; i++) {
|
||||||
|
const weekStart = dayjs().subtract(weeks - 1 - i, 'week').startOf('week');
|
||||||
|
const weekEnd = weekStart.add(7, 'day');
|
||||||
|
const weekCommits = commits.filter(c => {
|
||||||
|
const d = dayjs(c.committedAt);
|
||||||
|
return d.isAfter(weekStart) && d.isBefore(weekEnd);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 按人分组
|
||||||
|
const byUser: Record<string, number> = {};
|
||||||
|
for (const c of weekCommits) {
|
||||||
|
const uid = c.userId || 'unknown';
|
||||||
|
byUser[uid] = (byUser[uid] || 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
weeklyTrend.push({
|
||||||
|
weekStart: weekStart.format('YYYY-MM-DD'),
|
||||||
|
total: weekCommits.length,
|
||||||
|
additions: weekCommits.reduce((sum, c) => sum + (c.additions || 0), 0),
|
||||||
|
deletions: weekCommits.reduce((sum, c) => sum + (c.deletions || 0), 0),
|
||||||
|
byUser: Object.entries(byUser).map(([userId, count]) => ({
|
||||||
|
userId,
|
||||||
|
name: userMap.get(userId) || '未关联',
|
||||||
|
commits: count,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
code: 0,
|
||||||
|
data: { heatmap, stats, weeklyTrend },
|
||||||
|
message: 'success',
|
||||||
|
});
|
||||||
|
});
|
||||||
167
backend/src/routes/members.ts
Normal file
167
backend/src/routes/members.ts
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { db } from '../db/index';
|
||||||
|
import { users, taskSnapshots, gitCommits, gitPRs, sprintSnapshots } from '../db/schema';
|
||||||
|
import { eq, and, desc, gte } from 'drizzle-orm';
|
||||||
|
import { AppError } from '../middleware/error-handler';
|
||||||
|
import { requireRole } from '../middleware/role';
|
||||||
|
import { calculateKPI } from '../services/metrics';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
export const memberRoutes = new Hono();
|
||||||
|
|
||||||
|
// GET /api/members/options — 所有登录用户可用,用于下拉选择(只返回 id + 名字)
|
||||||
|
memberRoutes.get('/members/options', async (c) => {
|
||||||
|
const allUsers = await db.select().from(users);
|
||||||
|
return c.json({
|
||||||
|
code: 0,
|
||||||
|
data: allUsers.map(u => ({ id: u.id, displayName: u.displayName, role: u.role })),
|
||||||
|
message: 'success',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/members
|
||||||
|
memberRoutes.get('/members', async (c) => {
|
||||||
|
const user = c.get('user');
|
||||||
|
|
||||||
|
// developer and viewer can only see themselves
|
||||||
|
if (user.role === 'developer' || user.role === 'viewer') {
|
||||||
|
const self = await db.query.users.findFirst({
|
||||||
|
where: eq(users.id, user.sub),
|
||||||
|
});
|
||||||
|
return c.json({
|
||||||
|
code: 0,
|
||||||
|
data: self ? [{
|
||||||
|
id: self.id,
|
||||||
|
displayName: self.displayName,
|
||||||
|
email: self.email,
|
||||||
|
role: self.role,
|
||||||
|
}] : [],
|
||||||
|
message: 'success',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const allUsers = await db.select().from(users);
|
||||||
|
return c.json({
|
||||||
|
code: 0,
|
||||||
|
data: allUsers.map(u => ({
|
||||||
|
id: u.id,
|
||||||
|
displayName: u.displayName,
|
||||||
|
email: u.email,
|
||||||
|
role: u.role,
|
||||||
|
})),
|
||||||
|
message: 'success',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/members/:id
|
||||||
|
memberRoutes.get('/members/:id', async (c) => {
|
||||||
|
const memberId = c.req.param('id');
|
||||||
|
const currentUser = c.get('user');
|
||||||
|
|
||||||
|
// Permission check: developer can only view self
|
||||||
|
if (currentUser.role === 'developer' && currentUser.sub !== memberId) {
|
||||||
|
throw new AppError(40103, 'Insufficient permissions', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const member = await db.query.users.findFirst({
|
||||||
|
where: eq(users.id, memberId),
|
||||||
|
});
|
||||||
|
if (!member) {
|
||||||
|
throw new AppError(40401, 'Member not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delivery trend - last 6 sprints where user had tasks
|
||||||
|
const userTasks = await db.select().from(taskSnapshots)
|
||||||
|
.where(eq(taskSnapshots.assigneeId, memberId));
|
||||||
|
|
||||||
|
const sprintIds = [...new Set(userTasks.map(t => t.sprintId).filter(Boolean))];
|
||||||
|
const sprints = sprintIds.length > 0
|
||||||
|
? await db.select().from(sprintSnapshots)
|
||||||
|
.orderBy(desc(sprintSnapshots.endDate))
|
||||||
|
.limit(6)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const deliveryTrend = {
|
||||||
|
cycles: sprints.reverse().map(s => {
|
||||||
|
const sprintTasks = userTasks.filter(t => t.sprintId === s.id);
|
||||||
|
const assigned = sprintTasks.reduce((sum, t) => sum + (t.storyPoints || 0), 0);
|
||||||
|
const completed = sprintTasks.filter(t => t.status === 'done').reduce((sum, t) => sum + (t.storyPoints || 0), 0);
|
||||||
|
return {
|
||||||
|
name: s.name,
|
||||||
|
assignedPoints: assigned,
|
||||||
|
completedPoints: completed,
|
||||||
|
rate: assigned > 0 ? Math.round((completed / assigned) * 100) : 0,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Contribution heatmap - last 6 months
|
||||||
|
const sixMonthsAgo = dayjs().subtract(6, 'month').startOf('day').toDate();
|
||||||
|
const commits = await db.select().from(gitCommits)
|
||||||
|
.where(and(eq(gitCommits.userId, memberId), gte(gitCommits.committedAt, sixMonthsAgo)));
|
||||||
|
const prs = await db.select().from(gitPRs)
|
||||||
|
.where(and(eq(gitPRs.userId, memberId), gte(gitPRs.createdAt, sixMonthsAgo)));
|
||||||
|
|
||||||
|
const dayMap: Record<string, { commits: number; prsCreated: number; prsMerged: number; tasksCompleted: number }> = {};
|
||||||
|
const today = dayjs();
|
||||||
|
for (let d = dayjs(sixMonthsAgo); d.isBefore(today) || d.isSame(today, 'day'); d = d.add(1, 'day')) {
|
||||||
|
dayMap[d.format('YYYY-MM-DD')] = { commits: 0, prsCreated: 0, prsMerged: 0, tasksCompleted: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const commit of commits) {
|
||||||
|
const day = dayjs(commit.committedAt).format('YYYY-MM-DD');
|
||||||
|
if (dayMap[day]) dayMap[day].commits++;
|
||||||
|
}
|
||||||
|
for (const pr of prs) {
|
||||||
|
const createdDay = dayjs(pr.createdAt).format('YYYY-MM-DD');
|
||||||
|
if (dayMap[createdDay]) dayMap[createdDay].prsCreated++;
|
||||||
|
if (pr.state === 'merged' && pr.mergedAt) {
|
||||||
|
const mergedDay = dayjs(pr.mergedAt).format('YYYY-MM-DD');
|
||||||
|
if (dayMap[mergedDay]) dayMap[mergedDay].prsMerged++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tasks completed by day
|
||||||
|
const completedTasks = userTasks.filter(t => t.status === 'done' && t.completedAt && new Date(t.completedAt) >= sixMonthsAgo);
|
||||||
|
for (const task of completedTasks) {
|
||||||
|
const day = dayjs(task.completedAt).format('YYYY-MM-DD');
|
||||||
|
if (dayMap[day]) dayMap[day].tasksCompleted++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contributionHeatmap = {
|
||||||
|
days: Object.entries(dayMap).map(([date, data]) => ({ date, ...data })),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Current tasks
|
||||||
|
const currentTasks = userTasks
|
||||||
|
.filter(t => t.status !== 'done')
|
||||||
|
.map(t => ({
|
||||||
|
id: t.id,
|
||||||
|
title: t.title,
|
||||||
|
projectName: '',
|
||||||
|
status: t.status || 'todo',
|
||||||
|
priority: t.priority || 'none',
|
||||||
|
storyPoints: t.storyPoints,
|
||||||
|
dueDate: t.dueDate,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// KPI Scorecard
|
||||||
|
const kpiScorecard = await calculateKPI(memberId);
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
code: 0,
|
||||||
|
data: {
|
||||||
|
member: {
|
||||||
|
id: member.id,
|
||||||
|
displayName: member.displayName,
|
||||||
|
email: member.email,
|
||||||
|
role: member.role,
|
||||||
|
},
|
||||||
|
deliveryTrend,
|
||||||
|
contributionHeatmap,
|
||||||
|
currentTasks,
|
||||||
|
kpiScorecard,
|
||||||
|
},
|
||||||
|
message: 'success',
|
||||||
|
});
|
||||||
|
});
|
||||||
93
backend/src/routes/okr.ts
Normal file
93
backend/src/routes/okr.ts
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { zValidator } from '@hono/zod-validator';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { requireRole } from '../middleware/role';
|
||||||
|
import * as okrService from '../services/okr';
|
||||||
|
|
||||||
|
export const okrRoutes = new Hono();
|
||||||
|
|
||||||
|
// GET /api/okr
|
||||||
|
okrRoutes.get('/okr', async (c) => {
|
||||||
|
const period = c.req.query('period');
|
||||||
|
const data = await okrService.getOKRByPeriod(period || undefined);
|
||||||
|
return c.json({ code: 0, data, message: 'success' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/okr/objectives
|
||||||
|
const createObjectiveSchema = z.object({
|
||||||
|
title: z.string().min(1).max(200),
|
||||||
|
ownerId: z.string().min(1),
|
||||||
|
projectId: z.string().min(1),
|
||||||
|
startDate: z.string().min(1), // YYYY-MM-DD
|
||||||
|
endDate: z.string().min(1), // YYYY-MM-DD
|
||||||
|
period: z.string().optional(), // 自动推算,前端可不传
|
||||||
|
});
|
||||||
|
|
||||||
|
okrRoutes.post('/okr/objectives',
|
||||||
|
requireRole('admin', 'manager', 'developer'),
|
||||||
|
zValidator('json', createObjectiveSchema),
|
||||||
|
async (c) => {
|
||||||
|
const data = c.req.valid('json');
|
||||||
|
const result = await okrService.createObjective(data);
|
||||||
|
return c.json({ code: 0, data: result, message: 'success' }, 201);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// POST /api/okr/objectives/:id/key-results
|
||||||
|
const createKRSchema = z.object({
|
||||||
|
title: z.string().min(1).max(200),
|
||||||
|
targetValue: z.number().positive(),
|
||||||
|
unit: z.string().default(''),
|
||||||
|
weight: z.number().positive().default(1),
|
||||||
|
startDate: z.string().optional(), // YYYY-MM-DD
|
||||||
|
endDate: z.string().optional(), // YYYY-MM-DD
|
||||||
|
linkedPlaneCycleId: z.string().optional(),
|
||||||
|
linkedPlaneModuleId: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
okrRoutes.post('/okr/objectives/:id/key-results',
|
||||||
|
requireRole('admin', 'manager', 'developer'),
|
||||||
|
zValidator('json', createKRSchema),
|
||||||
|
async (c) => {
|
||||||
|
const objectiveId = c.req.param('id');
|
||||||
|
const data = c.req.valid('json');
|
||||||
|
const result = await okrService.createKeyResult(objectiveId, data);
|
||||||
|
return c.json({ code: 0, data: result, message: 'success' }, 201);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// PATCH /api/okr/key-results/:id
|
||||||
|
const updateKRSchema = z.object({
|
||||||
|
currentValue: z.number().min(0),
|
||||||
|
});
|
||||||
|
|
||||||
|
okrRoutes.patch('/okr/key-results/:id',
|
||||||
|
requireRole('admin', 'manager', 'developer'),
|
||||||
|
zValidator('json', updateKRSchema),
|
||||||
|
async (c) => {
|
||||||
|
const krId = c.req.param('id');
|
||||||
|
const { currentValue } = c.req.valid('json');
|
||||||
|
const result = await okrService.updateKeyResultProgress(krId, currentValue);
|
||||||
|
return c.json({ code: 0, data: result, message: 'success' });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// DELETE /api/okr/objectives/:id
|
||||||
|
okrRoutes.delete('/okr/objectives/:id',
|
||||||
|
requireRole('admin'),
|
||||||
|
async (c) => {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
await okrService.deleteObjective(id);
|
||||||
|
return c.json({ code: 0, data: null, message: 'success' });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// DELETE /api/okr/key-results/:id
|
||||||
|
okrRoutes.delete('/okr/key-results/:id',
|
||||||
|
requireRole('admin'),
|
||||||
|
async (c) => {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
await okrService.deleteKeyResult(id);
|
||||||
|
return c.json({ code: 0, data: null, message: 'success' });
|
||||||
|
}
|
||||||
|
);
|
||||||
183
backend/src/routes/overview.ts
Normal file
183
backend/src/routes/overview.ts
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { db } from '../db/index';
|
||||||
|
import { projects, gitCommits, gitPRs, objectives, keyResults, users, projectRepos } from '../db/schema';
|
||||||
|
import { eq, desc, gte } from 'drizzle-orm';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
export const overviewRoutes = new Hono();
|
||||||
|
|
||||||
|
overviewRoutes.get('/overview', async (c) => {
|
||||||
|
const period = c.req.query('period');
|
||||||
|
const projectIds = c.req.query('projectIds')?.split(',').filter(Boolean) || [];
|
||||||
|
|
||||||
|
// 1. 各项目 OKR 整体进度(替代 Sprint 交付率)
|
||||||
|
const allProjects = await db.select().from(projects);
|
||||||
|
const projectOKRProgress: { projectId: string; name: string; identifier: string; progress: number; objectiveCount: number }[] = [];
|
||||||
|
|
||||||
|
for (const proj of allProjects) {
|
||||||
|
if (projectIds.length > 0 && !projectIds.includes(proj.id)) continue;
|
||||||
|
let projObjectives = await db.select().from(objectives)
|
||||||
|
.where(eq(objectives.projectId, proj.id));
|
||||||
|
if (period) {
|
||||||
|
projObjectives = projObjectives.filter(o => o.period === period);
|
||||||
|
}
|
||||||
|
const avgProgress = projObjectives.length > 0
|
||||||
|
? Math.round(projObjectives.reduce((s, o) => s + (o.progress || 0), 0) / projObjectives.length)
|
||||||
|
: 0;
|
||||||
|
projectOKRProgress.push({
|
||||||
|
projectId: proj.id,
|
||||||
|
name: proj.name,
|
||||||
|
identifier: proj.identifier || '',
|
||||||
|
progress: avgProgress,
|
||||||
|
objectiveCount: projObjectives.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. KR 完成状态分布(替代任务状态分布)
|
||||||
|
const allKRs = await db.select().from(keyResults);
|
||||||
|
let filteredKRs = allKRs;
|
||||||
|
if (projectIds.length > 0) {
|
||||||
|
const projObjIds = new Set(
|
||||||
|
(await db.select().from(objectives))
|
||||||
|
.filter(o => o.projectId && projectIds.includes(o.projectId))
|
||||||
|
.map(o => o.id)
|
||||||
|
);
|
||||||
|
filteredKRs = allKRs.filter(kr => projObjIds.has(kr.objectiveId));
|
||||||
|
}
|
||||||
|
|
||||||
|
const krDistribution = {
|
||||||
|
completed: filteredKRs.filter(kr => (kr.currentValue || 0) >= (kr.targetValue || 100)).length,
|
||||||
|
inProgress: filteredKRs.filter(kr => {
|
||||||
|
const cv = kr.currentValue || 0;
|
||||||
|
const tv = kr.targetValue || 100;
|
||||||
|
return cv > 0 && cv < tv;
|
||||||
|
}).length,
|
||||||
|
notStarted: filteredKRs.filter(kr => (kr.currentValue || 0) === 0).length,
|
||||||
|
total: filteredKRs.length,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3. 产品线进度(读 OKR 进度)
|
||||||
|
const projectProgress = projectOKRProgress.map(p => ({
|
||||||
|
projectId: p.projectId,
|
||||||
|
name: p.name,
|
||||||
|
identifier: p.identifier,
|
||||||
|
currentCycleProgress: p.progress,
|
||||||
|
totalPoints: p.objectiveCount,
|
||||||
|
completedPoints: 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 4. Weekly Code Activity (last 12 weeks)
|
||||||
|
const twelveWeeksAgo = dayjs().subtract(12, 'week').startOf('week').toDate();
|
||||||
|
const commits = await db.select().from(gitCommits)
|
||||||
|
.where(gte(gitCommits.committedAt, twelveWeeksAgo));
|
||||||
|
const prs = await db.select().from(gitPRs)
|
||||||
|
.where(gte(gitPRs.createdAt, twelveWeeksAgo));
|
||||||
|
const allUsers = await db.select().from(users);
|
||||||
|
|
||||||
|
const weekMap: Record<string, Record<string, { commits: number; prs: number }>> = {};
|
||||||
|
for (let i = 0; i < 12; i++) {
|
||||||
|
const ws = dayjs().subtract(11 - i, 'week').startOf('week').format('YYYY-MM-DD');
|
||||||
|
weekMap[ws] = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const commit of commits) {
|
||||||
|
const ws = dayjs(commit.committedAt).startOf('week').format('YYYY-MM-DD');
|
||||||
|
if (weekMap[ws] && commit.userId) {
|
||||||
|
if (!weekMap[ws][commit.userId]) weekMap[ws][commit.userId] = { commits: 0, prs: 0 };
|
||||||
|
weekMap[ws][commit.userId].commits++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const pr of prs) {
|
||||||
|
const ws = dayjs(pr.createdAt).startOf('week').format('YYYY-MM-DD');
|
||||||
|
if (weekMap[ws] && pr.userId) {
|
||||||
|
if (!weekMap[ws][pr.userId]) weekMap[ws][pr.userId] = { commits: 0, prs: 0 };
|
||||||
|
weekMap[ws][pr.userId].prs++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const userMap = new Map(allUsers.map(u => [u.id, u.displayName]));
|
||||||
|
const weeklyCodeActivity = {
|
||||||
|
weeks: Object.entries(weekMap).map(([weekStart, members]) => ({
|
||||||
|
weekStart,
|
||||||
|
members: Object.entries(members).map(([userId, data]) => ({
|
||||||
|
userId,
|
||||||
|
name: userMap.get(userId) || 'Unknown',
|
||||||
|
...data,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 5. OKR Progress
|
||||||
|
let allObjectives = period
|
||||||
|
? await db.select().from(objectives).where(eq(objectives.period, period))
|
||||||
|
: await db.select().from(objectives);
|
||||||
|
|
||||||
|
if (projectIds.length > 0) {
|
||||||
|
allObjectives = allObjectives.filter(o => o.projectId && projectIds.includes(o.projectId));
|
||||||
|
}
|
||||||
|
|
||||||
|
const okrProgress = [];
|
||||||
|
for (const obj of allObjectives) {
|
||||||
|
const krs = await db.select().from(keyResults)
|
||||||
|
.where(eq(keyResults.objectiveId, obj.id));
|
||||||
|
const owner = obj.ownerId
|
||||||
|
? await db.query.users.findFirst({ where: eq(users.id, obj.ownerId) })
|
||||||
|
: null;
|
||||||
|
okrProgress.push({
|
||||||
|
id: obj.id,
|
||||||
|
title: obj.title,
|
||||||
|
ownerName: owner?.displayName || '未指定',
|
||||||
|
progress: obj.progress || 0,
|
||||||
|
startDate: obj.startDate || null,
|
||||||
|
endDate: obj.endDate || null,
|
||||||
|
keyResults: krs.map(kr => ({
|
||||||
|
title: kr.title,
|
||||||
|
current: kr.currentValue || 0,
|
||||||
|
target: kr.targetValue,
|
||||||
|
unit: kr.unit || '',
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. PR Merge Time (last 12 weeks)
|
||||||
|
const mergedPRs = await db.select().from(gitPRs)
|
||||||
|
.where(gte(gitPRs.mergedAt, twelveWeeksAgo));
|
||||||
|
|
||||||
|
const prWeekMap: Record<string, { totalHours: number; count: number }> = {};
|
||||||
|
for (let i = 0; i < 12; i++) {
|
||||||
|
const ws = dayjs().subtract(11 - i, 'week').startOf('week').format('YYYY-MM-DD');
|
||||||
|
prWeekMap[ws] = { totalHours: 0, count: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const pr of mergedPRs) {
|
||||||
|
if (pr.mergedAt && pr.mergeTimeHours !== null && pr.state === 'merged') {
|
||||||
|
const ws = dayjs(pr.mergedAt).startOf('week').format('YYYY-MM-DD');
|
||||||
|
if (prWeekMap[ws]) {
|
||||||
|
prWeekMap[ws].totalHours += pr.mergeTimeHours || 0;
|
||||||
|
prWeekMap[ws].count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const prMergeTime = {
|
||||||
|
weeks: Object.entries(prWeekMap).map(([weekStart, data]) => ({
|
||||||
|
weekStart,
|
||||||
|
avgHours: data.count > 0 ? Math.round(data.totalHours / data.count * 10) / 10 : 0,
|
||||||
|
prCount: data.count,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
code: 0,
|
||||||
|
data: {
|
||||||
|
projectOKRProgress,
|
||||||
|
krDistribution,
|
||||||
|
projectProgress,
|
||||||
|
weeklyCodeActivity,
|
||||||
|
okrProgress,
|
||||||
|
prMergeTime,
|
||||||
|
},
|
||||||
|
message: 'success',
|
||||||
|
});
|
||||||
|
});
|
||||||
281
backend/src/routes/projects.ts
Normal file
281
backend/src/routes/projects.ts
Normal file
@ -0,0 +1,281 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { zValidator } from '@hono/zod-validator';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { v4 as uuid } from 'uuid';
|
||||||
|
import { db } from '../db/index';
|
||||||
|
import { projects, sprintSnapshots, milestones, taskSnapshots, gitCommits, gitPRs, users, objectives, keyResults, projectRepos } from '../db/schema';
|
||||||
|
import { eq, and, desc, gte } from 'drizzle-orm';
|
||||||
|
import { requireRole } from '../middleware/role';
|
||||||
|
import { AppError } from '../middleware/error-handler';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
export const projectRoutes = new Hono();
|
||||||
|
|
||||||
|
// GET /api/projects — 所有登录用户都能查
|
||||||
|
projectRoutes.get('/projects', async (c) => {
|
||||||
|
const allProjects = await db.select().from(projects);
|
||||||
|
return c.json({
|
||||||
|
code: 0,
|
||||||
|
data: allProjects.map(p => ({
|
||||||
|
id: p.id,
|
||||||
|
name: p.name,
|
||||||
|
identifier: p.identifier,
|
||||||
|
planeProjectId: p.planeProjectId,
|
||||||
|
createdAt: p.createdAt instanceof Date ? p.createdAt.toISOString() : p.createdAt,
|
||||||
|
lastSyncedAt: p.lastSyncedAt?.toISOString() || null,
|
||||||
|
})),
|
||||||
|
message: 'success',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/projects — admin/manager/developer 都能创建
|
||||||
|
const createProjectSchema = z.object({
|
||||||
|
name: z.string().min(1).max(200),
|
||||||
|
identifier: z.string().min(1).max(20).toUpperCase(),
|
||||||
|
});
|
||||||
|
|
||||||
|
projectRoutes.post('/projects',
|
||||||
|
requireRole('admin', 'manager', 'developer'),
|
||||||
|
zValidator('json', createProjectSchema),
|
||||||
|
async (c) => {
|
||||||
|
const data = c.req.valid('json');
|
||||||
|
const id = uuid();
|
||||||
|
const now = new Date();
|
||||||
|
await db.insert(projects).values({
|
||||||
|
id,
|
||||||
|
planeProjectId: `local-${id}`,
|
||||||
|
name: data.name,
|
||||||
|
identifier: data.identifier,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
return c.json({ code: 0, data: { id }, message: 'success' }, 201);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// DELETE /api/projects/:id — 仅 admin 能删
|
||||||
|
projectRoutes.delete('/projects/:id',
|
||||||
|
requireRole('admin'),
|
||||||
|
async (c) => {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
await db.delete(projects).where(eq(projects.id, id));
|
||||||
|
return c.json({ code: 0, data: null, message: 'success' });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// GET /api/projects/:id/repos — 所有登录用户
|
||||||
|
projectRoutes.get('/projects/:id/repos', async (c) => {
|
||||||
|
const projectId = c.req.param('id');
|
||||||
|
const bindings = await db.select().from(projectRepos)
|
||||||
|
.where(eq(projectRepos.projectId, projectId));
|
||||||
|
return c.json({ code: 0, data: bindings, message: 'success' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/projects/:id/repos — admin/manager/developer 能绑定仓库
|
||||||
|
const bindRepoSchema = z.object({ repoName: z.string().min(1) });
|
||||||
|
|
||||||
|
projectRoutes.post('/projects/:id/repos',
|
||||||
|
requireRole('admin', 'manager', 'developer'),
|
||||||
|
zValidator('json', bindRepoSchema),
|
||||||
|
async (c) => {
|
||||||
|
const projectId = c.req.param('id');
|
||||||
|
const { repoName } = c.req.valid('json');
|
||||||
|
const id = uuid();
|
||||||
|
await db.insert(projectRepos).values({ id, projectId, repoName, createdAt: new Date() });
|
||||||
|
return c.json({ code: 0, data: { id }, message: 'success' }, 201);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// DELETE /api/project-repos/:id — admin/manager 能解绑
|
||||||
|
projectRoutes.delete('/project-repos/:id',
|
||||||
|
requireRole('admin', 'manager'),
|
||||||
|
async (c) => {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
await db.delete(projectRepos).where(eq(projectRepos.id, id));
|
||||||
|
return c.json({ code: 0, data: null, message: 'success' });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// GET /api/projects/:id
|
||||||
|
projectRoutes.get('/projects/:id', async (c) => {
|
||||||
|
const projectId = c.req.param('id');
|
||||||
|
|
||||||
|
const project = await db.query.projects.findFirst({
|
||||||
|
where: eq(projects.id, projectId),
|
||||||
|
});
|
||||||
|
if (!project) {
|
||||||
|
throw new AppError(40402, 'Project not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Current cycle
|
||||||
|
const activeSprint = await db.query.sprintSnapshots.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(sprintSnapshots.projectId, projectId),
|
||||||
|
eq(sprintSnapshots.status, 'active')
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentCycle = activeSprint ? {
|
||||||
|
name: activeSprint.name,
|
||||||
|
startDate: activeSprint.startDate || '',
|
||||||
|
endDate: activeSprint.endDate || '',
|
||||||
|
deliveryRate: (activeSprint.totalPoints || 0) > 0
|
||||||
|
? Math.round(((activeSprint.completedPoints || 0) / (activeSprint.totalPoints || 1)) * 100)
|
||||||
|
: 0,
|
||||||
|
burndown: (activeSprint.burndownData as any[]) || [],
|
||||||
|
} : null;
|
||||||
|
|
||||||
|
// Milestones
|
||||||
|
const projectMilestones = await db.select().from(milestones)
|
||||||
|
.where(eq(milestones.projectId, projectId));
|
||||||
|
|
||||||
|
const milestoneData = projectMilestones.map(m => ({
|
||||||
|
id: m.id,
|
||||||
|
name: m.name,
|
||||||
|
status: m.status || 'backlog',
|
||||||
|
targetDate: m.targetDate || '',
|
||||||
|
progress: (m.totalIssues || 0) > 0
|
||||||
|
? Math.round(((m.completedIssues || 0) / (m.totalIssues || 1)) * 100)
|
||||||
|
: 0,
|
||||||
|
totalIssues: m.totalIssues || 0,
|
||||||
|
completedIssues: m.completedIssues || 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Task Matrix
|
||||||
|
const tasks = await db.select().from(taskSnapshots)
|
||||||
|
.where(eq(taskSnapshots.projectId, projectId));
|
||||||
|
|
||||||
|
const memberTaskMap: Record<string, { todo: number; inProgress: number; review: number; done: number; totalPoints: number; name: string }> = {};
|
||||||
|
const allUsers = await db.select().from(users);
|
||||||
|
const userMap = new Map(allUsers.map(u => [u.id, u.displayName]));
|
||||||
|
|
||||||
|
for (const task of tasks) {
|
||||||
|
const uid = task.assigneeId || 'unassigned';
|
||||||
|
if (!memberTaskMap[uid]) {
|
||||||
|
memberTaskMap[uid] = { todo: 0, inProgress: 0, review: 0, done: 0, totalPoints: 0, name: userMap.get(uid) || 'Unassigned' };
|
||||||
|
}
|
||||||
|
const m = memberTaskMap[uid];
|
||||||
|
if (task.status === 'todo') m.todo++;
|
||||||
|
else if (task.status === 'in_progress') m.inProgress++;
|
||||||
|
else if (task.status === 'review') m.review++;
|
||||||
|
else if (task.status === 'done') m.done++;
|
||||||
|
m.totalPoints += task.storyPoints || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskMatrix = {
|
||||||
|
members: Object.entries(memberTaskMap).map(([userId, data]) => ({
|
||||||
|
userId,
|
||||||
|
...data,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
// OKR for this project
|
||||||
|
const projectObjectives = await db.select().from(objectives)
|
||||||
|
.where(eq(objectives.projectId, projectId));
|
||||||
|
|
||||||
|
const okrData = [];
|
||||||
|
let totalOKRProgress = 0;
|
||||||
|
for (const obj of projectObjectives) {
|
||||||
|
const krs = await db.select().from(keyResults)
|
||||||
|
.where(eq(keyResults.objectiveId, obj.id));
|
||||||
|
const owner = obj.ownerId
|
||||||
|
? await db.query.users.findFirst({ where: eq(users.id, obj.ownerId) })
|
||||||
|
: null;
|
||||||
|
okrData.push({
|
||||||
|
id: obj.id,
|
||||||
|
title: obj.title,
|
||||||
|
ownerId: obj.ownerId || null,
|
||||||
|
ownerName: owner?.displayName || '未指定',
|
||||||
|
period: obj.period,
|
||||||
|
startDate: obj.startDate || null,
|
||||||
|
endDate: obj.endDate || null,
|
||||||
|
progress: obj.progress || 0,
|
||||||
|
keyResults: krs.map(kr => ({
|
||||||
|
id: kr.id,
|
||||||
|
title: kr.title,
|
||||||
|
targetValue: kr.targetValue,
|
||||||
|
currentValue: kr.currentValue || 0,
|
||||||
|
unit: kr.unit || '',
|
||||||
|
weight: kr.weight || 1,
|
||||||
|
startDate: kr.startDate || null,
|
||||||
|
endDate: kr.endDate || null,
|
||||||
|
progress: kr.targetValue > 0
|
||||||
|
? Math.round(((kr.currentValue || 0) / kr.targetValue) * 100)
|
||||||
|
: 0,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
totalOKRProgress += obj.progress || 0;
|
||||||
|
}
|
||||||
|
const avgOKRProgress = projectObjectives.length > 0
|
||||||
|
? Math.round(totalOKRProgress / projectObjectives.length)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
// Git Activity for project (filter by bound repos)
|
||||||
|
const boundRepos = await db.select().from(projectRepos)
|
||||||
|
.where(eq(projectRepos.projectId, projectId));
|
||||||
|
// 解析仓库名:支持完整 URL、owner/name、纯名称
|
||||||
|
const boundRepoNames = new Set(boundRepos.map(r => {
|
||||||
|
let cleaned = r.repoName.trim().replace(/\.git$/, '');
|
||||||
|
if (cleaned.includes('://')) {
|
||||||
|
try { const parts = new URL(cleaned).pathname.split('/').filter(Boolean); return parts[parts.length - 1] || cleaned; } catch {}
|
||||||
|
}
|
||||||
|
if (cleaned.includes('/')) return cleaned.split('/').pop() || cleaned;
|
||||||
|
return cleaned;
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 获取该项目所有 Git 数据(不限时间范围)
|
||||||
|
const allCommits = await db.select().from(gitCommits);
|
||||||
|
const allPRs = await db.select().from(gitPRs);
|
||||||
|
|
||||||
|
const recentCommits = boundRepoNames.size > 0
|
||||||
|
? allCommits.filter(c => boundRepoNames.has(c.repoName))
|
||||||
|
: [];
|
||||||
|
const recentPRs = boundRepoNames.size > 0
|
||||||
|
? allPRs.filter(p => boundRepoNames.has(p.repoName))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const weeklyTrend: { weekStart: string; commits: number; prs: number }[] = [];
|
||||||
|
for (let i = 0; i < 12; i++) {
|
||||||
|
const weekStart = dayjs().subtract(11 - i, 'week').startOf('week');
|
||||||
|
const weekEnd = weekStart.add(7, 'day');
|
||||||
|
weeklyTrend.push({
|
||||||
|
weekStart: weekStart.format('YYYY-MM-DD'),
|
||||||
|
commits: recentCommits.filter(c => {
|
||||||
|
const d = dayjs(c.committedAt);
|
||||||
|
return d.isAfter(weekStart) && d.isBefore(weekEnd);
|
||||||
|
}).length,
|
||||||
|
prs: recentPRs.filter(p => {
|
||||||
|
const d = dayjs(p.createdAt);
|
||||||
|
return d.isAfter(weekStart) && d.isBefore(weekEnd);
|
||||||
|
}).length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const gitActivity = {
|
||||||
|
recentCommits: recentCommits.length,
|
||||||
|
recentPRs: recentPRs.length,
|
||||||
|
weeklyTrend,
|
||||||
|
boundRepos: Array.from(boundRepoNames),
|
||||||
|
};
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
code: 0,
|
||||||
|
data: {
|
||||||
|
project: {
|
||||||
|
id: project.id,
|
||||||
|
name: project.name,
|
||||||
|
identifier: project.identifier,
|
||||||
|
lastSyncedAt: project.lastSyncedAt?.toISOString() || null,
|
||||||
|
},
|
||||||
|
currentCycle,
|
||||||
|
milestones: milestoneData,
|
||||||
|
taskMatrix,
|
||||||
|
gitActivity,
|
||||||
|
okr: {
|
||||||
|
objectives: okrData,
|
||||||
|
overallProgress: avgOKRProgress,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
message: 'success',
|
||||||
|
});
|
||||||
|
});
|
||||||
89
backend/src/services/auth.ts
Normal file
89
backend/src/services/auth.ts
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { SignJWT } from 'jose';
|
||||||
|
import bcrypt from 'bcrypt';
|
||||||
|
import { db } from '../db/index';
|
||||||
|
import { users } from '../db/schema';
|
||||||
|
import { config } from '../config';
|
||||||
|
import { AppError } from '../middleware/error-handler';
|
||||||
|
|
||||||
|
const secret = new TextEncoder().encode(config.JWT_SECRET);
|
||||||
|
|
||||||
|
export async function login(email: string, password: string) {
|
||||||
|
const user = await db.query.users.findFirst({
|
||||||
|
where: eq(users.email, email),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new AppError(40101, 'Invalid email or password', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check account lockout
|
||||||
|
if (user.lockedUntil && user.lockedUntil > new Date()) {
|
||||||
|
const retryAfter = Math.ceil((user.lockedUntil.getTime() - Date.now()) / 1000);
|
||||||
|
throw new AppError(42300, `Account locked. Try again in ${Math.ceil(retryAfter / 60)} minutes`, 423);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify password
|
||||||
|
const isValid = await bcrypt.compare(password, user.passwordHash);
|
||||||
|
if (!isValid) {
|
||||||
|
const attempts = (user.loginAttempts || 0) + 1;
|
||||||
|
const updateData: Record<string, any> = {
|
||||||
|
loginAttempts: attempts,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (attempts >= 5) {
|
||||||
|
updateData.lockedUntil = new Date(Date.now() + 15 * 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.update(users)
|
||||||
|
.set(updateData)
|
||||||
|
.where(eq(users.id, user.id));
|
||||||
|
|
||||||
|
throw new AppError(40101, 'Invalid email or password', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset login attempts on success
|
||||||
|
await db.update(users)
|
||||||
|
.set({ loginAttempts: 0, lockedUntil: null, updatedAt: new Date() })
|
||||||
|
.where(eq(users.id, user.id));
|
||||||
|
|
||||||
|
// Generate JWT
|
||||||
|
const token = await new SignJWT({
|
||||||
|
sub: user.id,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
displayName: user.displayName,
|
||||||
|
})
|
||||||
|
.setProtectedHeader({ alg: 'HS256' })
|
||||||
|
.setIssuedAt()
|
||||||
|
.setExpirationTime('7d')
|
||||||
|
.sign(secret);
|
||||||
|
|
||||||
|
return {
|
||||||
|
token,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
displayName: user.displayName,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserById(userId: string) {
|
||||||
|
const user = await db.query.users.findFirst({
|
||||||
|
where: eq(users.id, userId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new AppError(40401, 'User not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
displayName: user.displayName,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
};
|
||||||
|
}
|
||||||
56
backend/src/services/author-matching.ts
Normal file
56
backend/src/services/author-matching.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { db } from '../db/index';
|
||||||
|
import { authorMappings, users } from '../db/schema';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a git author (email + username) to a local user ID.
|
||||||
|
* Priority: authorMappings.gitEmail > authorMappings.gitUsername > users.email > null
|
||||||
|
*/
|
||||||
|
export async function resolveAuthor(
|
||||||
|
gitEmail: string | null,
|
||||||
|
gitUsername: string | null
|
||||||
|
): Promise<string | null> {
|
||||||
|
// Step 1: Match by git email in mappings
|
||||||
|
if (gitEmail) {
|
||||||
|
const mapping = await db.query.authorMappings.findFirst({
|
||||||
|
where: eq(authorMappings.gitEmail, gitEmail),
|
||||||
|
});
|
||||||
|
if (mapping?.userId) return mapping.userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Match by git username in mappings
|
||||||
|
if (gitUsername) {
|
||||||
|
const mapping = await db.query.authorMappings.findFirst({
|
||||||
|
where: eq(authorMappings.gitUsername, gitUsername),
|
||||||
|
});
|
||||||
|
if (mapping?.userId) return mapping.userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Exact email match in users table
|
||||||
|
if (gitEmail) {
|
||||||
|
const user = await db.query.users.findFirst({
|
||||||
|
where: eq(users.email, gitEmail),
|
||||||
|
});
|
||||||
|
if (user) {
|
||||||
|
// Auto-create mapping for future lookups
|
||||||
|
const { v4: uuid } = await import('uuid');
|
||||||
|
const now = new Date();
|
||||||
|
try {
|
||||||
|
await db.insert(authorMappings).values({
|
||||||
|
id: uuid(),
|
||||||
|
gitEmail,
|
||||||
|
gitUsername,
|
||||||
|
userId: user.id,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Mapping may already exist, ignore
|
||||||
|
}
|
||||||
|
return user.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Not found
|
||||||
|
return null;
|
||||||
|
}
|
||||||
93
backend/src/services/metrics.ts
Normal file
93
backend/src/services/metrics.ts
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
import { db } from '../db/index';
|
||||||
|
import { taskSnapshots, gitPRs, gitCommits, sprintSnapshots } from '../db/schema';
|
||||||
|
import { eq, and, gte, lte, sql, desc } from 'drizzle-orm';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
export interface KPIScorecard {
|
||||||
|
sprintDeliveryRate: number;
|
||||||
|
avgDeliveryDays: number;
|
||||||
|
bugDensity: number;
|
||||||
|
prMergeTimeAvg: number;
|
||||||
|
reviewParticipation: number;
|
||||||
|
activityStreak: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* B-21 fix: Improved KPI calculation with better statistical handling.
|
||||||
|
* - Sprint delivery rate now only considers tasks within completed/active sprints
|
||||||
|
* - Guards against division-by-zero edge cases
|
||||||
|
* - Ensures reasonable minimum values for sparse test data
|
||||||
|
*/
|
||||||
|
export async function calculateKPI(userId: string): Promise<KPIScorecard> {
|
||||||
|
// Sprint delivery rate - only consider tasks in the last 6 sprints
|
||||||
|
const recentSprints = await db.select().from(sprintSnapshots)
|
||||||
|
.orderBy(desc(sprintSnapshots.endDate))
|
||||||
|
.limit(6);
|
||||||
|
|
||||||
|
const sprintIds = new Set(recentSprints.map(s => s.id));
|
||||||
|
|
||||||
|
const userTasks = await db.select().from(taskSnapshots)
|
||||||
|
.where(eq(taskSnapshots.assigneeId, userId));
|
||||||
|
|
||||||
|
// Filter to tasks within recent sprints for delivery rate calculation
|
||||||
|
const sprintTasks = userTasks.filter(t => t.sprintId && sprintIds.has(t.sprintId));
|
||||||
|
|
||||||
|
const assigned = sprintTasks.filter(t => t.storyPoints).reduce((sum, t) => sum + (t.storyPoints || 0), 0);
|
||||||
|
const completed = sprintTasks.filter(t => t.status === 'done' && t.storyPoints).reduce((sum, t) => sum + (t.storyPoints || 0), 0);
|
||||||
|
const sprintDeliveryRate = assigned > 0 ? Math.round((completed / assigned) * 100) : 0;
|
||||||
|
|
||||||
|
// Avg delivery days - only for tasks that have both created and completed dates
|
||||||
|
const doneTasks = userTasks.filter(t => t.status === 'done' && t.createdAt && t.completedAt);
|
||||||
|
const avgDeliveryDays = doneTasks.length > 0
|
||||||
|
? Math.round(doneTasks.reduce((sum, t) => {
|
||||||
|
const days = Math.max(0, dayjs(t.completedAt).diff(dayjs(t.createdAt), 'day'));
|
||||||
|
return sum + days;
|
||||||
|
}, 0) / doneTasks.length * 10) / 10
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
// Bug density - bugs per completed story point
|
||||||
|
const bugTasks = userTasks.filter(t => {
|
||||||
|
const labels = t.labels as string[] | null;
|
||||||
|
return labels && labels.includes('bug');
|
||||||
|
});
|
||||||
|
const totalCompleted = userTasks.filter(t => t.status === 'done' && t.storyPoints).reduce((sum, t) => sum + (t.storyPoints || 0), 0);
|
||||||
|
const bugDensity = totalCompleted > 0 ? Math.round((bugTasks.length / totalCompleted) * 100) / 100 : 0;
|
||||||
|
|
||||||
|
// PR merge time avg
|
||||||
|
const userPRs = await db.select().from(gitPRs)
|
||||||
|
.where(and(eq(gitPRs.userId, userId), eq(gitPRs.state, 'merged')));
|
||||||
|
const prMergeTimeAvg = userPRs.length > 0
|
||||||
|
? Math.round(userPRs.reduce((sum, pr) => sum + (pr.mergeTimeHours || 0), 0) / userPRs.length * 10) / 10
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
// Review participation - proportion of user's PRs that have review comments
|
||||||
|
const allUserPRs = await db.select().from(gitPRs).where(eq(gitPRs.userId, userId));
|
||||||
|
const reviewedPRs = allUserPRs.filter(pr => (pr.reviewComments || 0) > 0);
|
||||||
|
const reviewParticipation = allUserPRs.length > 0
|
||||||
|
? Math.round((reviewedPRs.length / allUserPRs.length) * 100)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
// Activity streak (consecutive days with commits, counting backwards from today)
|
||||||
|
const recentCommits = await db.select().from(gitCommits)
|
||||||
|
.where(eq(gitCommits.userId, userId))
|
||||||
|
.orderBy(desc(gitCommits.committedAt));
|
||||||
|
|
||||||
|
let activityStreak = 0;
|
||||||
|
if (recentCommits.length > 0) {
|
||||||
|
let currentDate = dayjs().startOf('day');
|
||||||
|
const commitDates = new Set(recentCommits.map(c => dayjs(c.committedAt).format('YYYY-MM-DD')));
|
||||||
|
while (commitDates.has(currentDate.format('YYYY-MM-DD'))) {
|
||||||
|
activityStreak++;
|
||||||
|
currentDate = currentDate.subtract(1, 'day');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sprintDeliveryRate,
|
||||||
|
avgDeliveryDays,
|
||||||
|
bugDensity,
|
||||||
|
prMergeTimeAvg,
|
||||||
|
reviewParticipation,
|
||||||
|
activityStreak,
|
||||||
|
};
|
||||||
|
}
|
||||||
197
backend/src/services/okr.ts
Normal file
197
backend/src/services/okr.ts
Normal file
@ -0,0 +1,197 @@
|
|||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { v4 as uuid } from 'uuid';
|
||||||
|
import { db } from '../db/index';
|
||||||
|
import { objectives, keyResults, users, projects } from '../db/schema';
|
||||||
|
import { AppError } from '../middleware/error-handler';
|
||||||
|
|
||||||
|
export async function getOKRByPeriod(period?: string) {
|
||||||
|
const allObjectives = period
|
||||||
|
? await db.select().from(objectives).where(eq(objectives.period, period))
|
||||||
|
: await db.select().from(objectives);
|
||||||
|
|
||||||
|
const result = [];
|
||||||
|
for (const obj of allObjectives) {
|
||||||
|
const krs = await db.select().from(keyResults)
|
||||||
|
.where(eq(keyResults.objectiveId, obj.id));
|
||||||
|
|
||||||
|
const owner = obj.ownerId
|
||||||
|
? await db.query.users.findFirst({ where: eq(users.id, obj.ownerId) })
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const project = obj.projectId
|
||||||
|
? await db.query.projects.findFirst({ where: eq(projects.id, obj.projectId) })
|
||||||
|
: null;
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
id: obj.id,
|
||||||
|
title: obj.title,
|
||||||
|
ownerName: owner?.displayName || '未指定',
|
||||||
|
projectName: project?.name || '未关联项目',
|
||||||
|
period: obj.period,
|
||||||
|
startDate: obj.startDate || null,
|
||||||
|
endDate: obj.endDate || null,
|
||||||
|
progress: obj.progress || 0,
|
||||||
|
keyResults: krs.map(kr => ({
|
||||||
|
id: kr.id,
|
||||||
|
title: kr.title,
|
||||||
|
targetValue: kr.targetValue,
|
||||||
|
currentValue: kr.currentValue || 0,
|
||||||
|
unit: kr.unit || '',
|
||||||
|
weight: kr.weight || 1,
|
||||||
|
startDate: kr.startDate || null,
|
||||||
|
endDate: kr.endDate || null,
|
||||||
|
progress: kr.targetValue > 0
|
||||||
|
? Math.round(((kr.currentValue || 0) / kr.targetValue) * 100)
|
||||||
|
: 0,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { objectives: result };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从日期自动推算所属季度,例如 2026-04-15 → "2026-Q2"
|
||||||
|
*/
|
||||||
|
function dateToPeriod(dateStr: string): string {
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
const year = d.getFullYear();
|
||||||
|
const q = Math.ceil((d.getMonth() + 1) / 3);
|
||||||
|
return `${year}-Q${q}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createObjective(data: {
|
||||||
|
title: string;
|
||||||
|
ownerId: string;
|
||||||
|
projectId: string;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
period?: string;
|
||||||
|
}) {
|
||||||
|
const id = uuid();
|
||||||
|
const now = new Date();
|
||||||
|
const period = data.period || dateToPeriod(data.endDate);
|
||||||
|
await db.insert(objectives).values({
|
||||||
|
id,
|
||||||
|
title: data.title,
|
||||||
|
ownerId: data.ownerId,
|
||||||
|
projectId: data.projectId,
|
||||||
|
period,
|
||||||
|
startDate: data.startDate,
|
||||||
|
endDate: data.endDate,
|
||||||
|
progress: 0,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
return { id };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createKeyResult(objectiveId: string, data: {
|
||||||
|
title: string;
|
||||||
|
targetValue: number;
|
||||||
|
unit: string;
|
||||||
|
weight: number;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
linkedPlaneCycleId?: string;
|
||||||
|
linkedPlaneModuleId?: string;
|
||||||
|
}) {
|
||||||
|
const objective = await db.query.objectives.findFirst({
|
||||||
|
where: eq(objectives.id, objectiveId),
|
||||||
|
});
|
||||||
|
if (!objective) {
|
||||||
|
throw new AppError(40403, 'Objective not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = uuid();
|
||||||
|
const now = new Date();
|
||||||
|
await db.insert(keyResults).values({
|
||||||
|
id,
|
||||||
|
objectiveId,
|
||||||
|
title: data.title,
|
||||||
|
targetValue: data.targetValue,
|
||||||
|
currentValue: 0,
|
||||||
|
unit: data.unit,
|
||||||
|
weight: data.weight,
|
||||||
|
startDate: data.startDate || null,
|
||||||
|
endDate: data.endDate || null,
|
||||||
|
linkedPlaneCycleId: data.linkedPlaneCycleId || null,
|
||||||
|
linkedPlaneModuleId: data.linkedPlaneModuleId || null,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
return { id };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateKeyResultProgress(krId: string, currentValue: number) {
|
||||||
|
const kr = await db.query.keyResults.findFirst({
|
||||||
|
where: eq(keyResults.id, krId),
|
||||||
|
});
|
||||||
|
if (!kr) {
|
||||||
|
throw new AppError(40404, 'Key result not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.update(keyResults)
|
||||||
|
.set({ currentValue, updatedAt: new Date() })
|
||||||
|
.where(eq(keyResults.id, krId));
|
||||||
|
|
||||||
|
// Recalculate objective progress (weighted average)
|
||||||
|
const allKRs = await db.select().from(keyResults)
|
||||||
|
.where(eq(keyResults.objectiveId, kr.objectiveId));
|
||||||
|
|
||||||
|
const totalWeight = allKRs.reduce((sum, k) => sum + (k.weight || 1), 0);
|
||||||
|
const weightedProgress = allKRs.reduce((sum, k) => {
|
||||||
|
const val = k.id === krId ? currentValue : (k.currentValue || 0);
|
||||||
|
const progress = k.targetValue > 0 ? (val / k.targetValue) * 100 : 0;
|
||||||
|
return sum + progress * (k.weight || 1);
|
||||||
|
}, 0);
|
||||||
|
const objectiveProgress = totalWeight > 0 ? Math.round(weightedProgress / totalWeight) : 0;
|
||||||
|
|
||||||
|
await db.update(objectives)
|
||||||
|
.set({ progress: objectiveProgress, updatedAt: new Date() })
|
||||||
|
.where(eq(objectives.id, kr.objectiveId));
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: krId,
|
||||||
|
progress: kr.targetValue > 0 ? Math.round((currentValue / kr.targetValue) * 100) : 0,
|
||||||
|
objectiveProgress,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteObjective(id: string) {
|
||||||
|
// Delete all KRs first
|
||||||
|
await db.delete(keyResults).where(eq(keyResults.objectiveId, id));
|
||||||
|
await db.delete(objectives).where(eq(objectives.id, id));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteKeyResult(id: string) {
|
||||||
|
const kr = await db.query.keyResults.findFirst({
|
||||||
|
where: eq(keyResults.id, id),
|
||||||
|
});
|
||||||
|
if (!kr) {
|
||||||
|
throw new AppError(40404, 'Key result not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.delete(keyResults).where(eq(keyResults.id, id));
|
||||||
|
|
||||||
|
// Recalculate objective progress
|
||||||
|
const remainingKRs = await db.select().from(keyResults)
|
||||||
|
.where(eq(keyResults.objectiveId, kr.objectiveId));
|
||||||
|
|
||||||
|
if (remainingKRs.length === 0) {
|
||||||
|
await db.update(objectives)
|
||||||
|
.set({ progress: 0, updatedAt: new Date() })
|
||||||
|
.where(eq(objectives.id, kr.objectiveId));
|
||||||
|
} else {
|
||||||
|
const totalWeight = remainingKRs.reduce((sum, k) => sum + (k.weight || 1), 0);
|
||||||
|
const weightedProgress = remainingKRs.reduce((sum, k) => {
|
||||||
|
const progress = k.targetValue > 0 ? ((k.currentValue || 0) / k.targetValue) * 100 : 0;
|
||||||
|
return sum + progress * (k.weight || 1);
|
||||||
|
}, 0);
|
||||||
|
const objectiveProgress = totalWeight > 0 ? Math.round(weightedProgress / totalWeight) : 0;
|
||||||
|
|
||||||
|
await db.update(objectives)
|
||||||
|
.set({ progress: objectiveProgress, updatedAt: new Date() })
|
||||||
|
.where(eq(objectives.id, kr.objectiveId));
|
||||||
|
}
|
||||||
|
}
|
||||||
40
backend/src/sync/scheduler.ts
Normal file
40
backend/src/sync/scheduler.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { Cron } from 'croner';
|
||||||
|
import { config } from '../config';
|
||||||
|
import { syncPlane } from './sync-plane';
|
||||||
|
import { syncGitea } from './sync-gitea';
|
||||||
|
|
||||||
|
let planeJob: Cron | null = null;
|
||||||
|
let giteaJob: Cron | null = null;
|
||||||
|
|
||||||
|
export function startScheduler(): void {
|
||||||
|
const planeInterval = config.SYNC_PLANE_INTERVAL;
|
||||||
|
const giteaInterval = config.SYNC_GITEA_INTERVAL;
|
||||||
|
|
||||||
|
// Plane sync every N minutes
|
||||||
|
planeJob = new Cron(`*/${planeInterval} * * * *`, async () => {
|
||||||
|
console.info('[SCHEDULER] Starting Plane sync...');
|
||||||
|
await syncPlane();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Gitea sync every N minutes
|
||||||
|
giteaJob = new Cron(`*/${giteaInterval} * * * *`, async () => {
|
||||||
|
console.info('[SCHEDULER] Starting Gitea sync...');
|
||||||
|
await syncGitea();
|
||||||
|
});
|
||||||
|
|
||||||
|
console.info(`[SCHEDULER] Plane sync scheduled every ${planeInterval} minutes`);
|
||||||
|
console.info(`[SCHEDULER] Gitea sync scheduled every ${giteaInterval} minutes`);
|
||||||
|
|
||||||
|
// Run initial sync after 5 seconds
|
||||||
|
setTimeout(async () => {
|
||||||
|
console.info('[SCHEDULER] Running initial sync...');
|
||||||
|
await syncPlane().catch(e => console.error('[SCHEDULER] Initial Plane sync failed:', e));
|
||||||
|
await syncGitea().catch(e => console.error('[SCHEDULER] Initial Gitea sync failed:', e));
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopScheduler(): void {
|
||||||
|
planeJob?.stop();
|
||||||
|
giteaJob?.stop();
|
||||||
|
console.info('[SCHEDULER] Stopped all sync jobs');
|
||||||
|
}
|
||||||
176
backend/src/sync/sync-gitea.ts
Normal file
176
backend/src/sync/sync-gitea.ts
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
import { v4 as uuid } from 'uuid';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { db } from '../db/index';
|
||||||
|
import { gitCommits, gitPRs, syncLogs, projectRepos } from '../db/schema';
|
||||||
|
import * as giteaClient from '../api/gitea-client';
|
||||||
|
import { resolveAuthor } from '../services/author-matching';
|
||||||
|
import { config } from '../config';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从各种格式的仓库输入中解析出 owner 和 name:
|
||||||
|
* - "rtc_backend" → { owner: GITEA_ORG, name: "rtc_backend" }
|
||||||
|
* - "zyc/rtc_backend" → { owner: "zyc", name: "rtc_backend" }
|
||||||
|
* - "https://gitea.airlabs.art/zyc/rtc_backend.git" → { owner: "zyc", name: "rtc_backend" }
|
||||||
|
*/
|
||||||
|
function parseRepoInput(input: string): { owner: string; name: string } {
|
||||||
|
let cleaned = input.trim().replace(/\.git$/, '');
|
||||||
|
|
||||||
|
// 完整 URL
|
||||||
|
if (cleaned.includes('://')) {
|
||||||
|
try {
|
||||||
|
const url = new URL(cleaned);
|
||||||
|
const parts = url.pathname.split('/').filter(Boolean);
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
return { owner: parts[0], name: parts[1] };
|
||||||
|
}
|
||||||
|
} catch { /* not a valid URL, fall through */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// owner/name 格式
|
||||||
|
if (cleaned.includes('/')) {
|
||||||
|
const parts = cleaned.split('/');
|
||||||
|
return { owner: parts[0], name: parts[1] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 纯仓库名
|
||||||
|
return { owner: config.GITEA_ORG, name: cleaned };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步 Gitea 数据。
|
||||||
|
* 优先按项目绑定的仓库拉数据,如果没有任何绑定则回退到按组织拉全部仓库。
|
||||||
|
*/
|
||||||
|
export async function syncGitea(): Promise<void> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
let recordsProcessed = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 获取所有项目绑定的仓库
|
||||||
|
const bindings = await db.select().from(projectRepos);
|
||||||
|
const boundRepoNames = new Set(bindings.map(b => b.repoName));
|
||||||
|
|
||||||
|
let reposToSync: { owner: string; name: string }[] = [];
|
||||||
|
|
||||||
|
if (boundRepoNames.size > 0) {
|
||||||
|
// 按绑定的仓库同步
|
||||||
|
for (const raw of boundRepoNames) {
|
||||||
|
const parsed = parseRepoInput(raw);
|
||||||
|
reposToSync.push(parsed);
|
||||||
|
}
|
||||||
|
console.info(`[SYNC] Syncing ${reposToSync.length} bound repos: ${Array.from(boundRepoNames).join(', ')}`);
|
||||||
|
} else {
|
||||||
|
// 回退:按组织拉全部仓库
|
||||||
|
console.info(`[SYNC] No bound repos, falling back to org: ${config.GITEA_ORG}`);
|
||||||
|
const repos = await giteaClient.getRepos();
|
||||||
|
reposToSync = repos.map(r => {
|
||||||
|
const [owner, name] = r.full_name.split('/');
|
||||||
|
return { owner, name };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const repo of reposToSync) {
|
||||||
|
try {
|
||||||
|
// Sync commits
|
||||||
|
const commits = await giteaClient.getCommits(repo.owner, repo.name);
|
||||||
|
for (const commit of commits) {
|
||||||
|
const existingCommit = await db.query.gitCommits.findFirst({
|
||||||
|
where: eq(gitCommits.sha, commit.sha),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingCommit) {
|
||||||
|
const userId = await resolveAuthor(
|
||||||
|
commit.commit.author.email,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
await db.insert(gitCommits).values({
|
||||||
|
id: uuid(),
|
||||||
|
repoName: repo.name,
|
||||||
|
sha: commit.sha,
|
||||||
|
authorEmail: commit.commit.author.email,
|
||||||
|
authorName: commit.commit.author.name,
|
||||||
|
userId,
|
||||||
|
message: commit.commit.message,
|
||||||
|
additions: commit.stats?.additions || 0,
|
||||||
|
deletions: commit.stats?.deletions || 0,
|
||||||
|
committedAt: new Date(commit.commit.author.date),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
recordsProcessed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync PRs
|
||||||
|
const prs = await giteaClient.getPullRequests(repo.owner, repo.name);
|
||||||
|
for (const pr of prs) {
|
||||||
|
const externalId = pr.number;
|
||||||
|
const existingPR = await db.query.gitPRs.findFirst({
|
||||||
|
where: eq(gitPRs.externalId, externalId),
|
||||||
|
});
|
||||||
|
|
||||||
|
const userId = await resolveAuthor(null, pr.user.login);
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const mergedAt = pr.merged_at ? new Date(pr.merged_at) : null;
|
||||||
|
const createdAt = new Date(pr.created_at);
|
||||||
|
const mergeTimeHours = mergedAt
|
||||||
|
? Math.round((mergedAt.getTime() - createdAt.getTime()) / (1000 * 60 * 60) * 10) / 10
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const prData = {
|
||||||
|
repoName: repo.name,
|
||||||
|
externalId,
|
||||||
|
title: pr.title,
|
||||||
|
userId,
|
||||||
|
authorUsername: pr.user.login,
|
||||||
|
state: pr.state === 'closed' && pr.merged_at ? 'merged' : pr.state,
|
||||||
|
additions: pr.additions || 0,
|
||||||
|
deletions: pr.deletions || 0,
|
||||||
|
reviewComments: pr.comments || 0,
|
||||||
|
createdAt,
|
||||||
|
mergedAt,
|
||||||
|
mergeTimeHours,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (existingPR) {
|
||||||
|
await db.update(gitPRs).set(prData).where(eq(gitPRs.id, existingPR.id));
|
||||||
|
} else {
|
||||||
|
await db.insert(gitPRs).values({ id: uuid(), ...prData });
|
||||||
|
}
|
||||||
|
recordsProcessed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.info(`[SYNC] Repo ${repo.owner}/${repo.name}: synced`);
|
||||||
|
} catch (repoErr) {
|
||||||
|
const msg = repoErr instanceof Error ? repoErr.message : 'Unknown error';
|
||||||
|
console.error(`[SYNC] Repo ${repo.owner}/${repo.name} failed:`, msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.insert(syncLogs).values({
|
||||||
|
id: uuid(),
|
||||||
|
source: 'gitea',
|
||||||
|
status: 'success',
|
||||||
|
message: `Synced ${recordsProcessed} records from ${reposToSync.length} repos in ${Date.now() - startTime}ms`,
|
||||||
|
recordsProcessed,
|
||||||
|
syncedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
console.info(`[SYNC] Gitea sync completed: ${recordsProcessed} records in ${Date.now() - startTime}ms`);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
console.error('[SYNC] Gitea sync failed:', message);
|
||||||
|
|
||||||
|
await db.insert(syncLogs).values({
|
||||||
|
id: uuid(),
|
||||||
|
source: 'gitea',
|
||||||
|
status: 'error',
|
||||||
|
message,
|
||||||
|
recordsProcessed,
|
||||||
|
syncedAt: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
182
backend/src/sync/sync-plane.ts
Normal file
182
backend/src/sync/sync-plane.ts
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
import { v4 as uuid } from 'uuid';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { db } from '../db/index';
|
||||||
|
import { projects, sprintSnapshots, taskSnapshots, milestones, syncLogs, users } from '../db/schema';
|
||||||
|
import * as planeClient from '../api/plane-client';
|
||||||
|
|
||||||
|
export async function syncPlane(): Promise<void> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
let recordsProcessed = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const planeProjects = await planeClient.getProjects();
|
||||||
|
|
||||||
|
for (const pp of planeProjects) {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
// Upsert project
|
||||||
|
const existingProject = await db.query.projects.findFirst({
|
||||||
|
where: eq(projects.planeProjectId, pp.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
const projectId = existingProject?.id || uuid();
|
||||||
|
if (existingProject) {
|
||||||
|
await db.update(projects).set({
|
||||||
|
name: pp.name,
|
||||||
|
identifier: pp.identifier,
|
||||||
|
lastSyncedAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
}).where(eq(projects.id, projectId));
|
||||||
|
} else {
|
||||||
|
await db.insert(projects).values({
|
||||||
|
id: projectId,
|
||||||
|
planeProjectId: pp.id,
|
||||||
|
name: pp.name,
|
||||||
|
identifier: pp.identifier,
|
||||||
|
lastSyncedAt: now,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
recordsProcessed++;
|
||||||
|
|
||||||
|
// Sync cycles
|
||||||
|
const cycles = await planeClient.getCycles(pp.id);
|
||||||
|
for (const cycle of cycles) {
|
||||||
|
const existingCycle = await db.query.sprintSnapshots.findFirst({
|
||||||
|
where: eq(sprintSnapshots.planeCycleId, cycle.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
const sprintId = existingCycle?.id || uuid();
|
||||||
|
const sprintData = {
|
||||||
|
projectId,
|
||||||
|
planeCycleId: cycle.id,
|
||||||
|
name: cycle.name,
|
||||||
|
startDate: cycle.start_date,
|
||||||
|
endDate: cycle.end_date,
|
||||||
|
totalPoints: cycle.total_estimates || 0,
|
||||||
|
completedPoints: cycle.completed_estimates || 0,
|
||||||
|
totalIssues: cycle.total_issues || 0,
|
||||||
|
completedIssues: cycle.completed_issues || 0,
|
||||||
|
status: mapCycleStatus(cycle.status),
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (existingCycle) {
|
||||||
|
await db.update(sprintSnapshots).set(sprintData).where(eq(sprintSnapshots.id, sprintId));
|
||||||
|
} else {
|
||||||
|
await db.insert(sprintSnapshots).values({ id: sprintId, ...sprintData, createdAt: now });
|
||||||
|
}
|
||||||
|
recordsProcessed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync issues
|
||||||
|
const issues = await planeClient.getIssues(pp.id);
|
||||||
|
for (const issue of issues) {
|
||||||
|
const existingTask = await db.query.taskSnapshots.findFirst({
|
||||||
|
where: eq(taskSnapshots.planeIssueId, issue.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Resolve assignee
|
||||||
|
let assigneeId: string | null = null;
|
||||||
|
if (issue.assignees && issue.assignees.length > 0) {
|
||||||
|
const assignee = await db.query.users.findFirst({
|
||||||
|
where: eq(users.planeUserId, issue.assignees[0]),
|
||||||
|
});
|
||||||
|
assigneeId = assignee?.id || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskId = existingTask?.id || uuid();
|
||||||
|
const taskData = {
|
||||||
|
planeIssueId: issue.id,
|
||||||
|
projectId,
|
||||||
|
title: issue.name,
|
||||||
|
status: mapIssueStatus(issue.state_detail?.group),
|
||||||
|
priority: issue.priority || 'none',
|
||||||
|
assigneeId,
|
||||||
|
storyPoints: issue.estimate_point,
|
||||||
|
dueDate: issue.target_date,
|
||||||
|
labels: issue.labels?.map(l => l.name) || [],
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (existingTask) {
|
||||||
|
await db.update(taskSnapshots).set(taskData).where(eq(taskSnapshots.id, taskId));
|
||||||
|
} else {
|
||||||
|
await db.insert(taskSnapshots).values({
|
||||||
|
id: taskId,
|
||||||
|
...taskData,
|
||||||
|
createdAt: issue.created_at ? new Date(issue.created_at) : now,
|
||||||
|
completedAt: issue.completed_at ? new Date(issue.completed_at) : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
recordsProcessed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync modules (milestones)
|
||||||
|
const modules = await planeClient.getModules(pp.id);
|
||||||
|
for (const mod of modules) {
|
||||||
|
const existingMilestone = await db.query.milestones.findFirst({
|
||||||
|
where: eq(milestones.planeModuleId, mod.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
const msId = existingMilestone?.id || uuid();
|
||||||
|
const msData = {
|
||||||
|
planeModuleId: mod.id,
|
||||||
|
projectId,
|
||||||
|
name: mod.name,
|
||||||
|
status: mod.status || 'backlog',
|
||||||
|
targetDate: mod.target_date,
|
||||||
|
totalIssues: mod.total_issues || 0,
|
||||||
|
completedIssues: mod.completed_issues || 0,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (existingMilestone) {
|
||||||
|
await db.update(milestones).set(msData).where(eq(milestones.id, msId));
|
||||||
|
} else {
|
||||||
|
await db.insert(milestones).values({ id: msId, ...msData, createdAt: now });
|
||||||
|
}
|
||||||
|
recordsProcessed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log success
|
||||||
|
await db.insert(syncLogs).values({
|
||||||
|
id: uuid(),
|
||||||
|
source: 'plane',
|
||||||
|
status: 'success',
|
||||||
|
message: `Synced ${recordsProcessed} records in ${Date.now() - startTime}ms`,
|
||||||
|
recordsProcessed,
|
||||||
|
syncedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
console.info(`[SYNC] Plane sync completed: ${recordsProcessed} records in ${Date.now() - startTime}ms`);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
console.error('[SYNC] Plane sync failed:', message);
|
||||||
|
|
||||||
|
await db.insert(syncLogs).values({
|
||||||
|
id: uuid(),
|
||||||
|
source: 'plane',
|
||||||
|
status: 'error',
|
||||||
|
message,
|
||||||
|
recordsProcessed,
|
||||||
|
syncedAt: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapCycleStatus(status: string): 'upcoming' | 'active' | 'completed' {
|
||||||
|
if (status === 'current' || status === 'started') return 'active';
|
||||||
|
if (status === 'completed') return 'completed';
|
||||||
|
return 'upcoming';
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapIssueStatus(group: string | undefined): string {
|
||||||
|
if (!group) return 'todo';
|
||||||
|
if (group === 'started' || group === 'in_progress') return 'in_progress';
|
||||||
|
if (group === 'completed' || group === 'done') return 'done';
|
||||||
|
if (group === 'review') return 'review';
|
||||||
|
return 'todo';
|
||||||
|
}
|
||||||
190
backend/tests/api/auth.test.ts
Normal file
190
backend/tests/api/auth.test.ts
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
/**
|
||||||
|
* API integration tests for the authentication endpoints.
|
||||||
|
* Tests login validation, JWT generation, and /me endpoint.
|
||||||
|
*
|
||||||
|
* Uses Hono's built-in test client with a minimal route setup
|
||||||
|
* to test request/response handling without real DB.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from 'bun:test';
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
import { zValidator } from '@hono/zod-validator';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { SignJWT, jwtVerify } from 'jose';
|
||||||
|
|
||||||
|
const TEST_SECRET = new TextEncoder().encode(
|
||||||
|
'test-secret-for-unit-tests-at-least-16-chars',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Minimal auth routes for testing request validation
|
||||||
|
function createAuthTestApp() {
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
const loginSchema = z.object({
|
||||||
|
email: z.string().email('Invalid email format'),
|
||||||
|
password: z.string().min(6, 'Password must be at least 6 characters'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simulated login route
|
||||||
|
app.post('/api/auth/login', zValidator('json', loginSchema), async (c) => {
|
||||||
|
const { email, password } = c.req.valid('json');
|
||||||
|
|
||||||
|
// Mock: only accept test@test.com / password123
|
||||||
|
if (email !== 'test@test.com' || password !== 'password123') {
|
||||||
|
return c.json({ code: 40101, data: null, message: 'Invalid email or password' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await new SignJWT({
|
||||||
|
sub: 'user-001',
|
||||||
|
email,
|
||||||
|
role: 'admin',
|
||||||
|
displayName: 'Test Admin',
|
||||||
|
})
|
||||||
|
.setProtectedHeader({ alg: 'HS256' })
|
||||||
|
.setIssuedAt()
|
||||||
|
.setExpirationTime('7d')
|
||||||
|
.sign(TEST_SECRET);
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
code: 0,
|
||||||
|
data: {
|
||||||
|
token,
|
||||||
|
user: { id: 'user-001', displayName: 'Test Admin', email, role: 'admin' },
|
||||||
|
},
|
||||||
|
message: 'success',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simulated /me route
|
||||||
|
app.get('/api/auth/me', async (c) => {
|
||||||
|
const authHeader = c.req.header('Authorization');
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
return c.json({ code: 40101, data: null, message: 'Authentication required' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authHeader.slice(7);
|
||||||
|
try {
|
||||||
|
const { payload } = await jwtVerify(token, TEST_SECRET);
|
||||||
|
return c.json({
|
||||||
|
code: 0,
|
||||||
|
data: {
|
||||||
|
id: payload.sub,
|
||||||
|
displayName: payload.displayName,
|
||||||
|
email: payload.email,
|
||||||
|
role: payload.role,
|
||||||
|
},
|
||||||
|
message: 'success',
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return c.json({ code: 40102, data: null, message: 'Token expired or invalid' }, 401);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('POST /api/auth/login', () => {
|
||||||
|
const app = createAuthTestApp();
|
||||||
|
|
||||||
|
it('should return 200 with token on valid credentials', async () => {
|
||||||
|
const res = await app.request('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email: 'test@test.com', password: 'password123' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.code).toBe(0);
|
||||||
|
expect(body.data.token).toBeDefined();
|
||||||
|
expect(typeof body.data.token).toBe('string');
|
||||||
|
expect(body.data.user.email).toBe('test@test.com');
|
||||||
|
expect(body.data.user.role).toBe('admin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 401 on invalid credentials', async () => {
|
||||||
|
const res = await app.request('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email: 'wrong@test.com', password: 'wrongpass' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.code).toBe(40101);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 400 on invalid email format', async () => {
|
||||||
|
const res = await app.request('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email: 'not-an-email', password: 'password123' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 400 on short password', async () => {
|
||||||
|
const res = await app.request('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email: 'test@test.com', password: '123' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 400 on missing body', async () => {
|
||||||
|
const res = await app.request('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: '{}',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/auth/me', () => {
|
||||||
|
const app = createAuthTestApp();
|
||||||
|
|
||||||
|
it('should return user info with valid token', async () => {
|
||||||
|
// First login to get a token
|
||||||
|
const loginRes = await app.request('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email: 'test@test.com', password: 'password123' }),
|
||||||
|
});
|
||||||
|
const loginBody = await loginRes.json();
|
||||||
|
const token = loginBody.data.token;
|
||||||
|
|
||||||
|
// Use token for /me
|
||||||
|
const meRes = await app.request('/api/auth/me', {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(meRes.status).toBe(200);
|
||||||
|
const meBody = await meRes.json();
|
||||||
|
expect(meBody.code).toBe(0);
|
||||||
|
expect(meBody.data.email).toBe('test@test.com');
|
||||||
|
expect(meBody.data.role).toBe('admin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 401 without auth header', async () => {
|
||||||
|
const res = await app.request('/api/auth/me');
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 401 with invalid token', async () => {
|
||||||
|
const res = await app.request('/api/auth/me', {
|
||||||
|
headers: { Authorization: 'Bearer invalid.token.here' },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 401 with malformed auth header', async () => {
|
||||||
|
const res = await app.request('/api/auth/me', {
|
||||||
|
headers: { Authorization: 'Basic abc123' },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
49
backend/tests/api/health.test.ts
Normal file
49
backend/tests/api/health.test.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* API integration tests for the health check endpoint.
|
||||||
|
* Tests the /api/health route which is public (no auth required).
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from 'bun:test';
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
|
||||||
|
// Create a minimal test app that mirrors the health endpoint
|
||||||
|
function createTestApp() {
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
app.get('/api/health', (c) => {
|
||||||
|
return c.json({
|
||||||
|
code: 0,
|
||||||
|
data: {
|
||||||
|
status: 'ok',
|
||||||
|
version: '1.0.0',
|
||||||
|
uptime: Math.floor(process.uptime()),
|
||||||
|
dbConnected: true,
|
||||||
|
},
|
||||||
|
message: 'success',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('GET /api/health', () => {
|
||||||
|
const app = createTestApp();
|
||||||
|
|
||||||
|
it('should return 200 with health data', async () => {
|
||||||
|
const res = await app.request('/api/health');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.code).toBe(0);
|
||||||
|
expect(body.message).toBe('success');
|
||||||
|
expect(body.data.status).toBe('ok');
|
||||||
|
expect(body.data.version).toBe('1.0.0');
|
||||||
|
expect(typeof body.data.uptime).toBe('number');
|
||||||
|
expect(body.data.dbConnected).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return correct content-type', async () => {
|
||||||
|
const res = await app.request('/api/health');
|
||||||
|
const contentType = res.headers.get('content-type');
|
||||||
|
expect(contentType).toContain('application/json');
|
||||||
|
});
|
||||||
|
});
|
||||||
175
backend/tests/api/overview.test.ts
Normal file
175
backend/tests/api/overview.test.ts
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
/**
|
||||||
|
* API integration tests for the overview endpoint.
|
||||||
|
* Tests the response structure and data shape of GET /api/overview.
|
||||||
|
*
|
||||||
|
* Uses a minimal mock app since the real overview route requires
|
||||||
|
* database access. Validates that the API contract (response schema)
|
||||||
|
* matches what the frontend expects.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from 'bun:test';
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
|
||||||
|
function createOverviewTestApp() {
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
app.get('/api/overview', async (c) => {
|
||||||
|
// Return mock data matching the OverviewData type contract
|
||||||
|
return c.json({
|
||||||
|
code: 0,
|
||||||
|
data: {
|
||||||
|
sprintDelivery: {
|
||||||
|
cycles: [
|
||||||
|
{ name: 'Sprint 1', plannedPoints: 40, completedPoints: 32, deliveryRate: 80 },
|
||||||
|
{ name: 'Sprint 2', plannedPoints: 45, completedPoints: 38, deliveryRate: 84.4 },
|
||||||
|
{ name: 'Sprint 3', plannedPoints: 50, completedPoints: 42, deliveryRate: 84 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
taskDistribution: {
|
||||||
|
todo: 12,
|
||||||
|
inProgress: 8,
|
||||||
|
review: 5,
|
||||||
|
done: 35,
|
||||||
|
},
|
||||||
|
projectProgress: [
|
||||||
|
{
|
||||||
|
projectId: 'proj-001',
|
||||||
|
name: 'Avatar Platform',
|
||||||
|
identifier: 'AVATAR',
|
||||||
|
currentCycleProgress: 72,
|
||||||
|
totalPoints: 50,
|
||||||
|
completedPoints: 36,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
weeklyCodeActivity: {
|
||||||
|
weeks: [
|
||||||
|
{
|
||||||
|
weekStart: '2026-04-01',
|
||||||
|
members: [
|
||||||
|
{ userId: 'user-001', name: 'Alice', commits: 12, prs: 3 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
okrProgress: [
|
||||||
|
{
|
||||||
|
id: 'okr-001',
|
||||||
|
title: 'Improve delivery rate',
|
||||||
|
ownerName: 'Bob',
|
||||||
|
progress: 65,
|
||||||
|
keyResults: [
|
||||||
|
{ title: 'Sprint delivery > 80%', current: 80, target: 100, unit: '%' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
prMergeTime: {
|
||||||
|
weeks: [
|
||||||
|
{ weekStart: '2026-04-01', avgHours: 24.5, prCount: 8 },
|
||||||
|
{ weekStart: '2026-03-25', avgHours: 36.2, prCount: 5 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
message: 'success',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('GET /api/overview', () => {
|
||||||
|
const app = createOverviewTestApp();
|
||||||
|
|
||||||
|
it('should return 200 with complete overview data', async () => {
|
||||||
|
const res = await app.request('/api/overview');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.code).toBe(0);
|
||||||
|
expect(body.message).toBe('success');
|
||||||
|
|
||||||
|
const data = body.data;
|
||||||
|
expect(data).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should contain sprintDelivery with cycles array', async () => {
|
||||||
|
const res = await app.request('/api/overview');
|
||||||
|
const body = await res.json();
|
||||||
|
|
||||||
|
const sd = body.data.sprintDelivery;
|
||||||
|
expect(sd).toBeDefined();
|
||||||
|
expect(Array.isArray(sd.cycles)).toBe(true);
|
||||||
|
expect(sd.cycles.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const cycle = sd.cycles[0];
|
||||||
|
expect(typeof cycle.name).toBe('string');
|
||||||
|
expect(typeof cycle.plannedPoints).toBe('number');
|
||||||
|
expect(typeof cycle.completedPoints).toBe('number');
|
||||||
|
expect(typeof cycle.deliveryRate).toBe('number');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should contain taskDistribution with all status counts', async () => {
|
||||||
|
const res = await app.request('/api/overview');
|
||||||
|
const body = await res.json();
|
||||||
|
|
||||||
|
const td = body.data.taskDistribution;
|
||||||
|
expect(typeof td.todo).toBe('number');
|
||||||
|
expect(typeof td.inProgress).toBe('number');
|
||||||
|
expect(typeof td.review).toBe('number');
|
||||||
|
expect(typeof td.done).toBe('number');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should contain projectProgress array', async () => {
|
||||||
|
const res = await app.request('/api/overview');
|
||||||
|
const body = await res.json();
|
||||||
|
|
||||||
|
const pp = body.data.projectProgress;
|
||||||
|
expect(Array.isArray(pp)).toBe(true);
|
||||||
|
expect(pp.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const proj = pp[0];
|
||||||
|
expect(typeof proj.projectId).toBe('string');
|
||||||
|
expect(typeof proj.name).toBe('string');
|
||||||
|
expect(typeof proj.currentCycleProgress).toBe('number');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should contain weeklyCodeActivity with weeks', async () => {
|
||||||
|
const res = await app.request('/api/overview');
|
||||||
|
const body = await res.json();
|
||||||
|
|
||||||
|
const wca = body.data.weeklyCodeActivity;
|
||||||
|
expect(Array.isArray(wca.weeks)).toBe(true);
|
||||||
|
expect(wca.weeks.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const week = wca.weeks[0];
|
||||||
|
expect(typeof week.weekStart).toBe('string');
|
||||||
|
expect(Array.isArray(week.members)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should contain okrProgress array', async () => {
|
||||||
|
const res = await app.request('/api/overview');
|
||||||
|
const body = await res.json();
|
||||||
|
|
||||||
|
const okr = body.data.okrProgress;
|
||||||
|
expect(Array.isArray(okr)).toBe(true);
|
||||||
|
expect(okr.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const obj = okr[0];
|
||||||
|
expect(typeof obj.id).toBe('string');
|
||||||
|
expect(typeof obj.title).toBe('string');
|
||||||
|
expect(typeof obj.progress).toBe('number');
|
||||||
|
expect(Array.isArray(obj.keyResults)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should contain prMergeTime with weeks', async () => {
|
||||||
|
const res = await app.request('/api/overview');
|
||||||
|
const body = await res.json();
|
||||||
|
|
||||||
|
const pmt = body.data.prMergeTime;
|
||||||
|
expect(Array.isArray(pmt.weeks)).toBe(true);
|
||||||
|
expect(pmt.weeks.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const week = pmt.weeks[0];
|
||||||
|
expect(typeof week.weekStart).toBe('string');
|
||||||
|
expect(typeof week.avgHours).toBe('number');
|
||||||
|
expect(typeof week.prCount).toBe('number');
|
||||||
|
});
|
||||||
|
});
|
||||||
85
backend/tests/setup.ts
Normal file
85
backend/tests/setup.ts
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* Test setup for DevPerf Dashboard Backend
|
||||||
|
*
|
||||||
|
* This file provides common test utilities and mock helpers for the
|
||||||
|
* Hono-based API test suite. Since the real DB requires SQLite and
|
||||||
|
* Drizzle migrations, unit tests operate on the Hono app directly
|
||||||
|
* using Hono's built-in test client (app.request).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { SignJWT } from 'jose';
|
||||||
|
|
||||||
|
// Shared test JWT secret (matches .env.example)
|
||||||
|
export const TEST_JWT_SECRET = 'test-secret-for-unit-tests-at-least-16-chars';
|
||||||
|
const secret = new TextEncoder().encode(TEST_JWT_SECRET);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a valid JWT token for test requests.
|
||||||
|
*/
|
||||||
|
export async function createTestToken(payload: {
|
||||||
|
sub: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
displayName: string;
|
||||||
|
}): Promise<string> {
|
||||||
|
return new SignJWT(payload)
|
||||||
|
.setProtectedHeader({ alg: 'HS256' })
|
||||||
|
.setIssuedAt()
|
||||||
|
.setExpirationTime('1h')
|
||||||
|
.sign(secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard test user fixtures.
|
||||||
|
*/
|
||||||
|
export const TEST_USERS = {
|
||||||
|
admin: {
|
||||||
|
sub: 'user-admin-001',
|
||||||
|
email: 'admin@test.com',
|
||||||
|
role: 'admin',
|
||||||
|
displayName: 'Test Admin',
|
||||||
|
},
|
||||||
|
manager: {
|
||||||
|
sub: 'user-mgr-001',
|
||||||
|
email: 'manager@test.com',
|
||||||
|
role: 'manager',
|
||||||
|
displayName: 'Test Manager',
|
||||||
|
},
|
||||||
|
developer: {
|
||||||
|
sub: 'user-dev-001',
|
||||||
|
email: 'dev@test.com',
|
||||||
|
role: 'developer',
|
||||||
|
displayName: 'Test Developer',
|
||||||
|
},
|
||||||
|
viewer: {
|
||||||
|
sub: 'user-viewer-001',
|
||||||
|
email: 'viewer@test.com',
|
||||||
|
role: 'viewer',
|
||||||
|
displayName: 'Test Viewer',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to build request with auth header.
|
||||||
|
*/
|
||||||
|
export function authHeaders(token: string): Record<string, string> {
|
||||||
|
return {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert a JSON response matches the standard ApiResponse shape.
|
||||||
|
*/
|
||||||
|
export function assertApiResponse(body: any) {
|
||||||
|
if (typeof body.code !== 'number') {
|
||||||
|
throw new Error(`Expected body.code to be a number, got ${typeof body.code}`);
|
||||||
|
}
|
||||||
|
if (typeof body.message !== 'string') {
|
||||||
|
throw new Error(`Expected body.message to be a string, got ${typeof body.message}`);
|
||||||
|
}
|
||||||
|
if (!('data' in body)) {
|
||||||
|
throw new Error('Expected body to have a "data" property');
|
||||||
|
}
|
||||||
|
}
|
||||||
107
backend/tests/unit/auth-service.test.ts
Normal file
107
backend/tests/unit/auth-service.test.ts
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for JWT token generation and verification logic.
|
||||||
|
* Tests the jose-based JWT flow independent of the database.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from 'bun:test';
|
||||||
|
import { SignJWT, jwtVerify } from 'jose';
|
||||||
|
|
||||||
|
const TEST_SECRET = new TextEncoder().encode(
|
||||||
|
'test-secret-for-unit-tests-at-least-16-chars',
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('JWT Token Generation', () => {
|
||||||
|
it('should generate a valid JWT with expected claims', async () => {
|
||||||
|
const token = await new SignJWT({
|
||||||
|
sub: 'user-001',
|
||||||
|
email: 'test@example.com',
|
||||||
|
role: 'admin',
|
||||||
|
displayName: 'Test User',
|
||||||
|
})
|
||||||
|
.setProtectedHeader({ alg: 'HS256' })
|
||||||
|
.setIssuedAt()
|
||||||
|
.setExpirationTime('7d')
|
||||||
|
.sign(TEST_SECRET);
|
||||||
|
|
||||||
|
expect(typeof token).toBe('string');
|
||||||
|
expect(token.split('.').length).toBe(3); // JWT has 3 parts
|
||||||
|
|
||||||
|
// Verify the token
|
||||||
|
const { payload } = await jwtVerify(token, TEST_SECRET);
|
||||||
|
expect(payload.sub).toBe('user-001');
|
||||||
|
expect(payload.email).toBe('test@example.com');
|
||||||
|
expect(payload.role).toBe('admin');
|
||||||
|
expect(payload.displayName).toBe('Test User');
|
||||||
|
expect(payload.exp).toBeDefined();
|
||||||
|
expect(payload.iat).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject tampered tokens', async () => {
|
||||||
|
const token = await new SignJWT({ sub: 'user-001' })
|
||||||
|
.setProtectedHeader({ alg: 'HS256' })
|
||||||
|
.setExpirationTime('1h')
|
||||||
|
.sign(TEST_SECRET);
|
||||||
|
|
||||||
|
// Tamper with the token by changing a character
|
||||||
|
const parts = token.split('.');
|
||||||
|
parts[1] = parts[1].slice(0, -1) + (parts[1].slice(-1) === 'A' ? 'B' : 'A');
|
||||||
|
const tampered = parts.join('.');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await jwtVerify(tampered, TEST_SECRET);
|
||||||
|
expect(true).toBe(false); // Should not reach here
|
||||||
|
} catch (err: any) {
|
||||||
|
expect(err.code).toBe('ERR_JWS_SIGNATURE_VERIFICATION_FAILED');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject tokens signed with wrong secret', async () => {
|
||||||
|
const wrongSecret = new TextEncoder().encode('wrong-secret-that-is-long-enough');
|
||||||
|
const token = await new SignJWT({ sub: 'user-001' })
|
||||||
|
.setProtectedHeader({ alg: 'HS256' })
|
||||||
|
.setExpirationTime('1h')
|
||||||
|
.sign(wrongSecret);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await jwtVerify(token, TEST_SECRET);
|
||||||
|
expect(true).toBe(false);
|
||||||
|
} catch (err: any) {
|
||||||
|
expect(err.code).toBe('ERR_JWS_SIGNATURE_VERIFICATION_FAILED');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject expired tokens', async () => {
|
||||||
|
const token = await new SignJWT({ sub: 'user-001' })
|
||||||
|
.setProtectedHeader({ alg: 'HS256' })
|
||||||
|
.setExpirationTime('0s') // Already expired
|
||||||
|
.sign(TEST_SECRET);
|
||||||
|
|
||||||
|
// Wait a tiny bit to ensure expiration
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await jwtVerify(token, TEST_SECRET);
|
||||||
|
expect(true).toBe(false);
|
||||||
|
} catch (err: any) {
|
||||||
|
expect(err.code).toBe('ERR_JWT_EXPIRED');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include all role types in valid token payloads', async () => {
|
||||||
|
const roles = ['admin', 'manager', 'developer', 'viewer'];
|
||||||
|
|
||||||
|
for (const role of roles) {
|
||||||
|
const token = await new SignJWT({
|
||||||
|
sub: `user-${role}`,
|
||||||
|
email: `${role}@test.com`,
|
||||||
|
role,
|
||||||
|
displayName: `Test ${role}`,
|
||||||
|
})
|
||||||
|
.setProtectedHeader({ alg: 'HS256' })
|
||||||
|
.setExpirationTime('1h')
|
||||||
|
.sign(TEST_SECRET);
|
||||||
|
|
||||||
|
const { payload } = await jwtVerify(token, TEST_SECRET);
|
||||||
|
expect(payload.role).toBe(role);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
41
backend/tests/unit/config.test.ts
Normal file
41
backend/tests/unit/config.test.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for backend configuration module.
|
||||||
|
* Tests environment variable validation using Zod schema.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from 'bun:test';
|
||||||
|
|
||||||
|
describe('Config Module', () => {
|
||||||
|
it('should export config object with expected keys', async () => {
|
||||||
|
// We set JWT_SECRET env var to ensure validation passes
|
||||||
|
process.env.JWT_SECRET = 'test-secret-for-unit-tests-at-least-16-chars';
|
||||||
|
|
||||||
|
// Dynamic import to get fresh module
|
||||||
|
const { config } = await import('../../src/config');
|
||||||
|
|
||||||
|
expect(config).toBeDefined();
|
||||||
|
expect(typeof config.PORT).toBe('number');
|
||||||
|
expect(typeof config.JWT_SECRET).toBe('string');
|
||||||
|
expect(config.JWT_SECRET.length).toBeGreaterThanOrEqual(16);
|
||||||
|
expect(typeof config.DATABASE_PATH).toBe('string');
|
||||||
|
expect(typeof config.PLANE_BASE_URL).toBe('string');
|
||||||
|
expect(typeof config.GITEA_BASE_URL).toBe('string');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have correct default PORT', async () => {
|
||||||
|
process.env.JWT_SECRET = 'test-secret-for-unit-tests-at-least-16-chars';
|
||||||
|
|
||||||
|
const { config } = await import('../../src/config');
|
||||||
|
|
||||||
|
// Default PORT from schema is 3200
|
||||||
|
expect(config.PORT).toBe(3200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have valid default URLs', async () => {
|
||||||
|
process.env.JWT_SECRET = 'test-secret-for-unit-tests-at-least-16-chars';
|
||||||
|
|
||||||
|
const { config } = await import('../../src/config');
|
||||||
|
|
||||||
|
expect(config.PLANE_BASE_URL).toMatch(/^https?:\/\//);
|
||||||
|
expect(config.GITEA_BASE_URL).toMatch(/^https?:\/\//);
|
||||||
|
});
|
||||||
|
});
|
||||||
23
backend/tsconfig.json
Normal file
23
backend/tsconfig.json
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"types": ["bun-types"],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": ["node_modules", "dist", "tests"]
|
||||||
|
}
|
||||||
31
deploy/nginx.conf
Normal file
31
deploy/nginx.conf
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Vue Router history mode
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# API proxy
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://dashboard-api:3200;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Gzip
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
|
||||||
|
gzip_min_length 1000;
|
||||||
|
|
||||||
|
# Cache static assets
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
|
||||||
|
expires 30d;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
}
|
||||||
53
docker-compose.yml
Normal file
53
docker-compose.yml
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
dashboard-api:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
ports:
|
||||||
|
- "3200:3200"
|
||||||
|
environment:
|
||||||
|
- DATABASE_PATH=/data/devperf.db
|
||||||
|
- JWT_SECRET=${JWT_SECRET:-change-me-in-production-32chars}
|
||||||
|
- PORT=3200
|
||||||
|
- PLANE_BASE_URL=${PLANE_BASE_URL:-http://plane-api:8000}
|
||||||
|
- PLANE_API_TOKEN=${PLANE_API_TOKEN}
|
||||||
|
- PLANE_WORKSPACE_SLUG=${PLANE_WORKSPACE_SLUG:-jasonqiyuan}
|
||||||
|
- GITEA_BASE_URL=${GITEA_BASE_URL:-http://gitea:3000}
|
||||||
|
- GITEA_API_TOKEN=${GITEA_API_TOKEN}
|
||||||
|
- GITEA_ORG=${GITEA_ORG:-jasonqiyuan}
|
||||||
|
- SYNC_PLANE_INTERVAL=${SYNC_PLANE_INTERVAL:-15}
|
||||||
|
- SYNC_GITEA_INTERVAL=${SYNC_GITEA_INTERVAL:-30}
|
||||||
|
- ADMIN_EMAIL=${ADMIN_EMAIL:-admin@jasonqiyuan.com}
|
||||||
|
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-Admin123!}
|
||||||
|
volumes:
|
||||||
|
- dashboard-data:/data
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- devperf-net
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:3200/api/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
dashboard-web:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
ports:
|
||||||
|
- "3201:80"
|
||||||
|
depends_on:
|
||||||
|
dashboard-api:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- devperf-net
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
dashboard-data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
devperf-net:
|
||||||
|
external: true
|
||||||
1
frontend/.env.example
Normal file
1
frontend/.env.example
Normal file
@ -0,0 +1 @@
|
|||||||
|
VITE_API_BASE_URL=http://localhost:3200
|
||||||
11
frontend/Dockerfile
Normal file
11
frontend/Dockerfile
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
FROM node:22-slim AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN npm ci || npm install
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY --from=builder /app/dist/ /usr/share/nginx/html/
|
||||||
|
COPY ../deploy/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
EXPOSE 80
|
||||||
46
frontend/__tests__/components/BurndownChart.test.ts
Normal file
46
frontend/__tests__/components/BurndownChart.test.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* Component tests for BurndownChart.
|
||||||
|
* Validates rendering with burndown data points.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { mount } from '@vue/test-utils';
|
||||||
|
import BurndownChart from '@/components/charts/BurndownChart.vue';
|
||||||
|
|
||||||
|
const mockBurndown = [
|
||||||
|
{ date: '2026-04-01', ideal: 100, actual: 100 },
|
||||||
|
{ date: '2026-04-03', ideal: 80, actual: 85 },
|
||||||
|
{ date: '2026-04-05', ideal: 60, actual: 70 },
|
||||||
|
{ date: '2026-04-07', ideal: 40, actual: 55 },
|
||||||
|
{ date: '2026-04-09', ideal: 20, actual: 35 },
|
||||||
|
{ date: '2026-04-11', ideal: 0, actual: 10 },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('BurndownChart', () => {
|
||||||
|
it('should mount with burndown data', () => {
|
||||||
|
const wrapper = mount(BurndownChart, {
|
||||||
|
props: { burndown: mockBurndown, sprintName: 'Sprint 5' },
|
||||||
|
});
|
||||||
|
expect(wrapper.exists()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have chart container', () => {
|
||||||
|
const wrapper = mount(BurndownChart, {
|
||||||
|
props: { burndown: mockBurndown },
|
||||||
|
});
|
||||||
|
expect(wrapper.find('.burndown-chart').exists()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty burndown gracefully', () => {
|
||||||
|
const wrapper = mount(BurndownChart, {
|
||||||
|
props: { burndown: [] },
|
||||||
|
});
|
||||||
|
expect(wrapper.exists()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle single data point', () => {
|
||||||
|
const wrapper = mount(BurndownChart, {
|
||||||
|
props: { burndown: [{ date: '2026-04-01', ideal: 50, actual: 50 }] },
|
||||||
|
});
|
||||||
|
expect(wrapper.exists()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
46
frontend/__tests__/components/ContributionHeatmap.test.ts
Normal file
46
frontend/__tests__/components/ContributionHeatmap.test.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* Component tests for ContributionHeatmap.
|
||||||
|
* Validates rendering with heatmap day data.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { mount } from '@vue/test-utils';
|
||||||
|
import ContributionHeatmap from '@/components/charts/ContributionHeatmap.vue';
|
||||||
|
|
||||||
|
const mockDays = [
|
||||||
|
{ date: '2026-01-15', commits: 3, prsCreated: 1, prsMerged: 0, tasksCompleted: 2 },
|
||||||
|
{ date: '2026-01-16', commits: 0, prsCreated: 0, prsMerged: 0, tasksCompleted: 0 },
|
||||||
|
{ date: '2026-01-17', commits: 8, prsCreated: 2, prsMerged: 1, tasksCompleted: 4 },
|
||||||
|
{ date: '2026-01-18', commits: 1, prsCreated: 0, prsMerged: 0, tasksCompleted: 1 },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('ContributionHeatmap', () => {
|
||||||
|
it('should mount with days data', () => {
|
||||||
|
const wrapper = mount(ContributionHeatmap, {
|
||||||
|
props: { days: mockDays },
|
||||||
|
});
|
||||||
|
expect(wrapper.exists()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have heatmap container', () => {
|
||||||
|
const wrapper = mount(ContributionHeatmap, {
|
||||||
|
props: { days: mockDays },
|
||||||
|
});
|
||||||
|
expect(wrapper.find('.contribution-heatmap').exists()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty days gracefully', () => {
|
||||||
|
const wrapper = mount(ContributionHeatmap, {
|
||||||
|
props: { days: [] },
|
||||||
|
});
|
||||||
|
expect(wrapper.exists()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle single day', () => {
|
||||||
|
const wrapper = mount(ContributionHeatmap, {
|
||||||
|
props: {
|
||||||
|
days: [{ date: '2026-04-01', commits: 5, prsCreated: 1, prsMerged: 1, tasksCompleted: 3 }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(wrapper.exists()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
71
frontend/__tests__/components/KPIRadarChart.test.ts
Normal file
71
frontend/__tests__/components/KPIRadarChart.test.ts
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* Component tests for KPIRadarChart.
|
||||||
|
* Validates rendering with KPI scorecard data.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { mount } from '@vue/test-utils';
|
||||||
|
import KPIRadarChart from '@/components/charts/KPIRadarChart.vue';
|
||||||
|
|
||||||
|
const mockScorecard = {
|
||||||
|
sprintDeliveryRate: 82,
|
||||||
|
avgDeliveryDays: 4.5,
|
||||||
|
bugDensity: 0.08,
|
||||||
|
prMergeTimeAvg: 24,
|
||||||
|
reviewParticipation: 75,
|
||||||
|
activityStreak: 12,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('KPIRadarChart', () => {
|
||||||
|
it('should mount with scorecard data', () => {
|
||||||
|
const wrapper = mount(KPIRadarChart, {
|
||||||
|
props: { scorecard: mockScorecard },
|
||||||
|
});
|
||||||
|
expect(wrapper.exists()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have chart container', () => {
|
||||||
|
const wrapper = mount(KPIRadarChart, {
|
||||||
|
props: { scorecard: mockScorecard },
|
||||||
|
});
|
||||||
|
expect(wrapper.find('.kpi-radar-chart').exists()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle null scorecard', () => {
|
||||||
|
const wrapper = mount(KPIRadarChart, {
|
||||||
|
props: { scorecard: null },
|
||||||
|
});
|
||||||
|
expect(wrapper.exists()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle extreme values', () => {
|
||||||
|
const wrapper = mount(KPIRadarChart, {
|
||||||
|
props: {
|
||||||
|
scorecard: {
|
||||||
|
sprintDeliveryRate: 100,
|
||||||
|
avgDeliveryDays: 0,
|
||||||
|
bugDensity: 0,
|
||||||
|
prMergeTimeAvg: 0,
|
||||||
|
reviewParticipation: 100,
|
||||||
|
activityStreak: 365,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(wrapper.exists()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle all-zero values', () => {
|
||||||
|
const wrapper = mount(KPIRadarChart, {
|
||||||
|
props: {
|
||||||
|
scorecard: {
|
||||||
|
sprintDeliveryRate: 0,
|
||||||
|
avgDeliveryDays: 0,
|
||||||
|
bugDensity: 0,
|
||||||
|
prMergeTimeAvg: 0,
|
||||||
|
reviewParticipation: 0,
|
||||||
|
activityStreak: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(wrapper.exists()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
59
frontend/__tests__/components/OKRProgressBars.test.ts
Normal file
59
frontend/__tests__/components/OKRProgressBars.test.ts
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* Component tests for OKRProgressBars.
|
||||||
|
* Validates rendering with OKR objective data.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { mount } from '@vue/test-utils';
|
||||||
|
import OKRProgressBars from '@/components/charts/OKRProgressBars.vue';
|
||||||
|
|
||||||
|
const mockObjectives = [
|
||||||
|
{
|
||||||
|
id: 'o1',
|
||||||
|
title: 'Improve delivery rate to 85%',
|
||||||
|
ownerName: 'Alice',
|
||||||
|
progress: 72,
|
||||||
|
keyResults: [
|
||||||
|
{ title: 'Sprint delivery > 80%', current: 80, target: 100, unit: '%' },
|
||||||
|
{ title: 'Reduce bug density', current: 0.08, target: 0.05, unit: '/kloc' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'o2',
|
||||||
|
title: 'Ship v2.0 platform',
|
||||||
|
ownerName: 'Bob',
|
||||||
|
progress: 45,
|
||||||
|
keyResults: [
|
||||||
|
{ title: 'Core features completed', current: 3, target: 5, unit: 'features' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('OKRProgressBars', () => {
|
||||||
|
it('should mount with objectives data', () => {
|
||||||
|
const wrapper = mount(OKRProgressBars, {
|
||||||
|
props: { objectives: mockObjectives },
|
||||||
|
});
|
||||||
|
expect(wrapper.exists()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have chart container', () => {
|
||||||
|
const wrapper = mount(OKRProgressBars, {
|
||||||
|
props: { objectives: mockObjectives },
|
||||||
|
});
|
||||||
|
expect(wrapper.find('.okr-progress-bars').exists()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty objectives', () => {
|
||||||
|
const wrapper = mount(OKRProgressBars, {
|
||||||
|
props: { objectives: [] },
|
||||||
|
});
|
||||||
|
expect(wrapper.exists()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle single objective', () => {
|
||||||
|
const wrapper = mount(OKRProgressBars, {
|
||||||
|
props: { objectives: [mockObjectives[0]] },
|
||||||
|
});
|
||||||
|
expect(wrapper.exists()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
44
frontend/__tests__/components/PRMergeTimeChart.test.ts
Normal file
44
frontend/__tests__/components/PRMergeTimeChart.test.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* Component tests for PRMergeTimeChart.
|
||||||
|
* Validates rendering with PR merge time data.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { mount } from '@vue/test-utils';
|
||||||
|
import PRMergeTimeChart from '@/components/charts/PRMergeTimeChart.vue';
|
||||||
|
|
||||||
|
const mockWeeks = [
|
||||||
|
{ weekStart: '2026-03-02', avgHours: 18.5, prCount: 6 },
|
||||||
|
{ weekStart: '2026-03-09', avgHours: 24.2, prCount: 8 },
|
||||||
|
{ weekStart: '2026-03-16', avgHours: 52.1, prCount: 4 },
|
||||||
|
{ weekStart: '2026-03-23', avgHours: 36.8, prCount: 10 },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('PRMergeTimeChart', () => {
|
||||||
|
it('should mount with weeks data', () => {
|
||||||
|
const wrapper = mount(PRMergeTimeChart, {
|
||||||
|
props: { weeks: mockWeeks },
|
||||||
|
});
|
||||||
|
expect(wrapper.exists()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have chart container', () => {
|
||||||
|
const wrapper = mount(PRMergeTimeChart, {
|
||||||
|
props: { weeks: mockWeeks },
|
||||||
|
});
|
||||||
|
expect(wrapper.find('.pr-merge-time-chart').exists()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle custom warning threshold', () => {
|
||||||
|
const wrapper = mount(PRMergeTimeChart, {
|
||||||
|
props: { weeks: mockWeeks, warningThreshold: 24 },
|
||||||
|
});
|
||||||
|
expect(wrapper.exists()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty weeks', () => {
|
||||||
|
const wrapper = mount(PRMergeTimeChart, {
|
||||||
|
props: { weeks: [] },
|
||||||
|
});
|
||||||
|
expect(wrapper.exists()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
45
frontend/__tests__/components/ProjectProgressBars.test.ts
Normal file
45
frontend/__tests__/components/ProjectProgressBars.test.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* Component tests for ProjectProgressBars.
|
||||||
|
* Validates rendering with project progress data.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { mount } from '@vue/test-utils';
|
||||||
|
import ProjectProgressBars from '@/components/charts/ProjectProgressBars.vue';
|
||||||
|
|
||||||
|
const mockProjects = [
|
||||||
|
{ projectId: 'p1', name: 'Avatar Platform', identifier: 'AVATAR', currentCycleProgress: 82, totalPoints: 50, completedPoints: 41 },
|
||||||
|
{ projectId: 'p2', name: 'Airflow Pipeline', identifier: 'AIRFLOW', currentCycleProgress: 64, totalPoints: 40, completedPoints: 26 },
|
||||||
|
{ projectId: 'p3', name: 'Data Lake', identifier: 'DLAKE', currentCycleProgress: 45, totalPoints: 30, completedPoints: 14 },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('ProjectProgressBars', () => {
|
||||||
|
it('should mount with projects data', () => {
|
||||||
|
const wrapper = mount(ProjectProgressBars, {
|
||||||
|
props: { projects: mockProjects },
|
||||||
|
});
|
||||||
|
expect(wrapper.exists()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have chart container', () => {
|
||||||
|
const wrapper = mount(ProjectProgressBars, {
|
||||||
|
props: { projects: mockProjects },
|
||||||
|
});
|
||||||
|
expect(wrapper.find('.project-progress-bars').exists()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty projects', () => {
|
||||||
|
const wrapper = mount(ProjectProgressBars, {
|
||||||
|
props: { projects: [] },
|
||||||
|
});
|
||||||
|
expect(wrapper.exists()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should emit project-click event', async () => {
|
||||||
|
const wrapper = mount(ProjectProgressBars, {
|
||||||
|
props: { projects: mockProjects },
|
||||||
|
});
|
||||||
|
// The component registers click handlers on the ECharts instance
|
||||||
|
// In test environment with mocked ECharts, we verify the component mounts
|
||||||
|
expect(wrapper.exists()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
54
frontend/__tests__/components/SprintDeliveryChart.test.ts
Normal file
54
frontend/__tests__/components/SprintDeliveryChart.test.ts
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* Component tests for SprintDeliveryChart.
|
||||||
|
* Validates rendering, prop handling, and chart option computation.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { mount } from '@vue/test-utils';
|
||||||
|
import SprintDeliveryChart from '@/components/charts/SprintDeliveryChart.vue';
|
||||||
|
|
||||||
|
const mockCycles = [
|
||||||
|
{ name: 'Sprint 1', plannedPoints: 40, completedPoints: 32, deliveryRate: 80 },
|
||||||
|
{ name: 'Sprint 2', plannedPoints: 45, completedPoints: 38, deliveryRate: 84.4 },
|
||||||
|
{ name: 'Sprint 3', plannedPoints: 50, completedPoints: 42, deliveryRate: 84 },
|
||||||
|
{ name: 'Sprint 4', plannedPoints: 55, completedPoints: 40, deliveryRate: 72.7 },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('SprintDeliveryChart', () => {
|
||||||
|
it('should mount without errors', () => {
|
||||||
|
const wrapper = mount(SprintDeliveryChart, {
|
||||||
|
props: { cycles: mockCycles },
|
||||||
|
});
|
||||||
|
expect(wrapper.exists()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have a chart container element', () => {
|
||||||
|
const wrapper = mount(SprintDeliveryChart, {
|
||||||
|
props: { cycles: mockCycles },
|
||||||
|
});
|
||||||
|
const container = wrapper.find('.sprint-delivery-chart');
|
||||||
|
expect(container.exists()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept empty cycles array', () => {
|
||||||
|
const wrapper = mount(SprintDeliveryChart, {
|
||||||
|
props: { cycles: [] },
|
||||||
|
});
|
||||||
|
expect(wrapper.exists()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept custom targetRate prop', () => {
|
||||||
|
const wrapper = mount(SprintDeliveryChart, {
|
||||||
|
props: { cycles: mockCycles, targetRate: 90 },
|
||||||
|
});
|
||||||
|
expect(wrapper.exists()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have minimum height of 260px', () => {
|
||||||
|
const wrapper = mount(SprintDeliveryChart, {
|
||||||
|
props: { cycles: mockCycles },
|
||||||
|
});
|
||||||
|
const container = wrapper.find('.sprint-delivery-chart');
|
||||||
|
// Check that the scoped style includes min-height
|
||||||
|
expect(container.exists()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
41
frontend/__tests__/components/TaskStatusPie.test.ts
Normal file
41
frontend/__tests__/components/TaskStatusPie.test.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* Component tests for TaskStatusPie.
|
||||||
|
* Validates rendering with various data distributions.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { mount } from '@vue/test-utils';
|
||||||
|
import TaskStatusPie from '@/components/charts/TaskStatusPie.vue';
|
||||||
|
|
||||||
|
describe('TaskStatusPie', () => {
|
||||||
|
it('should mount with default props', () => {
|
||||||
|
const wrapper = mount(TaskStatusPie);
|
||||||
|
expect(wrapper.exists()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should mount with realistic data', () => {
|
||||||
|
const wrapper = mount(TaskStatusPie, {
|
||||||
|
props: {
|
||||||
|
data: { todo: 12, inProgress: 8, review: 5, done: 35 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(wrapper.find('.task-status-pie').exists()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle all-zero data gracefully', () => {
|
||||||
|
const wrapper = mount(TaskStatusPie, {
|
||||||
|
props: {
|
||||||
|
data: { todo: 0, inProgress: 0, review: 0, done: 0 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(wrapper.exists()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle single-status data', () => {
|
||||||
|
const wrapper = mount(TaskStatusPie, {
|
||||||
|
props: {
|
||||||
|
data: { todo: 0, inProgress: 0, review: 0, done: 50 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(wrapper.exists()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
61
frontend/__tests__/components/WeeklyCodeActivity.test.ts
Normal file
61
frontend/__tests__/components/WeeklyCodeActivity.test.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* Component tests for WeeklyCodeActivity.
|
||||||
|
* Validates rendering with stacked area chart data.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { mount } from '@vue/test-utils';
|
||||||
|
import WeeklyCodeActivity from '@/components/charts/WeeklyCodeActivity.vue';
|
||||||
|
|
||||||
|
const mockWeeks = [
|
||||||
|
{
|
||||||
|
weekStart: '2026-03-02',
|
||||||
|
members: [
|
||||||
|
{ userId: 'u1', name: 'Alice', commits: 12, prs: 3 },
|
||||||
|
{ userId: 'u2', name: 'Bob', commits: 8, prs: 2 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
weekStart: '2026-03-09',
|
||||||
|
members: [
|
||||||
|
{ userId: 'u1', name: 'Alice', commits: 15, prs: 4 },
|
||||||
|
{ userId: 'u2', name: 'Bob', commits: 10, prs: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('WeeklyCodeActivity', () => {
|
||||||
|
it('should mount with weeks data', () => {
|
||||||
|
const wrapper = mount(WeeklyCodeActivity, {
|
||||||
|
props: { weeks: mockWeeks },
|
||||||
|
});
|
||||||
|
expect(wrapper.exists()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have chart container', () => {
|
||||||
|
const wrapper = mount(WeeklyCodeActivity, {
|
||||||
|
props: { weeks: mockWeeks },
|
||||||
|
});
|
||||||
|
expect(wrapper.find('.weekly-code-activity').exists()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty weeks', () => {
|
||||||
|
const wrapper = mount(WeeklyCodeActivity, {
|
||||||
|
props: { weeks: [] },
|
||||||
|
});
|
||||||
|
expect(wrapper.exists()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle single member', () => {
|
||||||
|
const wrapper = mount(WeeklyCodeActivity, {
|
||||||
|
props: {
|
||||||
|
weeks: [
|
||||||
|
{
|
||||||
|
weekStart: '2026-03-02',
|
||||||
|
members: [{ userId: 'u1', name: 'Solo', commits: 5, prs: 1 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(wrapper.exists()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
54
frontend/__tests__/setup.ts
Normal file
54
frontend/__tests__/setup.ts
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* Vitest setup file for Vue component tests.
|
||||||
|
*
|
||||||
|
* Provides global mocks for:
|
||||||
|
* - ECharts (avoid canvas rendering in jsdom)
|
||||||
|
* - ResizeObserver (not available in jsdom)
|
||||||
|
* - Vue Router (for components that use router-link)
|
||||||
|
*/
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
|
||||||
|
// Mock ECharts to avoid canvas/WebGL errors in jsdom
|
||||||
|
vi.mock('echarts/core', () => ({
|
||||||
|
use: vi.fn(),
|
||||||
|
init: vi.fn(() => ({
|
||||||
|
setOption: vi.fn(),
|
||||||
|
resize: vi.fn(),
|
||||||
|
dispose: vi.fn(),
|
||||||
|
on: vi.fn(),
|
||||||
|
off: vi.fn(),
|
||||||
|
getWidth: vi.fn(() => 800),
|
||||||
|
getHeight: vi.fn(() => 400),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('echarts/charts', () => ({
|
||||||
|
BarChart: {},
|
||||||
|
LineChart: {},
|
||||||
|
PieChart: {},
|
||||||
|
RadarChart: {},
|
||||||
|
HeatmapChart: {},
|
||||||
|
CustomChart: {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('echarts/components', () => ({
|
||||||
|
GridComponent: {},
|
||||||
|
TooltipComponent: {},
|
||||||
|
LegendComponent: {},
|
||||||
|
TitleComponent: {},
|
||||||
|
DataZoomComponent: {},
|
||||||
|
ToolboxComponent: {},
|
||||||
|
VisualMapComponent: {},
|
||||||
|
CalendarComponent: {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('echarts/renderers', () => ({
|
||||||
|
CanvasRenderer: {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock ResizeObserver
|
||||||
|
global.ResizeObserver = vi.fn().mockImplementation(() => ({
|
||||||
|
observe: vi.fn(),
|
||||||
|
unobserve: vi.fn(),
|
||||||
|
disconnect: vi.fn(),
|
||||||
|
}));
|
||||||
16
frontend/index.html
Normal file
16
frontend/index.html
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>DevPerf Dashboard</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
4734
frontend/package-lock.json
generated
Normal file
4734
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
frontend/package.json
Normal file
34
frontend/package.json
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "devperf-dashboard-frontend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vue-tsc --noEmit && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"typecheck": "vue-tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"vue": "^3.5.0",
|
||||||
|
"vue-router": "^4.5.0",
|
||||||
|
"pinia": "^2.3.0",
|
||||||
|
"naive-ui": "^2.40.0",
|
||||||
|
"axios": "^1.7.0",
|
||||||
|
"echarts": "^5.5.1",
|
||||||
|
"vue-echarts": "^7.0.0",
|
||||||
|
"dayjs": "^1.11.13",
|
||||||
|
"@vicons/ionicons5": "^0.12.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.2.0",
|
||||||
|
"vite": "^6.0.0",
|
||||||
|
"vue-tsc": "^2.2.0",
|
||||||
|
"typescript": "^5.7.0",
|
||||||
|
"vitest": "^2.1.0",
|
||||||
|
"@vue/test-utils": "^2.4.0",
|
||||||
|
"jsdom": "^25.0.0",
|
||||||
|
"@types/node": "^22.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
14
frontend/src/App.vue
Normal file
14
frontend/src/App.vue
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { NConfigProvider, NMessageProvider, NDialogProvider } from 'naive-ui';
|
||||||
|
import { naiveThemeOverrides } from './styles/naive-overrides';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NConfigProvider :theme-overrides="naiveThemeOverrides">
|
||||||
|
<NMessageProvider>
|
||||||
|
<NDialogProvider>
|
||||||
|
<router-view />
|
||||||
|
</NDialogProvider>
|
||||||
|
</NMessageProvider>
|
||||||
|
</NConfigProvider>
|
||||||
|
</template>
|
||||||
67
frontend/src/api/admin.ts
Normal file
67
frontend/src/api/admin.ts
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import request from './request';
|
||||||
|
|
||||||
|
export function getAdminUsersApi() {
|
||||||
|
return request.get('/api/admin/users');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createUserApi(data: { displayName: string; email: string; password: string; role: string; planeUserId?: string; gitUsername?: string }) {
|
||||||
|
return request.post('/api/admin/users', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateUserApi(id: string, data: Record<string, any>) {
|
||||||
|
return request.patch(`/api/admin/users/${id}`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteUserApi(id: string) {
|
||||||
|
return request.delete(`/api/admin/users/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAuthorMappingsApi() {
|
||||||
|
return request.get('/api/admin/author-mappings');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMappingApi(data: { gitEmail?: string; gitUsername?: string; userId: string }) {
|
||||||
|
return request.post('/api/admin/author-mappings', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteMappingApi(id: string) {
|
||||||
|
return request.delete(`/api/admin/author-mappings/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Projects(所有登录用户可访问)
|
||||||
|
export function getAdminProjectsApi() {
|
||||||
|
return request.get('/api/projects');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createProjectApi(data: { name: string; identifier: string }) {
|
||||||
|
return request.post('/api/projects', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteProjectApi(id: string) {
|
||||||
|
return request.delete(`/api/projects/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Project Repos
|
||||||
|
export function getProjectReposApi(projectId: string) {
|
||||||
|
return request.get(`/api/projects/${projectId}/repos`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bindRepoApi(projectId: string, repoName: string) {
|
||||||
|
return request.post(`/api/projects/${projectId}/repos`, { repoName });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unbindRepoApi(bindingId: string) {
|
||||||
|
return request.delete(`/api/project-repos/${bindingId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAvailableReposApi() {
|
||||||
|
return request.get('/api/admin/available-repos');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function triggerSyncApi(data?: { source?: string }) {
|
||||||
|
return request.post('/api/admin/sync/trigger', data || {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSyncLogsApi(params?: { page?: number; pageSize?: number }) {
|
||||||
|
return request.get('/api/admin/sync/logs', { params });
|
||||||
|
}
|
||||||
9
frontend/src/api/auth.ts
Normal file
9
frontend/src/api/auth.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import request from './request';
|
||||||
|
|
||||||
|
export function loginApi(email: string, password: string) {
|
||||||
|
return request.post('/api/auth/login', { email, password });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMeApi() {
|
||||||
|
return request.get('/api/auth/me');
|
||||||
|
}
|
||||||
5
frontend/src/api/git.ts
Normal file
5
frontend/src/api/git.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import request from './request';
|
||||||
|
|
||||||
|
export function getGitActivityApi(params?: { userId?: string; weeks?: number }) {
|
||||||
|
return request.get('/api/git/activity', { params });
|
||||||
|
}
|
||||||
9
frontend/src/api/members.ts
Normal file
9
frontend/src/api/members.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import request from './request';
|
||||||
|
|
||||||
|
export function getMemberListApi() {
|
||||||
|
return request.get('/api/members');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMemberDetailApi(id: string) {
|
||||||
|
return request.get(`/api/members/${id}`);
|
||||||
|
}
|
||||||
25
frontend/src/api/okr.ts
Normal file
25
frontend/src/api/okr.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import request from './request';
|
||||||
|
|
||||||
|
export function getOKRApi(params?: { period?: string }) {
|
||||||
|
return request.get('/api/okr', { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createObjectiveApi(data: { title: string; ownerId: string; projectId: string; startDate: string; endDate: string; period?: string }) {
|
||||||
|
return request.post('/api/okr/objectives', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createKeyResultApi(objectiveId: string, data: { title: string; targetValue: number; unit: string; weight: number; startDate?: string; endDate?: string }) {
|
||||||
|
return request.post(`/api/okr/objectives/${objectiveId}/key-results`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateKeyResultApi(krId: string, data: { currentValue: number }) {
|
||||||
|
return request.patch(`/api/okr/key-results/${krId}`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteObjectiveApi(id: string) {
|
||||||
|
return request.delete(`/api/okr/objectives/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteKeyResultApi(id: string) {
|
||||||
|
return request.delete(`/api/okr/key-results/${id}`);
|
||||||
|
}
|
||||||
9
frontend/src/api/overview.ts
Normal file
9
frontend/src/api/overview.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import request from './request';
|
||||||
|
|
||||||
|
export function getOverviewApi(params?: { period?: string; projectIds?: string }) {
|
||||||
|
return request.get('/api/overview', { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProjectListApi() {
|
||||||
|
return request.get('/api/projects');
|
||||||
|
}
|
||||||
5
frontend/src/api/projects.ts
Normal file
5
frontend/src/api/projects.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import request from './request';
|
||||||
|
|
||||||
|
export function getProjectDetailApi(id: string) {
|
||||||
|
return request.get(`/api/projects/${id}`);
|
||||||
|
}
|
||||||
33
frontend/src/api/request.ts
Normal file
33
frontend/src/api/request.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import { API_BASE_URL } from '@/config';
|
||||||
|
import router from '@/router';
|
||||||
|
|
||||||
|
const request = axios.create({
|
||||||
|
baseURL: API_BASE_URL,
|
||||||
|
timeout: 15000,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Request interceptor: inject JWT
|
||||||
|
request.interceptors.request.use((config) => {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Response interceptor: handle auth errors
|
||||||
|
request.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error) => {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
localStorage.removeItem('user');
|
||||||
|
router.push('/login?expired=true');
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default request;
|
||||||
131
frontend/src/components/charts/BurndownChart.vue
Normal file
131
frontend/src/components/charts/BurndownChart.vue
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, type PropType } from 'vue';
|
||||||
|
import { useECharts, CHART_COLORS } from '@/composables/useECharts';
|
||||||
|
import type { BurndownPoint } from '@/types';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
burndown: {
|
||||||
|
type: Array as PropType<BurndownPoint[]>,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
sprintName: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const chartOptions = computed(() => {
|
||||||
|
if (!props.burndown.length) {
|
||||||
|
return { title: { text: '暂无燃尽数据', left: 'center', top: 'center', textStyle: { color: '#9CA3AF', fontSize: 14 } } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const dates = props.burndown.map((p) => p.date);
|
||||||
|
const idealLine = props.burndown.map((p) => p.ideal);
|
||||||
|
const actualLine = props.burndown.map((p) => p.actual);
|
||||||
|
|
||||||
|
// Find today marker index (closest past date)
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
const todayIdx = dates.findIndex((d) => d >= today);
|
||||||
|
|
||||||
|
return {
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis' as const,
|
||||||
|
formatter(params: any) {
|
||||||
|
const idx = params[0]?.dataIndex ?? 0;
|
||||||
|
const pt = props.burndown[idx];
|
||||||
|
if (!pt) return '';
|
||||||
|
return `
|
||||||
|
<strong>${pt.date}</strong><br/>
|
||||||
|
理想: ${pt.ideal} 点<br/>
|
||||||
|
实际: <strong>${pt.actual} 点</strong><br/>
|
||||||
|
差距: ${pt.actual - pt.ideal > 0 ? '+' : ''}${(pt.actual - pt.ideal).toFixed(0)} 点
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
data: ['理想', '实际'],
|
||||||
|
bottom: 0,
|
||||||
|
textStyle: { fontSize: 12, color: '#6B7280' },
|
||||||
|
},
|
||||||
|
grid: { top: 16, right: 16, bottom: 36, left: 48, containLabel: false },
|
||||||
|
xAxis: {
|
||||||
|
type: 'category' as const,
|
||||||
|
data: dates,
|
||||||
|
axisLine: { lineStyle: { color: '#E5E7EB' } },
|
||||||
|
axisLabel: {
|
||||||
|
fontSize: 11,
|
||||||
|
color: '#6B7280',
|
||||||
|
formatter(val: string) {
|
||||||
|
// Show only month-day
|
||||||
|
return val.slice(5);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: 'value' as const,
|
||||||
|
name: '点数',
|
||||||
|
nameTextStyle: { fontSize: 11, color: '#9CA3AF' },
|
||||||
|
axisLine: { show: false },
|
||||||
|
splitLine: { lineStyle: { color: '#F3F4F6' } },
|
||||||
|
axisLabel: { fontSize: 11, color: '#9CA3AF' },
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: '理想',
|
||||||
|
type: 'line' as const,
|
||||||
|
data: idealLine,
|
||||||
|
lineStyle: { type: 'dashed' as const, color: '#9CA3AF', width: 2 },
|
||||||
|
symbol: 'none',
|
||||||
|
z: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '实际',
|
||||||
|
type: 'line' as const,
|
||||||
|
data: actualLine,
|
||||||
|
smooth: true,
|
||||||
|
symbol: 'circle',
|
||||||
|
symbolSize: 5,
|
||||||
|
itemStyle: { color: CHART_COLORS[0] },
|
||||||
|
lineStyle: { width: 2.5 },
|
||||||
|
areaStyle: {
|
||||||
|
color: {
|
||||||
|
type: 'linear' as const,
|
||||||
|
x: 0, y: 0, x2: 0, y2: 1,
|
||||||
|
colorStops: [
|
||||||
|
{ offset: 0, color: 'rgba(59, 89, 152, 0.12)' },
|
||||||
|
{ offset: 1, color: 'rgba(59, 89, 152, 0.01)' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
z: 2,
|
||||||
|
markLine: todayIdx >= 0 ? {
|
||||||
|
silent: true,
|
||||||
|
data: [{ xAxis: todayIdx }],
|
||||||
|
lineStyle: { type: 'solid' as const, color: '#D4920A', width: 1.5 },
|
||||||
|
label: {
|
||||||
|
formatter: '今天',
|
||||||
|
fontSize: 11,
|
||||||
|
color: '#D4920A',
|
||||||
|
},
|
||||||
|
} : undefined,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
animationDuration: 600,
|
||||||
|
animationEasing: 'cubicOut',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const { chartRef } = useECharts(chartOptions);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="chartRef" class="burndown-chart" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.burndown-chart {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 260px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
139
frontend/src/components/charts/ContributionHeatmap.vue
Normal file
139
frontend/src/components/charts/ContributionHeatmap.vue
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, type PropType } from 'vue';
|
||||||
|
import { useECharts } from '@/composables/useECharts';
|
||||||
|
import type { HeatmapDay } from '@/types';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
days: {
|
||||||
|
type: Array as PropType<HeatmapDay[]>,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Color stops for contribution intensity (GitHub-style)
|
||||||
|
const LEVEL_COLORS = [
|
||||||
|
'#EBEDF0', // 0 contributions
|
||||||
|
'#9BE9A8', // light
|
||||||
|
'#40C463', // medium
|
||||||
|
'#30A14E', // high
|
||||||
|
'#216E39', // very high
|
||||||
|
];
|
||||||
|
|
||||||
|
function getContributionLevel(day: HeatmapDay): number {
|
||||||
|
const total = day.commits + day.prsCreated + day.prsMerged + day.tasksCompleted;
|
||||||
|
if (total === 0) return 0;
|
||||||
|
if (total <= 2) return 1;
|
||||||
|
if (total <= 5) return 2;
|
||||||
|
if (total <= 10) return 3;
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chartOptions = computed(() => {
|
||||||
|
if (!props.days.length) {
|
||||||
|
return { title: { text: '暂无贡献数据', left: 'center', top: 'center', textStyle: { color: '#9CA3AF', fontSize: 14 } } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine date range for the calendar
|
||||||
|
const dates = props.days.map((d) => d.date).sort();
|
||||||
|
const startDate = dates[0];
|
||||||
|
const endDate = dates[dates.length - 1];
|
||||||
|
|
||||||
|
// Compute the year (or range) for the calendar component
|
||||||
|
const startYear = startDate.slice(0, 4);
|
||||||
|
const endYear = endDate.slice(0, 4);
|
||||||
|
const calendarRange = startYear === endYear ? startYear : [startDate, endDate];
|
||||||
|
|
||||||
|
// Build data: [date, total_contributions]
|
||||||
|
const heatmapData = props.days.map((d) => {
|
||||||
|
const total = d.commits + d.prsCreated + d.prsMerged + d.tasksCompleted;
|
||||||
|
return [d.date, total];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Max contribution for visualMap
|
||||||
|
const maxVal = Math.max(...heatmapData.map((d) => d[1] as number), 1);
|
||||||
|
|
||||||
|
return {
|
||||||
|
tooltip: {
|
||||||
|
formatter(params: any) {
|
||||||
|
const date = params.data[0];
|
||||||
|
const total = params.data[1];
|
||||||
|
const dayData = props.days.find((d) => d.date === date);
|
||||||
|
if (!dayData) return `${date}: ${total} 次贡献`;
|
||||||
|
return `
|
||||||
|
<strong>${date}</strong><br/>
|
||||||
|
提交: ${dayData.commits}<br/>
|
||||||
|
创建 PR: ${dayData.prsCreated}<br/>
|
||||||
|
合并 PR: ${dayData.prsMerged}<br/>
|
||||||
|
完成任务: ${dayData.tasksCompleted}<br/>
|
||||||
|
<strong>总计: ${total}</strong>
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
visualMap: {
|
||||||
|
show: true,
|
||||||
|
type: 'piecewise' as const,
|
||||||
|
orient: 'horizontal' as const,
|
||||||
|
left: 'center',
|
||||||
|
bottom: 0,
|
||||||
|
pieces: [
|
||||||
|
{ lte: 0, color: LEVEL_COLORS[0], label: '0' },
|
||||||
|
{ gt: 0, lte: 2, color: LEVEL_COLORS[1], label: '1-2' },
|
||||||
|
{ gt: 2, lte: 5, color: LEVEL_COLORS[2], label: '3-5' },
|
||||||
|
{ gt: 5, lte: 10, color: LEVEL_COLORS[3], label: '6-10' },
|
||||||
|
{ gt: 10, color: LEVEL_COLORS[4], label: '10+' },
|
||||||
|
],
|
||||||
|
itemWidth: 12,
|
||||||
|
itemHeight: 12,
|
||||||
|
itemGap: 4,
|
||||||
|
textStyle: { fontSize: 10, color: '#9CA3AF' },
|
||||||
|
},
|
||||||
|
calendar: {
|
||||||
|
top: 16,
|
||||||
|
left: 40,
|
||||||
|
right: 16,
|
||||||
|
bottom: 40,
|
||||||
|
range: calendarRange,
|
||||||
|
cellSize: ['auto', 14],
|
||||||
|
splitLine: { show: false },
|
||||||
|
itemStyle: {
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: '#FFFFFF',
|
||||||
|
borderRadius: 2,
|
||||||
|
},
|
||||||
|
yearLabel: { show: false },
|
||||||
|
monthLabel: {
|
||||||
|
fontSize: 11,
|
||||||
|
color: '#6B7280',
|
||||||
|
},
|
||||||
|
dayLabel: {
|
||||||
|
firstDay: 1, // Monday
|
||||||
|
fontSize: 10,
|
||||||
|
color: '#9CA3AF',
|
||||||
|
nameMap: ['日', '一', '二', '三', '四', '五', '六'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: 'heatmap' as const,
|
||||||
|
coordinateSystem: 'calendar' as const,
|
||||||
|
data: heatmapData,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
animationDuration: 500,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const { chartRef } = useECharts(chartOptions);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="chartRef" class="contribution-heatmap" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.contribution-heatmap {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 160px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
105
frontend/src/components/charts/KPIRadarChart.vue
Normal file
105
frontend/src/components/charts/KPIRadarChart.vue
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, type PropType } from 'vue';
|
||||||
|
import { useECharts, CHART_COLORS } from '@/composables/useECharts';
|
||||||
|
import type { KPIScorecard } from '@/types';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
scorecard: {
|
||||||
|
type: Object as PropType<KPIScorecard | null>,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const chartOptions = computed(() => {
|
||||||
|
const kpi = props.scorecard;
|
||||||
|
if (!kpi) {
|
||||||
|
return { title: { text: '暂无 KPI 数据', left: 'center', top: 'center', textStyle: { color: '#9CA3AF', fontSize: 14 } } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize KPI values to 0-100 scale for radar
|
||||||
|
const deliveryRate = Math.min(kpi.sprintDeliveryRate, 100);
|
||||||
|
const deliverySpeed = Math.max(0, 100 - kpi.avgDeliveryDays * 5);
|
||||||
|
const codeQuality = Math.max(0, 100 - kpi.bugDensity * 100);
|
||||||
|
const prEfficiency = Math.max(0, 100 - kpi.prMergeTimeAvg);
|
||||||
|
const reviewActivity = Math.min(kpi.reviewParticipation, 100);
|
||||||
|
|
||||||
|
return {
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'item' as const,
|
||||||
|
},
|
||||||
|
radar: {
|
||||||
|
indicator: [
|
||||||
|
{ name: '交付率', max: 100 },
|
||||||
|
{ name: '交付速度', max: 100 },
|
||||||
|
{ name: '代码质量', max: 100 },
|
||||||
|
{ name: 'PR 效率', max: 100 },
|
||||||
|
{ name: '评审参与', max: 100 },
|
||||||
|
],
|
||||||
|
shape: 'polygon' as const,
|
||||||
|
center: ['50%', '50%'],
|
||||||
|
radius: '68%',
|
||||||
|
axisName: {
|
||||||
|
color: '#6B7280',
|
||||||
|
fontSize: 11,
|
||||||
|
},
|
||||||
|
splitArea: {
|
||||||
|
areaStyle: {
|
||||||
|
color: ['#FAFBFC', '#F3F4F6', '#ECEDF0', '#E5E7EB', '#D1D5DB'],
|
||||||
|
shadowColor: 'rgba(0, 0, 0, 0.03)',
|
||||||
|
shadowBlur: 4,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
splitLine: {
|
||||||
|
lineStyle: { color: '#E5E7EB' },
|
||||||
|
},
|
||||||
|
axisLine: {
|
||||||
|
lineStyle: { color: '#E5E7EB' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: 'radar' as const,
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
value: [deliveryRate, deliverySpeed, codeQuality, prEfficiency, reviewActivity],
|
||||||
|
name: 'KPI 评分',
|
||||||
|
symbol: 'circle',
|
||||||
|
symbolSize: 6,
|
||||||
|
itemStyle: { color: CHART_COLORS[0] },
|
||||||
|
lineStyle: { width: 2, color: CHART_COLORS[0] },
|
||||||
|
areaStyle: {
|
||||||
|
color: {
|
||||||
|
type: 'radial' as const,
|
||||||
|
x: 0.5, y: 0.5, r: 0.5,
|
||||||
|
colorStops: [
|
||||||
|
{ offset: 0, color: 'rgba(59, 89, 152, 0.3)' },
|
||||||
|
{ offset: 1, color: 'rgba(59, 89, 152, 0.05)' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
emphasis: {
|
||||||
|
lineStyle: { width: 3 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
animationDuration: 600,
|
||||||
|
animationEasing: 'cubicOut',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const { chartRef } = useECharts(chartOptions);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="chartRef" class="kpi-radar-chart" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.kpi-radar-chart {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 240px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
107
frontend/src/components/charts/OKRProgressBars.vue
Normal file
107
frontend/src/components/charts/OKRProgressBars.vue
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, type PropType } from 'vue';
|
||||||
|
import { useECharts, CHART_COLORS } from '@/composables/useECharts';
|
||||||
|
import type { OKRProgressItem } from '@/types';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
objectives: {
|
||||||
|
type: Array as PropType<OKRProgressItem[]>,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function progressColor(pct: number): string {
|
||||||
|
if (pct >= 70) return '#0D9668'; // success green
|
||||||
|
if (pct >= 40) return CHART_COLORS[0]; // primary indigo
|
||||||
|
return '#D4920A'; // amber warning
|
||||||
|
}
|
||||||
|
|
||||||
|
const chartOptions = computed(() => {
|
||||||
|
if (!props.objectives.length) {
|
||||||
|
return { title: { text: '暂无 OKR 数据', left: 'center', top: 'center', textStyle: { color: '#9CA3AF', fontSize: 14 } } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const names = props.objectives.map((o) => o.title);
|
||||||
|
const values = props.objectives.map((o) => Math.round(o.progress));
|
||||||
|
const bgValues = props.objectives.map(() => 100);
|
||||||
|
|
||||||
|
return {
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis' as const,
|
||||||
|
axisPointer: { type: 'shadow' as const },
|
||||||
|
formatter(params: any) {
|
||||||
|
const idx = params[0]?.dataIndex ?? 0;
|
||||||
|
const obj = props.objectives[idx];
|
||||||
|
if (!obj) return '';
|
||||||
|
const krList = obj.keyResults
|
||||||
|
.map((kr) => `${kr.title}: ${kr.current}/${kr.target} ${kr.unit}`)
|
||||||
|
.join('<br/>');
|
||||||
|
return `<strong>${obj.title}</strong><br/>负责人: ${obj.ownerName}<br/>进度: ${Math.round(obj.progress)}%<br/><br/>${krList}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
grid: { top: 8, right: 60, bottom: 8, left: 8, containLabel: true },
|
||||||
|
xAxis: {
|
||||||
|
type: 'value' as const,
|
||||||
|
max: 100,
|
||||||
|
show: false,
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: 'category' as const,
|
||||||
|
data: names,
|
||||||
|
inverse: true,
|
||||||
|
axisLine: { show: false },
|
||||||
|
axisTick: { show: false },
|
||||||
|
axisLabel: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#1A1F2E',
|
||||||
|
width: 160,
|
||||||
|
overflow: 'truncate' as const,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: 'bar' as const,
|
||||||
|
data: bgValues,
|
||||||
|
barWidth: 12,
|
||||||
|
barGap: '-100%',
|
||||||
|
itemStyle: { color: '#F3F4F6', borderRadius: 6 },
|
||||||
|
silent: true,
|
||||||
|
z: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'bar' as const,
|
||||||
|
data: values.map((v) => ({
|
||||||
|
value: v,
|
||||||
|
itemStyle: { color: progressColor(v), borderRadius: 6 },
|
||||||
|
})),
|
||||||
|
barWidth: 12,
|
||||||
|
z: 2,
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
position: 'right' as const,
|
||||||
|
formatter: '{c}%',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: '#1A1F2E',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
animationDuration: 500,
|
||||||
|
animationEasing: 'cubicOut',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const { chartRef } = useECharts(chartOptions);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="chartRef" class="okr-progress-bars" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.okr-progress-bars {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 260px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
125
frontend/src/components/charts/PRMergeTimeChart.vue
Normal file
125
frontend/src/components/charts/PRMergeTimeChart.vue
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, type PropType } from 'vue';
|
||||||
|
import { useECharts, CHART_COLORS } from '@/composables/useECharts';
|
||||||
|
import type { WeekPRData } from '@/types';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
weeks: {
|
||||||
|
type: Array as PropType<WeekPRData[]>,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
warningThreshold: {
|
||||||
|
type: Number,
|
||||||
|
default: 48,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const chartOptions = computed(() => {
|
||||||
|
const labels = props.weeks.map((w) => w.weekStart);
|
||||||
|
const avgHours = props.weeks.map((w) => w.avgHours);
|
||||||
|
const prCounts = props.weeks.map((w) => w.prCount);
|
||||||
|
|
||||||
|
return {
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis' as const,
|
||||||
|
formatter(params: any) {
|
||||||
|
const idx = params[0]?.dataIndex ?? 0;
|
||||||
|
const week = props.weeks[idx];
|
||||||
|
if (!week) return '';
|
||||||
|
return `
|
||||||
|
<strong>${week.weekStart}</strong><br/>
|
||||||
|
平均合入时间: <strong>${week.avgHours.toFixed(1)}h</strong><br/>
|
||||||
|
PR 数量: ${week.prCount}
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
data: ['平均合入时间', 'PR 数量', `${props.warningThreshold}h 预警线`],
|
||||||
|
bottom: 0,
|
||||||
|
textStyle: { fontSize: 11, color: '#6B7280' },
|
||||||
|
},
|
||||||
|
grid: { top: 16, right: 48, bottom: 40, left: 48, containLabel: false },
|
||||||
|
xAxis: {
|
||||||
|
type: 'category' as const,
|
||||||
|
data: labels,
|
||||||
|
axisLine: { lineStyle: { color: '#E5E7EB' } },
|
||||||
|
axisLabel: { fontSize: 11, color: '#6B7280' },
|
||||||
|
},
|
||||||
|
yAxis: [
|
||||||
|
{
|
||||||
|
type: 'value' as const,
|
||||||
|
name: '小时',
|
||||||
|
nameTextStyle: { fontSize: 11, color: '#9CA3AF' },
|
||||||
|
axisLine: { show: false },
|
||||||
|
splitLine: { lineStyle: { color: '#F3F4F6' } },
|
||||||
|
axisLabel: { fontSize: 11, color: '#9CA3AF', formatter: '{value}h' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'value' as const,
|
||||||
|
name: 'PR',
|
||||||
|
nameTextStyle: { fontSize: 11, color: '#9CA3AF' },
|
||||||
|
axisLine: { show: false },
|
||||||
|
splitLine: { show: false },
|
||||||
|
axisLabel: { fontSize: 11, color: '#9CA3AF' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: '平均合入时间',
|
||||||
|
type: 'line' as const,
|
||||||
|
data: avgHours,
|
||||||
|
smooth: true,
|
||||||
|
symbol: 'circle',
|
||||||
|
symbolSize: 6,
|
||||||
|
itemStyle: { color: CHART_COLORS[0] },
|
||||||
|
lineStyle: { width: 2.5 },
|
||||||
|
areaStyle: {
|
||||||
|
color: {
|
||||||
|
type: 'linear' as const,
|
||||||
|
x: 0, y: 0, x2: 0, y2: 1,
|
||||||
|
colorStops: [
|
||||||
|
{ offset: 0, color: 'rgba(59, 89, 152, 0.15)' },
|
||||||
|
{ offset: 1, color: 'rgba(59, 89, 152, 0.02)' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'PR 数量',
|
||||||
|
type: 'bar' as const,
|
||||||
|
yAxisIndex: 1,
|
||||||
|
data: prCounts,
|
||||||
|
barWidth: '40%',
|
||||||
|
itemStyle: {
|
||||||
|
color: 'rgba(43, 140, 163, 0.35)', // chart-5 with opacity
|
||||||
|
borderRadius: [3, 3, 0, 0],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: `${props.warningThreshold}h 预警线`,
|
||||||
|
type: 'line' as const,
|
||||||
|
data: labels.map(() => props.warningThreshold),
|
||||||
|
lineStyle: { type: 'dashed' as const, color: '#DC2626', width: 2 },
|
||||||
|
symbol: 'none',
|
||||||
|
tooltip: { show: false },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
animationDuration: 500,
|
||||||
|
animationEasing: 'cubicOut',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const { chartRef } = useECharts(chartOptions);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="chartRef" class="pr-merge-time-chart" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.pr-merge-time-chart {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 260px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
129
frontend/src/components/charts/ProjectProgressBars.vue
Normal file
129
frontend/src/components/charts/ProjectProgressBars.vue
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, type PropType } from 'vue';
|
||||||
|
import { useECharts, CHART_COLORS } from '@/composables/useECharts';
|
||||||
|
import type { ProjectProgressItem } from '@/types';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
projects: {
|
||||||
|
type: Array as PropType<ProjectProgressItem[]>,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'project-click', projectId: string): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const chartOptions = computed(() => {
|
||||||
|
const sorted = [...props.projects].sort(
|
||||||
|
(a, b) => b.currentCycleProgress - a.currentCycleProgress,
|
||||||
|
);
|
||||||
|
const names = sorted.map((p) => `${p.identifier} ${p.name}`.trim());
|
||||||
|
const values = sorted.map((p) => p.currentCycleProgress);
|
||||||
|
const bgValues = sorted.map(() => 100);
|
||||||
|
|
||||||
|
return {
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis' as const,
|
||||||
|
axisPointer: { type: 'shadow' as const },
|
||||||
|
formatter(params: any) {
|
||||||
|
const idx = params[0]?.dataIndex ?? 0;
|
||||||
|
const project = sorted[idx];
|
||||||
|
if (!project) return '';
|
||||||
|
return `
|
||||||
|
<strong>${project.identifier} ${project.name}</strong><br/>
|
||||||
|
进度: ${project.currentCycleProgress}%<br/>
|
||||||
|
${project.completedPoints}/${project.totalPoints} 点
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
grid: { top: 8, right: 60, bottom: 8, left: 8, containLabel: true },
|
||||||
|
xAxis: {
|
||||||
|
type: 'value' as const,
|
||||||
|
max: 100,
|
||||||
|
show: false,
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: 'category' as const,
|
||||||
|
data: names,
|
||||||
|
inverse: true,
|
||||||
|
axisLine: { show: false },
|
||||||
|
axisTick: { show: false },
|
||||||
|
axisLabel: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#1A1F2E',
|
||||||
|
width: 120,
|
||||||
|
overflow: 'truncate' as const,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: 'bar' as const,
|
||||||
|
data: bgValues,
|
||||||
|
barWidth: 14,
|
||||||
|
barGap: '-100%',
|
||||||
|
itemStyle: { color: '#F3F4F6', borderRadius: 7 },
|
||||||
|
silent: true,
|
||||||
|
z: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'bar' as const,
|
||||||
|
data: values.map((v) => ({
|
||||||
|
value: v,
|
||||||
|
itemStyle: {
|
||||||
|
color: v >= 80 ? CHART_COLORS[1] : v >= 50 ? CHART_COLORS[0] : CHART_COLORS[2],
|
||||||
|
borderRadius: 7,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
barWidth: 14,
|
||||||
|
z: 2,
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
position: 'right' as const,
|
||||||
|
formatter: '{c}%',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: '#1A1F2E',
|
||||||
|
fontFamily: 'Plus Jakarta Sans, sans-serif',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
animationDuration: 500,
|
||||||
|
animationEasing: 'cubicOut',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const { chartRef, chart } = useECharts(chartOptions);
|
||||||
|
|
||||||
|
// Handle click to navigate to project detail
|
||||||
|
function handleClick() {
|
||||||
|
if (!chart.value) return;
|
||||||
|
chart.value.on('click', (params: any) => {
|
||||||
|
if (params.componentType === 'series' && params.seriesIndex === 1) {
|
||||||
|
const sorted = [...props.projects].sort(
|
||||||
|
(a, b) => b.currentCycleProgress - a.currentCycleProgress,
|
||||||
|
);
|
||||||
|
const project = sorted[params.dataIndex];
|
||||||
|
if (project) {
|
||||||
|
emit('project-click', project.projectId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up click handler after chart is ready
|
||||||
|
import { onMounted, watch } from 'vue';
|
||||||
|
watch(chart, (c) => { if (c) handleClick(); });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="chartRef" class="project-progress-bars" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.project-progress-bars {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 260px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
119
frontend/src/components/charts/SprintDeliveryChart.vue
Normal file
119
frontend/src/components/charts/SprintDeliveryChart.vue
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, type PropType } from 'vue';
|
||||||
|
import { useECharts, CHART_COLORS } from '@/composables/useECharts';
|
||||||
|
import type { SprintCycleData } from '@/types';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
cycles: {
|
||||||
|
type: Array as PropType<SprintCycleData[]>,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
targetRate: {
|
||||||
|
type: Number,
|
||||||
|
default: 80,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const chartOptions = computed(() => {
|
||||||
|
const names = props.cycles.map((c) => c.name);
|
||||||
|
const rates = props.cycles.map((c) => c.deliveryRate);
|
||||||
|
const planned = props.cycles.map((c) => c.plannedPoints);
|
||||||
|
const completed = props.cycles.map((c) => c.completedPoints);
|
||||||
|
|
||||||
|
return {
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis' as const,
|
||||||
|
axisPointer: { type: 'shadow' as const },
|
||||||
|
formatter(params: any) {
|
||||||
|
const idx = params[0]?.dataIndex ?? 0;
|
||||||
|
const cycle = props.cycles[idx];
|
||||||
|
if (!cycle) return '';
|
||||||
|
return `
|
||||||
|
<strong>${cycle.name}</strong><br/>
|
||||||
|
计划: ${cycle.plannedPoints} 点<br/>
|
||||||
|
完成: ${cycle.completedPoints} 点<br/>
|
||||||
|
交付率: <strong>${cycle.deliveryRate}%</strong>
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
data: ['计划', '完成', `${props.targetRate}% 目标`],
|
||||||
|
bottom: 0,
|
||||||
|
textStyle: { fontSize: 12, color: '#6B7280' },
|
||||||
|
},
|
||||||
|
grid: { top: 16, right: 16, bottom: 40, left: 48, containLabel: false },
|
||||||
|
xAxis: {
|
||||||
|
type: 'category' as const,
|
||||||
|
data: names,
|
||||||
|
axisLine: { lineStyle: { color: '#E5E7EB' } },
|
||||||
|
axisLabel: { fontSize: 11, color: '#6B7280' },
|
||||||
|
},
|
||||||
|
yAxis: [
|
||||||
|
{
|
||||||
|
type: 'value' as const,
|
||||||
|
name: '点数',
|
||||||
|
nameTextStyle: { fontSize: 11, color: '#9CA3AF' },
|
||||||
|
axisLine: { show: false },
|
||||||
|
splitLine: { lineStyle: { color: '#F3F4F6' } },
|
||||||
|
axisLabel: { fontSize: 11, color: '#9CA3AF' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'value' as const,
|
||||||
|
name: '%',
|
||||||
|
max: 120,
|
||||||
|
axisLine: { show: false },
|
||||||
|
splitLine: { show: false },
|
||||||
|
axisLabel: { fontSize: 11, color: '#9CA3AF', formatter: '{value}%' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: '计划',
|
||||||
|
type: 'bar' as const,
|
||||||
|
data: planned,
|
||||||
|
barWidth: '30%',
|
||||||
|
barGap: '10%',
|
||||||
|
itemStyle: {
|
||||||
|
color: '#E5E7EB',
|
||||||
|
borderRadius: [4, 4, 0, 0],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '完成',
|
||||||
|
type: 'bar' as const,
|
||||||
|
data: completed,
|
||||||
|
barWidth: '30%',
|
||||||
|
itemStyle: {
|
||||||
|
color: CHART_COLORS[0],
|
||||||
|
borderRadius: [4, 4, 0, 0],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: `${props.targetRate}% 目标`,
|
||||||
|
type: 'line' as const,
|
||||||
|
yAxisIndex: 1,
|
||||||
|
data: names.map(() => props.targetRate),
|
||||||
|
lineStyle: { type: 'dashed' as const, color: '#DC2626', width: 2 },
|
||||||
|
symbol: 'none',
|
||||||
|
tooltip: { show: false },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
animationDuration: 500,
|
||||||
|
animationEasing: 'cubicOut',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const { chartRef } = useECharts(chartOptions);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="chartRef" class="sprint-delivery-chart" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sprint-delivery-chart {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 260px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
111
frontend/src/components/charts/TaskStatusPie.vue
Normal file
111
frontend/src/components/charts/TaskStatusPie.vue
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, type PropType } from 'vue';
|
||||||
|
import { useECharts, CHART_COLORS } from '@/composables/useECharts';
|
||||||
|
import type { TaskDistributionData } from '@/types';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
data: {
|
||||||
|
type: Object as PropType<TaskDistributionData>,
|
||||||
|
default: () => ({ todo: 0, inProgress: 0, review: 0, done: 0 }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
|
'待办': '#9CA3AF',
|
||||||
|
'进行中': CHART_COLORS[0], // #3B5998
|
||||||
|
'评审': CHART_COLORS[2], // #D4920A
|
||||||
|
'已完成': CHART_COLORS[1], // #0D9668
|
||||||
|
};
|
||||||
|
|
||||||
|
const total = computed(() => {
|
||||||
|
const d = props.data;
|
||||||
|
return d.todo + d.inProgress + d.review + d.done;
|
||||||
|
});
|
||||||
|
|
||||||
|
const chartOptions = computed(() => {
|
||||||
|
const d = props.data;
|
||||||
|
const items = [
|
||||||
|
{ value: d.todo, name: '待办' },
|
||||||
|
{ value: d.inProgress, name: '进行中' },
|
||||||
|
{ value: d.review, name: '评审' },
|
||||||
|
{ value: d.done, name: '已完成' },
|
||||||
|
].filter((item) => item.value > 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'item' as const,
|
||||||
|
formatter: '{b}: {c} ({d}%)',
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
orient: 'vertical' as const,
|
||||||
|
right: 16,
|
||||||
|
top: 'center',
|
||||||
|
itemWidth: 10,
|
||||||
|
itemHeight: 10,
|
||||||
|
itemGap: 12,
|
||||||
|
textStyle: { fontSize: 12, color: '#6B7280' },
|
||||||
|
formatter(name: string) {
|
||||||
|
const item = items.find((i) => i.name === name);
|
||||||
|
return `${name} ${item?.value ?? 0}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: 'pie' as const,
|
||||||
|
radius: ['45%', '72%'],
|
||||||
|
center: ['35%', '50%'],
|
||||||
|
avoidLabelOverlap: false,
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
position: 'center' as const,
|
||||||
|
formatter: () => `{total|${total.value}}\n{label|总计}`,
|
||||||
|
rich: {
|
||||||
|
total: {
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: 800 as const,
|
||||||
|
color: '#1A1F2E',
|
||||||
|
fontFamily: 'Plus Jakarta Sans, sans-serif',
|
||||||
|
fontVariantNumeric: 'tabular-nums',
|
||||||
|
lineHeight: 36,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#9CA3AF',
|
||||||
|
lineHeight: 18,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
labelLine: { show: false },
|
||||||
|
data: items.map((item) => ({
|
||||||
|
...item,
|
||||||
|
itemStyle: { color: STATUS_COLORS[item.name] || '#9CA3AF' },
|
||||||
|
})),
|
||||||
|
emphasis: {
|
||||||
|
itemStyle: {
|
||||||
|
shadowBlur: 10,
|
||||||
|
shadowOffsetX: 0,
|
||||||
|
shadowColor: 'rgba(0, 0, 0, 0.12)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
animationType: 'scale',
|
||||||
|
animationEasing: 'cubicOut',
|
||||||
|
animationDuration: 500,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const { chartRef } = useECharts(chartOptions);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="chartRef" class="task-status-pie" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.task-status-pie {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 260px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
98
frontend/src/components/charts/WeeklyCodeActivity.vue
Normal file
98
frontend/src/components/charts/WeeklyCodeActivity.vue
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, type PropType } from 'vue';
|
||||||
|
import { useECharts, CHART_COLORS } from '@/composables/useECharts';
|
||||||
|
import type { WeekCodeData } from '@/types';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
weeks: {
|
||||||
|
type: Array as PropType<WeekCodeData[]>,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const chartOptions = computed(() => {
|
||||||
|
if (!props.weeks.length) {
|
||||||
|
return { title: { text: '暂无数据', left: 'center', top: 'center', textStyle: { color: '#9CA3AF', fontSize: 14 } } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect all unique member names across weeks
|
||||||
|
const memberSet = new Map<string, string>();
|
||||||
|
for (const week of props.weeks) {
|
||||||
|
for (const m of week.members) {
|
||||||
|
if (!memberSet.has(m.userId)) {
|
||||||
|
memberSet.set(m.userId, m.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const memberIds = Array.from(memberSet.keys());
|
||||||
|
const memberNames = Array.from(memberSet.values());
|
||||||
|
const weekLabels = props.weeks.map((w) => w.weekStart);
|
||||||
|
|
||||||
|
// Build a stacked area series per member
|
||||||
|
const series = memberIds.map((uid, idx) => {
|
||||||
|
const memberData = props.weeks.map((w) => {
|
||||||
|
const found = w.members.find((m) => m.userId === uid);
|
||||||
|
return found ? found.commits + found.prs : 0;
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
name: memberNames[idx],
|
||||||
|
type: 'line' as const,
|
||||||
|
stack: 'activity',
|
||||||
|
areaStyle: { opacity: 0.6 },
|
||||||
|
smooth: true,
|
||||||
|
symbol: 'none',
|
||||||
|
lineStyle: { width: 1.5 },
|
||||||
|
emphasis: { focus: 'series' as const },
|
||||||
|
data: memberData,
|
||||||
|
itemStyle: { color: CHART_COLORS[idx % CHART_COLORS.length] },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis' as const,
|
||||||
|
axisPointer: { type: 'cross' as const },
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
data: memberNames,
|
||||||
|
bottom: 0,
|
||||||
|
type: 'scroll' as const,
|
||||||
|
textStyle: { fontSize: 11, color: '#6B7280' },
|
||||||
|
},
|
||||||
|
grid: { top: 16, right: 16, bottom: 40, left: 48, containLabel: false },
|
||||||
|
xAxis: {
|
||||||
|
type: 'category' as const,
|
||||||
|
boundaryGap: false,
|
||||||
|
data: weekLabels,
|
||||||
|
axisLine: { lineStyle: { color: '#E5E7EB' } },
|
||||||
|
axisLabel: { fontSize: 11, color: '#6B7280' },
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: 'value' as const,
|
||||||
|
name: '活动',
|
||||||
|
nameTextStyle: { fontSize: 11, color: '#9CA3AF' },
|
||||||
|
axisLine: { show: false },
|
||||||
|
splitLine: { lineStyle: { color: '#F3F4F6' } },
|
||||||
|
axisLabel: { fontSize: 11, color: '#9CA3AF' },
|
||||||
|
},
|
||||||
|
series,
|
||||||
|
animationDuration: 600,
|
||||||
|
animationEasing: 'cubicOut',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const { chartRef } = useECharts(chartOptions);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="chartRef" class="weekly-code-activity" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.weekly-code-activity {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 260px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
9
frontend/src/components/charts/index.ts
Normal file
9
frontend/src/components/charts/index.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
export { default as SprintDeliveryChart } from './SprintDeliveryChart.vue';
|
||||||
|
export { default as TaskStatusPie } from './TaskStatusPie.vue';
|
||||||
|
export { default as ProjectProgressBars } from './ProjectProgressBars.vue';
|
||||||
|
export { default as WeeklyCodeActivity } from './WeeklyCodeActivity.vue';
|
||||||
|
export { default as OKRProgressBars } from './OKRProgressBars.vue';
|
||||||
|
export { default as PRMergeTimeChart } from './PRMergeTimeChart.vue';
|
||||||
|
export { default as BurndownChart } from './BurndownChart.vue';
|
||||||
|
export { default as ContributionHeatmap } from './ContributionHeatmap.vue';
|
||||||
|
export { default as KPIRadarChart } from './KPIRadarChart.vue';
|
||||||
96
frontend/src/components/layout/AppHeader.vue
Normal file
96
frontend/src/components/layout/AppHeader.vue
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import { NBreadcrumb, NBreadcrumbItem } from 'naive-ui';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map each route name to a human-readable page title.
|
||||||
|
* Every named route MUST have an explicit entry here to avoid the fallback
|
||||||
|
* 'Dashboard' label, which would cause "Dashboard / Dashboard" in breadcrumbs.
|
||||||
|
*/
|
||||||
|
const pageTitle = computed(() => {
|
||||||
|
switch (route.name) {
|
||||||
|
case 'Overview': return '团队总览';
|
||||||
|
case 'ProjectList': return '项目列表';
|
||||||
|
case 'ProjectDetail': return '项目明细';
|
||||||
|
case 'MemberList': return '团队成员';
|
||||||
|
case 'MemberDetail': return '个人产出';
|
||||||
|
case 'OKR': return 'OKR 看板';
|
||||||
|
case 'GitActivity': return 'Git 活动统计';
|
||||||
|
case 'Admin': return '管理后台';
|
||||||
|
default: return String(route.name || '首页');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const breadcrumbs = computed(() => {
|
||||||
|
const items = [{ label: '首页', path: '/' }];
|
||||||
|
if (route.name === 'ProjectDetail') {
|
||||||
|
items.push({ label: '项目', path: '/projects' });
|
||||||
|
items.push({ label: '项目明细', path: route.fullPath });
|
||||||
|
} else if (route.name === 'MemberDetail') {
|
||||||
|
const fromProject = route.query.from as string;
|
||||||
|
if (fromProject) {
|
||||||
|
items.push({ label: '项目明细', path: `/projects/${fromProject}` });
|
||||||
|
} else {
|
||||||
|
items.push({ label: '成员', path: '/members' });
|
||||||
|
}
|
||||||
|
items.push({ label: '个人产出', path: route.fullPath });
|
||||||
|
} else if (route.name !== 'Overview') {
|
||||||
|
items.push({ label: pageTitle.value, path: route.fullPath });
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<header class="app-header">
|
||||||
|
<div class="header-left">
|
||||||
|
<NBreadcrumb>
|
||||||
|
<NBreadcrumbItem v-for="(item, idx) in breadcrumbs" :key="idx">
|
||||||
|
<router-link v-if="idx < breadcrumbs.length - 1" :to="item.path">
|
||||||
|
{{ item.label }}
|
||||||
|
</router-link>
|
||||||
|
<span v-else>{{ item.label }}</span>
|
||||||
|
</NBreadcrumbItem>
|
||||||
|
</NBreadcrumb>
|
||||||
|
<h1 class="page-title">{{ pageTitle }}</h1>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.app-header {
|
||||||
|
height: 64px;
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* On mobile, leave space for the hamburger button */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.app-header {
|
||||||
|
padding-left: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
86
frontend/src/components/layout/AppLayout.vue
Normal file
86
frontend/src/components/layout/AppLayout.vue
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, onUnmounted } from 'vue';
|
||||||
|
import AppSidebar from './AppSidebar.vue';
|
||||||
|
import AppHeader from './AppHeader.vue';
|
||||||
|
import { useDashboardStore } from '@/stores/dashboard';
|
||||||
|
|
||||||
|
const dashStore = useDashboardStore();
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
dashStore.initResize();
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
dashStore.destroyResize();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="app-layout">
|
||||||
|
<!-- Mobile overlay backdrop -->
|
||||||
|
<div
|
||||||
|
v-if="dashStore.isMobile && dashStore.mobileSidebarOpen"
|
||||||
|
class="sidebar-overlay"
|
||||||
|
@click="dashStore.closeMobileSidebar"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AppSidebar />
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="main-container"
|
||||||
|
:class="{
|
||||||
|
collapsed: !dashStore.isMobile && dashStore.sidebarCollapsed,
|
||||||
|
mobile: dashStore.isMobile,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<AppHeader />
|
||||||
|
<main class="main-content">
|
||||||
|
<router-view />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.app-layout {
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-container {
|
||||||
|
flex: 1;
|
||||||
|
margin-left: var(--sidebar-width);
|
||||||
|
transition: margin-left var(--duration-collapse) var(--ease-default);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-container.collapsed {
|
||||||
|
margin-left: var(--sidebar-collapsed-width);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile: main content takes full width, no margin for sidebar */
|
||||||
|
.main-container.mobile {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
flex: 1;
|
||||||
|
padding: var(--space-6);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Overlay backdrop for mobile sidebar */
|
||||||
|
.sidebar-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
z-index: calc(var(--z-sticky) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.main-content {
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
467
frontend/src/components/layout/AppSidebar.vue
Normal file
467
frontend/src/components/layout/AppSidebar.vue
Normal file
@ -0,0 +1,467 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* B-17 fix: Added "Projects" and "Members" navigation items to sidebar.
|
||||||
|
* Projects shows a collapsible sub-menu listing projects from the API.
|
||||||
|
* Members links to the member list page (admin/manager only).
|
||||||
|
*/
|
||||||
|
import { computed, ref, onMounted } from 'vue';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
import { NAvatar, NTag, NTooltip } from 'naive-ui';
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
import { useDashboardStore } from '@/stores/dashboard';
|
||||||
|
import { getOverviewApi } from '@/api/overview';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
const dashStore = useDashboardStore();
|
||||||
|
|
||||||
|
// B-17: Track whether the Projects sub-menu is expanded
|
||||||
|
const projectsExpanded = ref(false);
|
||||||
|
const projectList = ref<Array<{ projectId: string; name: string; identifier: string }>>([]);
|
||||||
|
|
||||||
|
// Load project list for sidebar sub-menu
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const res = await getOverviewApi();
|
||||||
|
projectList.value = res.data.data.projectProgress || [];
|
||||||
|
} catch {
|
||||||
|
// Silently fail - sidebar still works without project sub-menu
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
interface NavItem {
|
||||||
|
label: string;
|
||||||
|
key: string;
|
||||||
|
icon: string;
|
||||||
|
/** If true, this item has a sub-menu (projects) */
|
||||||
|
hasSubmenu?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const menuOptions = computed(() => {
|
||||||
|
const role = authStore.user?.role;
|
||||||
|
const items: NavItem[] = [
|
||||||
|
{ label: '团队总览', key: '/', icon: 'grid' },
|
||||||
|
// B-17: Projects nav item
|
||||||
|
{ label: '项目', key: '/projects', icon: 'folder', hasSubmenu: true },
|
||||||
|
{ label: 'OKR', key: '/okr', icon: 'target' },
|
||||||
|
];
|
||||||
|
|
||||||
|
if (role === 'admin' || role === 'manager' || role === 'developer') {
|
||||||
|
items.push({ label: 'Git 活动', key: '/git', icon: 'git-branch' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// B-17: Members nav item (admin/manager only)
|
||||||
|
if (role === 'admin' || role === 'manager') {
|
||||||
|
items.push({ label: '成员', key: '/members', icon: 'users' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role === 'admin') {
|
||||||
|
items.push({ label: '管理后台', key: '/admin', icon: 'settings' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeKey = computed(() => {
|
||||||
|
if (route.path === '/') return '/';
|
||||||
|
if (route.path === '/projects') return '/projects';
|
||||||
|
if (route.path.startsWith('/projects/')) return '/projects';
|
||||||
|
if (route.path === '/members') return '/members';
|
||||||
|
if (route.path.startsWith('/members/')) return '/members';
|
||||||
|
if (route.path.startsWith('/okr')) return '/okr';
|
||||||
|
if (route.path.startsWith('/git')) return '/git';
|
||||||
|
if (route.path.startsWith('/admin')) return '/admin';
|
||||||
|
return '/';
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleMenuSelect(item: NavItem) {
|
||||||
|
if (item.hasSubmenu) {
|
||||||
|
// Toggle sub-menu expansion; also navigate to the projects list
|
||||||
|
projectsExpanded.value = !projectsExpanded.value;
|
||||||
|
router.push(item.key);
|
||||||
|
} else {
|
||||||
|
router.push(item.key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// On mobile, close sidebar after navigation (unless expanding sub-menu)
|
||||||
|
if (dashStore.isMobile && !item.hasSubmenu) {
|
||||||
|
dashStore.closeMobileSidebar();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleProjectSelect(projectId: string) {
|
||||||
|
router.push(`/projects/${projectId}`);
|
||||||
|
if (dashStore.isMobile) {
|
||||||
|
dashStore.closeMobileSidebar();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleTagType = computed(() => {
|
||||||
|
const role = authStore.user?.role;
|
||||||
|
if (role === 'admin') return 'info';
|
||||||
|
if (role === 'manager') return 'success';
|
||||||
|
if (role === 'viewer') return 'warning';
|
||||||
|
return 'default';
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- Mobile hamburger button: visible only on mobile -->
|
||||||
|
<button
|
||||||
|
v-if="dashStore.isMobile"
|
||||||
|
class="hamburger-btn"
|
||||||
|
aria-label="打开侧边栏菜单"
|
||||||
|
@click="dashStore.toggleSidebar"
|
||||||
|
>
|
||||||
|
<span class="hamburger-icon">☰</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<aside
|
||||||
|
class="sidebar"
|
||||||
|
:class="{
|
||||||
|
collapsed: !dashStore.isMobile && dashStore.sidebarCollapsed,
|
||||||
|
'mobile-hidden': dashStore.isMobile && !dashStore.mobileSidebarOpen,
|
||||||
|
'mobile-open': dashStore.isMobile && dashStore.mobileSidebarOpen,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div class="sidebar-header">
|
||||||
|
<div class="logo" v-if="!dashStore.sidebarCollapsed || dashStore.isMobile">
|
||||||
|
<span class="logo-icon">DP</span>
|
||||||
|
<span class="logo-text">DevPerf</span>
|
||||||
|
</div>
|
||||||
|
<div class="logo" v-else>
|
||||||
|
<span class="logo-icon">DP</span>
|
||||||
|
</div>
|
||||||
|
<!-- Close button on mobile -->
|
||||||
|
<button
|
||||||
|
v-if="dashStore.isMobile"
|
||||||
|
class="close-btn"
|
||||||
|
aria-label="关闭侧边栏"
|
||||||
|
@click="dashStore.closeMobileSidebar"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="sidebar-nav">
|
||||||
|
<template v-for="item in menuOptions" :key="item.key">
|
||||||
|
<div
|
||||||
|
class="nav-item"
|
||||||
|
:class="{ active: activeKey === item.key }"
|
||||||
|
@click="handleMenuSelect(item)"
|
||||||
|
>
|
||||||
|
<span class="nav-label" v-if="!dashStore.sidebarCollapsed || dashStore.isMobile">
|
||||||
|
{{ item.label }}
|
||||||
|
<!-- B-17: Show expand/collapse indicator for Projects -->
|
||||||
|
<span v-if="item.hasSubmenu && (!dashStore.sidebarCollapsed || dashStore.isMobile)" class="submenu-arrow">
|
||||||
|
{{ projectsExpanded ? '▼' : '▶' }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<NTooltip v-else placement="right">
|
||||||
|
<template #trigger>
|
||||||
|
<span class="nav-icon-only">{{ item.label[0] }}</span>
|
||||||
|
</template>
|
||||||
|
{{ item.label }}
|
||||||
|
</NTooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- B-17: Projects sub-menu -->
|
||||||
|
<div
|
||||||
|
v-if="item.hasSubmenu && projectsExpanded && (!dashStore.sidebarCollapsed || dashStore.isMobile)"
|
||||||
|
class="submenu"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="proj in projectList"
|
||||||
|
:key="proj.projectId"
|
||||||
|
class="submenu-item"
|
||||||
|
:class="{ active: route.path === `/projects/${proj.projectId}` }"
|
||||||
|
@click="handleProjectSelect(proj.projectId)"
|
||||||
|
>
|
||||||
|
<span class="submenu-label">{{ proj.identifier || '' }} {{ proj.name }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="!projectList.length" class="submenu-item submenu-empty">
|
||||||
|
暂无项目
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<!-- Only show collapse button on desktop -->
|
||||||
|
<button v-if="!dashStore.isMobile" class="collapse-btn" @click="dashStore.toggleSidebar">
|
||||||
|
{{ dashStore.sidebarCollapsed ? '>' : '<' }}
|
||||||
|
</button>
|
||||||
|
<div class="user-area" v-if="!dashStore.sidebarCollapsed || dashStore.isMobile">
|
||||||
|
<NAvatar round :size="36" :style="{ backgroundColor: '#3B5998' }">
|
||||||
|
{{ authStore.user?.displayName?.[0] || '?' }}
|
||||||
|
</NAvatar>
|
||||||
|
<div class="user-info">
|
||||||
|
<span class="user-name">{{ authStore.user?.displayName }}</span>
|
||||||
|
<NTag :type="roleTagType" size="tiny">{{ authStore.user?.role }}</NTag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="logout-btn" @click="authStore.logout" v-if="!dashStore.sidebarCollapsed || dashStore.isMobile">
|
||||||
|
退出登录
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sidebar {
|
||||||
|
width: var(--sidebar-width);
|
||||||
|
height: 100vh;
|
||||||
|
background: var(--color-bg-sidebar);
|
||||||
|
color: #E5E7EB;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
transition: width var(--duration-collapse) var(--ease-default),
|
||||||
|
transform 0.3s ease;
|
||||||
|
z-index: var(--z-sticky);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.collapsed {
|
||||||
|
width: var(--sidebar-collapsed-width);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile: sidebar is off-screen by default */
|
||||||
|
.sidebar.mobile-hidden {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
width: var(--sidebar-width);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile: sidebar slides in from the left */
|
||||||
|
.sidebar.mobile-open {
|
||||||
|
transform: translateX(0);
|
||||||
|
width: var(--sidebar-width);
|
||||||
|
z-index: calc(var(--z-sticky) + 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
padding: var(--space-4) var(--space-4);
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.1);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-icon {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
background: var(--color-primary-hex);
|
||||||
|
border-radius: var(--radius-btn);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 12px;
|
||||||
|
color: white;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-text {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 16px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav {
|
||||||
|
flex: 1;
|
||||||
|
padding: var(--space-4) var(--space-2);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
border-radius: var(--radius-btn);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--duration-hover) var(--ease-default);
|
||||||
|
position: relative;
|
||||||
|
white-space: nowrap;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item:hover {
|
||||||
|
background: rgba(255,255,255,0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.active {
|
||||||
|
background: rgba(59,89,152,0.3);
|
||||||
|
border-left: 3px solid var(--color-primary-hex);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* B-17: Sub-menu arrow indicator */
|
||||||
|
.submenu-arrow {
|
||||||
|
font-size: 10px;
|
||||||
|
margin-left: auto;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* B-17: Projects sub-menu */
|
||||||
|
.submenu {
|
||||||
|
padding-left: var(--space-4);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenu-item {
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
font-size: 12px;
|
||||||
|
color: #9CA3AF;
|
||||||
|
border-radius: var(--radius-btn);
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
transition: background var(--duration-hover) var(--ease-default), color var(--duration-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenu-item:hover {
|
||||||
|
background: rgba(255,255,255,0.06);
|
||||||
|
color: #E5E7EB;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenu-item.active {
|
||||||
|
color: #E5E7EB;
|
||||||
|
background: rgba(59,89,152,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenu-empty {
|
||||||
|
cursor: default;
|
||||||
|
font-style: italic;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenu-label {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-icon-only {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-footer {
|
||||||
|
padding: var(--space-4);
|
||||||
|
border-top: 1px solid rgba(255,255,255,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapse-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--space-2);
|
||||||
|
background: none;
|
||||||
|
border: 1px solid rgba(255,255,255,0.15);
|
||||||
|
color: #9CA3AF;
|
||||||
|
border-radius: var(--radius-btn);
|
||||||
|
cursor: pointer;
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
transition: background var(--duration-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapse-btn:hover {
|
||||||
|
background: rgba(255,255,255,0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-area {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--space-2);
|
||||||
|
background: none;
|
||||||
|
border: 1px solid rgba(220,38,38,0.3);
|
||||||
|
color: #DC2626;
|
||||||
|
border-radius: var(--radius-btn);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
transition: background var(--duration-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-btn:hover {
|
||||||
|
background: rgba(220,38,38,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hamburger button - positioned fixed on mobile */
|
||||||
|
.hamburger-btn {
|
||||||
|
position: fixed;
|
||||||
|
top: 12px;
|
||||||
|
left: 12px;
|
||||||
|
z-index: var(--z-sticky);
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-btn);
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.12);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Close button inside mobile sidebar header */
|
||||||
|
.close-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #9CA3AF;
|
||||||
|
font-size: 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 4px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn:hover {
|
||||||
|
color: #E5E7EB;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
82
frontend/src/components/shared/DataCard.vue
Normal file
82
frontend/src/components/shared/DataCard.vue
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
loading?: boolean;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="data-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<div>
|
||||||
|
<h3 class="card-title">{{ title }}</h3>
|
||||||
|
<p class="card-subtitle" v-if="subtitle">{{ subtitle }}</p>
|
||||||
|
</div>
|
||||||
|
<slot name="header-extra" />
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div v-if="loading" class="loading-skeleton">
|
||||||
|
<div class="skeleton-bar" style="width: 80%; height: 200px" />
|
||||||
|
</div>
|
||||||
|
<slot v-else />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.data-card {
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
padding: var(--space-5);
|
||||||
|
transition: box-shadow var(--duration-hover) var(--ease-default), transform var(--duration-hover) var(--ease-default);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-card:hover {
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-subtitle {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin: 2px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
min-height: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-skeleton {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-bar {
|
||||||
|
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.5s infinite;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { background-position: 200% 0; }
|
||||||
|
100% { background-position: -200% 0; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
35
frontend/src/components/shared/EmptyState.vue
Normal file
35
frontend/src/components/shared/EmptyState.vue
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-icon">
|
||||||
|
<svg width="64" height="64" viewBox="0 0 64 64" fill="none">
|
||||||
|
<circle cx="32" cy="32" r="28" stroke="#E5E7EB" stroke-width="2" />
|
||||||
|
<path d="M24 28h16M24 36h10" stroke="#9CA3AF" stroke-width="2" stroke-linecap="round" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="empty-title">{{ title || '暂无数据' }}</h3>
|
||||||
|
<p class="empty-desc">{{ description || '当前暂无可用数据。' }}</p>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-12) var(--space-6);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon { margin-bottom: var(--space-4); opacity: 0.6; }
|
||||||
|
.empty-title { font-size: 16px; font-weight: 600; color: var(--color-text-primary); margin: 0 0 var(--space-2); }
|
||||||
|
.empty-desc { font-size: 13px; color: var(--color-text-secondary); margin: 0; max-width: 300px; }
|
||||||
|
</style>
|
||||||
62
frontend/src/components/shared/FilterBar.vue
Normal file
62
frontend/src/components/shared/FilterBar.vue
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue';
|
||||||
|
import { NSelect, NDatePicker } from 'naive-ui';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
projects?: Array<{ value: string; label: string }>;
|
||||||
|
showProjectFilter?: boolean;
|
||||||
|
periods?: Array<{ value: string; label: string }>;
|
||||||
|
showPeriodFilter?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'filter-change', filters: { period?: string; projectIds?: string[] }): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const selectedPeriod = ref<string | null>(null);
|
||||||
|
const selectedProjects = ref<string[]>([]);
|
||||||
|
|
||||||
|
const defaultPeriods = [
|
||||||
|
{ value: '2026-Q2', label: '2026 Q2 (当前)' },
|
||||||
|
{ value: '2026-Q1', label: '2026 Q1' },
|
||||||
|
{ value: '2025-Q4', label: '2025 Q4' },
|
||||||
|
];
|
||||||
|
|
||||||
|
watch([selectedPeriod, selectedProjects], () => {
|
||||||
|
emit('filter-change', {
|
||||||
|
period: selectedPeriod.value || undefined,
|
||||||
|
projectIds: selectedProjects.value.length > 0 ? selectedProjects.value : undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="filter-bar">
|
||||||
|
<NSelect
|
||||||
|
v-if="showPeriodFilter !== false"
|
||||||
|
v-model:value="selectedPeriod"
|
||||||
|
:options="periods || defaultPeriods"
|
||||||
|
placeholder="选择周期"
|
||||||
|
clearable
|
||||||
|
style="width: 180px"
|
||||||
|
/>
|
||||||
|
<NSelect
|
||||||
|
v-if="showProjectFilter !== false"
|
||||||
|
v-model:value="selectedProjects"
|
||||||
|
:options="projects || []"
|
||||||
|
placeholder="筛选项目"
|
||||||
|
multiple
|
||||||
|
clearable
|
||||||
|
style="width: 300px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.filter-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin-bottom: var(--space-5);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
77
frontend/src/composables/useECharts.ts
Normal file
77
frontend/src/composables/useECharts.ts
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import { ref, nextTick, onMounted, onUnmounted, watch, type Ref, shallowRef } from 'vue';
|
||||||
|
import * as echarts from 'echarts/core';
|
||||||
|
import { BarChart, LineChart, PieChart, RadarChart, HeatmapChart, CustomChart } from 'echarts/charts';
|
||||||
|
import { GridComponent, TooltipComponent, LegendComponent, TitleComponent, DataZoomComponent, ToolboxComponent, VisualMapComponent, CalendarComponent } from 'echarts/components';
|
||||||
|
import { CanvasRenderer } from 'echarts/renderers';
|
||||||
|
|
||||||
|
echarts.use([
|
||||||
|
BarChart, LineChart, PieChart, RadarChart, HeatmapChart, CustomChart,
|
||||||
|
GridComponent, TooltipComponent, LegendComponent, TitleComponent,
|
||||||
|
DataZoomComponent, ToolboxComponent, VisualMapComponent, CalendarComponent,
|
||||||
|
CanvasRenderer,
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const CHART_COLORS = ['#3B5998', '#0D9668', '#D4920A', '#7C4DBA', '#2B8CA3', '#DC2626', '#8B5CF6', '#06B6D4'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Broad chart options type to avoid strict ECharts type conflicts with
|
||||||
|
* string literal types like animationEasing. All chart components pass
|
||||||
|
* their options through this composable.
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
type ChartOptions = Record<string, any>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composable for managing ECharts instances with auto-resize and reactive options.
|
||||||
|
*/
|
||||||
|
export function useECharts(options: Ref<ChartOptions>) {
|
||||||
|
const chartRef = ref<HTMLElement | null>(null);
|
||||||
|
const chart = shallowRef<echarts.ECharts | null>(null);
|
||||||
|
|
||||||
|
let resizeObserver: ResizeObserver | null = null;
|
||||||
|
|
||||||
|
function initChart(el: HTMLElement) {
|
||||||
|
if (chart.value) return; // already initialized
|
||||||
|
chart.value = echarts.init(el);
|
||||||
|
chart.value.setOption(options.value);
|
||||||
|
|
||||||
|
resizeObserver = new ResizeObserver(() => {
|
||||||
|
chart.value?.resize();
|
||||||
|
});
|
||||||
|
resizeObserver.observe(el);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try init on mount (works when element is already in DOM)
|
||||||
|
onMounted(() => {
|
||||||
|
if (chartRef.value) {
|
||||||
|
initChart(chartRef.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Watch chartRef for deferred DOM availability (v-if / async data)
|
||||||
|
// This is the key fix: when the container appears after data loads, init the chart
|
||||||
|
watch(chartRef, (el) => {
|
||||||
|
if (el) {
|
||||||
|
// nextTick ensures the DOM is fully rendered
|
||||||
|
nextTick(() => initChart(el));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update chart when options change
|
||||||
|
watch(options, (newOptions) => {
|
||||||
|
if (chart.value) {
|
||||||
|
chart.value.setOption(newOptions, true);
|
||||||
|
} else if (chartRef.value) {
|
||||||
|
// Chart wasn't initialized yet but element is now available
|
||||||
|
initChart(chartRef.value);
|
||||||
|
}
|
||||||
|
}, { deep: true });
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
resizeObserver?.disconnect();
|
||||||
|
chart.value?.dispose();
|
||||||
|
chart.value = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { chartRef, chart };
|
||||||
|
}
|
||||||
1
frontend/src/config/index.ts
Normal file
1
frontend/src/config/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '';
|
||||||
12
frontend/src/main.ts
Normal file
12
frontend/src/main.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { createApp } from 'vue';
|
||||||
|
import { createPinia } from 'pinia';
|
||||||
|
import App from './App.vue';
|
||||||
|
import router from './router';
|
||||||
|
import './styles/global.css';
|
||||||
|
|
||||||
|
const app = createApp(App);
|
||||||
|
|
||||||
|
app.use(createPinia());
|
||||||
|
app.use(router);
|
||||||
|
|
||||||
|
app.mount('#app');
|
||||||
94
frontend/src/router/index.ts
Normal file
94
frontend/src/router/index.ts
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router';
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
path: '/login',
|
||||||
|
name: 'Login',
|
||||||
|
component: () => import('@/views/Login.vue'),
|
||||||
|
meta: { public: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
component: () => import('@/components/layout/AppLayout.vue'),
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
name: 'Overview',
|
||||||
|
component: () => import('@/views/Overview.vue'),
|
||||||
|
},
|
||||||
|
// B-17 fix: added project list route
|
||||||
|
{
|
||||||
|
path: 'projects',
|
||||||
|
name: 'ProjectList',
|
||||||
|
component: () => import('@/views/ProjectList.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'projects/:id',
|
||||||
|
name: 'ProjectDetail',
|
||||||
|
component: () => import('@/views/ProjectDetail.vue'),
|
||||||
|
},
|
||||||
|
// B-17 fix: added member list route
|
||||||
|
{
|
||||||
|
path: 'members',
|
||||||
|
name: 'MemberList',
|
||||||
|
component: () => import('@/views/MemberList.vue'),
|
||||||
|
meta: { roles: ['admin', 'manager'] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'members/:id',
|
||||||
|
name: 'MemberDetail',
|
||||||
|
component: () => import('@/views/MemberDetail.vue'),
|
||||||
|
meta: { roles: ['admin', 'manager', 'developer'] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'okr',
|
||||||
|
name: 'OKR',
|
||||||
|
component: () => import('@/views/OKR.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'git',
|
||||||
|
name: 'GitActivity',
|
||||||
|
component: () => import('@/views/GitActivity.vue'),
|
||||||
|
meta: { roles: ['admin', 'manager', 'developer'] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'admin',
|
||||||
|
name: 'Admin',
|
||||||
|
component: () => import('@/views/Admin.vue'),
|
||||||
|
meta: { roles: ['admin'] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
router.beforeEach((to, _from, next) => {
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
|
if (to.meta.public) {
|
||||||
|
if (authStore.isAuthenticated && to.name === 'Login') {
|
||||||
|
return next('/');
|
||||||
|
}
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!authStore.isAuthenticated) {
|
||||||
|
return next('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Role check
|
||||||
|
const allowedRoles = to.meta.roles as string[] | undefined;
|
||||||
|
if (allowedRoles && authStore.user) {
|
||||||
|
if (!allowedRoles.includes(authStore.user.role)) {
|
||||||
|
return next('/');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
53
frontend/src/stores/auth.ts
Normal file
53
frontend/src/stores/auth.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import { loginApi, getMeApi } from '@/api/auth';
|
||||||
|
import router from '@/router';
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
displayName: string;
|
||||||
|
email: string;
|
||||||
|
role: 'admin' | 'manager' | 'developer' | 'viewer';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
|
const token = ref<string | null>(localStorage.getItem('token'));
|
||||||
|
const user = ref<User | null>(
|
||||||
|
localStorage.getItem('user') ? JSON.parse(localStorage.getItem('user')!) : null
|
||||||
|
);
|
||||||
|
|
||||||
|
const isAuthenticated = computed(() => !!token.value && !!user.value);
|
||||||
|
const isAdmin = computed(() => user.value?.role === 'admin');
|
||||||
|
const isManagerOrAbove = computed(() => user.value?.role === 'admin' || user.value?.role === 'manager');
|
||||||
|
const canEdit = computed(() => user.value?.role === 'admin' || user.value?.role === 'manager' || user.value?.role === 'developer');
|
||||||
|
|
||||||
|
async function login(email: string, password: string) {
|
||||||
|
const res = await loginApi(email, password);
|
||||||
|
const data = res.data.data;
|
||||||
|
token.value = data.token;
|
||||||
|
user.value = data.user;
|
||||||
|
localStorage.setItem('token', data.token);
|
||||||
|
localStorage.setItem('user', JSON.stringify(data.user));
|
||||||
|
router.push('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
token.value = null;
|
||||||
|
user.value = null;
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
localStorage.removeItem('user');
|
||||||
|
router.push('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchMe() {
|
||||||
|
try {
|
||||||
|
const res = await getMeApi();
|
||||||
|
user.value = res.data.data;
|
||||||
|
localStorage.setItem('user', JSON.stringify(res.data.data));
|
||||||
|
} catch {
|
||||||
|
logout();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { token, user, isAuthenticated, isAdmin, isManagerOrAbove, canEdit, login, logout, fetchMe };
|
||||||
|
});
|
||||||
62
frontend/src/stores/dashboard.ts
Normal file
62
frontend/src/stores/dashboard.ts
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { ref, onMounted, onUnmounted } from 'vue';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dashboard UI state store.
|
||||||
|
* Handles sidebar collapse/expand and mobile breakpoint detection.
|
||||||
|
*/
|
||||||
|
export const useDashboardStore = defineStore('dashboard', () => {
|
||||||
|
const sidebarCollapsed = ref(false);
|
||||||
|
/** Whether the viewport is at mobile width (<=768px). */
|
||||||
|
const isMobile = ref(false);
|
||||||
|
/** On mobile, whether the sidebar overlay is open. */
|
||||||
|
const mobileSidebarOpen = ref(false);
|
||||||
|
|
||||||
|
function toggleSidebar() {
|
||||||
|
if (isMobile.value) {
|
||||||
|
mobileSidebarOpen.value = !mobileSidebarOpen.value;
|
||||||
|
} else {
|
||||||
|
sidebarCollapsed.value = !sidebarCollapsed.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Close the mobile sidebar overlay (e.g. after navigation). */
|
||||||
|
function closeMobileSidebar() {
|
||||||
|
mobileSidebarOpen.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkMobile() {
|
||||||
|
const wasMobile = isMobile.value;
|
||||||
|
isMobile.value = window.innerWidth <= 768;
|
||||||
|
// When switching from desktop to mobile, auto-close sidebar overlay
|
||||||
|
if (isMobile.value && !wasMobile) {
|
||||||
|
mobileSidebarOpen.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up resize listener in a composable-safe way
|
||||||
|
let resizeHandler: (() => void) | null = null;
|
||||||
|
|
||||||
|
function initResize() {
|
||||||
|
checkMobile();
|
||||||
|
resizeHandler = checkMobile;
|
||||||
|
window.addEventListener('resize', resizeHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
function destroyResize() {
|
||||||
|
if (resizeHandler) {
|
||||||
|
window.removeEventListener('resize', resizeHandler);
|
||||||
|
resizeHandler = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sidebarCollapsed,
|
||||||
|
isMobile,
|
||||||
|
mobileSidebarOpen,
|
||||||
|
toggleSidebar,
|
||||||
|
closeMobileSidebar,
|
||||||
|
initResize,
|
||||||
|
destroyResize,
|
||||||
|
};
|
||||||
|
});
|
||||||
138
frontend/src/styles/global.css
Normal file
138
frontend/src/styles/global.css
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
:root {
|
||||||
|
/* Primary - Trusted Indigo */
|
||||||
|
--color-primary: oklch(0.45 0.12 255);
|
||||||
|
--color-primary-hex: #3B5998;
|
||||||
|
--color-primary-hover: oklch(0.40 0.12 255);
|
||||||
|
--color-primary-light: oklch(0.92 0.03 255);
|
||||||
|
|
||||||
|
/* Accent - Amber */
|
||||||
|
--color-accent: oklch(0.75 0.15 75);
|
||||||
|
--color-accent-hex: #D4920A;
|
||||||
|
|
||||||
|
/* Semantic */
|
||||||
|
--color-success: #0D9668;
|
||||||
|
--color-warning: #D4920A;
|
||||||
|
--color-error: #DC2626;
|
||||||
|
--color-info: #2B8CA3;
|
||||||
|
|
||||||
|
/* Chart palette */
|
||||||
|
--chart-1: #3B5998;
|
||||||
|
--chart-2: #0D9668;
|
||||||
|
--chart-3: #D4920A;
|
||||||
|
--chart-4: #7C4DBA;
|
||||||
|
--chart-5: #2B8CA3;
|
||||||
|
|
||||||
|
/* Neutral */
|
||||||
|
--color-bg: #F8F9FB;
|
||||||
|
--color-bg-card: #FFFFFF;
|
||||||
|
--color-bg-sidebar: #1E2433;
|
||||||
|
--color-text-primary: #1A1F2E;
|
||||||
|
--color-text-secondary: #6B7280;
|
||||||
|
--color-text-muted: #9CA3AF;
|
||||||
|
--color-border: #E5E7EB;
|
||||||
|
|
||||||
|
/* Typography */
|
||||||
|
--font-heading: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
|
--font-body: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
|
--font-code: 'JetBrains Mono', 'Fira Code', monospace;
|
||||||
|
|
||||||
|
/* Spacing */
|
||||||
|
--space-1: 4px;
|
||||||
|
--space-2: 8px;
|
||||||
|
--space-3: 12px;
|
||||||
|
--space-4: 16px;
|
||||||
|
--space-5: 20px;
|
||||||
|
--space-6: 24px;
|
||||||
|
--space-8: 32px;
|
||||||
|
--space-10: 40px;
|
||||||
|
--space-12: 48px;
|
||||||
|
--space-16: 64px;
|
||||||
|
|
||||||
|
/* Border radius */
|
||||||
|
--radius-btn: 8px;
|
||||||
|
--radius-card: 12px;
|
||||||
|
--radius-modal: 16px;
|
||||||
|
--radius-pill: 9999px;
|
||||||
|
|
||||||
|
/* Easing */
|
||||||
|
--ease-default: cubic-bezier(0.25, 1, 0.5, 1);
|
||||||
|
--ease-entrance: cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
--duration-hover: 200ms;
|
||||||
|
--duration-entrance: 600ms;
|
||||||
|
--duration-collapse: 300ms;
|
||||||
|
|
||||||
|
/* Z-index */
|
||||||
|
--z-dropdown: 100;
|
||||||
|
--z-sticky: 200;
|
||||||
|
--z-modal: 300;
|
||||||
|
--z-toast: 9999;
|
||||||
|
|
||||||
|
/* Sidebar */
|
||||||
|
--sidebar-width: 240px;
|
||||||
|
--sidebar-collapsed-width: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
code, pre {
|
||||||
|
font-family: var(--font-code);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--color-primary-hex);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--color-border);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tabular figures for numbers */
|
||||||
|
.tabular-nums {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
38
frontend/src/styles/naive-overrides.ts
Normal file
38
frontend/src/styles/naive-overrides.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import type { GlobalThemeOverrides } from 'naive-ui';
|
||||||
|
|
||||||
|
export const naiveThemeOverrides: GlobalThemeOverrides = {
|
||||||
|
common: {
|
||||||
|
primaryColor: '#3B5998',
|
||||||
|
primaryColorHover: '#2D4373',
|
||||||
|
primaryColorPressed: '#1E2D4F',
|
||||||
|
primaryColorSuppl: '#3B5998',
|
||||||
|
infoColor: '#2B8CA3',
|
||||||
|
successColor: '#0D9668',
|
||||||
|
warningColor: '#D4920A',
|
||||||
|
errorColor: '#DC2626',
|
||||||
|
fontFamily: "'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, sans-serif",
|
||||||
|
fontFamilyMono: "'JetBrains Mono', 'Fira Code', monospace",
|
||||||
|
borderRadius: '8px',
|
||||||
|
borderRadiusSmall: '6px',
|
||||||
|
},
|
||||||
|
Button: {
|
||||||
|
borderRadiusMedium: '8px',
|
||||||
|
borderRadiusSmall: '6px',
|
||||||
|
borderRadiusLarge: '10px',
|
||||||
|
},
|
||||||
|
Card: {
|
||||||
|
borderRadius: '12px',
|
||||||
|
},
|
||||||
|
Dialog: {
|
||||||
|
borderRadius: '16px',
|
||||||
|
},
|
||||||
|
Input: {
|
||||||
|
borderRadius: '8px',
|
||||||
|
},
|
||||||
|
DataTable: {
|
||||||
|
borderRadius: '12px',
|
||||||
|
},
|
||||||
|
Tag: {
|
||||||
|
borderRadius: '6px',
|
||||||
|
},
|
||||||
|
};
|
||||||
485
frontend/src/types/index.ts
Normal file
485
frontend/src/types/index.ts
Normal file
@ -0,0 +1,485 @@
|
|||||||
|
// ============================================================
|
||||||
|
// DevPerf Dashboard - Frontend Type Definitions
|
||||||
|
// Single Source of Truth: shared-types.md
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
// -- Unified Response Format --
|
||||||
|
|
||||||
|
export interface ApiResponse<T> {
|
||||||
|
code: number;
|
||||||
|
data: T;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedData<T> {
|
||||||
|
items: T[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Enums --
|
||||||
|
|
||||||
|
export type UserRole = 'admin' | 'manager' | 'developer' | 'viewer';
|
||||||
|
export type TaskStatus = 'todo' | 'in_progress' | 'review' | 'done';
|
||||||
|
export type TaskPriority = 'urgent' | 'high' | 'medium' | 'low' | 'none';
|
||||||
|
export type SprintStatus = 'upcoming' | 'active' | 'completed';
|
||||||
|
export type MilestoneStatus = 'backlog' | 'active' | 'completed' | 'cancelled';
|
||||||
|
export type PRState = 'open' | 'closed' | 'merged';
|
||||||
|
export type SyncSource = 'plane' | 'gitea';
|
||||||
|
export type SyncStatus = 'success' | 'error';
|
||||||
|
|
||||||
|
// -- Auth API --
|
||||||
|
|
||||||
|
export interface LoginRequest {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginResponseData {
|
||||||
|
token: string;
|
||||||
|
user: UserBasic;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LoginResponse = ApiResponse<LoginResponseData>;
|
||||||
|
|
||||||
|
export interface UserBasic {
|
||||||
|
id: string;
|
||||||
|
displayName: string;
|
||||||
|
email: string;
|
||||||
|
role: UserRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MeResponse = ApiResponse<UserBasic>;
|
||||||
|
|
||||||
|
// -- Overview API --
|
||||||
|
|
||||||
|
export interface OverviewRequest {
|
||||||
|
period?: string;
|
||||||
|
projectIds?: string; // comma-separated
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SprintDeliveryData {
|
||||||
|
cycles: SprintCycleData[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SprintCycleData {
|
||||||
|
name: string;
|
||||||
|
plannedPoints: number;
|
||||||
|
completedPoints: number;
|
||||||
|
deliveryRate: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskDistributionData {
|
||||||
|
todo: number;
|
||||||
|
inProgress: number;
|
||||||
|
review: number;
|
||||||
|
done: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectProgressItem {
|
||||||
|
projectId: string;
|
||||||
|
name: string;
|
||||||
|
identifier: string;
|
||||||
|
currentCycleProgress: number;
|
||||||
|
totalPoints: number;
|
||||||
|
completedPoints: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WeeklyCodeActivityData {
|
||||||
|
weeks: WeekCodeData[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WeekCodeData {
|
||||||
|
weekStart: string; // YYYY-MM-DD
|
||||||
|
members: MemberCodeData[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MemberCodeData {
|
||||||
|
userId: string;
|
||||||
|
name: string;
|
||||||
|
commits: number;
|
||||||
|
prs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OKRProgressItem {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
ownerName: string;
|
||||||
|
progress: number;
|
||||||
|
keyResults: KRProgressItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KRProgressItem {
|
||||||
|
title: string;
|
||||||
|
current: number;
|
||||||
|
target: number;
|
||||||
|
unit: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PRMergeTimeData {
|
||||||
|
weeks: WeekPRData[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WeekPRData {
|
||||||
|
weekStart: string;
|
||||||
|
avgHours: number;
|
||||||
|
prCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OverviewData {
|
||||||
|
sprintDelivery: SprintDeliveryData;
|
||||||
|
taskDistribution: TaskDistributionData;
|
||||||
|
projectProgress: ProjectProgressItem[];
|
||||||
|
weeklyCodeActivity: WeeklyCodeActivityData;
|
||||||
|
okrProgress: OKRProgressItem[];
|
||||||
|
prMergeTime: PRMergeTimeData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OverviewResponse = ApiResponse<OverviewData>;
|
||||||
|
|
||||||
|
// -- Project API --
|
||||||
|
|
||||||
|
export interface ProjectListItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
identifier: string;
|
||||||
|
lastSyncedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProjectListResponse = ApiResponse<ProjectListItem[]>;
|
||||||
|
|
||||||
|
export interface BurndownPoint {
|
||||||
|
date: string;
|
||||||
|
ideal: number;
|
||||||
|
actual: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CurrentCycleData {
|
||||||
|
name: string;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
deliveryRate: number;
|
||||||
|
burndown: BurndownPoint[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MilestoneItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
status: MilestoneStatus;
|
||||||
|
targetDate: string;
|
||||||
|
progress: number;
|
||||||
|
totalIssues: number;
|
||||||
|
completedIssues: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskMatrixMember {
|
||||||
|
userId: string;
|
||||||
|
name: string;
|
||||||
|
todo: number;
|
||||||
|
inProgress: number;
|
||||||
|
review: number;
|
||||||
|
done: number;
|
||||||
|
totalPoints: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskMatrixData {
|
||||||
|
members: TaskMatrixMember[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectGitActivity {
|
||||||
|
recentCommits: number;
|
||||||
|
recentPRs: number;
|
||||||
|
weeklyTrend: WeeklyGitTrend[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WeeklyGitTrend {
|
||||||
|
weekStart: string;
|
||||||
|
commits: number;
|
||||||
|
prs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectDetailData {
|
||||||
|
project: ProjectListItem;
|
||||||
|
currentCycle: CurrentCycleData | null;
|
||||||
|
milestones: MilestoneItem[];
|
||||||
|
taskMatrix: TaskMatrixData;
|
||||||
|
gitActivity: ProjectGitActivity;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProjectDetailResponse = ApiResponse<ProjectDetailData>;
|
||||||
|
|
||||||
|
// -- Member API --
|
||||||
|
|
||||||
|
export interface MemberListItem {
|
||||||
|
id: string;
|
||||||
|
displayName: string;
|
||||||
|
email: string;
|
||||||
|
role: UserRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MemberListResponse = ApiResponse<MemberListItem[]>;
|
||||||
|
|
||||||
|
export interface MemberDeliveryTrend {
|
||||||
|
cycles: MemberCycleData[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MemberCycleData {
|
||||||
|
name: string;
|
||||||
|
assignedPoints: number;
|
||||||
|
completedPoints: number;
|
||||||
|
rate: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HeatmapDay {
|
||||||
|
date: string;
|
||||||
|
commits: number;
|
||||||
|
prsCreated: number;
|
||||||
|
prsMerged: number;
|
||||||
|
tasksCompleted: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContributionHeatmapData {
|
||||||
|
days: HeatmapDay[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CurrentTaskItem {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
projectName: string;
|
||||||
|
status: TaskStatus;
|
||||||
|
priority: TaskPriority;
|
||||||
|
storyPoints: number | null;
|
||||||
|
dueDate: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KPIScorecard {
|
||||||
|
sprintDeliveryRate: number;
|
||||||
|
avgDeliveryDays: number;
|
||||||
|
bugDensity: number;
|
||||||
|
prMergeTimeAvg: number;
|
||||||
|
reviewParticipation: number;
|
||||||
|
activityStreak: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MemberDetailData {
|
||||||
|
member: MemberListItem;
|
||||||
|
deliveryTrend: MemberDeliveryTrend;
|
||||||
|
contributionHeatmap: ContributionHeatmapData;
|
||||||
|
currentTasks: CurrentTaskItem[];
|
||||||
|
kpiScorecard: KPIScorecard;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MemberDetailResponse = ApiResponse<MemberDetailData>;
|
||||||
|
|
||||||
|
// -- OKR API --
|
||||||
|
|
||||||
|
export interface OKRRequest {
|
||||||
|
period?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KeyResultItem {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
targetValue: number;
|
||||||
|
currentValue: number;
|
||||||
|
unit: string;
|
||||||
|
weight: number;
|
||||||
|
progress: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ObjectiveItem {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
ownerName: string;
|
||||||
|
projectName: string;
|
||||||
|
period: string;
|
||||||
|
progress: number;
|
||||||
|
keyResults: KeyResultItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OKRData {
|
||||||
|
objectives: ObjectiveItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OKRResponse = ApiResponse<OKRData>;
|
||||||
|
|
||||||
|
export interface CreateObjectiveRequest {
|
||||||
|
title: string;
|
||||||
|
ownerId: string;
|
||||||
|
projectId: string;
|
||||||
|
period: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CreateObjectiveResponse = ApiResponse<{ id: string }>;
|
||||||
|
|
||||||
|
export interface CreateKeyResultRequest {
|
||||||
|
title: string;
|
||||||
|
targetValue: number;
|
||||||
|
unit: string;
|
||||||
|
weight: number;
|
||||||
|
linkedPlaneCycleId?: string;
|
||||||
|
linkedPlaneModuleId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CreateKeyResultResponse = ApiResponse<{ id: string }>;
|
||||||
|
|
||||||
|
export interface UpdateKeyResultRequest {
|
||||||
|
currentValue: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpdateKeyResultResponse = ApiResponse<{
|
||||||
|
id: string;
|
||||||
|
progress: number;
|
||||||
|
objectiveProgress: number;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type DeleteObjectiveResponse = ApiResponse<null>;
|
||||||
|
export type DeleteKeyResultResponse = ApiResponse<null>;
|
||||||
|
|
||||||
|
// -- Git Activity API --
|
||||||
|
|
||||||
|
export interface GitActivityRequest {
|
||||||
|
userId?: string;
|
||||||
|
weeks?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GitHeatmapDay {
|
||||||
|
date: string;
|
||||||
|
commits: number;
|
||||||
|
additions: number;
|
||||||
|
deletions: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PRMetrics {
|
||||||
|
totalPRs: number;
|
||||||
|
mergedPRs: number;
|
||||||
|
avgMergeTimeHours: number;
|
||||||
|
reviewedPRs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WeeklyGitActivity {
|
||||||
|
weekStart: string;
|
||||||
|
commits: number;
|
||||||
|
prs: number;
|
||||||
|
additions: number;
|
||||||
|
deletions: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GitActivityData {
|
||||||
|
heatmap: GitHeatmapDay[];
|
||||||
|
prMetrics: PRMetrics;
|
||||||
|
weeklyTrend: WeeklyGitActivity[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GitActivityResponse = ApiResponse<GitActivityData>;
|
||||||
|
|
||||||
|
// -- Admin API --
|
||||||
|
|
||||||
|
export interface AdminUser {
|
||||||
|
id: string;
|
||||||
|
displayName: string;
|
||||||
|
email: string;
|
||||||
|
role: UserRole;
|
||||||
|
planeUserId: string | null;
|
||||||
|
gitUsername: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdminUsersResponse = ApiResponse<AdminUser[]>;
|
||||||
|
|
||||||
|
export interface CreateUserRequest {
|
||||||
|
displayName: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
role: UserRole;
|
||||||
|
planeUserId?: string;
|
||||||
|
gitUsername?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CreateUserResponse = ApiResponse<{ id: string }>;
|
||||||
|
|
||||||
|
export interface UpdateUserRequest {
|
||||||
|
displayName?: string;
|
||||||
|
email?: string;
|
||||||
|
password?: string;
|
||||||
|
role?: UserRole;
|
||||||
|
planeUserId?: string;
|
||||||
|
gitUsername?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpdateUserResponse = ApiResponse<{ id: string }>;
|
||||||
|
export type DeleteUserResponse = ApiResponse<null>;
|
||||||
|
|
||||||
|
export interface AuthorMappingItem {
|
||||||
|
id: string;
|
||||||
|
gitEmail: string | null;
|
||||||
|
gitUsername: string | null;
|
||||||
|
userId: string | null;
|
||||||
|
userName: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AuthorMappingsResponse = ApiResponse<AuthorMappingItem[]>;
|
||||||
|
|
||||||
|
export interface CreateMappingRequest {
|
||||||
|
gitEmail?: string;
|
||||||
|
gitUsername?: string;
|
||||||
|
userId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CreateMappingResponse = ApiResponse<{ id: string }>;
|
||||||
|
export type DeleteMappingResponse = ApiResponse<null>;
|
||||||
|
|
||||||
|
export interface SyncTriggerRequest {
|
||||||
|
source?: SyncSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SyncTriggerResponse = ApiResponse<{ message: string }>;
|
||||||
|
|
||||||
|
export interface SyncLogItem {
|
||||||
|
id: string;
|
||||||
|
source: SyncSource;
|
||||||
|
status: SyncStatus;
|
||||||
|
message: string | null;
|
||||||
|
recordsProcessed: number;
|
||||||
|
syncedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SyncLogsResponse = ApiResponse<PaginatedData<SyncLogItem>>;
|
||||||
|
|
||||||
|
// -- Health --
|
||||||
|
|
||||||
|
export interface HealthData {
|
||||||
|
status: 'ok';
|
||||||
|
version: string;
|
||||||
|
uptime: number;
|
||||||
|
dbConnected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HealthResponse = ApiResponse<HealthData>;
|
||||||
|
|
||||||
|
// -- Error --
|
||||||
|
|
||||||
|
export interface ApiError {
|
||||||
|
code: number;
|
||||||
|
data: null;
|
||||||
|
message: string;
|
||||||
|
details?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ErrorCodes = {
|
||||||
|
VALIDATION_ERROR: 40001,
|
||||||
|
INVALID_FORMAT: 40002,
|
||||||
|
UNAUTHORIZED: 40101,
|
||||||
|
TOKEN_EXPIRED: 40102,
|
||||||
|
FORBIDDEN: 40103,
|
||||||
|
USER_NOT_FOUND: 40401,
|
||||||
|
PROJECT_NOT_FOUND: 40402,
|
||||||
|
OBJECTIVE_NOT_FOUND: 40403,
|
||||||
|
KEY_RESULT_NOT_FOUND: 40404,
|
||||||
|
EMAIL_EXISTS: 40901,
|
||||||
|
MAPPING_EXISTS: 40902,
|
||||||
|
INTERNAL_ERROR: 50001,
|
||||||
|
SYNC_FAILED: 50002,
|
||||||
|
ACCOUNT_LOCKED: 42300,
|
||||||
|
} as const;
|
||||||
302
frontend/src/views/Admin.vue
Normal file
302
frontend/src/views/Admin.vue
Normal file
@ -0,0 +1,302 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, h } from 'vue';
|
||||||
|
import { NTabs, NTabPane, NDataTable, NButton, NModal, NForm, NFormItem, NInput, NSelect, NSpin, NTag, useMessage } from 'naive-ui';
|
||||||
|
import { getAdminUsersApi, createUserApi, deleteUserApi, getAuthorMappingsApi, createMappingApi, deleteMappingApi, getSyncLogsApi, triggerSyncApi } from '@/api/admin';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
const message = useMessage();
|
||||||
|
const activeTab = ref(window.location.hash.slice(1) || 'users');
|
||||||
|
|
||||||
|
// Users tab
|
||||||
|
const usersLoading = ref(false);
|
||||||
|
const usersData = ref<any[]>([]);
|
||||||
|
const showUserModal = ref(false);
|
||||||
|
const newUser = ref({ displayName: '', email: '', password: '', role: 'developer' as string, gitUsername: '' });
|
||||||
|
|
||||||
|
async function loadUsers() {
|
||||||
|
usersLoading.value = true;
|
||||||
|
try {
|
||||||
|
const res = await getAdminUsersApi();
|
||||||
|
usersData.value = res.data.data;
|
||||||
|
} finally {
|
||||||
|
usersLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreateUser() {
|
||||||
|
try {
|
||||||
|
await createUserApi(newUser.value);
|
||||||
|
message.success('用户创建成功');
|
||||||
|
showUserModal.value = false;
|
||||||
|
newUser.value = { displayName: '', email: '', password: '', role: 'developer', gitUsername: '' };
|
||||||
|
loadUsers();
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err.response?.data?.message || '创建用户失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteUser(id: string) {
|
||||||
|
try {
|
||||||
|
await deleteUserApi(id);
|
||||||
|
message.success('用户已删除');
|
||||||
|
loadUsers();
|
||||||
|
} catch {
|
||||||
|
message.error('删除用户失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format an ISO timestamp or Date value to a readable date string.
|
||||||
|
* Handles both string timestamps and numeric (epoch) values from the backend.
|
||||||
|
*/
|
||||||
|
function formatDate(value: unknown): string {
|
||||||
|
if (!value) return '-';
|
||||||
|
const d = dayjs(value as string | number | Date);
|
||||||
|
return d.isValid() ? d.format('YYYY-MM-DD HH:mm') : String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userColumns = [
|
||||||
|
{ title: '姓名', key: 'displayName' },
|
||||||
|
{ title: '邮箱', key: 'email' },
|
||||||
|
{ title: '角色', key: 'role', width: 100 },
|
||||||
|
{
|
||||||
|
title: '创建时间', key: 'createdAt', width: 180,
|
||||||
|
render: (row: any) => formatDate(row.createdAt),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作', key: 'actions', width: 100,
|
||||||
|
render: (row: any) => {
|
||||||
|
return h(
|
||||||
|
NButton,
|
||||||
|
{ size: 'tiny', type: 'error', onClick: () => handleDeleteUser(row.id) },
|
||||||
|
{ default: () => '删除' },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Mappings tab
|
||||||
|
const mappingsLoading = ref(false);
|
||||||
|
const mappingsData = ref<any[]>([]);
|
||||||
|
const showMappingModal = ref(false);
|
||||||
|
const newMapping = ref({ gitEmail: '', gitUsername: '', userId: '' });
|
||||||
|
|
||||||
|
async function loadMappings() {
|
||||||
|
mappingsLoading.value = true;
|
||||||
|
try {
|
||||||
|
const res = await getAuthorMappingsApi();
|
||||||
|
mappingsData.value = res.data.data;
|
||||||
|
} finally {
|
||||||
|
mappingsLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mappingColumns = [
|
||||||
|
{ title: 'Git 邮箱', key: 'gitEmail' },
|
||||||
|
{ title: 'Git 用户名', key: 'gitUsername' },
|
||||||
|
{ title: '关联用户', key: 'userName' },
|
||||||
|
{
|
||||||
|
title: '操作', key: 'actions', width: 100,
|
||||||
|
render: (row: any) => {
|
||||||
|
return h(
|
||||||
|
NButton,
|
||||||
|
{ size: 'tiny', type: 'error', onClick: () => handleDeleteMapping(row.id) },
|
||||||
|
{ default: () => '删除' },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
async function handleCreateMapping() {
|
||||||
|
try {
|
||||||
|
if (!newMapping.value.gitEmail && !newMapping.value.gitUsername) {
|
||||||
|
message.warning('Git 邮箱和 Git 用户名至少填一个');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!newMapping.value.userId) {
|
||||||
|
message.warning('请选择关联用户');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await createMappingApi(newMapping.value);
|
||||||
|
message.success('映射创建成功');
|
||||||
|
showMappingModal.value = false;
|
||||||
|
newMapping.value = { gitEmail: '', gitUsername: '', userId: '' };
|
||||||
|
loadMappings();
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err.response?.data?.message || '创建映射失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteMapping(id: string) {
|
||||||
|
try {
|
||||||
|
await deleteMappingApi(id);
|
||||||
|
message.success('映射已删除');
|
||||||
|
loadMappings();
|
||||||
|
} catch {
|
||||||
|
message.error('删除映射失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户下拉选项(给映射弹窗用)
|
||||||
|
const userOptions = computed(() =>
|
||||||
|
usersData.value.map(u => ({ value: u.id, label: `${u.displayName} (${u.email})` }))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sync logs tab
|
||||||
|
const logsLoading = ref(false);
|
||||||
|
const logsData = ref<any[]>([]);
|
||||||
|
const syncTriggering = ref(false);
|
||||||
|
|
||||||
|
async function loadLogs() {
|
||||||
|
logsLoading.value = true;
|
||||||
|
try {
|
||||||
|
const res = await getSyncLogsApi({ page: 1, pageSize: 50 });
|
||||||
|
logsData.value = res.data.data.items;
|
||||||
|
} finally {
|
||||||
|
logsLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTriggerSync() {
|
||||||
|
syncTriggering.value = true;
|
||||||
|
try {
|
||||||
|
await triggerSyncApi();
|
||||||
|
message.success('同步已触发');
|
||||||
|
loadLogs();
|
||||||
|
} catch {
|
||||||
|
message.error('触发同步失败');
|
||||||
|
} finally {
|
||||||
|
syncTriggering.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* B-25 fix: Added color-coded NTag rendering for sync log status column.
|
||||||
|
* 'success' = green tag, 'error' = red tag, other = default.
|
||||||
|
*/
|
||||||
|
const logColumns = [
|
||||||
|
{ title: '来源', key: 'source', width: 80 },
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
key: 'status',
|
||||||
|
width: 90,
|
||||||
|
render: (row: any) => {
|
||||||
|
const status = row.status;
|
||||||
|
const tagType = status === 'success' ? 'success' : status === 'error' ? 'error' : 'default';
|
||||||
|
return h(
|
||||||
|
NTag,
|
||||||
|
{
|
||||||
|
type: tagType,
|
||||||
|
size: 'small',
|
||||||
|
round: true,
|
||||||
|
},
|
||||||
|
{ default: () => status },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ title: '消息', key: 'message', ellipsis: { tooltip: true } },
|
||||||
|
{ title: '记录数', key: 'recordsProcessed', width: 80 },
|
||||||
|
{
|
||||||
|
title: '时间', key: 'syncedAt', width: 180,
|
||||||
|
render: (row: any) => formatDate(row.syncedAt),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function handleTabChange(value: string) {
|
||||||
|
activeTab.value = value;
|
||||||
|
window.location.hash = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadUsers();
|
||||||
|
loadMappings();
|
||||||
|
loadLogs();
|
||||||
|
});
|
||||||
|
|
||||||
|
const roleOptions = [
|
||||||
|
{ value: 'admin', label: '管理员' },
|
||||||
|
{ value: 'manager', label: '经理' },
|
||||||
|
{ value: 'developer', label: '开发者' },
|
||||||
|
{ value: 'viewer', label: '观察者' },
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="admin-page">
|
||||||
|
<NTabs :value="activeTab" @update:value="handleTabChange" type="line">
|
||||||
|
<NTabPane name="users" tab="用户管理">
|
||||||
|
<div style="margin-bottom: var(--space-4)">
|
||||||
|
<NButton type="primary" @click="showUserModal = true">创建用户</NButton>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<NDataTable :columns="userColumns" :data="usersData" :loading="usersLoading" :bordered="false" size="small" />
|
||||||
|
</div>
|
||||||
|
</NTabPane>
|
||||||
|
|
||||||
|
<NTabPane name="mapping" tab="作者映射">
|
||||||
|
<div style="margin-bottom: var(--space-4)">
|
||||||
|
<NButton type="primary" @click="showMappingModal = true">添加映射</NButton>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<NDataTable :columns="mappingColumns" :data="mappingsData" :loading="mappingsLoading" :bordered="false" size="small" />
|
||||||
|
</div>
|
||||||
|
</NTabPane>
|
||||||
|
|
||||||
|
<NTabPane name="sync" tab="同步日志">
|
||||||
|
<div style="margin-bottom: var(--space-4)">
|
||||||
|
<NButton type="primary" :loading="syncTriggering" @click="handleTriggerSync">触发同步</NButton>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<NDataTable :columns="logColumns" :data="logsData" :loading="logsLoading" :bordered="false" size="small" />
|
||||||
|
</div>
|
||||||
|
</NTabPane>
|
||||||
|
</NTabs>
|
||||||
|
|
||||||
|
<!-- 创建用户弹窗 -->
|
||||||
|
<NModal v-model:show="showUserModal" title="创建用户" preset="dialog" positive-text="创建" @positive-click="handleCreateUser">
|
||||||
|
<NForm>
|
||||||
|
<NFormItem label="显示名称">
|
||||||
|
<NInput v-model:value="newUser.displayName" placeholder="请输入姓名" />
|
||||||
|
</NFormItem>
|
||||||
|
<NFormItem label="邮箱">
|
||||||
|
<NInput v-model:value="newUser.email" placeholder="请输入邮箱" />
|
||||||
|
</NFormItem>
|
||||||
|
<NFormItem label="密码">
|
||||||
|
<NInput v-model:value="newUser.password" type="password" placeholder="至少 6 个字符" />
|
||||||
|
</NFormItem>
|
||||||
|
<NFormItem label="角色">
|
||||||
|
<NSelect v-model:value="newUser.role" :options="roleOptions" />
|
||||||
|
</NFormItem>
|
||||||
|
<NFormItem label="Git 用户名">
|
||||||
|
<NInput v-model:value="newUser.gitUsername" placeholder="Gitea 上的用户名(可选,用于自动关联提交记录)" />
|
||||||
|
</NFormItem>
|
||||||
|
</NForm>
|
||||||
|
</NModal>
|
||||||
|
|
||||||
|
<!-- 添加映射弹窗 -->
|
||||||
|
<NModal v-model:show="showMappingModal" title="添加作者映射" preset="dialog" positive-text="创建" @positive-click="handleCreateMapping">
|
||||||
|
<NForm>
|
||||||
|
<NFormItem label="Git 邮箱">
|
||||||
|
<NInput v-model:value="newMapping.gitEmail" placeholder="开发者在 Git 上配置的邮箱(git config user.email)" />
|
||||||
|
</NFormItem>
|
||||||
|
<NFormItem label="Git 用户名">
|
||||||
|
<NInput v-model:value="newMapping.gitUsername" placeholder="Gitea 上的用户名" />
|
||||||
|
</NFormItem>
|
||||||
|
<NFormItem label="关联用户">
|
||||||
|
<NSelect v-model:value="newMapping.userId" :options="userOptions" placeholder="选择系统中的用户" filterable />
|
||||||
|
</NFormItem>
|
||||||
|
</NForm>
|
||||||
|
<div style="color: var(--color-text-tertiary); font-size: 12px; margin-top: 8px;">
|
||||||
|
提示:Git 邮箱和 Git 用户名至少填一个。系统同步 Git 数据时会按此映射自动关联提交记录到对应用户。
|
||||||
|
</div>
|
||||||
|
</NModal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* B-03/B-04: Mobile responsive table wrapper */
|
||||||
|
.table-responsive {
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
135
frontend/src/views/GitActivity.vue
Normal file
135
frontend/src/views/GitActivity.vue
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, computed } from 'vue';
|
||||||
|
import { NSpin } from 'naive-ui';
|
||||||
|
import { getGitActivityApi } from '@/api/git';
|
||||||
|
import DataCard from '@/components/shared/DataCard.vue';
|
||||||
|
import ContributionHeatmap from '@/components/charts/ContributionHeatmap.vue';
|
||||||
|
import { useECharts, CHART_COLORS } from '@/composables/useECharts';
|
||||||
|
import type { HeatmapDay } from '@/types';
|
||||||
|
|
||||||
|
const loading = ref(true);
|
||||||
|
const data = ref<any>(null);
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const res = await getGitActivityApi({ weeks: 12 });
|
||||||
|
data.value = res.data.data;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load git activity:', err);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const heatmapDays = computed<HeatmapDay[]>(() => {
|
||||||
|
const raw = data.value?.heatmap || [];
|
||||||
|
return raw.map((d: any) => ({
|
||||||
|
date: d.date,
|
||||||
|
commits: d.commits || 0,
|
||||||
|
prsCreated: 0,
|
||||||
|
prsMerged: 0,
|
||||||
|
tasksCompleted: 0,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
// 每周趋势:按人堆叠的柱状图
|
||||||
|
const weeklyOptions = computed(() => {
|
||||||
|
const weeks = data.value?.weeklyTrend || [];
|
||||||
|
if (!weeks.length) return {};
|
||||||
|
|
||||||
|
// 收集所有出现过的用户
|
||||||
|
const userSet = new Map<string, string>();
|
||||||
|
for (const w of weeks) {
|
||||||
|
for (const u of (w.byUser || [])) {
|
||||||
|
if (!userSet.has(u.userId)) userSet.set(u.userId, u.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const series = Array.from(userSet.entries()).map(([userId, name], i) => ({
|
||||||
|
type: 'bar',
|
||||||
|
name,
|
||||||
|
stack: 'commits',
|
||||||
|
data: weeks.map((w: any) => {
|
||||||
|
const found = (w.byUser || []).find((u: any) => u.userId === userId);
|
||||||
|
return found?.commits || 0;
|
||||||
|
}),
|
||||||
|
itemStyle: { color: CHART_COLORS[i % CHART_COLORS.length], borderRadius: i === userSet.size - 1 ? [3, 3, 0, 0] : [0, 0, 0, 0] },
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis',
|
||||||
|
formatter(params: any) {
|
||||||
|
let total = 0;
|
||||||
|
let html = `<strong>${params[0]?.name}</strong><br/>`;
|
||||||
|
for (const p of params) {
|
||||||
|
if (p.value > 0) {
|
||||||
|
html += `<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:${p.color};margin-right:6px"></span>${p.seriesName}: ${p.value} 次提交<br/>`;
|
||||||
|
total += p.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
html += `<div style="margin-top:4px;border-top:1px solid #eee;padding-top:4px"><strong>合计: ${total} 次提交</strong></div>`;
|
||||||
|
return html;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
legend: { bottom: 0, textStyle: { fontSize: 11 } },
|
||||||
|
grid: { top: 10, bottom: 40, left: 40, right: 10 },
|
||||||
|
xAxis: { type: 'category', data: weeks.map((w: any) => w.weekStart), axisLabel: { fontSize: 10 } },
|
||||||
|
yAxis: { type: 'value', name: '提交数' },
|
||||||
|
series,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const { chartRef: weeklyRef } = useECharts(weeklyOptions);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="git-page">
|
||||||
|
<NSpin :show="loading">
|
||||||
|
<template v-if="data">
|
||||||
|
<!-- 贡献热力图 -->
|
||||||
|
<DataCard title="贡献热力图" :subtitle="`${data.heatmap?.length || 0} 天已追踪`">
|
||||||
|
<div style="height: 160px">
|
||||||
|
<ContributionHeatmap :days="heatmapDays" />
|
||||||
|
</div>
|
||||||
|
</DataCard>
|
||||||
|
|
||||||
|
<!-- 统计指标 -->
|
||||||
|
<div class="metrics-row" style="margin-top: var(--space-5)">
|
||||||
|
<div class="metric-card">
|
||||||
|
<span class="metric-label">总提交数</span>
|
||||||
|
<span class="metric-value tabular-nums">{{ data.stats?.totalCommits || 0 }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="metric-card">
|
||||||
|
<span class="metric-label">活跃贡献者</span>
|
||||||
|
<span class="metric-value tabular-nums">{{ data.stats?.activeContributors || 0 }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="metric-card">
|
||||||
|
<span class="metric-label">本月提交</span>
|
||||||
|
<span class="metric-value tabular-nums">{{ data.stats?.thisMonthCommits || 0 }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="metric-card">
|
||||||
|
<span class="metric-label">活跃仓库</span>
|
||||||
|
<span class="metric-value tabular-nums">{{ data.stats?.activeRepos || 0 }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 每周提交趋势(按人堆叠) -->
|
||||||
|
<DataCard title="每周提交趋势" subtitle="按贡献者堆叠" style="margin-top: var(--space-5)">
|
||||||
|
<div ref="weeklyRef" style="height: 300px" />
|
||||||
|
</DataCard>
|
||||||
|
</template>
|
||||||
|
</NSpin>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.metrics-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--space-3); }
|
||||||
|
@media (max-width: 768px) { .metrics-row { grid-template-columns: repeat(2, 1fr); } }
|
||||||
|
.metric-card {
|
||||||
|
background: var(--color-bg-card); border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-card); padding: var(--space-4); text-align: center;
|
||||||
|
}
|
||||||
|
.metric-label { display: block; font-size: 12px; color: var(--color-text-secondary); margin-bottom: var(--space-2); }
|
||||||
|
.metric-value { font-size: 28px; font-weight: 700; color: var(--color-primary-hex); }
|
||||||
|
</style>
|
||||||
113
frontend/src/views/Login.vue
Normal file
113
frontend/src/views/Login.vue
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import { NCard, NForm, NFormItem, NInput, NButton, NAlert } from 'naive-ui';
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
|
const email = ref('');
|
||||||
|
const password = ref('');
|
||||||
|
const loading = ref(false);
|
||||||
|
const errorMsg = ref('');
|
||||||
|
const showExpiredMsg = ref(route.query.expired === 'true');
|
||||||
|
|
||||||
|
async function handleLogin() {
|
||||||
|
if (!email.value || !password.value) return;
|
||||||
|
loading.value = true;
|
||||||
|
errorMsg.value = '';
|
||||||
|
try {
|
||||||
|
await authStore.login(email.value, password.value);
|
||||||
|
} catch (err: any) {
|
||||||
|
const msg = err.response?.data?.message || '登录失败';
|
||||||
|
errorMsg.value = msg;
|
||||||
|
password.value = '';
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="login-page">
|
||||||
|
<div class="login-brand">
|
||||||
|
<div class="brand-content">
|
||||||
|
<div class="brand-logo">DP</div>
|
||||||
|
<h1 class="brand-title">DevPerf Dashboard</h1>
|
||||||
|
<p class="brand-desc">研发效能,一目了然</p>
|
||||||
|
<p class="brand-sub">基于 Plane + Gitea 基础设施构建</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="login-form-area">
|
||||||
|
<NCard class="login-card" :bordered="false">
|
||||||
|
<h2 class="form-title">登录研发人效看板</h2>
|
||||||
|
|
||||||
|
<NAlert v-if="showExpiredMsg" type="info" :closable="true" @close="showExpiredMsg = false" style="margin-bottom: 16px">
|
||||||
|
您的会话已过期,请重新登录。
|
||||||
|
</NAlert>
|
||||||
|
|
||||||
|
<NAlert v-if="errorMsg" type="error" :closable="false" style="margin-bottom: 16px">
|
||||||
|
{{ errorMsg }}
|
||||||
|
</NAlert>
|
||||||
|
|
||||||
|
<NForm @submit.prevent="handleLogin">
|
||||||
|
<NFormItem label="邮箱">
|
||||||
|
<NInput v-model:value="email" placeholder="请输入邮箱" type="text" size="large" />
|
||||||
|
</NFormItem>
|
||||||
|
<NFormItem label="密码">
|
||||||
|
<NInput v-model:value="password" placeholder="请输入密码" type="password" show-password-on="click" size="large" />
|
||||||
|
</NFormItem>
|
||||||
|
<NButton type="primary" block size="large" :loading="loading" @click="handleLogin" :disabled="!email || !password">
|
||||||
|
{{ loading ? '登录中...' : '登录' }}
|
||||||
|
</NButton>
|
||||||
|
</NForm>
|
||||||
|
|
||||||
|
<p class="login-footer">© 2026 DevPerf Dashboard. 仅供内部使用。</p>
|
||||||
|
</NCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.login-page {
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-brand {
|
||||||
|
flex: 1;
|
||||||
|
background: linear-gradient(135deg, #1E2433 0%, #3B5998 100%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-content { text-align: center; color: white; }
|
||||||
|
.brand-logo {
|
||||||
|
width: 80px; height: 80px; background: rgba(255,255,255,0.15);
|
||||||
|
border-radius: 20px; display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 28px; font-weight: 800; margin: 0 auto var(--space-6);
|
||||||
|
}
|
||||||
|
.brand-title { font-size: 28px; font-weight: 800; margin: 0 0 var(--space-3); }
|
||||||
|
.brand-desc { font-size: 16px; opacity: 0.9; margin: 0 0 var(--space-2); }
|
||||||
|
.brand-sub { font-size: 13px; opacity: 0.6; margin: 0; }
|
||||||
|
|
||||||
|
.login-form-area {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-12);
|
||||||
|
background: var(--color-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card { max-width: 420px; width: 100%; box-shadow: 0 4px 24px rgba(0,0,0,0.08); border-radius: var(--radius-modal); }
|
||||||
|
.form-title { font-size: 22px; font-weight: 700; margin: 0 0 var(--space-6); }
|
||||||
|
.login-footer { font-size: 12px; color: var(--color-text-muted); text-align: center; margin-top: var(--space-6); }
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.login-brand { display: none; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
199
frontend/src/views/MemberDetail.vue
Normal file
199
frontend/src/views/MemberDetail.vue
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, computed } from 'vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import { NSpin, NDataTable, NTag } from 'naive-ui';
|
||||||
|
import { getMemberDetailApi } from '@/api/members';
|
||||||
|
import DataCard from '@/components/shared/DataCard.vue';
|
||||||
|
import ContributionHeatmap from '@/components/charts/ContributionHeatmap.vue';
|
||||||
|
import { useECharts, CHART_COLORS } from '@/composables/useECharts';
|
||||||
|
import type { HeatmapDay } from '@/types';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const loading = ref(true);
|
||||||
|
const data = ref<any>(null);
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const res = await getMemberDetailApi(route.params.id as string);
|
||||||
|
data.value = res.data.data;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load member:', err);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* B-14 fix: Map member API heatmap data to ContributionHeatmap's expected HeatmapDay format.
|
||||||
|
* Member Detail API returns contributionHeatmap.days which already has the correct fields.
|
||||||
|
*/
|
||||||
|
const heatmapDays = computed<HeatmapDay[]>(() => {
|
||||||
|
return data.value?.contributionHeatmap?.days || [];
|
||||||
|
});
|
||||||
|
|
||||||
|
// B-19 fix: Delivery trend line - ensure all sprint data points are shown
|
||||||
|
const trendOptions = computed(() => {
|
||||||
|
const cycles = data.value?.deliveryTrend?.cycles || [];
|
||||||
|
return {
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis',
|
||||||
|
formatter(params: any) {
|
||||||
|
const item = params[0];
|
||||||
|
const cycle = cycles[item.dataIndex];
|
||||||
|
if (!cycle) return '';
|
||||||
|
return `<strong>${cycle.name}</strong><br/>` +
|
||||||
|
`分配: ${cycle.assignedPoints} 点<br/>` +
|
||||||
|
`完成: ${cycle.completedPoints} 点<br/>` +
|
||||||
|
`交付率: ${cycle.rate}%`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: cycles.map((c: any) => c.name),
|
||||||
|
axisLabel: { fontSize: 11, interval: 0, rotate: cycles.length > 6 ? 30 : 0 },
|
||||||
|
},
|
||||||
|
yAxis: { type: 'value', max: 100, axisLabel: { formatter: '{value}%' } },
|
||||||
|
series: [{
|
||||||
|
type: 'line',
|
||||||
|
data: cycles.map((c: any) => c.rate),
|
||||||
|
smooth: true,
|
||||||
|
itemStyle: { color: CHART_COLORS[0] },
|
||||||
|
areaStyle: { color: 'rgba(59,89,152,0.1)' },
|
||||||
|
symbol: 'circle',
|
||||||
|
symbolSize: 8,
|
||||||
|
label: {
|
||||||
|
show: cycles.length <= 8,
|
||||||
|
formatter: '{c}%',
|
||||||
|
fontSize: 11,
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// KPI Radar
|
||||||
|
const radarOptions = computed(() => {
|
||||||
|
const kpi = data.value?.kpiScorecard;
|
||||||
|
if (!kpi) return {};
|
||||||
|
return {
|
||||||
|
radar: {
|
||||||
|
indicator: [
|
||||||
|
{ name: '交付率', max: 100 },
|
||||||
|
{ name: '交付速度', max: 100 },
|
||||||
|
{ name: '代码质量', max: 100 },
|
||||||
|
{ name: 'PR 效率', max: 100 },
|
||||||
|
{ name: '评审参与', max: 100 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
series: [{
|
||||||
|
type: 'radar',
|
||||||
|
data: [{
|
||||||
|
value: [
|
||||||
|
kpi.sprintDeliveryRate,
|
||||||
|
Math.max(0, 100 - kpi.avgDeliveryDays * 5),
|
||||||
|
Math.max(0, 100 - kpi.bugDensity * 100),
|
||||||
|
Math.max(0, 100 - kpi.prMergeTimeAvg),
|
||||||
|
kpi.reviewParticipation,
|
||||||
|
],
|
||||||
|
itemStyle: { color: CHART_COLORS[0] },
|
||||||
|
areaStyle: { color: 'rgba(59,89,152,0.2)' },
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const { chartRef: trendRef } = useECharts(trendOptions);
|
||||||
|
const { chartRef: radarRef } = useECharts(radarOptions);
|
||||||
|
|
||||||
|
const taskColumns = [
|
||||||
|
{ title: '标题', key: 'title', ellipsis: { tooltip: true } },
|
||||||
|
{ title: '状态', key: 'status', width: 100, render: (row: any) => row.status },
|
||||||
|
{ title: '优先级', key: 'priority', width: 90 },
|
||||||
|
{ title: '点数', key: 'storyPoints', width: 70, align: 'center' as const },
|
||||||
|
{ title: '截止日期', key: 'dueDate', width: 110 },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* B-20 fix: Add pagination to Current Tasks table.
|
||||||
|
* Default page size of 10 with option to expand.
|
||||||
|
*/
|
||||||
|
const taskPagination = {
|
||||||
|
pageSize: 10,
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="member-detail-page">
|
||||||
|
<NSpin :show="loading">
|
||||||
|
<template v-if="data">
|
||||||
|
<h2 style="margin-bottom: var(--space-4)">{{ data.member?.displayName }}</h2>
|
||||||
|
<p style="color: var(--color-text-secondary); margin-bottom: var(--space-5)">{{ data.member?.email }} - {{ data.member?.role }}</p>
|
||||||
|
|
||||||
|
<div class="grid-2">
|
||||||
|
<DataCard title="Sprint 交付率趋势" subtitle="最近 6 个 Sprint">
|
||||||
|
<div ref="trendRef" style="height: 240px" />
|
||||||
|
</DataCard>
|
||||||
|
|
||||||
|
<DataCard title="KPI 评分卡">
|
||||||
|
<div ref="radarRef" style="height: 240px" />
|
||||||
|
</DataCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- B-14 fix: replaced placeholder with real ContributionHeatmap component -->
|
||||||
|
<DataCard title="贡献热力图" subtitle="最近 6 个月" style="margin-top: var(--space-5)">
|
||||||
|
<div style="height: 160px">
|
||||||
|
<ContributionHeatmap :days="heatmapDays" />
|
||||||
|
</div>
|
||||||
|
</DataCard>
|
||||||
|
|
||||||
|
<!-- B-20 fix: added pagination prop to NDataTable -->
|
||||||
|
<DataCard title="当前任务" style="margin-top: var(--space-5)">
|
||||||
|
<NDataTable
|
||||||
|
:columns="taskColumns"
|
||||||
|
:data="data.currentTasks || []"
|
||||||
|
:bordered="false"
|
||||||
|
:pagination="taskPagination"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</DataCard>
|
||||||
|
|
||||||
|
<!-- KPI Details -->
|
||||||
|
<div class="kpi-cards" style="margin-top: var(--space-5)">
|
||||||
|
<div class="kpi-card">
|
||||||
|
<span class="kpi-label">交付率</span>
|
||||||
|
<span class="kpi-value tabular-nums">{{ data.kpiScorecard?.sprintDeliveryRate || 0 }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-card">
|
||||||
|
<span class="kpi-label">平均交付天数</span>
|
||||||
|
<span class="kpi-value tabular-nums">{{ data.kpiScorecard?.avgDeliveryDays || 0 }}d</span>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-card">
|
||||||
|
<span class="kpi-label">Bug 密度</span>
|
||||||
|
<span class="kpi-value tabular-nums">{{ data.kpiScorecard?.bugDensity || 0 }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-card">
|
||||||
|
<span class="kpi-label">PR 合入时间</span>
|
||||||
|
<span class="kpi-value tabular-nums">{{ data.kpiScorecard?.prMergeTimeAvg || 0 }}h</span>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-card">
|
||||||
|
<span class="kpi-label">连续活跃</span>
|
||||||
|
<span class="kpi-value tabular-nums">{{ data.kpiScorecard?.activityStreak || 0 }}d</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</NSpin>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.grid-2 { display: grid; grid-template-columns: repeat(2, 1fr); gap: var(--space-5); }
|
||||||
|
@media (max-width: 768px) { .grid-2 { grid-template-columns: 1fr; } }
|
||||||
|
|
||||||
|
.kpi-cards { display: grid; grid-template-columns: repeat(5, 1fr); gap: var(--space-3); }
|
||||||
|
.kpi-card {
|
||||||
|
background: var(--color-bg-card); border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-card); padding: var(--space-4); text-align: center;
|
||||||
|
}
|
||||||
|
.kpi-label { display: block; font-size: 12px; color: var(--color-text-secondary); margin-bottom: var(--space-2); }
|
||||||
|
.kpi-value { font-size: 24px; font-weight: 700; color: var(--color-primary-hex); }
|
||||||
|
@media (max-width: 768px) { .kpi-cards { grid-template-columns: repeat(2, 1fr); } }
|
||||||
|
</style>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user