Compare commits

...

26 Commits

Author SHA1 Message Date
zyc
98fe3a396a fix(overview): 本周关键结果 / 异常事项·显示项目中文名替代 identifier
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m1s
之前显示 aircore-sw-002 这种内部技术 ID 不易识别,改成 projectName
(如"RTC底软件框架"),跟其他列表风格保持一致。后端字段早已返回,仅
前端模板切换。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 16:12:59 +08:00
zyc
7b5a2a823c fix(roi): 关闭 mock 营收 + 修 zod envBool 把 "false" 当 true 的 bug
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m6s
变更:
- scheduler 加判断: MOCK_REVENUE_API=false 且 URL 仍指 /mock 时跳过 revenue cron,
  避免对未挂载的端点 404 写错误日志
- config.ts 新增 envBool() preprocess: 替代 z.coerce.boolean(),
  正确把 "false"/"0"/"no" 识别为 false (zod 默认所有非空串都是 true)
- 影响:AI_ENABLED 和 MOCK_REVENUE_API 两个 boolean env 现在按字面值生效

副作用:
- 数据库 1997 条 mock revenue + 5 条 unmapped 已清空 (mysql DELETE 手动执行)
- 项目级 ROI 现在显示真实状态: 成本来自 commit 估算, 产出 ¥0 (待业务方接入)
- 真实营收 API 就绪后只需改 REVENUE_API_BASE_URL 即可恢复 cron

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:41:00 +08:00
zyc
4a2ed8d414 feat(ui+perf): Editorial Data Console 重设计 + 接口性能 + ROI 权限锁
UI 重设计 (Editorial Data Console 风):
- 设计令牌系统: OKLCH 色彩 + Newsreader/Geist/JetBrains Mono 字体 + exp easing
- 全局表格基线 (.n-data-table 统一 editorial 风 + .table-shell 卡片容器)
- DataCard / Naive UI 主题对齐新 token (深墨青主色 + 暖琥珀强调)
- RoiDashboard: 3 KPI 卡片同字号 + chip 多色筛选 + section editorial 节奏
- ProjectRoiBoard: hero 卡 highlight + ytd-strip 节奏化 (10/13/15px 三层字号)
- ProjectList: 自适应卡片 + 产品线 NSelect 筛选 + 拆出独立"类型"列 + 文本链接操作
- RevenuePieChart 重设计: donut + 中心总额 + 底部水平图例 (替代外部 callout 截断)
- 全部页面 width:100% + clamp() 流体 padding,断点驱动 auto-fit 网格
- AppSidebar 项目子菜单按产品线分组 + 可折叠 + localStorage 持久化

接口性能优化 (N+1 → 批量 + Map 索引):
- /api/overview: 8.5s → 0.5s (17×) - 消除 3 处循环 SQL 查询
- /api/okr:     11.3s → 0.3s (37×) - getOKRByPeriod 一次性 inArray 批量
- ROI 三处时间窗 (aggregate/timeseries/events) launchedAt 截断对齐

ROI 权限锁:
- 全部 ROI 端点统一 admin (roiRoutes 全局 requireRole)
- 路由 /roi + /projects/:id/roi meta.roles=['admin']
- 侧边栏 ROI 入口 + 项目详情打标按钮/分类标签全部 v-if isAdmin

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:28:48 +08:00
zyc
5af612e3fd feat(roi): ROI 动态规则引擎 v1 + 业务体系归属
后端:
- 事件流模型(project_cost_events / project_revenue_events)+ launchedAt 截断
- 3 大业务体系归属(airhubs/airflow/aircore) + 项目类型(hw/sw) + identifier 自动生成
- AI 三件套推荐(category + bizSystem + projectType)
- 营收 mock API + 外部对接规范 + 资产摊销 cron
- 5 个 migration(0003 ROI 引擎 / 0004 driver factors / 0005 biz system)
- 单测 11/11 过

前端:
- 项目级 ROI 看板:4 KPI 卡片 + 折线图(周/月/年)+ 成本/产出事件流并排
- 全公司决策罗盘:3 大 ROI 指标 + 业务线堆叠 + 分类筛选 chip
- 项目列表 + 侧边栏:按产品线分组(可折叠 + localStorage 持久化)
- Admin: ROI 策略配置 + 项目映射 + 未映射收容

数据:
- 23 项目全部 AI 自动分类 + 自动 identifier(airhubs-hw-001 这种)
- launchedAt 按各项目首次 commit 时间设置

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:20:22 +08:00
zyc
ad66228edc fix: bcrypt 替换为 bcryptjs 修复 pod CrashLoopBackOff
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m31s
oven/bun:1 镜像最近升到 bun 1.3.12,bcrypt 这个 native 模块的
prebuilt napi-v3/.node 二进制没装上,新 pod 启动时直接抛
"Cannot find module 'bcrypt_lib.node'" 死循环重启,旧 pod 一直没
被替换,env 变量、AI 同步代码都进不了线上。

bcryptjs 是纯 JS 实现,API 和哈希格式 (\$2a\$/\$2b\$) 与 bcrypt
完全兼容,可以直接读已有的密码 hash,避免 native binding 问题
反复踩坑。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 15:09:25 +08:00
zyc
08ec749cd8 feat(scheduler): 启动 + 跳过 AI 时写诊断日志到 sync_logs
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m2s
加入两条诊断写入:
1. 服务启动时立刻写 [BOOT] 日志,包含 AI_ENABLED / AI_API_KEY 长度
   / AI_MODEL / AI_BASE_URL,可在 sync_logs UI 直接确认 pod 拿到
   的环境变量
2. 每次 cron/手动触发若因 AI_ENABLED=false 或 AI_API_KEY 空被跳过,
   写一条 status=error 的 ai_okr 日志说明跳过原因

这样不需要 kubectl logs 就能定位"为什么 AI 没跑"。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 14:35:10 +08:00
zyc
33b3a2208e feat(okr-ai): AI 分析失败/空跑也写入 sync_logs
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m30s
之前 AI 分析只在「有 commits 且全部成功」时写 success 日志,
出错走 .catch 只打 stdout、空跑直接 return 不写。导致 UI 同步日志页
看不出 AI 是否真的跑过、为什么没产出。

改为:
- 空跑写 success(带 "No unanalyzed commits")
- 单仓库失败累计后写入 message 末尾
- 全仓库失败或外层异常写 status=error 并附 stack
- message 截断到 1000 字符避免超长

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 13:40:04 +08:00
zyc
8561f6190d fix(admin): 手动触发同步同时跑 AI OKR 分析
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m5s
之前 /admin/sync/trigger 只调用 syncGitea,导致 UI 上点"触发同步"
按钮无论多少次,OKR 都不会被更新。改为同步完成后立即跑 AI 分析,
逻辑与 scheduler.ts 的定时任务保持一致。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 13:35:24 +08:00
zyc
0dcb9c02d3 fix(deploy): k8s backend 注入 AI 相关环境变量
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 59m58s
生产环境 deployment 缺 AI_ENABLED/AI_API_KEY 等变量,导致 scheduler
里的 AI OKR 分析分支被跳过,cron 只跑了 gitea 同步而没触发 OKR 更新。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 11:19:52 +08:00
zyc
e1396b1479 feat(okr): 接入豆包AI自动分析Git提交生成OKR
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m4s
基于豆包(Doubao) LLM 分析 git commit messages,按仓库维度自动为每个
提交人生成、更新、标记完成 OKR:

- 新增 ai_analyzed_commits 表实现增量标记,每条 commit 只分析一次
- objectives/keyResults 新增 source、sourceKey 字段区分 AI 生成与手动创建
- keyResults.status 扩展支持 completed 状态
- 新增 llm-client.ts 封装豆包 Ark API 调用(原生 fetch,零依赖)
- 新增 okr-ai-sync.ts 核心服务:按仓库分组 → 构建 prompt → 调用 AI → 执行 actions
- scheduler 在 Git 同步后自动触发 AI 分析(受 AI_ENABLED 开关控制)
- 新增 POST /api/okr/ai-analyze 手动触发和 preview 预览端点
- 防重复三层保障:commit SHA 标记 + sourceKey 去重 + 项目 OKR 上下文

已验证:501 条 commits 全量分析,生成 37 个 Objectives、164 个 Key Results,
增量去重机制正常(重复调用返回 0 actions)。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 13:29:36 +08:00
zyc
7cd8bc1b9b feat: 同步全部分支的 Git 提交,不再仅限默认分支
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 46m54s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 10:57:04 +08:00
zyc
4fa69ca2bc fix: 项目明细 OKR 任务描述过长撑破栅格布局
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m8s
grid-template-columns 默认 1fr 的 min-content 下限会被超长不换行中文撑开,
改为 minmax(0,1fr) 让列可正常收缩;kr-name 改为 2 行 line-clamp,
避免单行省略把描述几乎全部隐藏。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 17:21:55 +08:00
zyc
512d3baca2 feat: 开发者可编辑项目、侧边栏项目列表优化、筛选器UI改进
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m1s
- 新增 PATCH /api/projects/:id 开发者有权限可编辑项目
- 侧边栏项目列表改用项目API直接拉取,路由切换时自动刷新
- 项目筛选器和权限分配下拉框只显示项目名称,标签自动折叠

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 15:02:42 +08:00
zyc
18e3ee18da fix: 删除项目时先清理关联数据,避免外键约束报错
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m2s
- 删除项目前清理 userProjectPermissions、projectRepos、OKR 数据
- 修复 admin 和普通路由两处删除逻辑

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 13:46:31 +08:00
zyc
17696f9049 perf: Nginx 开启 gzip 压缩和静态资源缓存,加速首次加载
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m2s
- gzip 压缩 JS/CSS/JSON 等资源(676KB→226KB)
- /assets/ 静态文件设置 1 年缓存(文件名含 hash)
- index.html 设置 no-cache 确保更新及时

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 11:43:15 +08:00
zyc
feb305c454 feat(权限): 开发者也支持项目级权限,创建项目自动获得权限
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m31s
- 开发者与观察者统一逻辑:未分配项目则无法查看数据
- 开发者创建项目时自动获得该项目的查看权限
- 管理员可在用户管理页面为开发者分配项目

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 11:33:01 +08:00
zyc
10ed4f090d feat: 项目编辑、成员编辑、性能优化及UI改进
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m2s
- 新增项目编辑功能(修改名称、标识)
- 团队成员页面增加编辑按钮(管理员可修改姓名、邮箱、角色)
- 项目详情接口性能优化:批量查询替代N+1,Git数据按仓库名过滤(8s→0.2s)
- 侧边栏和图表改为显示项目名称而非标识
- 同步日志按时间倒序排列

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 11:21:48 +08:00
zyc
4283824533 feat(权限): 观察者角色支持项目级查看权限
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m37s
- 新增 user_project_permissions 表,管理观察者可查看的项目
- 管理员可在用户管理页面为观察者分配项目权限
- 所有数据接口(总览、项目、OKR、Git活动)按分配的项目过滤
- 未分配项目的观察者看到空数据
- 同步日志按时间倒序排列

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 10:33:11 +08:00
zyc
d6dc0a882e fix: keep docker build cache, remove builder prune -a
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m19s
2026-04-13 19:39:55 +08:00
zyc
becc3b34d1 ci: test kubectl mount after runner restart
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m8s
2026-04-13 19:30:52 +08:00
zyc
7601ca5443 fix: detect mounted kubectl first, skip download
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 5s
2026-04-13 19:27:25 +08:00
zyc
c6d5a1c935 fix: use mounted kubectl, fallback to official dl.k8s.io
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
2026-04-13 19:23:46 +08:00
zyc
b8ecaf44d0 fix: add production domain to CORS whitelist
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
2026-04-13 18:52:54 +08:00
zyc
7b8f4331f6 debug: add kubeconfig diagnostics to CI
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m20s
2026-04-13 17:54:48 +08:00
zyc
8e2b1c04e7 fix: kubeconfig use base64 decode
Some checks failed
Build and Deploy / build-and-deploy (push) Failing after 3m13s
2026-04-13 17:49:14 +08:00
zyc
88f6614fd3 ci: trigger redeploy with updated kubeconfig
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
2026-04-13 17:38:00 +08:00
85 changed files with 7931 additions and 467 deletions

View File

@ -70,12 +70,14 @@ jobs:
- name: Setup Kubectl
run: |
if ! command -v kubectl &>/dev/null; then
for attempt in 1 2 3; do
curl -LO "https://files.m.daocloud.io/dl.k8s.io/release/v1.28.0/bin/linux/amd64/kubectl" && break
echo "Download attempt $attempt failed, retrying in 5s..." && sleep 5
done
chmod +x kubectl && mv kubectl /usr/bin/kubectl
if [ -f /usr/local/bin/kubectl ]; then
echo "Using mounted kubectl"
elif command -v kubectl &>/dev/null; then
echo "kubectl already in PATH"
else
echo "Downloading kubectl..."
curl -sLO "https://dl.k8s.io/release/v1.28.0/bin/linux/amd64/kubectl"
chmod +x kubectl && mv kubectl /usr/local/bin/kubectl
fi
kubectl version --client
@ -84,6 +86,9 @@ jobs:
mkdir -p $HOME/.kube
printf '%s\n' '${{ secrets.VOLCANO_INTERNAL_KUBE_CONFIG }}' > $HOME/.kube/config
chmod 600 $HOME/.kube/config
echo "kubeconfig lines: $(wc -l < $HOME/.kube/config)"
grep server $HOME/.kube/config || echo "WARNING: no server found in kubeconfig"
kubectl cluster-info 2>&1 | head -3 || true
- name: Deploy to K3s
id: deploy
@ -130,4 +135,5 @@ jobs:
run: |
docker container prune -f
docker image prune -f
docker builder prune -a -f
echo "Disk usage:"
df -h / | tail -1

421
AI-OKR-SYNC-PLAN.md Normal file
View File

@ -0,0 +1,421 @@
# AI 驱动的 OKR 自动生成与同步
## Context
DevPerf 已具备完整的 Git 同步管线Gitea → gitCommits 表)和 OKR CRUD 系统。
当前 OKR 需人工创建,缺少与 Git 提交的联动。
**目标**:每次 Git 同步后AI 分析新增 commit messages按仓库维度
- 新增 KR — commit 提到的功能点尚无对应 KR
- 更新进度 — commit 涉及已有 KR 的功能
- 标记完成 — commit 明确表示功能已完成
**分析粒度**:按仓库(每个仓库的新增 commits 做一次 AI 分析prompt 中包含提交人信息)
**OKR 归属**commit 的提交人 = KR/Objective 的负责人ownerId通过 gitCommits.userId 关联
**增量机制**:每条 commit 只分析一次,分析后打标记,后续同步不再重复处理
---
## 第一阶段Schema 变更
**文件**: `backend/src/db/schema.ts`
### 1a. 新增 `ai_analyzed_commits` 表(增量标记,防止重复分析)
```
aiAnalyzedCommits:
id varchar(50) PK
commitSha varchar(200) NOT NULL UNIQUE -- 对应 gitCommits.sha
batchId varchar(50) NOT NULL -- 一次 AI 调用的批次 ID
createdAt datetime NOT NULL
```
**核心作用**:每次同步后,只提取 `gitCommits` 中尚未出现在此表的 commit 送给 AI。分析完成后立即写入标记确保该 commit 不会被重复处理。
### 1b. objectives 表加字段
- `source` varchar(50) default `'manual'` — 值: `'manual'` | `'ai_generated'`
### 1c. keyResults 表加字段
- `source` varchar(50) default `'manual'` — 值: `'manual'` | `'ai_generated'`
- `sourceKey` varchar(300) nullable — AI 分配的功能标识符(如 `"user-login"`),用于语义去重
### 1d. keyResults.status 扩展
现有 `'active' | 'paused' | 'cancelled'`,新增 `'completed'`
字段类型是 varchar无需改 DDL只需让 `recalcObjectiveProgress` 正确处理 completed。
### 1e. syncLogs.source 枚举扩展
`mysqlEnum('source', ['plane', 'gitea'])` 改为 `['plane', 'gitea', 'ai_okr']`
---
## 第二阶段AI 配置与 LLM 客户端(豆包 Doubao
### 2a. 环境变量 (`backend/src/config.ts`)
新增:
```typescript
AI_ENABLED: z.coerce.boolean().default(false),
AI_API_KEY: z.string().default(''),
AI_MODEL: z.string().default('doubao-seed-2-0-pro-260215'),
AI_BASE_URL: z.string().default('https://ark.cn-beijing.volces.com/api/v3'),
```
**.env 示例**
```env
AI_ENABLED=true
AI_API_KEY=846b6981-9954-4c58-bb39-63079393bdb8
AI_MODEL=doubao-seed-2-0-pro-260215
AI_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
```
### 2b. 新建 `backend/src/services/llm-client.ts`
使用豆包(火山引擎 ArkAPI原生 fetch 实现,零额外依赖。
**豆包 API 调用格式**
```typescript
// POST https://ark.cn-beijing.volces.com/api/v3/chat/completions
// Header: Authorization: Bearer {AI_API_KEY}
// Header: Content-Type: application/json
export async function callLLM(systemPrompt: string, userPrompt: string): Promise<string> {
const url = `${config.AI_BASE_URL}/chat/completions`;
const body = {
model: config.AI_MODEL, // "doubao-seed-2-0-pro-260215"
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: userPrompt }
],
temperature: 0.3, // 低温度保证输出稳定
response_format: { type: "json_object" } // 强制 JSON 输出
};
const response = await fetch(url, {
method: 'POST',
headers: {
'Authorization': `Bearer ${config.AI_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(60000), // 60s 超时
});
if (!response.ok) {
throw new Error(`Doubao API error: ${response.status} ${await response.text()}`);
}
const data = await response.json();
return data.choices[0].message.content;
}
```
**要点**
- 豆包 Ark API 兼容 OpenAI `/chat/completions` 格式
- 使用 `response_format: { type: "json_object" }` 强制返回 JSON
- 超时 60s失败重试 1 次(间隔 3s
- `temperature: 0.3` 保证分析结果稳定一致
---
## 第三阶段:核心服务 — `backend/src/services/okr-ai-sync.ts`
### 整体流程
```
analyzeCommitsForOKR()
├─ 1. gatherUnanalyzedCommits()
│ SELECT gc.* FROM git_commits gc
│ LEFT JOIN ai_analyzed_commits aac ON gc.sha = aac.commit_sha
│ WHERE aac.id IS NULL
│ → 按 repoName 分组(每个仓库一批)
├─ 2. 遍历每个仓库的未分析 commits
│ a. 通过 projectRepos 找到该仓库关联的 projectId
│ b. 获取该项目当前所有 Objectives + KRs含 sourceKey
│ c. 构建 AI prompt该仓库的新 commits含每条 commit 的提交人信息 + 项目现有 OKR
│ d. 调用豆包 AI → 解析 JSON 响应
│ e. 执行 AI 返回的 actions
│ - create_objective → 创建时 ownerId = commit 提交人的 userIdAI 返回 authorUserId
│ - create_kr → 先查 sourceKey 防重 → 不存在才创建,归属到对应提交人
│ - update_progress → 更新 currentValue + 写 krLog
│ - complete_kr → status='completed' + 写 krLog
│ f. 重算 Objective progress
│ g. 标记这批 commits 为已分析(写入 aiAnalyzedCommits
└─ 3. 写入 syncLogs (source='ai_okr')
```
### 增量更新机制(核心)
```
第一次同步:
gitCommits: [A, B, C, D, E]
aiAnalyzedCommits: []
→ 未分析: [A, B, C, D, E] → 全部送 AI → 分析后标记 [A, B, C, D, E]
第二次同步(新增了 F, G
gitCommits: [A, B, C, D, E, F, G]
aiAnalyzedCommits: [A, B, C, D, E]
→ 未分析: [F, G] → 只送 F, G 给 AI → 分析后标记 [F, G]
第三次同步(无新增):
→ 未分析: [] → 跳过,不调用 AI
```
### AI Prompt 设计
**System Prompt**:
```
你是一个开发团队的 OKR 管理助手。你的任务是分析 git commit 记录,管理项目的 OKR目标与关键成果
你需要分析提交记录,输出 JSON 格式的操作指令:
判断逻辑:
- commit 包含 "完成"、"done"、"finished"、"实现完毕" 等完成语义 → 对应 KR 标记完成
- commit 是 feat/feature 类型但未完成 → 更新对应 KR 进度(根据描述估算百分比)
- commit 涉及的功能在现有 KR 中不存在 → 创建新 KR需同时提供时间节点
- fix/refactor/chore/docs 类 commit → 只在涉及明确功能时才操作
- 不要为同一个功能创建重复的 KR已有相同 sourceKey 的直接更新
新建 Objective 规则:
- 如果提交涉及的功能没有归属到任何已有 Objective → 可以创建新 Objective
- Objective 的 startDate 和 endDate 由你根据提交内容和功能复杂度判断
- period 格式为 "YYYY-Qn"(如 "2026-Q2"),根据 endDate 推算
新建 KR 规则:
- targetValue = 100, unit = "%"
- 根据 commit 语义估算 currentValue初步实现=30, 基本完成=70, 完成=100
- sourceKey 用小写英文短横线格式(如 "user-login", "payment-module"
- title 用中文描述
- startDate 和 endDate 由你根据功能复杂度和提交时间判断
```
**User Prompt 模板**:
```
仓库:{{repoName}}
所属项目:{{projectName}}
当前日期:{{today}}
该项目已有的 OKR
{{#each existingObjectives}}
Objective: id={{id}}, title="{{title}}", period={{period}}, 时间={{startDate}}~{{endDate}}, 进度={{progress}}%
Key Results:
{{#each keyResults}}
- id: {{id}}, title: "{{title}}", sourceKey: "{{sourceKey}}", status: {{status}}, 进度: {{currentValue}}/{{targetValue}}, 时间: {{startDate}}~{{endDate}}
{{/each}}
{{/each}}
{{如果没有已有 OKR: "该项目暂无 OKR 记录。"}}
该仓库的开发人员commit 提交人 → 系统用户映射):
{{#each authors}}
- userId: "{{userId}}", 姓名: {{displayName}}
{{/each}}
该仓库新增的提交记录(按时间排序,均为增量,之前的已处理过):
{{#each commits}}
- [{{committedAt}}] 提交人: {{authorName}}(userId={{userId}}) {{sha前7位}}: {{message}} (+{{additions}}/-{{deletions}})
{{/each}}
请分析以上提交,返回 JSON。注意每个 action 都必须带 ownerId 字段,值为提交人的 userId表示该 OKR 归谁负责。
{
"actions": [
{
"type": "create_objective",
"ownerId": "提交人的userId",
"title": "目标标题",
"startDate": "YYYY-MM-DD",
"endDate": "YYYY-MM-DD",
"reasoning": "为什么要创建这个目标",
"keyResults": [
{ "title": "...", "sourceKey": "...", "currentValue": number, "startDate": "YYYY-MM-DD", "endDate": "YYYY-MM-DD" }
]
},
{
"type": "create_kr",
"ownerId": "提交人的userId",
"objectiveId": "已有目标的id",
"title": "...",
"sourceKey": "...",
"currentValue": number,
"startDate": "YYYY-MM-DD",
"endDate": "YYYY-MM-DD",
"reasoning": "..."
},
{
"type": "update_progress",
"krId": "已有KR的id",
"newCurrentValue": number,
"reasoning": "..."
},
{
"type": "complete_kr",
"krId": "已有KR的id",
"reasoning": "..."
}
],
"summary": "一句话总结该仓库近期开发动态"
}
如果没有需要操作的内容,返回 {"actions": [], "summary": "..."}
```
### 防重复策略(三层保障)
| 层级 | 机制 | 说明 |
|------|------|------|
| **Commit 级** | `aiAnalyzedCommits` 表 | 已分析的 SHA 打标记,下次同步时过滤掉,不再送给 AI |
| **KR 级** | `sourceKey` 唯一性检查 | 创建前查 DB同 Objective 下同 sourceKey 只允许一条,重复则转为 update |
| **Objective 级** | 项目下已有 OKR 传入 prompt | AI 看到完整上下文,避免建议创建重复目标 |
### OKR 负责人归属
- commit 有 `userId`(通过 author-matching 映射到系统用户)
- AI prompt 中包含每条 commit 的提交人信息userId + displayName
- AI 返回 action 时必须携带 `ownerId`,即该 OKR 归哪个提交人负责
- 执行时:`create_objective``ownerId` = AI 指定的 userId`create_kr` 同理
- 跳过 `userId` 为 null 的 commits作者未映射到系统用户
**示例**:仓库 rtc_backend 有张三、李四两个人提交代码
- 张三的 commit 涉及"用户登录" → AI 创建 KR 归属张三
- 李四的 commit 涉及"支付模块" → AI 创建 KR 归属李四
- 同一个 Objective 下可以有不同负责人的 KR
### 时间节点(由 AI 判断)
- **不再固定取当前季度**,而是由 AI 根据以下信息决定:
- commit 的提交时间
- 功能的复杂度(从 commit 内容推断)
- 已有 OKR 的时间范围(避免冲突)
- AI 返回的 `startDate``endDate` 直接写入 Objective 和 KR
- `period` 根据 endDate 自动推算(复用已有 `dateToPeriod()`
- Objective 创建后,其日期范围由 AI 决定,后续也可由 AI 在新的分析中调整
### 完成判定
AI 返回 `complete_kr` action 时:
1. `currentValue = targetValue`(即 100
2. `status = 'completed'`
3. 写 krLog`action='completed'`, `detail='AI 根据 commit {sha} 判定完成:{reasoning}'`
4. `recalcObjectiveProgress()` 时 completed 的 KR 按 100% 计入
---
## 第四阶段:集成到同步流程
### 4a. 修改 `backend/src/sync/scheduler.ts`
```typescript
import { analyzeCommitsForOKR } from '../services/okr-ai-sync';
// 在 syncGitea() 之后调用
giteaJob = new Cron('0 2,19 * * *', async () => {
await syncGitea();
if (config.AI_ENABLED) {
console.info('[SCHEDULER] AI OKR 分析开始...');
await analyzeCommitsForOKR().catch(e =>
console.error('[SCHEDULER] AI OKR analysis failed:', e)
);
}
});
```
### 4b. 新增 API 端点 (`backend/src/routes/okr.ts`)
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/api/okr/ai-analyze` | 手动触发 AI 分析admin/manager |
| POST | `/api/okr/ai-analyze/preview` | 预览模式:返回 AI 建议但不执行 |
### 4c. 修改 `backend/src/services/okr.ts`
- 导出 `dateToPeriod()` 函数供 okr-ai-sync 使用
- `recalcObjectiveProgress()` 增加对 `status === 'completed'` 的处理(按 100% 计入进度)
---
## 第五阶段DB 迁移
新增迁移文件 `backend/drizzle/0002_add_ai_okr_fields.sql`
```sql
-- 新表:增量标记(已分析过的 commit 不再重复处理)
CREATE TABLE ai_analyzed_commits (
id VARCHAR(50) PRIMARY KEY,
commit_sha VARCHAR(200) NOT NULL,
batch_id VARCHAR(50) NOT NULL,
created_at DATETIME NOT NULL,
UNIQUE INDEX uniq_analyzed_sha (commit_sha)
);
-- objectives 加字段
ALTER TABLE objectives ADD COLUMN source VARCHAR(50) DEFAULT 'manual';
-- key_results 加字段
ALTER TABLE key_results ADD COLUMN source VARCHAR(50) DEFAULT 'manual';
ALTER TABLE key_results ADD COLUMN source_key VARCHAR(300) NULL;
-- syncLogs source 枚举扩展
ALTER TABLE sync_logs MODIFY COLUMN source ENUM('plane', 'gitea', 'ai_okr') NOT NULL;
```
---
## 关键文件清单
| 文件 | 操作 | 说明 |
|------|------|------|
| `backend/src/db/schema.ts` | 修改 | 新表 + 新字段 |
| `backend/src/config.ts` | 修改 | 豆包 AI 环境变量 |
| `backend/src/services/llm-client.ts` | **新建** | 豆包 API 调用封装 |
| `backend/src/services/okr-ai-sync.ts` | **新建** | 核心 AI 分析 + 增量更新逻辑 |
| `backend/src/services/okr.ts` | 修改 | 导出 dateToPeriod, completed 状态处理 |
| `backend/src/sync/scheduler.ts` | 修改 | 同步后触发 AI 分析 |
| `backend/src/routes/okr.ts` | 修改 | 新增手动触发端点 |
| `backend/drizzle/0002_*.sql` | **新建** | DB 迁移 |
---
## 实施顺序
1. Schema 变更 + 迁移文件(第一、五阶段)
2. config.ts 加豆包 AI 环境变量(第二阶段 2a
3. llm-client.ts — 豆包 API 封装(第二阶段 2b
4. okr.ts 小改(导出 dateToPeriod + completed 处理)
5. okr-ai-sync.ts 核心服务(第三阶段)
6. scheduler.ts 集成 + 新 API 端点(第四阶段)
7. 端到端测试
---
## 验证方案
1. 配置 `.env``AI_ENABLED=true``AI_API_KEY=...``AI_MODEL=doubao-seed-2-0-pro-260215`
2. 确保数据库中已有项目、用户、项目-仓库绑定、git commits 数据
3. 调用 `POST /api/okr/ai-analyze/preview` 查看 AI 返回的 actions不写库
4. 确认 actions 合理后,调用 `POST /api/okr/ai-analyze` 执行
5. 通过 `GET /api/okr` 验证 OKR 数据已生成,时间节点由 AI 合理设定
6. 再次调用 `POST /api/okr/ai-analyze`,确认不会重复生成(增量标记生效)
7. 手动触发 Git 同步拉到新 commits再次分析确认只处理新增 commits
8. 检查 krLogs 中有 AI 操作记录
9. 前端 OKR 页面查看显示是否正常
---
## 错误处理
| 场景 | 处理 |
|------|------|
| AI_ENABLED=false 或 API key 为空 | 静默跳过,不影响 Git 同步 |
| 豆包 API 返回非 JSON | 尝试用正则提取 ```json 块,失败则记 syncLogs error |
| 豆包 API 超时60s | 记错误日志commits 保持"未分析"状态,下次同步重试 |
| AI 建议更新不存在的 KR | 跳过该 action记 warning |
| AI 建议创建重复 sourceKey 的 KR | 转为 update_progress |
| commit 的 repo 没有绑定到任何项目 | 跳过该仓库的分析 |
| 某个仓库无新增 commits | 跳过,不调用 AI |

View File

@ -6,7 +6,7 @@
"name": "devperf-dashboard-backend",
"dependencies": {
"@hono/zod-validator": "^0.4.0",
"bcrypt": "^5.1.1",
"bcryptjs": "^2.4.3",
"better-sqlite3": "^11.7.0",
"croner": "^9.0.0",
"dayjs": "^1.11.13",
@ -18,7 +18,7 @@
"zod": "^3.24.0",
},
"devDependencies": {
"@types/bcrypt": "^5.0.2",
"@types/bcryptjs": "^2.4.6",
"@types/better-sqlite3": "^7.6.12",
"@types/uuid": "^10.0.0",
"bun-types": "^1.3.11",
@ -82,9 +82,7 @@
"@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/bcryptjs": ["@types/bcryptjs@2.4.6", "", {}, "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ=="],
"@types/better-sqlite3": ["@types/better-sqlite3@7.6.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA=="],
@ -92,23 +90,11 @@
"@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=="],
"aws-ssl-profiles": ["aws-ssl-profiles@1.1.2", "", {}, "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="],
"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=="],
"bcryptjs": ["bcryptjs@2.4.3", "", {}, "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ=="],
"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=="],
@ -116,21 +102,13 @@
"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=="],
"chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="],
"croner": ["croner@9.1.0", "", {}, "sha512-p9nwwR4qyT5W996vBZhdvBCnMhicY5ytZkR4D1Xj0wuTDEiMnjwR57Q3RXYY/s0EpX6Ay3vgIcfaR+ewGHsi+g=="],
@ -142,8 +120,6 @@
"deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="],
"delegates": ["delegates@1.0.0", "", {}, "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ=="],
"denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
@ -152,8 +128,6 @@
"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=="],
@ -166,38 +140,22 @@
"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=="],
"generate-function": ["generate-function@2.3.1", "", { "dependencies": { "is-property": "^1.0.2" } }, "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ=="],
"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=="],
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
"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=="],
"is-property": ["is-property@1.0.2", "", {}, "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g=="],
"jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="],
@ -206,20 +164,10 @@
"lru.min": ["lru.min@1.1.4", "", {}, "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA=="],
"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=="],
@ -232,20 +180,8 @@
"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=="],
@ -256,18 +192,12 @@
"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=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"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=="],
@ -278,22 +208,14 @@
"sql-escaper": ["sql-escaper@1.3.3", "", {}, "sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw=="],
"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=="],
@ -304,28 +226,12 @@
"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=="],

View File

@ -0,0 +1,14 @@
CREATE TABLE IF NOT EXISTS `user_project_permissions` (
`id` varchar(50) NOT NULL PRIMARY KEY,
`user_id` varchar(50) NOT NULL,
`project_id` varchar(50) NOT NULL,
`created_at` datetime NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE
);
--> statement-breakpoint
CREATE INDEX `idx_upp_user` ON `user_project_permissions` (`user_id`);
--> statement-breakpoint
CREATE INDEX `idx_upp_project` ON `user_project_permissions` (`project_id`);
--> statement-breakpoint
CREATE UNIQUE INDEX `uniq_upp_user_project` ON `user_project_permissions` (`user_id`, `project_id`);

View File

@ -0,0 +1,20 @@
-- AI Analyzed Commits 表(增量标记,防止重复分析)
CREATE TABLE IF NOT EXISTS `ai_analyzed_commits` (
`id` varchar(50) NOT NULL PRIMARY KEY,
`commit_sha` varchar(200) NOT NULL,
`batch_id` varchar(50) NOT NULL,
`created_at` datetime NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `uniq_analyzed_sha` ON `ai_analyzed_commits` (`commit_sha`);
--> statement-breakpoint
-- objectives 表加 source 字段
ALTER TABLE `objectives` ADD COLUMN `source` varchar(50) DEFAULT 'manual';
--> statement-breakpoint
-- key_results 表加 source 和 source_key 字段
ALTER TABLE `key_results` ADD COLUMN `source` varchar(50) DEFAULT 'manual';
--> statement-breakpoint
ALTER TABLE `key_results` ADD COLUMN `source_key` varchar(300) NULL;
--> statement-breakpoint
-- sync_logs source 枚举扩展
ALTER TABLE `sync_logs` MODIFY COLUMN `source` enum('plane','gitea','ai_okr') NOT NULL;

View File

@ -0,0 +1,122 @@
-- ROI 动态规则引擎(v2.0) — 事件流模型
-- 包含: projects 扩展字段、roi_strategies、project_cost_events、project_revenue_events、
-- project_revenue_mapping、unmapped_revenue_events、sync_logs.source 枚举扩展
-- ── 1. 扩展 projects 表 ──
ALTER TABLE `projects` ADD COLUMN `category` enum('cash_cow','efficiency_tool','moat','composite') NULL;
--> statement-breakpoint
ALTER TABLE `projects` ADD COLUMN `composite_strategies` json NULL;
--> statement-breakpoint
ALTER TABLE `projects` ADD COLUMN `owner_id` varchar(50) NULL;
--> statement-breakpoint
ALTER TABLE `projects` ADD COLUMN `tags` json NULL;
--> statement-breakpoint
ALTER TABLE `projects` ADD COLUMN `launched_at` datetime NULL;
--> statement-breakpoint
ALTER TABLE `projects` ADD COLUMN `v_asset` double NULL;
--> statement-breakpoint
CREATE INDEX `idx_projects_category` ON `projects` (`category`);
--> statement-breakpoint
-- ── 2. 扩展 sync_logs.source 枚举 ──
ALTER TABLE `sync_logs` MODIFY COLUMN `source` enum('plane','gitea','ai_okr','roi_cost_ingest','roi_revenue_ingest','roi_amortizer','roi_ai_driver') NOT NULL;
--> statement-breakpoint
-- ── 3. roi_strategies ──
CREATE TABLE IF NOT EXISTS `roi_strategies` (
`id` varchar(50) NOT NULL PRIMARY KEY,
`category` enum('cash_cow','efficiency_tool','moat','composite') NOT NULL,
`name` varchar(200) NOT NULL,
`formula_key` varchar(100) NOT NULL,
`params` json NOT NULL,
`updated_at` datetime NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `uniq_roi_strategy_category` ON `roi_strategies` (`category`);
--> statement-breakpoint
-- ── 4. project_cost_events ──
CREATE TABLE IF NOT EXISTS `project_cost_events` (
`id` varchar(50) NOT NULL PRIMARY KEY,
`project_id` varchar(50) NOT NULL,
`event_date` datetime NOT NULL,
`cost_type` enum('dev_hours','hardware_bom','service_fee','amortization','other') NOT NULL,
`amount` double NOT NULL,
`hours` double NULL,
`hourly_rate_used` double NULL,
`data_source` enum('auto_commits','auto_tasks','plane_actual','manual','amortization_cron') NOT NULL,
`ref_type` varchar(50) NULL,
`ref_id` varchar(200) NULL,
`notes` text NULL,
`created_by` varchar(50) NULL,
`created_at` datetime NOT NULL
);
--> statement-breakpoint
CREATE INDEX `idx_cost_events_project_date` ON `project_cost_events` (`project_id`, `event_date`);
--> statement-breakpoint
CREATE UNIQUE INDEX `uniq_cost_events_ref` ON `project_cost_events` (`project_id`, `ref_type`, `ref_id`);
--> statement-breakpoint
-- ── 5. project_revenue_events ──
CREATE TABLE IF NOT EXISTS `project_revenue_events` (
`id` varchar(50) NOT NULL PRIMARY KEY,
`project_id` varchar(50) NOT NULL,
`event_date` datetime NOT NULL,
`revenue_type` enum('direct_revenue','subscription','saved_cost','asset_value_add','refund','other') NOT NULL,
`amount` double NOT NULL,
`data_source` enum('api_pulled','manual','calculated','mock') NOT NULL,
`ref_type` varchar(50) NULL,
`ref_id` varchar(200) NULL,
`channel` varchar(50) NULL,
`notes` text NULL,
`created_by` varchar(50) NULL,
`created_at` datetime NOT NULL
);
--> statement-breakpoint
CREATE INDEX `idx_revenue_events_project_date` ON `project_revenue_events` (`project_id`, `event_date`);
--> statement-breakpoint
CREATE UNIQUE INDEX `uniq_revenue_events_ref` ON `project_revenue_events` (`project_id`, `ref_type`, `ref_id`);
--> statement-breakpoint
-- ── 6. project_revenue_mapping ──
CREATE TABLE IF NOT EXISTS `project_revenue_mapping` (
`id` varchar(50) NOT NULL PRIMARY KEY,
`project_id` varchar(50) NOT NULL,
`business_project_key` varchar(100) NOT NULL,
`enabled` int NULL DEFAULT 1,
`notes` text NULL,
`created_at` datetime NOT NULL,
`updated_at` datetime NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `uniq_revenue_mapping_business_key` ON `project_revenue_mapping` (`business_project_key`);
--> statement-breakpoint
CREATE INDEX `idx_revenue_mapping_project` ON `project_revenue_mapping` (`project_id`);
--> statement-breakpoint
-- ── 7. unmapped_revenue_events ──
CREATE TABLE IF NOT EXISTS `unmapped_revenue_events` (
`id` varchar(50) NOT NULL PRIMARY KEY,
`external_id` varchar(200) NOT NULL,
`business_project_key` varchar(100) NOT NULL,
`event_date` datetime NOT NULL,
`amount` double NOT NULL,
`revenue_type` varchar(50) NULL,
`channel` varchar(50) NULL,
`raw_payload` json NULL,
`status` enum('pending','resolved','ignored') NULL DEFAULT 'pending',
`resolved_event_id` varchar(50) NULL,
`created_at` datetime NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `uniq_unmapped_external_id` ON `unmapped_revenue_events` (`external_id`);
--> statement-breakpoint
CREATE INDEX `idx_unmapped_status` ON `unmapped_revenue_events` (`status`);
--> statement-breakpoint
-- ── 8. seed: 4 套默认策略参数 ──
INSERT INTO `roi_strategies` (`id`, `category`, `name`, `formula_key`, `params`, `updated_at`) VALUES
('strat-cash-cow', 'cash_cow', '现金牛', 'cash_cow', '{"hourlyRate":400,"commitHourCoef":0.5,"taskHourCoef":6}', NOW()),
('strat-efficiency-tool', 'efficiency_tool', '效能工具', 'efficiency_tool', '{"hourlyRate":400,"commitHourCoef":0.5,"taskHourCoef":6}', NOW()),
('strat-moat', 'moat', '资本护城河', 'moat', '{"hourlyRate":400,"amortYears":3,"commitHourCoef":0.5,"taskHourCoef":6}', NOW()),
('strat-composite', 'composite', '复合型', 'composite', '{"hourlyRate":400,"amortYears":3,"commitHourCoef":0.5,"taskHourCoef":6}', NOW());

View File

@ -0,0 +1,12 @@
-- ROI 引擎 AI 驱动因子文案表(月度快照)
CREATE TABLE IF NOT EXISTS `roi_driver_factors` (
`id` varchar(50) NOT NULL PRIMARY KEY,
`project_id` varchar(50) NOT NULL,
`period_key` varchar(20) NOT NULL,
`factors` json NOT NULL,
`context` json NULL,
`generated_at` datetime NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `uniq_driver_factors_project_period` ON `roi_driver_factors` (`project_id`, `period_key`);

View File

@ -0,0 +1,16 @@
-- 业务体系归属字段:bizSystem + projectType + planeIdentifier 备份
ALTER TABLE `projects` ADD COLUMN `biz_system` enum('airhubs','airflow','aircore') NULL;
--> statement-breakpoint
ALTER TABLE `projects` ADD COLUMN `project_type` enum('hardware','software') NULL;
--> statement-breakpoint
ALTER TABLE `projects` ADD COLUMN `plane_identifier` varchar(200) NULL;
--> statement-breakpoint
-- 把现有 identifier 一次性备份到 plane_identifier(以防 AI 覆盖后丢失)
UPDATE `projects` SET `plane_identifier` = `identifier` WHERE `plane_identifier` IS NULL;
--> statement-breakpoint
CREATE INDEX `idx_projects_biz_system` ON `projects` (`biz_system`);
--> statement-breakpoint
CREATE INDEX `idx_projects_project_type` ON `projects` (`project_type`);

View File

@ -8,6 +8,41 @@
"when": 1775707049155,
"tag": "0000_grey_anita_blake",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1775707049200,
"tag": "0001_add_user_project_permissions",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1777430400000,
"tag": "0002_add_ai_okr_fields",
"breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1779494400000,
"tag": "0003_add_roi_engine",
"breakpoints": true
},
{
"idx": 4,
"version": "6",
"when": 1779580800000,
"tag": "0004_add_roi_driver_factors",
"breakpoints": true
},
{
"idx": 5,
"version": "6",
"when": 1779667200000,
"tag": "0005_add_biz_system",
"breakpoints": true
}
]
}

View File

@ -14,7 +14,7 @@
},
"dependencies": {
"@hono/zod-validator": "^0.4.0",
"bcrypt": "^5.1.1",
"bcryptjs": "^2.4.3",
"better-sqlite3": "^11.7.0",
"croner": "^9.0.0",
"dayjs": "^1.11.13",
@ -26,7 +26,7 @@
"zod": "^3.24.0"
},
"devDependencies": {
"@types/bcrypt": "^5.0.2",
"@types/bcryptjs": "^2.4.6",
"@types/better-sqlite3": "^7.6.12",
"@types/uuid": "^10.0.0",
"bun-types": "^1.3.11",

View File

@ -0,0 +1,67 @@
/**
* 一次性脚本:对所有项目跑 AI (category + bizSystem + projectType),
* identifier(airhubs-hw-001 )+ mapping
*
* :
* bun run scripts/ai-classify-all.ts #
* bun run scripts/ai-classify-all.ts --force # ()
*/
import dayjs from 'dayjs';
import { eq } from 'drizzle-orm';
import { db } from '../src/db/index';
import { projects } from '../src/db/schema';
import { suggestProjectTag } from '../src/services/roi/ai-tag-suggester';
import { applyAutoIdentifier } from '../src/services/roi/identifier-generator';
const force = process.argv.includes('--force');
const all = await db.select().from(projects);
console.log(`Total projects: ${all.length}, force=${force}`);
let okCount = 0, skipCount = 0, failCount = 0;
const startedAt = Date.now();
for (const p of all) {
const label = `${p.planeIdentifier || p.identifier || '?'} (${p.name})`;
const alreadyFull = p.category && p.bizSystem && p.projectType;
if (!force && alreadyFull) {
console.log(` ⊘ SKIP ${label} — fully classified (${p.bizSystem}/${p.projectType}/${p.category})`);
skipCount += 1;
continue;
}
try {
console.log(` → AI ${label} ...`);
const sug = await suggestProjectTag(p.id);
const launchedAt = p.launchedAt ?? p.createdAt ?? dayjs().subtract(90, 'day').toDate();
const needsAsset = sug.suggestedCategory === 'moat';
const vAsset = needsAsset ? (p.vAsset ?? 100_000) : (p.vAsset ?? null);
// 1. 更新分类字段
await db.update(projects).set({
category: sug.suggestedCategory,
launchedAt: launchedAt as any,
vAsset: vAsset,
updatedAt: new Date(),
}).where(eq(projects.id, p.id));
// 2. 自动生成新 identifier + 同步 mapping
const newId = await applyAutoIdentifier(p.id, sug.suggestedBizSystem, sug.suggestedProjectType);
console.log(`${newId} | ${sug.suggestedBizSystem}/${sug.suggestedProjectType}/${sug.suggestedCategory} conf=${sug.confidence}`);
console.log(`${sug.reasoning.slice(0, 80)}`);
okCount += 1;
} catch (e) {
console.error(` ✗ FAIL ${label}: ${(e as Error).message.slice(0, 200)}`);
failCount += 1;
}
await new Promise(r => setTimeout(r, 1000));
}
const elapsed = ((Date.now() - startedAt) / 1000).toFixed(1);
console.log('');
console.log(`Done. ok=${okCount} skipped=${skipCount} failed=${failCount} elapsed=${elapsed}s`);
process.exit(0);

View File

@ -0,0 +1,60 @@
/**
* 一次性脚本: AI + category
* ()
* - 默认立项日:取项目 created_at() today-90
* - V_asset,AI moat 100,000(,)
* 用法: bun run scripts/ai-tag-all.ts [--force]
*/
import dayjs from 'dayjs';
import { eq } from 'drizzle-orm';
import { db } from '../src/db/index';
import { projects } from '../src/db/schema';
import { suggestProjectTag } from '../src/services/roi/ai-tag-suggester';
const force = process.argv.includes('--force');
const all = await db.select().from(projects);
console.log(`Total projects: ${all.length}, force=${force}`);
let okCount = 0, skipCount = 0, failCount = 0;
const startedAt = Date.now();
for (const p of all) {
const label = `${p.identifier || '?'} (${p.name})`;
if (!force && p.category) {
console.log(` ⊘ SKIP ${label} — already tagged as ${p.category}`);
skipCount += 1;
continue;
}
try {
console.log(` → AI ${label} ...`);
const sug = await suggestProjectTag(p.id);
const launchedAt = p.launchedAt ?? p.createdAt ?? dayjs().subtract(90, 'day').toDate();
// 护城河默认 V_asset 占位
const needsAsset = sug.suggestedCategory === 'moat';
const vAsset = needsAsset ? (p.vAsset ?? 100_000) : (p.vAsset ?? null);
await db.update(projects).set({
category: sug.suggestedCategory,
launchedAt: launchedAt as any,
vAsset: vAsset,
updatedAt: new Date(),
}).where(eq(projects.id, p.id));
console.log(`${sug.suggestedCategory} (conf=${sug.confidence}) — ${sug.reasoning.slice(0, 60)}`);
okCount += 1;
} catch (e) {
console.error(` ✗ FAIL ${label}: ${(e as Error).message.slice(0, 200)}`);
failCount += 1;
}
// 1 秒间隔避免 LLM 限流
await new Promise(r => setTimeout(r, 1000));
}
const elapsed = ((Date.now() - startedAt) / 1000).toFixed(1);
console.log('');
console.log(`Done. ok=${okCount} skipped=${skipCount} failed=${failCount} elapsed=${elapsed}s`);
process.exit(0);

View File

@ -0,0 +1,15 @@
/**
* 一次性脚本:回填过去 N cost_events( commits/tasks)
* 用法: bun run scripts/backfill-cost-events.ts [days=60]
*/
import dayjs from 'dayjs';
import { runCostEventIngest } from '../src/services/roi/cost-ingest';
const days = Number(process.argv[2] || 60);
const from = dayjs().subtract(days, 'day').startOf('day').toDate();
const to = dayjs().endOf('day').toDate();
console.log(`Backfilling cost events from ${from.toISOString()} to ${to.toISOString()}...`);
await runCostEventIngest({ from, to });
console.log('Done.');
process.exit(0);

View File

@ -0,0 +1,66 @@
/**
* 一次性脚本:把所有项目的 launchedAt commit
* repo / repo commit , 2026-01-01
*
* 用法:bun run scripts/backfill-launched-at.ts
*/
import { asc, eq, inArray } from 'drizzle-orm';
import { db } from '../src/db/index';
import { projects, projectRepos, gitCommits } from '../src/db/schema';
const DEFAULT_DATE = new Date('2026-01-01T00:00:00+08:00');
/** 抹除 .git 后缀和 URL 前缀,只保留仓库名 */
function normalizeRepoName(raw: string): string {
let cleaned = raw.trim().replace(/\.git$/, '');
if (cleaned.includes('://')) {
try {
const parts = new URL(cleaned).pathname.split('/').filter(Boolean);
return parts[parts.length - 1] || cleaned;
} catch { /* fallthrough */ }
}
if (cleaned.includes('/')) return cleaned.split('/').pop() || cleaned;
return cleaned;
}
const all = await db.select().from(projects);
console.log(`Total projects: ${all.length}`);
let withCommitsCount = 0, fallbackCount = 0;
for (const p of all) {
const repos = await db.select().from(projectRepos).where(eq(projectRepos.projectId, p.id));
const repoNames = repos.map(r => normalizeRepoName(r.repoName));
let launchedAt = DEFAULT_DATE;
let source = 'default-2026-01-01';
if (repoNames.length > 0) {
const earliest = await db.select({ committedAt: gitCommits.committedAt, repoName: gitCommits.repoName, sha: gitCommits.sha })
.from(gitCommits)
.where(inArray(gitCommits.repoName, repoNames))
.orderBy(asc(gitCommits.committedAt))
.limit(1);
if (earliest.length > 0 && earliest[0].committedAt) {
launchedAt = earliest[0].committedAt instanceof Date ? earliest[0].committedAt : new Date(earliest[0].committedAt);
source = `first commit ${earliest[0].repoName}/${earliest[0].sha?.slice(0, 7)}`;
withCommitsCount += 1;
} else {
fallbackCount += 1;
}
} else {
fallbackCount += 1;
}
await db.update(projects).set({
launchedAt,
updatedAt: new Date(),
}).where(eq(projects.id, p.id));
const label = `${p.identifier || p.id} (${p.name})`;
console.log(` ${label.padEnd(50)}${launchedAt.toISOString().slice(0, 10)} [${source}]`);
}
console.log('');
console.log(`Done. with-commits=${withCommitsCount} fallback=${fallbackCount}`);
process.exit(0);

View File

@ -0,0 +1,44 @@
/**
* 一次性脚本: mock API N
* seed project_revenue_mapping(identifier businessProjectKey)
* 用法: bun run scripts/backfill-revenue.ts [days=60]
*/
import { v4 as uuid } from 'uuid';
import dayjs from 'dayjs';
import { eq, inArray } from 'drizzle-orm';
import { db } from '../src/db/index';
import { projects, projectRevenueMapping } from '../src/db/schema';
import { runRevenueIngest } from '../src/services/roi/revenue-ingest';
const days = Number(process.argv[2] || 60);
// 1. seed mapping: identifier → projectId(没有就建)
const all = await db.select().from(projects);
const existing = await db.select().from(projectRevenueMapping);
const existingKeys = new Set(existing.map(m => m.businessProjectKey));
const toInsert = all
.filter(p => p.identifier && !existingKeys.has(p.identifier))
.map(p => ({
id: uuid(),
projectId: p.id,
businessProjectKey: p.identifier!,
enabled: 1,
notes: 'auto-seeded',
createdAt: new Date(),
updatedAt: new Date(),
}));
if (toInsert.length > 0) {
await db.insert(projectRevenueMapping).values(toInsert);
console.log(`Seeded ${toInsert.length} project mappings`);
}
// 2. 逐日拉 mock 数据
for (let i = days; i >= 0; i--) {
const date = dayjs().subtract(i, 'day').format('YYYY-MM-DD');
await runRevenueIngest(date);
if (i % 10 === 0) console.log(`${date}`);
}
console.log('Done.');
process.exit(0);

View File

@ -58,12 +58,22 @@ export async function getRepos(): Promise<GiteaRepo[]> {
return Array.isArray(data) ? data : [];
}
export async function getCommits(owner: string, repo: string, since?: string): Promise<GiteaCommit[]> {
export interface GiteaBranch {
name: string;
}
export async function getBranches(owner: string, repo: string): Promise<GiteaBranch[]> {
const data = await giteaGet<GiteaBranch[]>(`/repos/${owner}/${repo}/branches?limit=50`);
return Array.isArray(data) ? data : [];
}
export async function getCommits(owner: string, repo: string, since?: string, branch?: 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}`;
if (branch) path += `&sha=${encodeURIComponent(branch)}`;
const data = await giteaGet<GiteaCommit[]>(path);
if (!Array.isArray(data) || data.length === 0) break;
all.push(...data);

View File

@ -1,5 +1,12 @@
import { z } from 'zod';
// z.coerce.boolean() 把任何非空字符串当 true(包括"false"),用 preprocess 修正
const envBool = (defaultValue: boolean) =>
z.preprocess((v) => {
if (typeof v !== 'string') return v;
return ['true', '1', 'yes', 'on'].includes(v.toLowerCase());
}, z.boolean()).default(defaultValue);
const envSchema = z.object({
JWT_SECRET: z.string().min(16, 'JWT_SECRET must be at least 16 characters'),
PORT: z.coerce.number().default(3200),
@ -23,6 +30,17 @@ const envSchema = z.object({
ADMIN_EMAIL: z.string().email().default('admin@jasonqiyuan.com'),
ADMIN_PASSWORD: z.string().min(6).default('Admin123!'),
// AI (豆包 Doubao / 火山引擎 Ark)
AI_ENABLED: envBool(false),
AI_API_KEY: z.string().default(''),
AI_MODEL: z.string().default('doubao-seed-2-0-pro-260215'),
AI_BASE_URL: z.string().default('https://ark.cn-beijing.volces.com/api/v3'),
// ROI 外部营收 API
MOCK_REVENUE_API: envBool(false),
REVENUE_API_BASE_URL: z.string().default('http://localhost:3200/mock'),
REVENUE_API_KEY: z.string().default('mock-dev-key-12345'),
});
function loadConfig() {

View File

@ -35,10 +35,21 @@ export const projects = mysqlTable('projects', {
name: varchar('name', { length: 200 }).notNull(),
identifier: varchar('identifier', { length: 200 }),
lastSyncedAt: datetime('last_synced_at'),
// ── ROI 引擎字段 ──
category: mysqlEnum('category', ['cash_cow', 'efficiency_tool', 'moat', 'composite']),
compositeStrategies: json('composite_strategies'), // 复合型时勾选的子策略列表
bizSystem: mysqlEnum('biz_system', ['airhubs', 'airflow', 'aircore']), // 三大业务体系归属
projectType: mysqlEnum('project_type', ['hardware', 'software']), // 硬件/软件
planeIdentifier: varchar('plane_identifier', { length: 200 }), // 原始 Plane identifier 备份
ownerId: varchar('owner_id', { length: 50 }).references(() => users.id),
tags: json('tags'),
launchedAt: datetime('launched_at'), // 立项日 — 累计 ROI 起算点
vAsset: double('v_asset'), // 资本护城河的技术资产估值(元)
createdAt: datetime('created_at').notNull(),
updatedAt: datetime('updated_at').notNull(),
}, (table) => ({
planeProjectIdx: uniqueIndex('uniq_projects_plane_id').on(table.planeProjectId),
categoryIdx: index('idx_projects_category').on(table.category),
}));
// ── Sprint Snapshots ──
@ -154,6 +165,7 @@ export const objectives = mysqlTable('objectives', {
startDate: varchar('start_date', { length: 50 }),
endDate: varchar('end_date', { length: 50 }),
progress: double('progress').default(0),
source: varchar('source', { length: 50 }).default('manual'), // manual / ai_generated
createdAt: datetime('created_at').notNull(),
updatedAt: datetime('updated_at').notNull(),
}, (table) => ({
@ -170,11 +182,13 @@ export const keyResults = mysqlTable('key_results', {
currentValue: double('current_value').default(0),
unit: varchar('unit', { length: 100 }),
weight: double('weight').default(1),
status: varchar('status', { length: 100 }).default('active'), // active / paused / cancelled
status: varchar('status', { length: 100 }).default('active'), // active / paused / cancelled / completed
startDate: varchar('start_date', { length: 50 }),
endDate: varchar('end_date', { length: 50 }),
linkedPlaneCycleId: varchar('linked_plane_cycle_id', { length: 200 }),
linkedPlaneModuleId: varchar('linked_plane_module_id', { length: 200 }),
source: varchar('source', { length: 50 }).default('manual'), // manual / ai_generated
sourceKey: varchar('source_key', { length: 300 }), // AI 分配的功能标识符,用于语义去重
createdAt: datetime('created_at').notNull(),
updatedAt: datetime('updated_at').notNull(),
}, (table) => ({
@ -218,12 +232,134 @@ export const projectRepos = mysqlTable('project_repos', {
repoIdx: index('idx_project_repos_repo').on(table.repoName),
}));
// ── User Project Permissions (观察者可查看项目) ──
export const userProjectPermissions = mysqlTable('user_project_permissions', {
id: varchar('id', { length: 50 }).primaryKey(),
userId: varchar('user_id', { length: 50 }).references(() => users.id).notNull(),
projectId: varchar('project_id', { length: 50 }).references(() => projects.id).notNull(),
createdAt: datetime('created_at').notNull(),
}, (table) => ({
userIdx: index('idx_upp_user').on(table.userId),
projectIdx: index('idx_upp_project').on(table.projectId),
userProjectIdx: uniqueIndex('uniq_upp_user_project').on(table.userId, table.projectId),
}));
// ── Sync Logs ──
export const syncLogs = mysqlTable('sync_logs', {
id: varchar('id', { length: 50 }).primaryKey(),
source: mysqlEnum('source', ['plane', 'gitea']).notNull(),
source: mysqlEnum('source', ['plane', 'gitea', 'ai_okr', 'roi_cost_ingest', 'roi_revenue_ingest', 'roi_amortizer', 'roi_ai_driver']).notNull(),
status: mysqlEnum('status', ['success', 'error']).notNull(),
message: text('message'),
recordsProcessed: int('records_processed').default(0),
syncedAt: datetime('synced_at').notNull(),
});
// ── AI Analyzed Commits (增量标记,防止重复分析) ──
export const aiAnalyzedCommits = mysqlTable('ai_analyzed_commits', {
id: varchar('id', { length: 50 }).primaryKey(),
commitSha: varchar('commit_sha', { length: 200 }).notNull(),
batchId: varchar('batch_id', { length: 50 }).notNull(),
createdAt: datetime('created_at').notNull(),
}, (table) => ({
shaIdx: uniqueIndex('uniq_analyzed_sha').on(table.commitSha),
}));
// ───────────────────────────────────────────────────────────
// ROI 动态规则引擎(v2.0) — 事件流模型
// ───────────────────────────────────────────────────────────
// ── ROI 策略配置库(全局参数,4 分类各一行) ──
export const roiStrategies = mysqlTable('roi_strategies', {
id: varchar('id', { length: 50 }).primaryKey(),
category: mysqlEnum('category', ['cash_cow', 'efficiency_tool', 'moat', 'composite']).notNull(),
name: varchar('name', { length: 200 }).notNull(),
formulaKey: varchar('formula_key', { length: 100 }).notNull(),
// params 例: { hourlyRate: 400, amortYears: 3, commitHourCoef: 0.5, taskHourCoef: 6 }
params: json('params').notNull(),
updatedAt: datetime('updated_at').notNull(),
}, (table) => ({
categoryIdx: uniqueIndex('uniq_roi_strategy_category').on(table.category),
}));
// ── 项目成本事件流 ──
export const projectCostEvents = mysqlTable('project_cost_events', {
id: varchar('id', { length: 50 }).primaryKey(),
projectId: varchar('project_id', { length: 50 }).references(() => projects.id).notNull(),
eventDate: datetime('event_date').notNull(), // 营收发生的自然日(精确到日)
costType: mysqlEnum('cost_type', ['dev_hours', 'hardware_bom', 'service_fee', 'amortization', 'other']).notNull(),
amount: double('amount').notNull(), // 已折算成 CNY 的金额
hours: double('hours'), // 工时(仅 cost_type=dev_hours 时填,辅助溯源)
hourlyRateUsed: double('hourly_rate_used'), // 计算时使用的 R_h 快照
dataSource: mysqlEnum('data_source', ['auto_commits', 'auto_tasks', 'plane_actual', 'manual', 'amortization_cron']).notNull(),
refType: varchar('ref_type', { length: 50 }), // 'git_commit' | 'plane_task' | 'manual'
refId: varchar('ref_id', { length: 200 }), // 关联唯一 id(防重)
notes: text('notes'),
createdBy: varchar('created_by', { length: 50 }).references(() => users.id),
createdAt: datetime('created_at').notNull(),
}, (table) => ({
projectDateIdx: index('idx_cost_events_project_date').on(table.projectId, table.eventDate),
refUniqIdx: uniqueIndex('uniq_cost_events_ref').on(table.projectId, table.refType, table.refId),
}));
// ── 项目产出事件流 ──
export const projectRevenueEvents = mysqlTable('project_revenue_events', {
id: varchar('id', { length: 50 }).primaryKey(),
projectId: varchar('project_id', { length: 50 }).references(() => projects.id).notNull(),
eventDate: datetime('event_date').notNull(),
revenueType: mysqlEnum('revenue_type', ['direct_revenue', 'subscription', 'saved_cost', 'asset_value_add', 'refund', 'other']).notNull(),
amount: double('amount').notNull(), // 可负数(退款/冲账)
dataSource: mysqlEnum('data_source', ['api_pulled', 'manual', 'calculated', 'mock']).notNull(),
refType: varchar('ref_type', { length: 50 }),
refId: varchar('ref_id', { length: 200 }),
channel: varchar('channel', { length: 50 }), // 渠道:alipay/wechat/stripe 等
notes: text('notes'),
createdBy: varchar('created_by', { length: 50 }).references(() => users.id),
createdAt: datetime('created_at').notNull(),
}, (table) => ({
projectDateIdx: index('idx_revenue_events_project_date').on(table.projectId, table.eventDate),
refUniqIdx: uniqueIndex('uniq_revenue_events_ref').on(table.projectId, table.refType, table.refId),
}));
// ── 业务系统 → DevPerf 项目映射 ──
export const projectRevenueMapping = mysqlTable('project_revenue_mapping', {
id: varchar('id', { length: 50 }).primaryKey(),
projectId: varchar('project_id', { length: 50 }).references(() => projects.id).notNull(),
businessProjectKey: varchar('business_project_key', { length: 100 }).notNull(),
enabled: int('enabled').default(1),
notes: text('notes'),
createdAt: datetime('created_at').notNull(),
updatedAt: datetime('updated_at').notNull(),
}, (table) => ({
businessKeyIdx: uniqueIndex('uniq_revenue_mapping_business_key').on(table.businessProjectKey),
projectIdx: index('idx_revenue_mapping_project').on(table.projectId),
}));
// ── AI 生成的价值驱动因子文案(月度快照) ──
export const roiDriverFactors = mysqlTable('roi_driver_factors', {
id: varchar('id', { length: 50 }).primaryKey(),
projectId: varchar('project_id', { length: 50 }).references(() => projects.id).notNull(),
periodKey: varchar('period_key', { length: 20 }).notNull(), // YYYY-MM (上月) 或 'lifetime'
factors: json('factors').notNull(), // [{type, text}]
context: json('context'), // 当时的 ROI 数值快照
generatedAt: datetime('generated_at').notNull(),
}, (table) => ({
projectPeriodIdx: uniqueIndex('uniq_driver_factors_project_period').on(table.projectId, table.periodKey),
}));
// ── 未映射的营收事件(收容表) ──
export const unmappedRevenueEvents = mysqlTable('unmapped_revenue_events', {
id: varchar('id', { length: 50 }).primaryKey(),
externalId: varchar('external_id', { length: 200 }).notNull(),
businessProjectKey: varchar('business_project_key', { length: 100 }).notNull(),
eventDate: datetime('event_date').notNull(),
amount: double('amount').notNull(),
revenueType: varchar('revenue_type', { length: 50 }),
channel: varchar('channel', { length: 50 }),
rawPayload: json('raw_payload'),
status: mysqlEnum('status', ['pending', 'resolved', 'ignored']).default('pending'),
resolvedEventId: varchar('resolved_event_id', { length: 50 }), // 解决后关联到 revenue_events.id
createdAt: datetime('created_at').notNull(),
}, (table) => ({
externalIdx: uniqueIndex('uniq_unmapped_external_id').on(table.externalId),
statusIdx: index('idx_unmapped_status').on(table.status),
}));

View File

@ -6,7 +6,7 @@
import { db } from './index';
import { users } from './schema';
import { config } from '../config';
import bcrypt from 'bcrypt';
import bcrypt from 'bcryptjs';
import { v4 as uuid } from 'uuid';
import { eq } from 'drizzle-orm';

View File

@ -18,7 +18,7 @@ import {
users, projects, sprintSnapshots, taskSnapshots, milestones,
gitCommits, gitPRs, objectives, keyResults, authorMappings, syncLogs,
} from './schema';
import bcrypt from 'bcrypt';
import bcrypt from 'bcryptjs';
import { v4 as uuid } from 'uuid';
const now = new Date();

View File

@ -1,7 +1,7 @@
import { db } from './index';
import { users } from './schema';
import { config } from '../config';
import bcrypt from 'bcrypt';
import bcrypt from 'bcryptjs';
import { v4 as uuid } from 'uuid';
import { eq } from 'drizzle-orm';

View File

@ -11,6 +11,8 @@ import { memberRoutes } from './routes/members';
import { okrRoutes } from './routes/okr';
import { gitRoutes } from './routes/git';
import { adminRoutes } from './routes/admin';
import { mockRevenueRoutes } from './routes/mock-revenue';
import { roiRoutes } from './routes/roi';
// Importing db triggers auto-migration on first load (B-07 fix)
import { db } from './db/index';
import { seedAdminUser } from './db/seed-auto';
@ -20,7 +22,7 @@ const app = new Hono();
// Global middleware
app.use('*', cors({
origin: ['http://localhost:5173', 'http://localhost:3201'],
origin: ['http://localhost:5173', 'http://localhost:3201', 'https://devperf.airlabs.art'],
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowHeaders: ['Content-Type', 'Authorization'],
}));
@ -44,6 +46,12 @@ app.get('/api/health', (c) => {
// Auth routes (public)
app.route('/api/auth', authRoutes);
// Mock 营收 API (开发期,挂在 /mock 避开 /api/* 的 JWT auth)
if (config.MOCK_REVENUE_API) {
app.route('/mock', mockRevenueRoutes);
console.info('[Mock] Revenue API mock mounted at /mock/revenue/*');
}
// Protected routes
app.use('/api/*', authMiddleware);
app.route('/api', overviewRoutes);
@ -52,6 +60,7 @@ app.route('/api', memberRoutes);
app.route('/api', okrRoutes);
app.route('/api', gitRoutes);
app.route('/api', adminRoutes);
app.route('/api', roiRoutes);
// Error handler
app.onError(errorHandler);
@ -71,4 +80,5 @@ console.info(`DevPerf Dashboard API starting on port ${port}`);
export default {
port,
fetch: app.fetch,
idleTimeout: 120, // AI 分析可能耗时较长
};

View File

@ -0,0 +1,55 @@
import type { MiddlewareHandler } from 'hono';
import { AppError } from './error-handler';
/**
* AI
* - perUserPerMinute: 每用户每分钟最多 N
* - perProjectPerDay: 每项目每天最多 M ( query/param projectId)
*
* ,(,)
*/
interface Counter {
count: number;
windowStart: number;
}
const userMinuteCounters = new Map<string, Counter>();
const projectDayCounters = new Map<string, Counter>();
function tick(map: Map<string, Counter>, key: string, windowMs: number, limit: number): boolean {
const now = Date.now();
const c = map.get(key);
if (!c || now - c.windowStart >= windowMs) {
map.set(key, { count: 1, windowStart: now });
return true;
}
if (c.count >= limit) return false;
c.count += 1;
return true;
}
export function aiRateLimit(opts: {
perUserPerMinute?: number;
perProjectPerDay?: number;
projectIdParam?: string; // 哪个 param 是 projectId,默认 'id'
} = {}): MiddlewareHandler {
const perUser = opts.perUserPerMinute ?? 5;
const perProject = opts.perProjectPerDay ?? 20;
const projectParam = opts.projectIdParam ?? 'id';
return async (c, next) => {
const user = c.get('user');
if (!user) throw new AppError(40101, 'Authentication required', 401);
if (!tick(userMinuteCounters, user.sub, 60_000, perUser)) {
throw new AppError(42901, `AI 调用过于频繁,每分钟最多 ${perUser}`, 429);
}
const projectId = c.req.param(projectParam);
if (projectId) {
if (!tick(projectDayCounters, projectId, 24 * 3600_000, perProject)) {
throw new AppError(42902, `该项目今日 AI 调用已达上限 ${perProject}`, 429);
}
}
await next();
};
}

View File

@ -0,0 +1,35 @@
import { eq } from 'drizzle-orm';
import { db } from '../db/index';
import { projects, userProjectPermissions } from '../db/schema';
import type { JWTPayload } from './auth';
/**
* 写权限: admin ; owner
* (, userProjectPermissions can_write )
*/
export async function hasProjectWriteAccess(user: JWTPayload, projectId: string): Promise<boolean> {
if (user.role === 'admin') return true;
const [project] = await db.select({ ownerId: projects.ownerId })
.from(projects)
.where(eq(projects.id, projectId))
.limit(1);
return project?.ownerId === user.sub;
}
/**
* 读权限: admin/manager ;developer/viewer owner userProjectPermissions
*/
export async function hasProjectReadAccess(user: JWTPayload, projectId: string): Promise<boolean> {
if (user.role === 'admin' || user.role === 'manager') return true;
const [project] = await db.select({ ownerId: projects.ownerId })
.from(projects)
.where(eq(projects.id, projectId))
.limit(1);
if (project?.ownerId === user.sub) return true;
const perm = await db.select().from(userProjectPermissions)
.where(eq(userProjectPermissions.userId, user.sub))
.limit(50);
return perm.some(p => p.projectId === projectId);
}

View File

@ -1,11 +1,11 @@
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { eq } from 'drizzle-orm';
import { eq, desc } from 'drizzle-orm';
import { v4 as uuid } from 'uuid';
import bcrypt from 'bcrypt';
import bcrypt from 'bcryptjs';
import { db } from '../db/index';
import { users, authorMappings, syncLogs, projects, projectRepos, gitCommits, gitPRs } from '../db/schema';
import { users, authorMappings, syncLogs, projects, projectRepos, gitCommits, gitPRs, userProjectPermissions, objectives, keyResults, krLogs } from '../db/schema';
import { requireRole } from '../middleware/role';
import { AppError } from '../middleware/error-handler';
@ -18,6 +18,12 @@ adminRoutes.use('/admin/*', requireRole('admin'));
adminRoutes.get('/admin/users', async (c) => {
const allUsers = await db.select().from(users);
const allPerms = await db.select().from(userProjectPermissions);
const permsByUser = new Map<string, string[]>();
for (const p of allPerms) {
if (!permsByUser.has(p.userId)) permsByUser.set(p.userId, []);
permsByUser.get(p.userId)!.push(p.projectId);
}
return c.json({
code: 0,
data: allUsers.map(u => ({
@ -27,6 +33,7 @@ adminRoutes.get('/admin/users', async (c) => {
role: u.role,
planeUserId: u.planeUserId,
gitUsername: u.gitUsername,
allowedProjectIds: permsByUser.get(u.id) || [],
createdAt: u.createdAt.toISOString(),
})),
message: 'success',
@ -118,6 +125,10 @@ adminRoutes.get('/admin/projects', async (c) => {
id: p.id,
name: p.name,
identifier: p.identifier,
planeIdentifier: p.planeIdentifier || null,
bizSystem: p.bizSystem || null,
projectType: p.projectType || null,
category: p.category || null,
planeProjectId: p.planeProjectId,
createdAt: p.createdAt instanceof Date ? p.createdAt.toISOString() : p.createdAt,
})),
@ -147,8 +158,41 @@ adminRoutes.post('/admin/projects', zValidator('json', createProjectSchema), asy
return c.json({ code: 0, data: { id }, message: 'success' }, 201);
});
const updateProjectSchema = z.object({
name: z.string().min(1).max(200).optional(),
identifier: z.string().min(1).max(20).toUpperCase().optional(),
});
adminRoutes.patch('/admin/projects/:id', zValidator('json', updateProjectSchema), async (c) => {
const id = c.req.param('id');
const data = c.req.valid('json');
const project = await db.query.projects.findFirst({ where: eq(projects.id, id) });
if (!project) {
throw new AppError(40402, 'Project not found', 404);
}
const updateData: Record<string, any> = { updatedAt: new Date() };
if (data.name) updateData.name = data.name;
if (data.identifier) updateData.identifier = data.identifier;
await db.update(projects).set(updateData).where(eq(projects.id, id));
return c.json({ code: 0, data: { id }, message: 'success' });
});
adminRoutes.delete('/admin/projects/:id', async (c) => {
const id = c.req.param('id');
await db.delete(userProjectPermissions).where(eq(userProjectPermissions.projectId, id));
await db.delete(projectRepos).where(eq(projectRepos.projectId, id));
const objs = await db.select().from(objectives).where(eq(objectives.projectId, id));
for (const obj of objs) {
const krs = await db.select().from(keyResults).where(eq(keyResults.objectiveId, obj.id));
for (const kr of krs) {
await db.delete(krLogs).where(eq(krLogs.krId, kr.id));
}
await db.delete(keyResults).where(eq(keyResults.objectiveId, obj.id));
}
await db.delete(objectives).where(eq(objectives.projectId, id));
await db.delete(projects).where(eq(projects.id, id));
return c.json({ code: 0, data: null, message: 'success' });
});
@ -249,15 +293,69 @@ adminRoutes.delete('/admin/author-mappings/:id', async (c) => {
return c.json({ code: 0, data: null, message: 'success' });
});
// ── User Project Permissions (观察者项目权限) ──
// GET /api/admin/users/:id/projects — 获取用户可查看的项目列表
adminRoutes.get('/admin/users/:id/projects', async (c) => {
const userId = c.req.param('id');
const perms = await db.select().from(userProjectPermissions)
.where(eq(userProjectPermissions.userId, userId));
return c.json({
code: 0,
data: perms.map(p => p.projectId),
message: 'success',
});
});
// PUT /api/admin/users/:id/projects — 设置用户可查看的项目(全量替换)
const setUserProjectsSchema = z.object({
projectIds: z.array(z.string()),
});
adminRoutes.put('/admin/users/:id/projects', zValidator('json', setUserProjectsSchema), async (c) => {
const userId = c.req.param('id');
const { projectIds } = c.req.valid('json');
// 先删除现有权限
await db.delete(userProjectPermissions)
.where(eq(userProjectPermissions.userId, userId));
// 批量插入新权限
if (projectIds.length > 0) {
const now = new Date();
await db.insert(userProjectPermissions).values(
projectIds.map(projectId => ({
id: uuid(),
userId,
projectId,
createdAt: now,
}))
);
}
return c.json({ code: 0, data: { projectIds }, message: 'success' });
});
// ── Sync ──
adminRoutes.post('/admin/sync/trigger', async (c) => {
const { syncGitea } = await import('../sync/sync-gitea');
const { analyzeCommitsForOKR } = await import('../services/okr-ai-sync');
const { config } = await import('../config');
// 异步执行,不阻塞响应
syncGitea().catch(err => console.error('[SYNC] Manual trigger failed:', err));
(async () => {
try {
await syncGitea();
if (config.AI_ENABLED && config.AI_API_KEY) {
await analyzeCommitsForOKR();
}
} catch (err) {
console.error('[SYNC] Manual trigger failed:', err);
}
})();
return c.json({
code: 0,
data: { message: '同步已触发,请稍后刷新查看结果' },
data: { message: '同步 + AI 分析已触发,请稍后刷新查看结果' },
message: 'success',
});
});
@ -266,7 +364,7 @@ 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 allLogs = await db.select().from(syncLogs).orderBy(desc(syncLogs.syncedAt));
const total = allLogs.length;
const items = allLogs.slice((page - 1) * pageSize, page * pageSize);

View File

@ -1,8 +1,9 @@
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 { gitCommits, gitPRs, users, projectRepos } from '../db/schema';
import { eq, and, gte, desc, inArray } from 'drizzle-orm';
import { AppError } from '../middleware/error-handler';
import { getAllowedProjectIds } from '../services/permissions';
import dayjs from 'dayjs';
export const gitRoutes = new Hono();
@ -13,8 +14,25 @@ gitRoutes.get('/git/activity', async (c) => {
const queryUserId = c.req.query('userId');
const weeks = parseInt(c.req.query('weeks') || '12');
// 观察者:允许访问但限定到已分配项目的仓库
let allowedRepoNames: Set<string> | null = null;
if (user.role === 'viewer') {
throw new AppError(40103, 'Insufficient permissions', 403);
const allowedIds = await getAllowedProjectIds(user);
if (allowedIds !== null && allowedIds.length > 0) {
const repos = await db.select().from(projectRepos)
.where(inArray(projectRepos.projectId, allowedIds));
allowedRepoNames = new Set(repos.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;
}));
} else {
// 没有分配任何项目,返回空数据
return c.json({ code: 0, data: { heatmap: [], stats: { totalCommits: 0, activeContributors: 0, thisMonthCommits: 0, activeRepos: 0 }, weeklyTrend: [] }, message: 'success' });
}
}
let targetUserId: string | undefined;
@ -30,7 +48,12 @@ gitRoutes.get('/git/activity', async (c) => {
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;
let commits = await commitQuery;
// 观察者:过滤到分配项目的仓库
if (allowedRepoNames) {
commits = commits.filter(c => allowedRepoNames!.has(c.repoName));
}
// Heatmap按天
const dayMap: Record<string, { commits: number; additions: number; deletions: number }> = {};
@ -49,7 +72,10 @@ gitRoutes.get('/git/activity', async (c) => {
const heatmap = Object.entries(dayMap).map(([date, data]) => ({ date, ...data }));
// 统计指标(替代原来的 PR 指标)
const allCommits = await db.select().from(gitCommits);
let allCommits = await db.select().from(gitCommits);
if (allowedRepoNames) {
allCommits = allCommits.filter(c => allowedRepoNames!.has(c.repoName));
}
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;

View File

@ -0,0 +1,51 @@
import { Hono } from 'hono';
import { config } from '../config';
import { generateMockRevenueForDate, listMockBusinessProjects } from '../services/roi/revenue-ingest/mock-generator';
/**
* Mock API,"附录 A:外部营收 API 接入规范"
* MOCK_REVENUE_API=true , /mock( /api/* , JWT auth)
*
* :
* GET /mock/revenue/daily?date=YYYY-MM-DD
* GET /mock/revenue/projects
*/
export const mockRevenueRoutes = new Hono();
mockRevenueRoutes.use('*', async (c, next) => {
// 鉴权:严格按附录 A 的 Bearer Token
const auth = c.req.header('Authorization') || '';
const match = auth.match(/^Bearer\s+(.+)$/i);
if (!match || match[1] !== config.REVENUE_API_KEY) {
return c.json({ error: 'UNAUTHORIZED' }, 401);
}
await next();
});
mockRevenueRoutes.get('/revenue/daily', async (c) => {
const date = c.req.query('date');
if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) {
return c.json({ error: 'INVALID_DATE', message: 'date must be YYYY-MM-DD' }, 400);
}
try {
const events = await generateMockRevenueForDate(date);
return c.json({
date,
events,
nextCursor: null, // mock 不分页(数据量小)
totalCount: events.length,
});
} catch (e) {
return c.json({ error: 'INTERNAL', message: (e as Error).message }, 500);
}
});
mockRevenueRoutes.get('/revenue/projects', async (c) => {
try {
const projects = await listMockBusinessProjects();
return c.json({ projects, totalCount: projects.length });
} catch (e) {
return c.json({ error: 'INTERNAL', message: (e as Error).message }, 500);
}
});

View File

@ -2,14 +2,26 @@ import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { requireRole } from '../middleware/role';
import { getAllowedProjectIds } from '../services/permissions';
import * as okrService from '../services/okr';
import { analyzeCommitsForOKR } from '../services/okr-ai-sync';
export const okrRoutes = new Hono();
// GET /api/okr
okrRoutes.get('/okr', async (c) => {
const user = c.get('user');
const period = c.req.query('period');
const data = await okrService.getOKRByPeriod(period || undefined);
// 观察者:只返回已分配项目的 OKR
const allowedIds = await getAllowedProjectIds(user);
if (allowedIds !== null) {
data.objectives = data.objectives.filter(
(obj: any) => obj.projectId && allowedIds.includes(obj.projectId)
);
}
return c.json({ code: 0, data, message: 'success' });
});
@ -138,6 +150,24 @@ okrRoutes.get('/okr/key-results/:id/logs', async (c) => {
return c.json({ code: 0, data: logs, message: 'success' });
});
// POST /api/okr/ai-analyze — 手动触发 AI 分析
okrRoutes.post('/okr/ai-analyze',
requireRole('admin', 'manager'),
async (c) => {
const result = await analyzeCommitsForOKR(false);
return c.json({ code: 0, data: result, message: 'success' });
}
);
// POST /api/okr/ai-analyze/preview — 预览模式(不写库)
okrRoutes.post('/okr/ai-analyze/preview',
requireRole('admin', 'manager'),
async (c) => {
const result = await analyzeCommitsForOKR(true);
return c.json({ code: 0, data: result, message: 'success' });
}
);
// DELETE /api/okr/objectives/:id
okrRoutes.delete('/okr/objectives/:id',
requireRole('admin'),

View File

@ -1,16 +1,36 @@
import { Hono } from 'hono';
import { db } from '../db/index';
import { projects, gitCommits, gitPRs, objectives, keyResults, users, projectRepos, krLogs } from '../db/schema';
import { eq, desc, gte } from 'drizzle-orm';
import { eq, desc, gte, inArray } from 'drizzle-orm';
import { getAllowedProjectIds } from '../services/permissions';
import dayjs from 'dayjs';
/** 根据项目绑定的仓库名,提取纯仓库名 Set */
async function getAllowedRepoNames(allowedProjectIds: string[]): Promise<Set<string>> {
if (allowedProjectIds.length === 0) return new Set();
const repos = await db.select().from(projectRepos)
.where(inArray(projectRepos.projectId, allowedProjectIds));
return new Set(repos.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;
}));
}
export const overviewRoutes = new Hono();
async function getRecentCommits() {
const recent = await db.select().from(gitCommits).orderBy(desc(gitCommits.committedAt)).limit(15);
async function getRecentCommits(allowedRepos?: Set<string>) {
const recent = await db.select().from(gitCommits).orderBy(desc(gitCommits.committedAt)).limit(50);
const allUsers = await db.select().from(users);
const userMap = new Map(allUsers.map(u => [u.id, u.displayName]));
return recent.map(c => ({
let filtered = recent;
if (allowedRepos) {
filtered = recent.filter(c => allowedRepos.has(c.repoName));
}
return filtered.slice(0, 15).map(c => ({
sha: c.sha?.slice(0, 7) || '',
message: (c.message || '').split('\n')[0].slice(0, 60),
authorName: c.userId ? userMap.get(c.userId) || c.authorName : c.authorName,
@ -20,17 +40,62 @@ async function getRecentCommits() {
}
overviewRoutes.get('/overview', async (c) => {
const user = c.get('user');
const period = c.req.query('period');
const projectIds = c.req.query('projectIds')?.split(',').filter(Boolean) || [];
let projectIds = c.req.query('projectIds')?.split(',').filter(Boolean) || [];
// 1. 各项目 OKR 整体进度(替代 Sprint 交付率)
const allProjects = await db.select().from(projects);
// 观察者:强制限定到已分配的项目
const allowedIds = await getAllowedProjectIds(user);
let mustFilterByProject = false;
if (allowedIds !== null) {
mustFilterByProject = true;
if (projectIds.length > 0) {
projectIds = projectIds.filter(id => allowedIds.includes(id));
} else {
projectIds = allowedIds;
}
}
// 观察者:获取可访问的仓库名(用于过滤 git 相关数据)
const allowedRepos = mustFilterByProject ? await getAllowedRepoNames(projectIds) : undefined;
// 观察者:获取可访问项目下的 objective IDs用于过滤 KR 数据)
let allowedObjIds: Set<string> | undefined;
if (mustFilterByProject) {
const objs = projectIds.length > 0
? (await db.select().from(objectives)).filter(o => o.projectId && projectIds.includes(o.projectId))
: [];
allowedObjIds = new Set(objs.map(o => o.id));
}
// ─── 性能优化:一次性批量拉取,内存里做 join,避免 N+1 ───
const [allProjects, allObjectivesRaw, allKRs, allUsersData] = await Promise.all([
db.select().from(projects),
db.select().from(objectives),
db.select().from(keyResults),
db.select().from(users),
]);
// 索引化方便 O(1) 查找
const objectivesByProject = new Map<string, typeof allObjectivesRaw>();
for (const o of allObjectivesRaw) {
if (!o.projectId) continue;
if (!objectivesByProject.has(o.projectId)) objectivesByProject.set(o.projectId, []);
objectivesByProject.get(o.projectId)!.push(o);
}
const krsByObjective = new Map<string, typeof allKRs>();
for (const kr of allKRs) {
if (!krsByObjective.has(kr.objectiveId)) krsByObjective.set(kr.objectiveId, []);
krsByObjective.get(kr.objectiveId)!.push(kr);
}
const usersById = new Map(allUsersData.map(u => [u.id, u]));
const projectsById = new Map(allProjects.map(p => [p.id, p]));
const objectivesById = new Map(allObjectivesRaw.map(o => [o.id, o]));
// 1. 各项目 OKR 整体进度
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 ((mustFilterByProject || projectIds.length > 0) && !projectIds.includes(proj.id)) continue;
let projObjectives = objectivesByProject.get(proj.id) || [];
if (period) {
projObjectives = projObjectives.filter(o => o.period === period);
}
@ -46,14 +111,11 @@ overviewRoutes.get('/overview', async (c) => {
});
}
// 2. KR 完成状态分布(替代任务状态分布)
const allKRs = await db.select().from(keyResults);
// 2. KR 完成状态分布
let filteredKRs = allKRs;
if (projectIds.length > 0) {
if (mustFilterByProject || projectIds.length > 0) {
const projObjIds = new Set(
(await db.select().from(objectives))
.filter(o => o.projectId && projectIds.includes(o.projectId))
.map(o => o.id)
allObjectivesRaw.filter(o => o.projectId && projectIds.includes(o.projectId)).map(o => o.id)
);
filteredKRs = allKRs.filter(kr => projObjIds.has(kr.objectiveId));
}
@ -81,11 +143,17 @@ overviewRoutes.get('/overview', async (c) => {
// 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 [commitsRaw, prsRaw] = await Promise.all([
db.select().from(gitCommits).where(gte(gitCommits.committedAt, twelveWeeksAgo)),
db.select().from(gitPRs).where(gte(gitPRs.createdAt, twelveWeeksAgo)),
]);
let commits = commitsRaw;
let prs = prsRaw;
if (allowedRepos) {
commits = commits.filter(c => allowedRepos.has(c.repoName));
prs = prs.filter(p => allowedRepos.has(p.repoName));
}
const allUsers = allUsersData; // 复用上面已 fetch 的
const weekMap: Record<string, Record<string, { commits: number; prs: number }>> = {};
for (let i = 0; i < 12; i++) {
@ -121,23 +189,19 @@ overviewRoutes.get('/overview', async (c) => {
})),
};
// 5. OKR Progress
// 5. OKR Progress(用前面已 fetch 的 allObjectivesRaw + krsByObjective + usersById)
let allObjectives = period
? await db.select().from(objectives).where(eq(objectives.period, period))
: await db.select().from(objectives);
? allObjectivesRaw.filter(o => o.period === period)
: allObjectivesRaw;
if (projectIds.length > 0) {
if (mustFilterByProject || 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({
const okrProgress = allObjectives.map(obj => {
const krs = krsByObjective.get(obj.id) || [];
const owner = obj.ownerId ? usersById.get(obj.ownerId) : null;
return {
id: obj.id,
title: obj.title,
ownerName: owner?.displayName || '未指定',
@ -150,16 +214,19 @@ overviewRoutes.get('/overview', async (c) => {
target: kr.targetValue,
unit: kr.unit || '',
})),
};
});
}
// 6. 指定周的 KR支持 weekOffset 参数0=本周1=下周,-1=上周)
const weekOffset = parseInt(c.req.query('weekOffset') || '0');
const weekStart = dayjs().startOf('week').add(weekOffset, 'week');
const weekEnd = dayjs().endOf('week').add(weekOffset, 'week');
const allKRsRaw = await db.select().from(keyResults);
const allObjsMap = new Map((await db.select().from(objectives)).map(o => [o.id, o]));
let allKRsRaw = allKRs; // 复用前面 fetch
const allObjsMap = objectivesById;
if (allowedObjIds) {
allKRsRaw = allKRsRaw.filter(kr => allowedObjIds!.has(kr.objectiveId));
}
const thisWeekKRs = allKRsRaw.filter(kr => {
if (!kr.endDate) return false;
@ -178,12 +245,8 @@ overviewRoutes.get('/overview', async (c) => {
const urgentKRs = [];
for (const kr of thisWeekKRs.slice(0, 20)) {
const obj = allObjsMap.get(kr.objectiveId);
const owner = obj?.ownerId
? await db.query.users.findFirst({ where: eq(users.id, obj.ownerId) })
: null;
const proj = obj?.projectId
? await db.query.projects.findFirst({ where: eq(projects.id, obj.projectId) })
: null;
const owner = obj?.ownerId ? usersById.get(obj.ownerId) : null;
const proj = obj?.projectId ? projectsById.get(obj.projectId) : null;
const endDate = kr.endDate || '';
const isOverdue = dayjs(endDate).isBefore(dayjs().startOf('day'));
@ -235,22 +298,24 @@ overviewRoutes.get('/overview', async (c) => {
return order(a) - order(b);
});
// 批量取异常 KRs 的最后日志(单次 IN 查询代替循环)
const abnormalKrIds = abnormalKRs.slice(0, 20).map(k => k.id);
const lastLogByKr = new Map<string, string>();
if (abnormalKrIds.length > 0) {
const logs = await db.select().from(krLogs)
.where(inArray(krLogs.krId, abnormalKrIds))
.orderBy(desc(krLogs.createdAt));
for (const log of logs) {
if (!lastLogByKr.has(log.krId)) lastLogByKr.set(log.krId, log.detail || '');
}
}
const overdueList = [];
for (const kr of abnormalKRs.slice(0, 20)) {
const obj = allObjsMap.get(kr.objectiveId);
const owner = obj?.ownerId
? await db.query.users.findFirst({ where: eq(users.id, obj.ownerId) })
: null;
const proj = obj?.projectId
? await db.query.projects.findFirst({ where: eq(projects.id, obj.projectId) })
: null;
// 获取最后一条操作日志作为原因
const lastLog = await db.select().from(krLogs)
.where(eq(krLogs.krId, kr.id))
.orderBy(desc(krLogs.createdAt))
.limit(1);
const reason = lastLog[0]?.detail || '';
const owner = obj?.ownerId ? usersById.get(obj.ownerId) : null;
const proj = obj?.projectId ? projectsById.get(obj.projectId) : null;
const reason = lastLogByKr.get(kr.id) || '';
let itemStatus: string;
let statusLabel: string;
@ -303,7 +368,7 @@ overviewRoutes.get('/overview', async (c) => {
weekLabel: weekStart.format('MM/DD') + ' ~ ' + weekEnd.format('MM/DD'),
},
overdueKRs: overdueList,
recentCommits: await getRecentCommits(),
recentCommits: await getRecentCommits(allowedRepos),
},
message: 'success',
});

View File

@ -3,24 +3,34 @@ 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, krLogs } from '../db/schema';
import { eq, and, desc, gte } from 'drizzle-orm';
import { projects, sprintSnapshots, milestones, taskSnapshots, gitCommits, gitPRs, users, objectives, keyResults, projectRepos, krLogs, userProjectPermissions } from '../db/schema';
import { eq, and, desc, gte, inArray } from 'drizzle-orm';
import { requireRole } from '../middleware/role';
import { AppError } from '../middleware/error-handler';
import { getAllowedProjectIds } from '../services/permissions';
import dayjs from 'dayjs';
export const projectRoutes = new Hono();
// GET /api/projects — 所有登录用户都能查
// GET /api/projects — 所有登录用户都能查(观察者仅返回已分配的项目)
projectRoutes.get('/projects', async (c) => {
const allProjects = await db.select().from(projects);
const user = c.get('user');
const allowedIds = await getAllowedProjectIds(user);
let allProjects = await db.select().from(projects);
if (allowedIds !== null) {
allProjects = allProjects.filter(p => allowedIds.includes(p.id));
}
return c.json({
code: 0,
data: allProjects.map(p => ({
id: p.id,
name: p.name,
identifier: p.identifier,
planeIdentifier: p.planeIdentifier || null,
planeProjectId: p.planeProjectId,
category: p.category || null,
bizSystem: p.bizSystem || null,
projectType: p.projectType || null,
createdAt: p.createdAt instanceof Date ? p.createdAt.toISOString() : p.createdAt,
lastSyncedAt: p.lastSyncedAt?.toISOString() || null,
})),
@ -38,6 +48,7 @@ projectRoutes.post('/projects',
requireRole('admin', 'manager', 'developer'),
zValidator('json', createProjectSchema),
async (c) => {
const user = c.get('user');
const data = c.req.valid('json');
const id = uuid();
const now = new Date();
@ -49,6 +60,17 @@ projectRoutes.post('/projects',
createdAt: now,
updatedAt: now,
});
// 开发者创建项目时自动获得该项目的查看权限
if (user.role === 'developer') {
await db.insert(userProjectPermissions).values({
id: uuid(),
userId: user.sub,
projectId: id,
createdAt: now,
});
}
return c.json({ code: 0, data: { id }, message: 'success' }, 201);
}
);
@ -58,11 +80,60 @@ projectRoutes.delete('/projects/:id',
requireRole('admin'),
async (c) => {
const id = c.req.param('id');
// 先清理关联数据(外键约束)
await db.delete(userProjectPermissions).where(eq(userProjectPermissions.projectId, id));
await db.delete(projectRepos).where(eq(projectRepos.projectId, id));
// 清理 OKRKR logs → KR → Objectives
const objs = await db.select().from(objectives).where(eq(objectives.projectId, id));
for (const obj of objs) {
const krs = await db.select().from(keyResults).where(eq(keyResults.objectiveId, obj.id));
for (const kr of krs) {
await db.delete(krLogs).where(eq(krLogs.krId, kr.id));
}
await db.delete(keyResults).where(eq(keyResults.objectiveId, obj.id));
}
await db.delete(objectives).where(eq(objectives.projectId, id));
await db.delete(projects).where(eq(projects.id, id));
return c.json({ code: 0, data: null, message: 'success' });
}
);
// PATCH /api/projects/:id — 开发者(有权限)或管理员可编辑
const updateProjectSchema = z.object({
name: z.string().min(1).max(200).optional(),
identifier: z.string().min(1).max(20).toUpperCase().optional(),
});
projectRoutes.patch('/projects/:id',
requireRole('admin', 'manager', 'developer'),
zValidator('json', updateProjectSchema),
async (c) => {
const id = c.req.param('id');
const user = c.get('user');
const data = c.req.valid('json');
// 开发者需要有该项目的权限
if (user.role === 'developer') {
const allowedIds = await getAllowedProjectIds(user);
if (allowedIds !== null && !allowedIds.includes(id)) {
throw new AppError(40103, 'Insufficient permissions', 403);
}
}
const project = await db.query.projects.findFirst({ where: eq(projects.id, id) });
if (!project) {
throw new AppError(40402, 'Project not found', 404);
}
const updateData: Record<string, any> = { updatedAt: new Date() };
if (data.name) updateData.name = data.name;
if (data.identifier) updateData.identifier = data.identifier;
await db.update(projects).set(updateData).where(eq(projects.id, id));
return c.json({ code: 0, data: { id }, message: 'success' });
}
);
// GET /api/projects/:id/repos — 所有登录用户
projectRoutes.get('/projects/:id/repos', async (c) => {
const projectId = c.req.param('id');
@ -99,6 +170,13 @@ projectRoutes.delete('/project-repos/:id',
// GET /api/projects/:id
projectRoutes.get('/projects/:id', async (c) => {
const projectId = c.req.param('id');
const user = c.get('user');
// 观察者只能查看已分配的项目
const allowedIds = await getAllowedProjectIds(user);
if (allowedIds !== null && !allowedIds.includes(projectId)) {
throw new AppError(40103, 'Insufficient permissions', 403);
}
const project = await db.query.projects.findFirst({
where: eq(projects.id, projectId),
@ -169,32 +247,47 @@ projectRoutes.get('/projects/:id', async (c) => {
})),
};
// OKR for this project
// OKR for this project (batch queries to avoid N+1)
const projectObjectives = await db.select().from(objectives)
.where(eq(objectives.projectId, projectId));
const objIds = projectObjectives.map(o => o.id);
const allKRsForProject = objIds.length > 0
? await db.select().from(keyResults).where(inArray(keyResults.objectiveId, objIds))
: [];
const krIds = allKRsForProject.map(kr => kr.id);
const allLogsForProject = krIds.length > 0
? await db.select().from(krLogs).where(inArray(krLogs.krId, krIds)).orderBy(desc(krLogs.createdAt))
: [];
// Group KRs by objective, logs by KR
const krsByObj = new Map<string, typeof allKRsForProject>();
for (const kr of allKRsForProject) {
if (!krsByObj.has(kr.objectiveId)) krsByObj.set(kr.objectiveId, []);
krsByObj.get(kr.objectiveId)!.push(kr);
}
const logsByKR = new Map<string, typeof allLogsForProject>();
for (const log of allLogsForProject) {
if (!logsByKR.has(log.krId)) logsByKR.set(log.krId, []);
const arr = logsByKR.get(log.krId)!;
if (arr.length < 5) arr.push(log);
}
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;
const krs = krsByObj.get(obj.id) || [];
okrData.push({
id: obj.id,
title: obj.title,
ownerId: obj.ownerId || null,
ownerName: owner?.displayName || '未指定',
ownerName: obj.ownerId ? (userMap.get(obj.ownerId) || '未指定') : '未指定',
period: obj.period,
startDate: obj.startDate || null,
endDate: obj.endDate || null,
progress: obj.progress || 0,
keyResults: await Promise.all(krs.map(async kr => {
const logs = await db.select().from(krLogs)
.where(eq(krLogs.krId, kr.id))
.orderBy(desc(krLogs.createdAt))
.limit(5);
keyResults: krs.map(kr => {
const logs = logsByKR.get(kr.id) || [];
const wasPostponed = logs.some(l => l.action === 'postponed');
const lastPostponeReason = logs.find(l => l.action === 'postponed')?.detail || null;
return {
@ -213,7 +306,7 @@ projectRoutes.get('/projects/:id', async (c) => {
? Math.round(((kr.currentValue || 0) / kr.targetValue) * 100)
: 0,
};
})),
}),
});
totalOKRProgress += obj.progress || 0;
}
@ -234,15 +327,13 @@ projectRoutes.get('/projects/:id', async (c) => {
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))
// 获取该项目绑定仓库的 Git 数据(按仓库名过滤,避免全表扫描)
const boundRepoNamesList = Array.from(boundRepoNames);
const recentCommits = boundRepoNamesList.length > 0
? await db.select().from(gitCommits).where(inArray(gitCommits.repoName, boundRepoNamesList))
: [];
const recentPRs = boundRepoNames.size > 0
? allPRs.filter(p => boundRepoNames.has(p.repoName))
const recentPRs = boundRepoNamesList.length > 0
? await db.select().from(gitPRs).where(inArray(gitPRs.repoName, boundRepoNamesList))
: [];
const weeklyTrend: { weekStart: string; commits: number; prs: number }[] = [];
@ -276,7 +367,15 @@ projectRoutes.get('/projects/:id', async (c) => {
id: project.id,
name: project.name,
identifier: project.identifier,
planeIdentifier: project.planeIdentifier || null,
lastSyncedAt: project.lastSyncedAt?.toISOString() || null,
category: project.category || null,
compositeStrategies: project.compositeStrategies || null,
bizSystem: project.bizSystem || null,
projectType: project.projectType || null,
launchedAt: project.launchedAt instanceof Date ? project.launchedAt.toISOString() : project.launchedAt,
vAsset: project.vAsset ?? null,
ownerId: project.ownerId || null,
},
currentCycle,
milestones: milestoneData,

475
backend/src/routes/roi.ts Normal file
View File

@ -0,0 +1,475 @@
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { v4 as uuid } from 'uuid';
import { and, desc, eq, gte, lte } from 'drizzle-orm';
import dayjs from 'dayjs';
import { db } from '../db/index';
import {
projects, projectCostEvents, projectRevenueEvents,
roiStrategies, projectRevenueMapping, unmappedRevenueEvents,
roiDriverFactors,
} from '../db/schema';
import { requireRole } from '../middleware/role';
import { AppError } from '../middleware/error-handler';
import { hasProjectReadAccess, hasProjectWriteAccess } from '../middleware/project-access';
import { aiRateLimit } from '../middleware/ai-rate-limit';
import { getAllowedProjectIds } from '../services/permissions';
import { aggregate, aggregateMany } from '../services/roi/aggregator';
import { timeseries, type Granularity } from '../services/roi/timeseries';
import { invalidateStrategyCache } from '../services/roi/strategy-params';
import { suggestProjectTag } from '../services/roi/ai-tag-suggester';
import { applyAutoIdentifier } from '../services/roi/identifier-generator';
export const roiRoutes = new Hono();
// ✱ 全局权限:所有 ROI 相关端点(含 /api/projects/:id/{tag,cost-events,...})只允许 admin
roiRoutes.use('*', requireRole('admin'));
// ──────────────────────────────────────────
// 核心查询接口
// ──────────────────────────────────────────
const aggregateQuerySchema = z.object({
projectId: z.string().min(1),
from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
});
// GET /api/roi/aggregate?projectId=&from=&to=
roiRoutes.get('/roi/aggregate', zValidator('query', aggregateQuerySchema), async (c) => {
const user = c.get('user');
const { projectId, from, to } = c.req.valid('query');
if (!(await hasProjectReadAccess(user, projectId))) {
throw new AppError(40103, 'No access to project', 403);
}
const fromDate = new Date(from + 'T00:00:00+08:00');
const toDate = new Date(to + 'T23:59:59+08:00');
const result = await aggregate(projectId, fromDate, toDate);
return c.json({ code: 0, data: result, message: 'success' });
});
const timeseriesQuerySchema = aggregateQuerySchema.extend({
granularity: z.enum(['day', 'week', 'month', 'year']).default('month'),
});
// GET /api/roi/timeseries?projectId=&from=&to=&granularity=
roiRoutes.get('/roi/timeseries', zValidator('query', timeseriesQuerySchema), async (c) => {
const user = c.get('user');
const { projectId, from, to, granularity } = c.req.valid('query');
if (!(await hasProjectReadAccess(user, projectId))) {
throw new AppError(40103, 'No access to project', 403);
}
const fromDate = new Date(from + 'T00:00:00+08:00');
const toDate = new Date(to + 'T23:59:59+08:00');
const buckets = await timeseries(projectId, fromDate, toDate, granularity as Granularity);
return c.json({ code: 0, data: buckets, message: 'success' });
});
const dashboardQuerySchema = z.object({
from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
});
// GET /api/roi/dashboard?from=&to= — 全公司汇总(自动按权限过滤项目)
roiRoutes.get('/roi/dashboard',
requireRole('admin', 'manager'),
zValidator('query', dashboardQuerySchema),
async (c) => {
const user = c.get('user');
const { from, to } = c.req.valid('query');
const allowedIds = await getAllowedProjectIds(user); // admin/manager => null
let allProjects = await db.select().from(projects);
if (allowedIds !== null) {
allProjects = allProjects.filter(p => allowedIds.includes(p.id));
}
const projectIds = allProjects.map(p => p.id);
const fromDate = new Date(from + 'T00:00:00+08:00');
const toDate = new Date(to + 'T23:59:59+08:00');
const results = await aggregateMany(projectIds, fromDate, toDate);
// 按 category 分组汇总(给堆叠图用)
const byCategory: Record<string, { totalCost: number; totalRevenue: number; netProfit: number; projectCount: number }> = {};
let totalCost = 0, totalRevenue = 0;
const projectMap = new Map(allProjects.map(p => [p.id, p]));
const projectCards = results.map(r => {
const p = projectMap.get(r.projectId);
const cat = p?.category || 'uncategorized';
if (!byCategory[cat]) byCategory[cat] = { totalCost: 0, totalRevenue: 0, netProfit: 0, projectCount: 0 };
byCategory[cat].totalCost += r.totalCost;
byCategory[cat].totalRevenue += r.totalRevenue;
byCategory[cat].netProfit += r.netProfit;
byCategory[cat].projectCount += 1;
totalCost += r.totalCost;
totalRevenue += r.totalRevenue;
return {
projectId: r.projectId,
name: p?.name || '',
identifier: p?.identifier || '',
category: p?.category || null,
totalCost: r.totalCost,
totalRevenue: r.totalRevenue,
roiValue: r.roiValue,
confidence: r.confidence,
};
});
const companyRoi = totalCost > 0 ? Math.round((totalRevenue - totalCost) / totalCost * 10000) / 100 : null;
return c.json({
code: 0,
data: {
from, to,
summary: {
totalCost: Math.round(totalCost * 100) / 100,
totalRevenue: Math.round(totalRevenue * 100) / 100,
netProfit: Math.round((totalRevenue - totalCost) * 100) / 100,
roiValue: companyRoi,
projectCount: projectIds.length,
},
byCategory,
projects: projectCards,
},
message: 'success',
});
});
// ──────────────────────────────────────────
// 事件流写入/查询
// ──────────────────────────────────────────
const createCostEventSchema = z.object({
eventDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
costType: z.enum(['dev_hours', 'hardware_bom', 'service_fee', 'amortization', 'other']),
amount: z.number().min(0).max(1e8),
hours: z.number().min(0).optional().nullable(),
notes: z.string().max(500).optional().nullable(),
});
// POST /api/projects/:id/cost-events
roiRoutes.post('/projects/:id/cost-events',
zValidator('json', createCostEventSchema),
async (c) => {
const user = c.get('user');
const projectId = c.req.param('id');
if (!(await hasProjectWriteAccess(user, projectId))) {
throw new AppError(40103, 'No write access to project', 403);
}
const body = c.req.valid('json');
const id = uuid();
await db.insert(projectCostEvents).values({
id,
projectId,
eventDate: new Date(body.eventDate + 'T00:00:00+08:00'),
costType: body.costType,
amount: body.amount,
hours: body.hours ?? null,
hourlyRateUsed: null,
dataSource: 'manual',
refType: 'manual',
refId: id, // 手动事件用自己的 id 当 refId,保证唯一
notes: body.notes ?? null,
createdBy: user.sub,
createdAt: new Date(),
});
return c.json({ code: 0, data: { id }, message: 'success' });
});
const createRevenueEventSchema = z.object({
eventDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
revenueType: z.enum(['direct_revenue', 'subscription', 'saved_cost', 'asset_value_add', 'refund', 'other']),
amount: z.number().min(-1e8).max(1e8),
channel: z.string().max(50).optional().nullable(),
notes: z.string().max(500).optional().nullable(),
});
// POST /api/projects/:id/revenue-events
roiRoutes.post('/projects/:id/revenue-events',
zValidator('json', createRevenueEventSchema),
async (c) => {
const user = c.get('user');
const projectId = c.req.param('id');
if (!(await hasProjectWriteAccess(user, projectId))) {
throw new AppError(40103, 'No write access to project', 403);
}
const body = c.req.valid('json');
const id = uuid();
await db.insert(projectRevenueEvents).values({
id,
projectId,
eventDate: new Date(body.eventDate + 'T00:00:00+08:00'),
revenueType: body.revenueType,
amount: body.amount,
dataSource: 'manual',
refType: 'manual',
refId: id,
channel: body.channel ?? null,
notes: body.notes ?? null,
createdBy: user.sub,
createdAt: new Date(),
});
return c.json({ code: 0, data: { id }, message: 'success' });
});
const listEventsQuerySchema = z.object({
type: z.enum(['cost', 'revenue']),
from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
limit: z.coerce.number().min(1).max(500).default(100),
});
// GET /api/projects/:id/events?type=cost|revenue&from=&to=
roiRoutes.get('/projects/:id/events', zValidator('query', listEventsQuerySchema), async (c) => {
const user = c.get('user');
const projectId = c.req.param('id');
if (!(await hasProjectReadAccess(user, projectId))) {
throw new AppError(40103, 'No access to project', 403);
}
const { type, from, to, limit } = c.req.valid('query');
let fromDate = from ? new Date(from + 'T00:00:00+08:00') : dayjs().subtract(90, 'day').toDate();
const toDate = to ? new Date(to + 'T23:59:59+08:00') : new Date();
// 截断到 launchedAt(若有),跟 aggregate/timeseries 对齐 —— 事件流只显示算入 ROI 的事件
const [project] = await db.select().from(projects).where(eq(projects.id, projectId)).limit(1);
if (project?.launchedAt) {
const launchedAt = project.launchedAt instanceof Date ? project.launchedAt : new Date(project.launchedAt);
if (fromDate < launchedAt) fromDate = launchedAt;
}
if (type === 'cost') {
const rows = await db.select().from(projectCostEvents).where(and(
eq(projectCostEvents.projectId, projectId),
gte(projectCostEvents.eventDate, fromDate),
lte(projectCostEvents.eventDate, toDate)
)).orderBy(desc(projectCostEvents.eventDate)).limit(limit);
return c.json({ code: 0, data: rows, message: 'success' });
} else {
const rows = await db.select().from(projectRevenueEvents).where(and(
eq(projectRevenueEvents.projectId, projectId),
gte(projectRevenueEvents.eventDate, fromDate),
lte(projectRevenueEvents.eventDate, toDate)
)).orderBy(desc(projectRevenueEvents.eventDate)).limit(limit);
return c.json({ code: 0, data: rows, message: 'success' });
}
});
// DELETE /api/projects/:id/events/:eventId
roiRoutes.delete('/projects/:id/events/:eventId', async (c) => {
const user = c.get('user');
const projectId = c.req.param('id');
const eventId = c.req.param('eventId');
const type = c.req.query('type');
if (type !== 'cost' && type !== 'revenue') {
throw new AppError(40001, 'type query must be cost or revenue', 400);
}
if (!(await hasProjectWriteAccess(user, projectId))) {
throw new AppError(40103, 'No write access to project', 403);
}
if (type === 'cost') {
await db.delete(projectCostEvents).where(and(
eq(projectCostEvents.id, eventId),
eq(projectCostEvents.projectId, projectId)
));
} else {
await db.delete(projectRevenueEvents).where(and(
eq(projectRevenueEvents.id, eventId),
eq(projectRevenueEvents.projectId, projectId)
));
}
return c.json({ code: 0, data: null, message: 'success' });
});
// ──────────────────────────────────────────
// 策略配置
// ──────────────────────────────────────────
// GET /api/roi/strategies
roiRoutes.get('/roi/strategies', async (c) => {
const rows = await db.select().from(roiStrategies);
return c.json({ code: 0, data: rows, message: 'success' });
});
const patchStrategySchema = z.object({
params: z.object({
hourlyRate: z.number().min(0).max(10000).optional(),
amortYears: z.number().min(1).max(20).optional(),
commitHourCoef: z.number().min(0).max(40).optional(),
taskHourCoef: z.number().min(0).max(80).optional(),
}),
});
// PATCH /api/roi/strategies/:id
roiRoutes.patch('/roi/strategies/:id',
requireRole('admin'),
zValidator('json', patchStrategySchema),
async (c) => {
const id = c.req.param('id');
const body = c.req.valid('json');
const [existing] = await db.select().from(roiStrategies).where(eq(roiStrategies.id, id)).limit(1);
if (!existing) throw new AppError(40401, 'Strategy not found', 404);
const merged = { ...(existing.params as object), ...body.params };
await db.update(roiStrategies).set({
params: merged,
updatedAt: new Date(),
}).where(eq(roiStrategies.id, id));
invalidateStrategyCache();
return c.json({ code: 0, data: null, message: 'success' });
});
// ──────────────────────────────────────────
// 项目打标
// ──────────────────────────────────────────
const tagProjectSchema = z.object({
category: z.enum(['cash_cow', 'efficiency_tool', 'moat', 'composite']),
compositeStrategies: z.array(z.enum(['cash_cow', 'efficiency_tool', 'moat'])).optional().nullable(),
bizSystem: z.enum(['airhubs', 'airflow', 'aircore']).optional().nullable(),
projectType: z.enum(['hardware', 'software']).optional().nullable(),
ownerId: z.string().optional().nullable(),
launchedAt: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().nullable(),
vAsset: z.number().min(0).max(1e10).optional().nullable(),
tags: z.array(z.string()).optional().nullable(),
});
// POST /api/projects/:id/tag
roiRoutes.post('/projects/:id/tag',
zValidator('json', tagProjectSchema),
async (c) => {
const user = c.get('user');
const projectId = c.req.param('id');
if (!(await hasProjectWriteAccess(user, projectId))) {
throw new AppError(40103, 'No write access to project', 403);
}
const body = c.req.valid('json');
// 复合型 + 包含 moat,或者 category=moat,vAsset 必填
const needsAsset = body.category === 'moat' ||
(body.category === 'composite' && (body.compositeStrategies || []).includes('moat'));
if (needsAsset && (!body.vAsset || body.vAsset <= 0)) {
throw new AppError(40002, '资本护城河项目必须填写 vAsset (技术资产估值)', 422);
}
await db.update(projects).set({
category: body.category,
compositeStrategies: body.compositeStrategies ?? null,
ownerId: body.ownerId ?? null,
launchedAt: body.launchedAt ? new Date(body.launchedAt + 'T00:00:00+08:00') : null,
vAsset: body.vAsset ?? null,
tags: body.tags ?? null,
updatedAt: new Date(),
}).where(eq(projects.id, projectId));
// 若同时给出 bizSystem + projectType,自动生成新 identifier
let newIdentifier: string | null = null;
if (body.bizSystem && body.projectType) {
newIdentifier = await applyAutoIdentifier(projectId, body.bizSystem, body.projectType);
}
return c.json({ code: 0, data: { identifier: newIdentifier }, message: 'success' });
});
// ──────────────────────────────────────────
// 项目映射(业务方 key → DevPerf project)
// ──────────────────────────────────────────
// GET /api/roi/mapping
roiRoutes.get('/roi/mapping', requireRole('admin', 'manager'), async (c) => {
const rows = await db.select().from(projectRevenueMapping);
return c.json({ code: 0, data: rows, message: 'success' });
});
const createMappingSchema = z.object({
projectId: z.string().min(1),
businessProjectKey: z.string().min(1).max(100),
enabled: z.boolean().default(true),
notes: z.string().max(500).optional().nullable(),
});
// POST /api/roi/mapping
roiRoutes.post('/roi/mapping',
requireRole('admin'),
zValidator('json', createMappingSchema),
async (c) => {
const body = c.req.valid('json');
const now = new Date();
await db.insert(projectRevenueMapping).values({
id: uuid(),
projectId: body.projectId,
businessProjectKey: body.businessProjectKey,
enabled: body.enabled ? 1 : 0,
notes: body.notes ?? null,
createdAt: now,
updatedAt: now,
});
return c.json({ code: 0, data: null, message: 'success' });
});
// DELETE /api/roi/mapping/:id
roiRoutes.delete('/roi/mapping/:id', requireRole('admin'), async (c) => {
const id = c.req.param('id');
await db.delete(projectRevenueMapping).where(eq(projectRevenueMapping.id, id));
return c.json({ code: 0, data: null, message: 'success' });
});
// GET /api/roi/unmapped — 列出未映射的营收(管理员处理)
roiRoutes.get('/roi/unmapped', requireRole('admin', 'manager'), async (c) => {
const rows = await db.select().from(unmappedRevenueEvents)
.where(eq(unmappedRevenueEvents.status, 'pending'))
.orderBy(desc(unmappedRevenueEvents.createdAt))
.limit(200);
return c.json({ code: 0, data: rows, message: 'success' });
});
// ──────────────────────────────────────────
// AI
// ──────────────────────────────────────────
// POST /api/projects/:id/suggest-tag — AI 推荐项目分类
roiRoutes.post('/projects/:id/suggest-tag',
requireRole('admin', 'manager'),
aiRateLimit({ perUserPerMinute: 5, perProjectPerDay: 20 }),
async (c) => {
const user = c.get('user');
const projectId = c.req.param('id');
if (!(await hasProjectWriteAccess(user, projectId))) {
throw new AppError(40103, 'No write access to project', 403);
}
try {
const result = await suggestProjectTag(projectId);
return c.json({ code: 0, data: result, message: 'success' });
} catch (e) {
const msg = (e as Error).message;
throw new AppError(50003, `AI 推荐失败: ${msg.slice(0, 200)}`, 502);
}
});
// GET /api/projects/:id/driver-factors?periodKey=YYYY-MM — 查询某月驱动因子文案
roiRoutes.get('/projects/:id/driver-factors', async (c) => {
const user = c.get('user');
const projectId = c.req.param('id');
if (!(await hasProjectReadAccess(user, projectId))) {
throw new AppError(40103, 'No access to project', 403);
}
const periodKey = c.req.query('periodKey');
let rows;
if (periodKey) {
rows = await db.select().from(roiDriverFactors).where(and(
eq(roiDriverFactors.projectId, projectId),
eq(roiDriverFactors.periodKey, periodKey)
));
} else {
// 不传则返回最近 3 个月
rows = await db.select().from(roiDriverFactors)
.where(eq(roiDriverFactors.projectId, projectId))
.orderBy(desc(roiDriverFactors.generatedAt))
.limit(3);
}
return c.json({ code: 0, data: rows, message: 'success' });
});

View File

@ -1,6 +1,6 @@
import { eq } from 'drizzle-orm';
import { SignJWT } from 'jose';
import bcrypt from 'bcrypt';
import bcrypt from 'bcryptjs';
import { db } from '../db/index';
import { users } from '../db/schema';
import { config } from '../config';

View File

@ -0,0 +1,78 @@
import { config } from '../config';
/**
* ArkLLM API
* OpenAI /chat/completions
*/
export async function callLLM(systemPrompt: string, userPrompt: string): Promise<string> {
if (!config.AI_API_KEY) {
throw new Error('AI_API_KEY is not configured');
}
const url = `${config.AI_BASE_URL}/chat/completions`;
const body = {
model: config.AI_MODEL,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt },
],
temperature: 0.3,
};
let lastError: Error | null = null;
// 最多重试 1 次
for (let attempt = 0; attempt < 2; attempt++) {
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Authorization': `Bearer ${config.AI_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(120000), // 120s豆包模型响应较慢
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Doubao API error ${response.status}: ${errorText}`);
}
const data = await response.json();
return data.choices[0].message.content;
} catch (err) {
lastError = err instanceof Error ? err : new Error(String(err));
console.warn(`[LLM] Attempt ${attempt + 1} failed: ${lastError.message}`);
if (attempt === 0) {
await new Promise(resolve => setTimeout(resolve, 3000));
}
}
}
throw lastError!;
}
/**
* LLM JSON
* parse ```json 代码块
*/
export function parseLLMJson<T = any>(text: string): T {
// 先直接尝试
try {
return JSON.parse(text);
} catch {
// 尝试提取 ```json ... ``` 代码块
const match = text.match(/```json\s*([\s\S]*?)```/);
if (match) {
return JSON.parse(match[1].trim());
}
// 尝试提取第一个 { ... } 块
const braceMatch = text.match(/\{[\s\S]*\}/);
if (braceMatch) {
return JSON.parse(braceMatch[0]);
}
throw new Error(`Failed to parse LLM response as JSON: ${text.substring(0, 200)}`);
}
}

View File

@ -0,0 +1,608 @@
import { v4 as uuid } from 'uuid';
import { eq, isNull, sql, inArray } from 'drizzle-orm';
import { db } from '../db/index';
import {
gitCommits, aiAnalyzedCommits, projectRepos, projects,
objectives, keyResults, krLogs, syncLogs, users,
} from '../db/schema';
import { callLLM, parseLLMJson } from './llm-client';
import { dateToPeriod, recalcObjectiveProgress } from './okr';
import dayjs from 'dayjs';
// ── Types ──
interface AIAction {
type: 'create_objective' | 'create_kr' | 'update_progress' | 'complete_kr';
ownerId?: string;
objectiveId?: string;
krId?: string;
title?: string;
sourceKey?: string;
currentValue?: number;
newCurrentValue?: number;
startDate?: string;
endDate?: string;
reasoning?: string;
keyResults?: {
title: string;
sourceKey: string;
currentValue: number;
startDate?: string;
endDate?: string;
}[];
}
interface AIResponse {
actions: AIAction[];
summary: string;
}
interface CommitGroup {
repoName: string;
projectId: string;
projectName: string;
commits: {
sha: string;
authorName: string | null;
userId: string | null;
message: string | null;
additions: number;
deletions: number;
committedAt: Date;
}[];
}
// ── System Prompt ──
const SYSTEM_PROMPT = `你是一个开发团队的 OKR 管理助手。你的任务是分析 git commit 记录,管理项目的 OKR目标与关键成果
JSON
- commit "完成""done""finished""实现完毕" KR
- commit feat/feature KR
- commit KR <EFBFBD><EFBFBD> KR
- fix/refactor/chore/docs commit
- KR sourceKey
Objective
- Objective Objective
- Objective startDate endDate
- period "YYYY-Qn" "2026-Q2" endDate
KR
- targetValue = 100, unit = "%"
- commit currentValue=30, =70, =100
- sourceKey 线 "user-login", "payment-module"
- title
- startDate endDate
action ownerId userId OKR `;
// ── Core Functions ──
/**
* commits repoName
*/
async function gatherUnanalyzedCommits(): Promise<CommitGroup[]> {
// 获取所有已分析的 SHA
const analyzed = await db.select({ sha: aiAnalyzedCommits.commitSha }).from(aiAnalyzedCommits);
const analyzedSet = new Set(analyzed.map(a => a.sha));
// 获取所有 commits有 userId 的,且 4 月份及之后的)
const cutoffDate = new Date('2026-01-01T00:00:00');
const allCommits = await db.select().from(gitCommits);
const unanalyzed = allCommits.filter(c =>
c.userId && !analyzedSet.has(c.sha) && new Date(c.committedAt) >= cutoffDate
);
if (unanalyzed.length === 0) return [];
// 获取 projectRepos 映射 repoName → projectId
const bindings = await db.select().from(projectRepos);
const repoToProject = new Map<string, string>();
for (const b of bindings) {
// 支持多种格式的 repoName 匹配
const name = extractRepoName(b.repoName);
repoToProject.set(name, b.projectId);
}
// 获取 projects 名称
const allProjects = await db.select().from(projects);
const projectMap = new Map(allProjects.map(p => [p.id, p.name]));
// 按 repoName 分组
const groups = new Map<string, CommitGroup>();
for (const commit of unanalyzed) {
const projectId = repoToProject.get(commit.repoName);
if (!projectId) continue; // 仓库未绑定项目,跳过
if (!groups.has(commit.repoName)) {
groups.set(commit.repoName, {
repoName: commit.repoName,
projectId,
projectName: projectMap.get(projectId) || commit.repoName,
commits: [],
});
}
groups.get(commit.repoName)!.commits.push({
sha: commit.sha,
authorName: commit.authorName,
userId: commit.userId,
message: commit.message,
additions: commit.additions || 0,
deletions: commit.deletions || 0,
committedAt: commit.committedAt,
});
}
// 按时间排序每组的 commits
for (const group of groups.values()) {
group.commits.sort((a, b) => new Date(a.committedAt).getTime() - new Date(b.committedAt).getTime());
}
return Array.from(groups.values());
}
/**
*
*/
function extractRepoName(input: string): string {
let cleaned = input.trim().replace(/\.git$/, '');
if (cleaned.includes('://')) {
try {
const url = new URL(cleaned);
const parts = url.pathname.split('/').filter(Boolean);
return parts.length >= 2 ? parts[1] : parts[0];
} catch { /* fall through */ }
}
if (cleaned.includes('/')) {
return cleaned.split('/').pop()!;
}
return cleaned;
}
/**
* AI prompt
*/
async function buildUserPrompt(group: CommitGroup): Promise<string> {
// 获取该项目下所有现有 OKR
const existingObjs = await db.select().from(objectives)
.where(eq(objectives.projectId, group.projectId));
// 获取开发人员列表(从 commits 中提取 unique userId
const authorIds = [...new Set(group.commits.map(c => c.userId).filter(Boolean))] as string[];
const authorUsers = authorIds.length > 0
? await db.select().from(users).where(inArray(users.id, authorIds))
: [];
const userMap = new Map(authorUsers.map(u => [u.id, u.displayName]));
let prompt = `仓库:${group.repoName}\n所属项目${group.projectName}\n当前日期${dayjs().format('YYYY-MM-DD')}\n\n`;
// 开发人员列表
prompt += `该仓库的开发人员commit 提交人 → 系统用户映射):\n`;
for (const userId of authorIds) {
prompt += `- userId: "${userId}", 姓名: ${userMap.get(userId) || '未知'}\n`;
}
prompt += '\n';
// 已有 OKR
if (existingObjs.length > 0) {
prompt += '该项目已有的 OKR\n';
for (const obj of existingObjs) {
const owner = obj.ownerId ? userMap.get(obj.ownerId) : null;
prompt += `Objective: id="${obj.id}", title="${obj.title}", 负责人=${owner || '未指定'}, period=${obj.period}, 时间=${obj.startDate || '?'}~${obj.endDate || '?'}, 进度=${obj.progress || 0}%\n`;
const krs = await db.select().from(keyResults)
.where(eq(keyResults.objectiveId, obj.id));
if (krs.length > 0) {
prompt += ' Key Results:\n';
for (const kr of krs) {
prompt += ` - id: "${kr.id}", title: "${kr.title}", sourceKey: "${kr.sourceKey || ''}", status: ${kr.status}, 进度: ${kr.currentValue || 0}/${kr.targetValue}, 时间: ${kr.startDate || '?'}~${kr.endDate || '?'}\n`;
}
}
}
} else {
prompt += '该项目暂无 OKR 记录。\n';
}
// 新增 commits
prompt += `\n该仓库新增的提交记录按时间排序均为增量之前的已处理过\n`;
for (const commit of group.commits) {
const date = dayjs(commit.committedAt).format('YYYY-MM-DD HH:mm');
const sha7 = commit.sha.substring(0, 7);
const name = commit.authorName || '未知';
const userId = commit.userId || '?';
prompt += `- [${date}] 提交人: ${name}(userId=${userId}) ${sha7}: ${commit.message || '(无消息)'} (+${commit.additions}/-${commit.deletions})\n`;
}
prompt += `\n请分析以上提交返回 JSON。注意每个 action 都必须带 ownerId 字段,值为提交人的 userId表示该 OKR 归谁负责。
{
"actions": [
{
"type": "create_objective",
"ownerId": "提交人的userId",
"title": "目标标题",
"startDate": "YYYY-MM-DD",
"endDate": "YYYY-MM-DD",
"reasoning": "为什么要创建这个目标",
"keyResults": [
{ "title": "...", "sourceKey": "...", "currentValue": 30, "startDate": "YYYY-MM-DD", "endDate": "YYYY-MM-DD" }
]
},
{
"type": "create_kr",
"ownerId": "提交人的userId",
"objectiveId": "已有目标的id",
"title": "...",
"sourceKey": "...",
"currentValue": 30,
"startDate": "YYYY-MM-DD",
"endDate": "YYYY-MM-DD",
"reasoning": "..."
},
{
"type": "update_progress",
"krId": "已有KR的id",
"newCurrentValue": 70,
"reasoning": "..."
},
{
"type": "complete_kr",
"krId": "已有KR的id",
"reasoning": "..."
}
],
"summary": "一句话总结该仓库近期开发动态"
}
{"actions": [], "summary": "..."}`;
return prompt;
}
/**
* AI actions
*/
async function executeActions(actions: AIAction[], projectId: string): Promise<number> {
let executedCount = 0;
const now = new Date();
for (const action of actions) {
try {
switch (action.type) {
case 'create_objective': {
if (!action.title || !action.ownerId) break;
const objId = uuid();
// 计算当前季度末
const now2 = dayjs();
const currentQuarter = Math.ceil((now2.month() + 1) / 3);
const quarterEnd = dayjs(`${now2.year()}-${currentQuarter * 3}-01`).endOf('month');
const endDate = action.endDate || quarterEnd.format('YYYY-MM-DD');
const period = dateToPeriod(endDate);
await db.insert(objectives).values({
id: objId,
title: action.title,
ownerId: action.ownerId,
projectId,
period,
startDate: action.startDate || dayjs().format('YYYY-MM-DD'),
endDate,
progress: 0,
source: 'ai_generated',
createdAt: now,
updatedAt: now,
});
// 创建附带的 KRs
if (action.keyResults?.length) {
for (const krData of action.keyResults) {
// sourceKey 去重检查
if (krData.sourceKey) {
const existing = await db.select().from(keyResults)
.where(eq(keyResults.sourceKey, krData.sourceKey));
if (existing.some(kr => kr.objectiveId === objId)) continue;
}
const krId = uuid();
await db.insert(keyResults).values({
id: krId,
objectiveId: objId,
title: krData.title,
targetValue: 100,
currentValue: krData.currentValue || 0,
unit: '%',
weight: 1,
status: krData.currentValue >= 100 ? 'completed' : 'active',
startDate: krData.startDate || action.startDate,
endDate: krData.endDate || action.endDate,
source: 'ai_generated',
sourceKey: krData.sourceKey || null,
createdAt: now,
updatedAt: now,
});
await addAILog(krId, 'created', `AI 自动创建: ${action.reasoning || ''}`);
executedCount++;
}
}
await recalcObjectiveProgress(objId);
executedCount++;
console.info(`[AI-OKR] Created objective: ${action.title}`);
break;
}
case 'create_kr': {
if (!action.objectiveId || !action.title) break;
// 验证 objective 存在
const obj = await db.query.objectives.findFirst({
where: eq(objectives.id, action.objectiveId),
});
if (!obj) {
console.warn(`[AI-OKR] Objective ${action.objectiveId} not found, skipping create_kr`);
break;
}
// sourceKey 去重
if (action.sourceKey) {
const existing = await db.select().from(keyResults)
.where(eq(keyResults.sourceKey, action.sourceKey));
if (existing.some(kr => kr.objectiveId === action.objectiveId)) {
// 已存在同 sourceKey转为更新进度
const existingKR = existing.find(kr => kr.objectiveId === action.objectiveId)!;
if (action.currentValue && action.currentValue > (existingKR.currentValue || 0)) {
await db.update(keyResults)
.set({ currentValue: action.currentValue, updatedAt: now })
.where(eq(keyResults.id, existingKR.id));
await addAILog(existingKR.id, 'progress_update',
`AI 更新sourceKey 重复转更新): ${existingKR.currentValue}${action.currentValue}`);
await recalcObjectiveProgress(action.objectiveId);
}
executedCount++;
break;
}
}
const krId = uuid();
await db.insert(keyResults).values({
id: krId,
objectiveId: action.objectiveId,
title: action.title,
targetValue: 100,
currentValue: action.currentValue || 0,
unit: '%',
weight: 1,
status: (action.currentValue || 0) >= 100 ? 'completed' : 'active',
startDate: action.startDate || null,
endDate: action.endDate || null,
source: 'ai_generated',
sourceKey: action.sourceKey || null,
createdAt: now,
updatedAt: now,
});
await addAILog(krId, 'created', `AI 自动创建: ${action.reasoning || ''}`);
await recalcObjectiveProgress(action.objectiveId);
executedCount++;
console.info(`[AI-OKR] Created KR: ${action.title}`);
break;
}
case 'update_progress': {
if (!action.krId || action.newCurrentValue === undefined) break;
const kr = await db.query.keyResults.findFirst({
where: eq(keyResults.id, action.krId),
});
if (!kr) {
console.warn(`[AI-OKR] KR ${action.krId} not found, skipping update_progress`);
break;
}
// 只允许进度前进,不允许后退
if (action.newCurrentValue <= (kr.currentValue || 0)) break;
const clampedValue = Math.min(action.newCurrentValue, kr.targetValue);
await db.update(keyResults)
.set({ currentValue: clampedValue, updatedAt: now })
.where(eq(keyResults.id, action.krId));
await addAILog(action.krId, 'progress_update',
`AI 更新进度: ${kr.currentValue}${clampedValue}${action.reasoning || ''}`);
await recalcObjectiveProgress(kr.objectiveId);
executedCount++;
console.info(`[AI-OKR] Updated KR progress: ${kr.title}${clampedValue}`);
break;
}
case 'complete_kr': {
if (!action.krId) break;
const kr = await db.query.keyResults.findFirst({
where: eq(keyResults.id, action.krId),
});
if (!kr) {
console.warn(`[AI-OKR] KR ${action.krId} not found, skipping complete_kr`);
break;
}
if (kr.status === 'completed') break; // 已完成则跳过
await db.update(keyResults)
.set({ status: 'completed', currentValue: kr.targetValue, updatedAt: now })
.where(eq(keyResults.id, action.krId));
await addAILog(action.krId, 'completed',
`AI 判定完成: ${action.reasoning || ''}`);
await recalcObjectiveProgress(kr.objectiveId);
executedCount++;
console.info(`[AI-OKR] Completed KR: ${kr.title}`);
break;
}
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`[AI-OKR] Error executing action ${action.type}: ${msg}`);
}
}
return executedCount;
}
/**
* KR AI
*/
async function addAILog(krId: string, action: string, detail: string) {
await db.insert(krLogs).values({
id: uuid(),
krId,
action,
detail,
operatorId: null,
operatorName: 'AI System',
createdAt: new Date(),
});
}
/**
* commits
*/
async function markCommitsAnalyzed(shas: string[], batchId: string) {
const now = new Date();
for (const sha of shas) {
try {
await db.insert(aiAnalyzedCommits).values({
id: uuid(),
commitSha: sha,
batchId,
createdAt: now,
});
} catch {
// 可能已经存在unique constraint忽略
}
}
}
// ── Main Entry ──
export interface AnalyzeResult {
totalCommits: number;
reposProcessed: number;
actionsExecuted: number;
summaries: { repo: string; summary: string }[];
}
/**
* commits OKR
* @param dryRun true AI
*/
export async function analyzeCommitsForOKR(dryRun = false): Promise<AnalyzeResult> {
const startTime = Date.now();
const batchId = uuid();
let totalCommits = 0;
let actionsExecuted = 0;
const summaries: { repo: string; summary: string }[] = [];
const repoErrors: { repo: string; error: string }[] = [];
try {
const groups = await gatherUnanalyzedCommits();
if (groups.length === 0) {
console.info('[AI-OKR] No unanalyzed commits found, skipping');
const elapsed = Date.now() - startTime;
await db.insert(syncLogs).values({
id: uuid(),
source: 'ai_okr',
status: 'success',
message: `${dryRun ? '[DRY RUN] ' : ''}No unanalyzed commits, skipped in ${elapsed}ms`,
recordsProcessed: 0,
syncedAt: new Date(),
});
return { totalCommits: 0, reposProcessed: 0, actionsExecuted: 0, summaries: [] };
}
const MAX_COMMITS_PER_BATCH = 30;
for (const group of groups) {
try {
totalCommits += group.commits.length;
// 分批处理,每批最多 30 条 commits
const batches: typeof group.commits[] = [];
for (let i = 0; i < group.commits.length; i += MAX_COMMITS_PER_BATCH) {
batches.push(group.commits.slice(i, i + MAX_COMMITS_PER_BATCH));
}
for (let batchIdx = 0; batchIdx < batches.length; batchIdx++) {
const batchCommits = batches[batchIdx];
const batchGroup = { ...group, commits: batchCommits };
console.info(`[AI-OKR] Analyzing ${batchCommits.length} commits for repo: ${group.repoName} (batch ${batchIdx + 1}/${batches.length})`);
// 构建 prompt 并调用 AI
const userPrompt = await buildUserPrompt(batchGroup);
const rawResponse = await callLLM(SYSTEM_PROMPT, userPrompt);
const aiResponse = parseLLMJson<AIResponse>(rawResponse);
if (!aiResponse.actions || !Array.isArray(aiResponse.actions)) {
console.warn(`[AI-OKR] Invalid AI response for ${group.repoName}, skipping batch`);
await markCommitsAnalyzed(batchCommits.map(c => c.sha), batchId);
continue;
}
if (batchIdx === batches.length - 1) {
summaries.push({ repo: group.repoName, summary: aiResponse.summary || '' });
}
if (!dryRun) {
const count = await executeActions(aiResponse.actions, group.projectId);
actionsExecuted += count;
await markCommitsAnalyzed(batchCommits.map(c => c.sha), batchId);
} else {
console.info(`[AI-OKR] [DRY RUN] Would execute ${aiResponse.actions.length} actions for ${group.repoName}`);
actionsExecuted += aiResponse.actions.length;
}
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`[AI-OKR] Failed to analyze repo ${group.repoName}: ${msg}`);
repoErrors.push({ repo: group.repoName, error: msg });
}
}
// 记录 sync log成功或部分失败
const elapsed = Date.now() - startTime;
const status = repoErrors.length === groups.length ? 'error' : 'success';
const errorSuffix = repoErrors.length > 0
? `; failed repos: ${repoErrors.map(e => `${e.repo}(${e.error})`).join(' | ')}`
: '';
await db.insert(syncLogs).values({
id: uuid(),
source: 'ai_okr',
status,
message: `${dryRun ? '[DRY RUN] ' : ''}Analyzed ${totalCommits} commits from ${groups.length} repos, executed ${actionsExecuted} actions in ${elapsed}ms${errorSuffix}`.slice(0, 1000),
recordsProcessed: actionsExecuted,
syncedAt: new Date(),
});
console.info(`[AI-OKR] Completed: ${totalCommits} commits, ${groups.length} repos, ${actionsExecuted} actions in ${elapsed}ms`);
return { totalCommits, reposProcessed: groups.length, actionsExecuted, summaries };
} catch (err) {
const msg = err instanceof Error ? `${err.message}\n${err.stack || ''}` : String(err);
const elapsed = Date.now() - startTime;
console.error(`[AI-OKR] Fatal error: ${msg}`);
await db.insert(syncLogs).values({
id: uuid(),
source: 'ai_okr',
status: 'error',
message: `${dryRun ? '[DRY RUN] ' : ''}Fatal error after ${elapsed}ms: ${msg}`.slice(0, 1000),
recordsProcessed: actionsExecuted,
syncedAt: new Date(),
});
throw err;
}
}

View File

@ -1,4 +1,4 @@
import { eq } from 'drizzle-orm';
import { eq, inArray } from 'drizzle-orm';
import { v4 as uuid } from 'uuid';
import { db } from '../db/index';
import { objectives, keyResults, users, projects, krLogs } from '../db/schema';
@ -6,41 +6,67 @@ import { desc } from 'drizzle-orm';
import { AppError } from '../middleware/error-handler';
export async function getOKRByPeriod(period?: string) {
// 1. 拿 objectives(按 period 可选过滤)
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));
if (allObjectives.length === 0) return { objectives: [] };
const owner = obj.ownerId
? await db.query.users.findFirst({ where: eq(users.id, obj.ownerId) })
: null;
const objIds = allObjectives.map(o => o.id);
const ownerIds = Array.from(new Set(allObjectives.map(o => o.ownerId).filter(Boolean) as string[]));
const projectIds = Array.from(new Set(allObjectives.map(o => o.projectId).filter(Boolean) as string[]));
const project = obj.projectId
? await db.query.projects.findFirst({ where: eq(projects.id, obj.projectId) })
: null;
// 2. 一次性批量拉 KRs / users / projects
const [allKRs, allOwners, allProjects] = await Promise.all([
db.select().from(keyResults).where(inArray(keyResults.objectiveId, objIds)),
ownerIds.length > 0 ? db.select().from(users).where(inArray(users.id, ownerIds)) : Promise.resolve([]),
projectIds.length > 0 ? db.select().from(projects).where(inArray(projects.id, projectIds)) : Promise.resolve([]),
]);
result.push({
// 3. 一次性批量拉所有 KR 的 logs(只取 postponed 类型,减少传输)
const krIds = allKRs.map(k => k.id);
const allLogs = krIds.length > 0
? await db.select().from(krLogs)
.where(inArray(krLogs.krId, krIds))
.orderBy(desc(krLogs.createdAt))
: [];
// 4. 索引化
const krsByObj = new Map<string, typeof allKRs>();
for (const kr of allKRs) {
if (!krsByObj.has(kr.objectiveId)) krsByObj.set(kr.objectiveId, []);
krsByObj.get(kr.objectiveId)!.push(kr);
}
const ownerById = new Map(allOwners.map(u => [u.id, u]));
const projectById = new Map(allProjects.map(p => [p.id, p]));
// 每个 KR 取它的"最近一条 postponed log"
const postponedByKr = new Map<string, string>();
for (const log of allLogs) { // 已按 createdAt desc 排序
if (log.action === 'postponed' && !postponedByKr.has(log.krId)) {
postponedByKr.set(log.krId, log.detail || '');
}
}
// 5. 组装(纯内存,O(n))
const result = allObjectives.map(obj => {
const krs = krsByObj.get(obj.id) || [];
const owner = obj.ownerId ? ownerById.get(obj.ownerId) : null;
const project = obj.projectId ? projectById.get(obj.projectId) : null;
return {
id: obj.id,
title: obj.title,
ownerName: owner?.displayName || '未指定',
projectId: obj.projectId || null,
projectName: project?.name || '未关联项目',
period: obj.period,
startDate: obj.startDate || null,
endDate: obj.endDate || null,
progress: obj.progress || 0,
keyResults: await Promise.all(krs.map(async kr => {
// 查是否有延期记录
const postponeLog = await db.select().from(krLogs)
.where(eq(krLogs.krId, kr.id))
.orderBy(desc(krLogs.createdAt))
.limit(5);
const wasPostponed = postponeLog.some(l => l.action === 'postponed');
const lastPostponeReason = postponeLog.find(l => l.action === 'postponed')?.detail || null;
keyResults: krs.map(kr => {
const lastPostponeReason = postponedByKr.get(kr.id) ?? null;
return {
id: kr.id,
title: kr.title,
@ -49,7 +75,7 @@ export async function getOKRByPeriod(period?: string) {
unit: kr.unit || '',
weight: kr.weight || 1,
status: kr.status || 'active',
wasPostponed,
wasPostponed: lastPostponeReason !== null,
lastPostponeReason,
startDate: kr.startDate || null,
endDate: kr.endDate || null,
@ -57,9 +83,9 @@ export async function getOKRByPeriod(period?: string) {
? Math.round(((kr.currentValue || 0) / kr.targetValue) * 100)
: 0,
};
})),
}),
};
});
}
return { objectives: result };
}
@ -67,7 +93,7 @@ export async function getOKRByPeriod(period?: string) {
/**
* 2026-04-15 "2026-Q2"
*/
function dateToPeriod(dateStr: string): string {
export function dateToPeriod(dateStr: string): string {
const d = new Date(dateStr);
const year = d.getFullYear();
const q = Math.ceil((d.getMonth() + 1) / 3);
@ -255,14 +281,17 @@ async function addKRLog(krId: string, action: string, detail: string | null, ope
});
}
async function recalcObjectiveProgress(objectiveId: string) {
export async function recalcObjectiveProgress(objectiveId: string) {
const allKRs = await db.select().from(keyResults)
.where(eq(keyResults.objectiveId, objectiveId));
// 只算 active 和已完成的 KR暂停和取消的不计入
// 只算 active 和 completed 的 KR暂停和取消的不计入
const activeKRs = allKRs.filter(kr => kr.status !== 'paused' && kr.status !== 'cancelled');
const totalWeight = activeKRs.reduce((sum, k) => sum + (k.weight || 1), 0);
const weightedProgress = activeKRs.reduce((sum, k) => {
const progress = k.targetValue > 0 ? ((k.currentValue || 0) / k.targetValue) * 100 : 0;
// completed 状态按 100% 计入
const progress = k.status === 'completed'
? 100
: (k.targetValue > 0 ? ((k.currentValue || 0) / k.targetValue) * 100 : 0);
return sum + progress * (k.weight || 1);
}, 0);
const objProgress = totalWeight > 0 ? Math.round(weightedProgress / totalWeight) : 0;

View File

@ -0,0 +1,21 @@
import { eq } from 'drizzle-orm';
import { db } from '../db/index';
import { userProjectPermissions } from '../db/schema';
import type { JWTPayload } from '../middleware/auth';
/**
* ID
* - admin / manager null
* - viewer / developer ID
*/
export async function getAllowedProjectIds(user: JWTPayload): Promise<string[] | null> {
if (user.role !== 'viewer' && user.role !== 'developer') {
return null; // admin / manager 不做项目级过滤
}
const perms = await db.select({ projectId: userProjectPermissions.projectId })
.from(userProjectPermissions)
.where(eq(userProjectPermissions.userId, user.sub));
return perms.map(p => p.projectId);
}

View File

@ -0,0 +1,150 @@
import { and, eq, gte, lte } from 'drizzle-orm';
import { db } from '../../db/index';
import { projects, projectCostEvents, projectRevenueEvents } from '../../db/schema';
import { calculateBep } from './bep-calculator';
import { evaluateConfidence } from './confidence-evaluator';
import type { AggregateResult, CostBreakdown, RevenueBreakdown } from './types';
function toDate(input: string | Date): Date {
return input instanceof Date ? input : new Date(input);
}
function toIsoDay(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
const ZERO_COST: CostBreakdown = { devHours: 0, hardwareBom: 0, serviceFee: 0, amortization: 0, other: 0 };
const ZERO_REVENUE: RevenueBreakdown = { directRevenue: 0, subscription: 0, savedCost: 0, assetValueAdd: 0, refund: 0, other: 0 };
/**
* [from, to] , ROI
* - from/to projects.launchedAt launchedAt
* - TotalCost = 0 roiValue null
*/
export async function aggregate(
projectId: string,
from: string | Date,
to: string | Date
): Promise<AggregateResult> {
let fromDate = toDate(from);
const toDateObj = toDate(to);
// 截断到 launchedAt
const [project] = await db.select().from(projects).where(eq(projects.id, projectId)).limit(1);
if (project?.launchedAt) {
const launchedAt = project.launchedAt instanceof Date ? project.launchedAt : new Date(project.launchedAt);
if (fromDate < launchedAt) fromDate = launchedAt;
}
// 拉取窗口内的事件
const costEvents = await db.select().from(projectCostEvents).where(
and(
eq(projectCostEvents.projectId, projectId),
gte(projectCostEvents.eventDate, fromDate),
lte(projectCostEvents.eventDate, toDateObj)
)
);
const revenueEvents = await db.select().from(projectRevenueEvents).where(
and(
eq(projectRevenueEvents.projectId, projectId),
gte(projectRevenueEvents.eventDate, fromDate),
lte(projectRevenueEvents.eventDate, toDateObj)
)
);
// 聚合
const costBreakdown: CostBreakdown = { ...ZERO_COST };
let totalCost = 0;
for (const e of costEvents) {
totalCost += e.amount;
switch (e.costType) {
case 'dev_hours': costBreakdown.devHours += e.amount; break;
case 'hardware_bom': costBreakdown.hardwareBom += e.amount; break;
case 'service_fee': costBreakdown.serviceFee += e.amount; break;
case 'amortization': costBreakdown.amortization += e.amount; break;
default: costBreakdown.other += e.amount;
}
}
const revenueBreakdown: RevenueBreakdown = { ...ZERO_REVENUE };
let totalRevenue = 0;
for (const e of revenueEvents) {
totalRevenue += e.amount;
switch (e.revenueType) {
case 'direct_revenue': revenueBreakdown.directRevenue += e.amount; break;
case 'subscription': revenueBreakdown.subscription += e.amount; break;
case 'saved_cost': revenueBreakdown.savedCost += e.amount; break;
case 'asset_value_add': revenueBreakdown.assetValueAdd += e.amount; break;
case 'refund': revenueBreakdown.refund += e.amount; break;
default: revenueBreakdown.other += e.amount;
}
}
const netProfit = totalRevenue - totalCost;
const roiValue = totalCost > 0 ? (netProfit / totalCost) * 100 : null;
const confidence = evaluateConfidence(costEvents, revenueEvents);
// BEP 只在累计窗口(from = launchedAt)有意义,且 roi < 100% 时计算
const isLifetimeWindow = project?.launchedAt && Math.abs(fromDate.getTime() - new Date(project.launchedAt).getTime()) < 24 * 3600 * 1000;
let bepDays: number | null = null;
if (isLifetimeWindow && roiValue !== null) {
bepDays = await calculateBep(projectId, totalCost, totalRevenue, toDateObj);
}
return {
projectId,
from: toIsoDay(fromDate),
to: toIsoDay(toDateObj),
totalCost: round2(totalCost),
totalRevenue: round2(totalRevenue),
netProfit: round2(netProfit),
roiValue: roiValue === null ? null : round2(roiValue),
confidence,
bepDays,
costBreakdown: {
devHours: round2(costBreakdown.devHours),
hardwareBom: round2(costBreakdown.hardwareBom),
serviceFee: round2(costBreakdown.serviceFee),
amortization: round2(costBreakdown.amortization),
other: round2(costBreakdown.other),
},
revenueBreakdown: {
directRevenue: round2(revenueBreakdown.directRevenue),
subscription: round2(revenueBreakdown.subscription),
savedCost: round2(revenueBreakdown.savedCost),
assetValueAdd: round2(revenueBreakdown.assetValueAdd),
refund: round2(revenueBreakdown.refund),
other: round2(revenueBreakdown.other),
},
costEventCount: costEvents.length,
revenueEventCount: revenueEvents.length,
};
}
function round2(n: number): number {
return Math.round(n * 100) / 100;
}
/**
* ()
* , DB
*/
export async function aggregateMany(
projectIds: string[],
from: string | Date,
to: string | Date,
concurrency = 8
): Promise<AggregateResult[]> {
const results: AggregateResult[] = [];
for (let i = 0; i < projectIds.length; i += concurrency) {
const batch = projectIds.slice(i, i + concurrency);
const batchResults = await Promise.all(batch.map(id => aggregate(id, from, to)));
results.push(...batchResults);
}
return results;
}

View File

@ -0,0 +1,167 @@
import { v4 as uuid } from 'uuid';
import { and, desc, eq, gte } from 'drizzle-orm';
import dayjs from 'dayjs';
import { db } from '../../db/index';
import {
projects, projectRepos, gitCommits, objectives,
roiDriverFactors, syncLogs,
} from '../../db/schema';
import { callLLM, parseLLMJson } from '../llm-client';
import { aggregate } from './aggregator';
export interface DriverFactor {
type: '现金流驱动' | '降本增效驱动' | '技术资产驱动';
text: string;
}
const SYSTEM_PROMPT = `你是软件项目价值分析师。给定项目本月 ROI 数据和近期工作内容,生成 1-3 条"价值驱动因子"文案,告诉管理者这个项目的价值来源是什么。
:
- 现金流驱动: 项目直接产生营收,
- 降本增效驱动: 项目通过工具化/
- 技术资产驱动: 项目沉淀了技术能力//,
1-3 JSON :
{
"factors": [
{ "type": "现金流驱动" | "降本增效驱动" | "技术资产驱动", "text": "60字内的简短说明" }
]
}
text 60 ,,使`;
const ALLOWED_TYPES = new Set(['现金流驱动', '降本增效驱动', '技术资产驱动']);
/**
* "上月", roi_driver_factors
* periodKey = YYYY-MM
*/
export async function generateDriverFactorsForProject(projectId: string, asOf: Date = new Date()): Promise<DriverFactor[]> {
const lastMonth = dayjs(asOf).subtract(1, 'month');
const periodKey = lastMonth.format('YYYY-MM');
// 上月 ROI
const monthStart = lastMonth.startOf('month').toDate();
const monthEnd = lastMonth.endOf('month').toDate();
const monthAgg = await aggregate(projectId, monthStart, monthEnd);
// 累计 ROI(从 launchedAt 起)
const [project] = await db.select().from(projects).where(eq(projects.id, projectId)).limit(1);
if (!project) throw new Error(`Project not found: ${projectId}`);
const launchedAt = project.launchedAt ? new Date(project.launchedAt) : monthStart;
const lifetimeAgg = await aggregate(projectId, launchedAt, monthEnd);
// 近期 commits 摘要
const repos = await db.select().from(projectRepos).where(eq(projectRepos.projectId, projectId));
const repoNames = new Set(repos.map(r => r.repoName));
let commitSummary = '(无近期提交)';
if (repoNames.size > 0) {
const recent = await db.select().from(gitCommits)
.where(gte(gitCommits.committedAt, monthStart))
.orderBy(desc(gitCommits.committedAt))
.limit(30);
const projCommits = recent.filter(c => repoNames.has(c.repoName));
if (projCommits.length > 0) {
commitSummary = projCommits
.map(c => `- ${(c.message || '').split('\n')[0].slice(0, 80)}`)
.slice(0, 15)
.join('\n');
}
}
// OKR 进展
const objs = await db.select().from(objectives).where(eq(objectives.projectId, projectId)).limit(5);
const okrSummary = objs.length > 0
? objs.map(o => `- ${o.title} (进度 ${Math.round((o.progress || 0) * 100)}%)`).join('\n')
: '(无 OKR)';
const userPrompt = `项目: ${project.name} (定位: ${project.category || '未打标'})
ROI (${periodKey}):
- : ¥${monthAgg.totalCost.toLocaleString()}
- : ¥${monthAgg.totalRevenue.toLocaleString()}
- ROI: ${monthAgg.roiValue === null ? 'N/A' : monthAgg.roiValue + '%'}
- : ¥${monthAgg.revenueBreakdown.directRevenue}
- : ¥${monthAgg.revenueBreakdown.savedCost}
- : ¥${monthAgg.revenueBreakdown.assetValueAdd}
ROI (): ${lifetimeAgg.roiValue === null ? 'N/A' : lifetimeAgg.roiValue + '%'}
commits:
${commitSummary}
OKR :
${okrSummary}
JSON `;
const raw = await callLLM(SYSTEM_PROMPT, userPrompt);
const parsed = parseLLMJson<{ factors: DriverFactor[] }>(raw);
// 校验
if (!Array.isArray(parsed.factors)) throw new Error('LLM response missing factors array');
const validFactors = parsed.factors
.filter(f => f && ALLOWED_TYPES.has(f.type) && typeof f.text === 'string')
.map(f => ({ type: f.type, text: f.text.slice(0, 80) }))
.slice(0, 3);
if (validFactors.length === 0) throw new Error('LLM returned no valid factors');
// upsert: 先 delete 旧的(同 project + period),再 insert 新的
await db.delete(roiDriverFactors).where(and(
eq(roiDriverFactors.projectId, projectId),
eq(roiDriverFactors.periodKey, periodKey),
));
await db.insert(roiDriverFactors).values({
id: uuid(),
projectId,
periodKey,
factors: validFactors,
context: {
monthRoi: monthAgg.roiValue,
lifetimeRoi: lifetimeAgg.roiValue,
monthCost: monthAgg.totalCost,
monthRevenue: monthAgg.totalRevenue,
},
generatedAt: new Date(),
});
return validFactors;
}
/**
* cron:为所有打标项目生成驱动因子
*/
export async function runMonthlyDriverFactorsGeneration(): Promise<void> {
const startedAt = Date.now();
let okCount = 0, failCount = 0;
const errors: string[] = [];
// 仅为已打标的项目生成
const allProjects = await db.select().from(projects);
const candidates = allProjects.filter(p => p.category !== null);
for (const p of candidates) {
try {
await generateDriverFactorsForProject(p.id);
okCount += 1;
} catch (e) {
failCount += 1;
const msg = `${p.identifier || p.id}: ${(e as Error).message}`;
errors.push(msg);
console.warn('[ROI-AI-DRIVER]', msg);
}
// 简单速率控制:每项目间隔 1 秒,避免 LLM 限流
await new Promise(r => setTimeout(r, 1000));
}
const elapsed = Date.now() - startedAt;
await db.insert(syncLogs).values({
id: uuid(),
source: 'roi_ai_driver',
status: failCount === 0 ? 'success' : 'error',
message: `driver factors: ok=${okCount} fail=${failCount} elapsed=${elapsed}ms${errors.length > 0 ? ' errors=' + errors.slice(0, 3).join('|') : ''}`,
recordsProcessed: okCount,
syncedAt: new Date(),
}).catch(() => {});
}

View File

@ -0,0 +1,116 @@
import { and, desc, eq, gte } from 'drizzle-orm';
import dayjs from 'dayjs';
import { db } from '../../db/index';
import {
projects, gitCommits, projectRepos, objectives,
} from '../../db/schema';
import { callLLM, parseLLMJson } from '../llm-client';
import type { RoiCategory } from './types';
import type { BizSystem, ProjectType } from './identifier-generator';
export interface TagSuggestion {
suggestedCategory: RoiCategory;
suggestedBizSystem: BizSystem;
suggestedProjectType: ProjectType;
confidence: number; // 0~1
reasoning: string;
}
const SYSTEM_PROMPT = `你是软件项目分析师。基于项目名称、近期 commits、OKR,从 3 个独立维度判断项目归属。
1:ROI category
- cash_cow(): /,SaaS API
- efficiency_tool(): /,, CI/CD
- moat(): ,
- composite(): 2 3 ,
2:业务体系 bizSystem()
- airhubs: 硬件与潮玩业务线(ToB/ToC ) AI
- airflow: 内容生成与效能线() OKR DevOpsIAM
- aircore: 底层技术基座() RTC AR/Live2D AI
3:技术属性 projectType
- hardware: 项目核心产物含硬件(PCB)
- software: 纯软件, Web/App//SDK/
JSON ,:
{
"suggestedCategory": "cash_cow" | "efficiency_tool" | "moat" | "composite",
"suggestedBizSystem": "airhubs" | "airflow" | "aircore",
"suggestedProjectType": "hardware" | "software",
"confidence": 0.0~1.0 ,,
"reasoning": "简短中文说明(100 字内),先说 bizSystem 归属,再说 category 和 projectType 依据"
}`;
/**
* AI
* 上下文: 项目名 + identifier + 30 commit messages + OKR title
*/
export async function suggestProjectTag(projectId: string): Promise<TagSuggestion> {
const [project] = await db.select().from(projects).where(eq(projects.id, projectId)).limit(1);
if (!project) throw new Error(`Project not found: ${projectId}`);
// 近 30 天 commits
const since = dayjs().subtract(30, 'day').toDate();
const repos = await db.select().from(projectRepos).where(eq(projectRepos.projectId, projectId));
const repoNames = new Set(repos.map(r => r.repoName));
let commitSummary = '';
if (repoNames.size > 0) {
const recent = await db.select().from(gitCommits)
.where(gte(gitCommits.committedAt, since))
.orderBy(desc(gitCommits.committedAt))
.limit(50);
const projCommits = recent.filter(c => repoNames.has(c.repoName));
commitSummary = projCommits
.map(c => `- ${(c.message || '').split('\n')[0].slice(0, 80)}`)
.slice(0, 25)
.join('\n') || '(无近期 commits)';
} else {
commitSummary = '(项目未绑定 repo)';
}
// 关联 OKR
const objs = await db.select({ title: objectives.title })
.from(objectives)
.where(eq(objectives.projectId, projectId))
.limit(10);
const okrSummary = objs.length > 0
? objs.map(o => `- ${o.title}`).join('\n')
: '(无 OKR)';
const userPrompt = `项目信息:
名称: ${project.name}
代号: ${project.identifier || '(无)'}
30 commit messages:
${commitSummary}
OKR :
${okrSummary}
JSON `;
const raw = await callLLM(SYSTEM_PROMPT, userPrompt);
const parsed = parseLLMJson<TagSuggestion>(raw);
// 校验
const validCategories: RoiCategory[] = ['cash_cow', 'efficiency_tool', 'moat', 'composite'];
const validBizSystems: BizSystem[] = ['airhubs', 'airflow', 'aircore'];
const validTypes: ProjectType[] = ['hardware', 'software'];
if (!validCategories.includes(parsed.suggestedCategory)) {
throw new Error(`Invalid category from LLM: ${parsed.suggestedCategory}`);
}
if (!validBizSystems.includes(parsed.suggestedBizSystem)) {
throw new Error(`Invalid bizSystem from LLM: ${parsed.suggestedBizSystem}`);
}
if (!validTypes.includes(parsed.suggestedProjectType)) {
throw new Error(`Invalid projectType from LLM: ${parsed.suggestedProjectType}`);
}
if (typeof parsed.confidence !== 'number' || parsed.confidence < 0 || parsed.confidence > 1) {
parsed.confidence = 0.5;
}
parsed.reasoning = (parsed.reasoning || '').slice(0, 250);
return parsed;
}

View File

@ -0,0 +1,60 @@
import { and, eq, gte, lte } from 'drizzle-orm';
import { db } from '../../db/index';
import { projectCostEvents, projectRevenueEvents } from '../../db/schema';
/**
* BEP ()
*
* :
* - 0: 已经回本( ROI >= 100%)
* - null: 30 <= 0,
* - 正整数: 预计还需多少天回本()
*/
export function bepFromTotals(
totalCost: number,
totalRevenue: number,
recentCost: number,
recentRevenue: number,
windowDays = 30
): number | null {
const deficit = totalCost - totalRevenue;
if (deficit <= 0) return 0;
const dailyNetIncome = (recentRevenue - recentCost) / windowDays;
if (dailyNetIncome <= 0) return null;
return Math.ceil(deficit / dailyNetIncome);
}
/**
* (BEP), DB
*/
export async function calculateBep(
projectId: string,
totalCost: number,
totalRevenue: number,
asOf: Date
): Promise<number | null> {
if (totalCost - totalRevenue <= 0) return 0; // 已回本,免去 DB 查询
const since = new Date(asOf);
since.setDate(since.getDate() - 30);
const [costAgg, revenueAgg] = await Promise.all([
db.select().from(projectCostEvents).where(and(
eq(projectCostEvents.projectId, projectId),
gte(projectCostEvents.eventDate, since),
lte(projectCostEvents.eventDate, asOf)
)),
db.select().from(projectRevenueEvents).where(and(
eq(projectRevenueEvents.projectId, projectId),
gte(projectRevenueEvents.eventDate, since),
lte(projectRevenueEvents.eventDate, asOf)
)),
]);
const recentCost = costAgg.reduce((sum, e) => sum + e.amount, 0);
const recentRevenue = revenueAgg.reduce((sum, e) => sum + e.amount, 0);
return bepFromTotals(totalCost, totalRevenue, recentCost, recentRevenue, 30);
}

View File

@ -0,0 +1,34 @@
import type { Confidence } from './types';
type CostEvent = { dataSource: string };
type RevenueEvent = { dataSource: string };
const HIGH_QUALITY_COST_SOURCES = new Set(['plane_actual', 'manual']);
/**
* :
* - High: 成本事件 80%+ plane_actual / manual,
* - Low: 只有成本无收益, auto_commits/auto_tasks
* - Medium: 其他情况
*/
export function evaluateConfidence(
costEvents: CostEvent[],
revenueEvents: RevenueEvent[]
): Confidence {
const totalCost = costEvents.length;
const totalRevenue = revenueEvents.length;
if (totalCost === 0 && totalRevenue === 0) return 'low';
// 无收益 = 直接 low(无法判断盈亏)
if (totalRevenue === 0) return 'low';
// 全自动估算 = low
const highQualityCount = costEvents.filter(e => HIGH_QUALITY_COST_SOURCES.has(e.dataSource)).length;
if (totalCost > 0 && highQualityCount === 0) return 'low';
// 80%+ 高质量 = high
if (totalCost > 0 && highQualityCount / totalCost >= 0.8) return 'high';
return 'medium';
}

View File

@ -0,0 +1,129 @@
import { v4 as uuid } from 'uuid';
import { and, eq, gte, inArray, lte } from 'drizzle-orm';
import dayjs from 'dayjs';
import { db } from '../../../db/index';
import { gitCommits, projectRepos, projects, projectCostEvents } from '../../../db/schema';
import { getStrategyParams } from '../strategy-params';
import type { RoiCategory } from '../types';
interface IngestResult {
insertedCount: number;
skippedCount: number;
projectStats: Record<string, number>;
}
/**
* [from, to] git commits cost_events
* project_repos repoName projectId
* (project_id, ref_type='git_commit', ref_id=sha)
*/
export async function ingestCommitsAsCostEvents(from: Date, to: Date): Promise<IngestResult> {
const result: IngestResult = { insertedCount: 0, skippedCount: 0, projectStats: {} };
// 1. 建 repoName → projectId 映射
const bindings = await db.select().from(projectRepos);
if (bindings.length === 0) {
return result; // 没有任何 repo 绑定项目,跳过
}
const repoToProject = new Map<string, string>();
for (const b of bindings) {
repoToProject.set(normalizeRepoName(b.repoName), b.projectId);
}
// 2. 拉项目的 category 字典(为不同 category 取不同 R_h)
const allProjects = await db.select().from(projects);
const projectCategory = new Map<string, RoiCategory | null>(
allProjects.map(p => [p.id, p.category as RoiCategory | null])
);
// 3. 拉时间窗内的 commits
const commits = await db.select().from(gitCommits).where(and(
gte(gitCommits.committedAt, from),
lte(gitCommits.committedAt, to)
));
if (commits.length === 0) return result;
// 4. 反查已 insert 过的 sha(在该项目 ref_id 上),批量过滤
const candidates = commits
.map(c => ({ commit: c, projectId: repoToProject.get(normalizeRepoName(c.repoName)) }))
.filter(x => x.projectId !== undefined) as { commit: typeof commits[0]; projectId: string }[];
if (candidates.length === 0) return result;
// 一次性查所有可能冲突的 (project_id, sha) 组合
const shasByProject = new Map<string, Set<string>>();
for (const { commit, projectId } of candidates) {
if (!shasByProject.has(projectId)) shasByProject.set(projectId, new Set());
shasByProject.get(projectId)!.add(commit.sha);
}
const existingShas = new Set<string>(); // key = projectId + '::' + sha
for (const [projectId, shas] of shasByProject) {
if (shas.size === 0) continue;
const existing = await db.select({
refId: projectCostEvents.refId,
}).from(projectCostEvents).where(and(
eq(projectCostEvents.projectId, projectId),
eq(projectCostEvents.refType, 'git_commit'),
inArray(projectCostEvents.refId, Array.from(shas))
));
for (const e of existing) {
if (e.refId) existingShas.add(`${projectId}::${e.refId}`);
}
}
// 5. 按项目分组批量插入
const now = new Date();
const toInsert: typeof projectCostEvents.$inferInsert[] = [];
for (const { commit, projectId } of candidates) {
const key = `${projectId}::${commit.sha}`;
if (existingShas.has(key)) {
result.skippedCount += 1;
continue;
}
const params = await getStrategyParams(projectCategory.get(projectId) ?? null);
const hours = params.commitHourCoef;
const amount = hours * params.hourlyRate;
toInsert.push({
id: uuid(),
projectId,
eventDate: commit.committedAt instanceof Date ? commit.committedAt : new Date(commit.committedAt),
costType: 'dev_hours',
amount,
hours,
hourlyRateUsed: params.hourlyRate,
dataSource: 'auto_commits',
refType: 'git_commit',
refId: commit.sha,
notes: (commit.message || '').split('\n')[0].slice(0, 200),
createdBy: null,
createdAt: now,
});
result.projectStats[projectId] = (result.projectStats[projectId] || 0) + 1;
}
if (toInsert.length > 0) {
// 分批插入避免单批过大
const BATCH_SIZE = 200;
for (let i = 0; i < toInsert.length; i += BATCH_SIZE) {
await db.insert(projectCostEvents).values(toInsert.slice(i, i + BATCH_SIZE));
}
}
result.insertedCount = toInsert.length;
return result;
}
/** 抹除 .git 后缀和 URL 前缀,只保留仓库名 */
function normalizeRepoName(raw: string): string {
let cleaned = raw.trim().replace(/\.git$/, '');
if (cleaned.includes('://')) {
try {
const parts = new URL(cleaned).pathname.split('/').filter(Boolean);
return parts[parts.length - 1] || cleaned;
} catch { /* fallthrough */ }
}
if (cleaned.includes('/')) return cleaned.split('/').pop() || cleaned;
return cleaned;
}

View File

@ -0,0 +1,104 @@
import { v4 as uuid } from 'uuid';
import { and, eq, gte, inArray, lte, isNotNull } from 'drizzle-orm';
import { db } from '../../../db/index';
import { taskSnapshots, projects, projectCostEvents } from '../../../db/schema';
import { getStrategyParams } from '../strategy-params';
import type { RoiCategory } from '../types';
interface IngestResult {
insertedCount: number;
skippedCount: number;
projectStats: Record<string, number>;
}
/**
* [from, to] Plane tasks cost_events
* task projectId , repo
* (project_id, ref_type='plane_task', ref_id=planeIssueId)
*
* taskSnapshots actual_hours , taskHourCoef ,
* dataSource='auto_tasks', Medium
*/
export async function ingestPlaneTasksAsCostEvents(from: Date, to: Date): Promise<IngestResult> {
const result: IngestResult = { insertedCount: 0, skippedCount: 0, projectStats: {} };
// 1. 拉时间窗内完成的 tasks(只算 completedAt 在窗口内的)
const completedTasks = await db.select().from(taskSnapshots).where(and(
isNotNull(taskSnapshots.completedAt),
isNotNull(taskSnapshots.projectId),
gte(taskSnapshots.completedAt, from),
lte(taskSnapshots.completedAt, to)
));
if (completedTasks.length === 0) return result;
// 2. 项目 category 字典
const allProjects = await db.select().from(projects);
const projectCategory = new Map<string, RoiCategory | null>(
allProjects.map(p => [p.id, p.category as RoiCategory | null])
);
// 3. 检查去重(按 projectId 分组查已存在的 plane_issue_id)
const issuesByProject = new Map<string, Set<string>>();
for (const t of completedTasks) {
if (!t.projectId) continue;
if (!issuesByProject.has(t.projectId)) issuesByProject.set(t.projectId, new Set());
issuesByProject.get(t.projectId)!.add(t.planeIssueId);
}
const existingKeys = new Set<string>();
for (const [projectId, issueIds] of issuesByProject) {
if (issueIds.size === 0) continue;
const existing = await db.select({
refId: projectCostEvents.refId,
}).from(projectCostEvents).where(and(
eq(projectCostEvents.projectId, projectId),
eq(projectCostEvents.refType, 'plane_task'),
inArray(projectCostEvents.refId, Array.from(issueIds))
));
for (const e of existing) {
if (e.refId) existingKeys.add(`${projectId}::${e.refId}`);
}
}
// 4. 准备 insert
const now = new Date();
const toInsert: typeof projectCostEvents.$inferInsert[] = [];
for (const task of completedTasks) {
if (!task.projectId || !task.completedAt) continue;
const key = `${task.projectId}::${task.planeIssueId}`;
if (existingKeys.has(key)) {
result.skippedCount += 1;
continue;
}
const params = await getStrategyParams(projectCategory.get(task.projectId) ?? null);
const hours = params.taskHourCoef;
const amount = hours * params.hourlyRate;
toInsert.push({
id: uuid(),
projectId: task.projectId,
eventDate: task.completedAt instanceof Date ? task.completedAt : new Date(task.completedAt),
costType: 'dev_hours',
amount,
hours,
hourlyRateUsed: params.hourlyRate,
dataSource: 'auto_tasks',
refType: 'plane_task',
refId: task.planeIssueId,
notes: task.title?.slice(0, 200) || null,
createdBy: null,
createdAt: now,
});
result.projectStats[task.projectId] = (result.projectStats[task.projectId] || 0) + 1;
}
if (toInsert.length > 0) {
const BATCH_SIZE = 200;
for (let i = 0; i < toInsert.length; i += BATCH_SIZE) {
await db.insert(projectCostEvents).values(toInsert.slice(i, i + BATCH_SIZE));
}
}
result.insertedCount = toInsert.length;
return result;
}

View File

@ -0,0 +1,65 @@
import { v4 as uuid } from 'uuid';
import dayjs from 'dayjs';
import { db } from '../../../db/index';
import { syncLogs } from '../../../db/schema';
import { ingestCommitsAsCostEvents } from './from-commits';
import { ingestPlaneTasksAsCostEvents } from './from-plane-tasks';
export interface RunOptions {
/** 要 ingest 的时间窗起点(含)。默认为昨天 00:00 */
from?: Date;
/** 要 ingest 的时间窗终点(含)。默认为昨天 23:59:59.999 */
to?: Date;
}
/**
* 主入口: [from, to] ingest cost_events
* commits plane tasks
* , syncLogs
*/
export async function runCostEventIngest(opts: RunOptions = {}): Promise<void> {
const from = opts.from ?? dayjs().subtract(1, 'day').startOf('day').toDate();
const to = opts.to ?? dayjs().subtract(1, 'day').endOf('day').toDate();
const startedAt = Date.now();
let totalInserted = 0;
let totalSkipped = 0;
const errors: string[] = [];
try {
const r1 = await ingestCommitsAsCostEvents(from, to);
totalInserted += r1.insertedCount;
totalSkipped += r1.skippedCount;
console.info(`[ROI-COST-INGEST] commits → cost: inserted=${r1.insertedCount} skipped=${r1.skippedCount}`);
} catch (e) {
const msg = `commits ingest failed: ${(e as Error).message}`;
console.error('[ROI-COST-INGEST]', msg);
errors.push(msg);
}
try {
const r2 = await ingestPlaneTasksAsCostEvents(from, to);
totalInserted += r2.insertedCount;
totalSkipped += r2.skippedCount;
console.info(`[ROI-COST-INGEST] tasks → cost: inserted=${r2.insertedCount} skipped=${r2.skippedCount}`);
} catch (e) {
const msg = `tasks ingest failed: ${(e as Error).message}`;
console.error('[ROI-COST-INGEST]', msg);
errors.push(msg);
}
const elapsed = Date.now() - startedAt;
const status = errors.length === 0 ? 'success' : 'error';
const message = errors.length === 0
? `cost ingest ok: inserted=${totalInserted} skipped=${totalSkipped} from=${dayjs(from).format('YYYY-MM-DD')} to=${dayjs(to).format('YYYY-MM-DD')} elapsed=${elapsed}ms`
: `cost ingest partial: inserted=${totalInserted} skipped=${totalSkipped} errors=${errors.join(' | ')}`;
await db.insert(syncLogs).values({
id: uuid(),
source: 'roi_cost_ingest',
status,
message,
recordsProcessed: totalInserted,
syncedAt: new Date(),
}).catch(e => console.error('[ROI-COST-INGEST] syncLog write failed:', e));
}

View File

@ -0,0 +1,86 @@
import { and, eq, like } from 'drizzle-orm';
import { db } from '../../db/index';
import { projects, projectRevenueMapping } from '../../db/schema';
export type BizSystem = 'airhubs' | 'airflow' | 'aircore';
export type ProjectType = 'hardware' | 'software';
const TYPE_SHORT: Record<ProjectType, string> = {
hardware: 'hw',
software: 'sw',
};
/**
* bizSystem + projectType identifier
* :`{bizSystem}-{hw|sw}-{3位序号}`, `airhubs-hw-001``airflow-sw-002`
* (bizSystem, projectType)
*/
export async function generateIdentifier(
bizSystem: BizSystem,
projectType: ProjectType
): Promise<string> {
const prefix = `${bizSystem}-${TYPE_SHORT[projectType]}-`;
const existing = await db.select({ identifier: projects.identifier })
.from(projects)
.where(and(
eq(projects.bizSystem, bizSystem),
eq(projects.projectType, projectType),
like(projects.identifier, `${prefix}%`)
));
let maxSeq = 0;
for (const row of existing) {
if (!row.identifier) continue;
const m = row.identifier.match(/-(\d{3})$/);
if (m) {
const n = parseInt(m[1], 10);
if (n > maxSeq) maxSeq = n;
}
}
const nextSeq = String(maxSeq + 1).padStart(3, '0');
return `${prefix}${nextSeq}`;
}
/**
* identifier , project_revenue_mapping businessProjectKey
* ( mock unmapped)
*
* newIdentifier
*/
export async function applyAutoIdentifier(
projectId: string,
bizSystem: BizSystem,
projectType: ProjectType
): Promise<string> {
// 已经有合规 identifier 时跳过
const [project] = await db.select().from(projects).where(eq(projects.id, projectId)).limit(1);
if (!project) throw new Error(`Project not found: ${projectId}`);
const expectedPrefix = `${bizSystem}-${TYPE_SHORT[projectType]}-`;
if (project.identifier?.startsWith(expectedPrefix) &&
project.bizSystem === bizSystem &&
project.projectType === projectType) {
return project.identifier; // 已是正确格式
}
const newId = await generateIdentifier(bizSystem, projectType);
const oldId = project.identifier;
await db.update(projects).set({
identifier: newId,
bizSystem,
projectType,
updatedAt: new Date(),
}).where(eq(projects.id, projectId));
// 同步更新 mapping(若存在)
if (oldId) {
await db.update(projectRevenueMapping).set({
businessProjectKey: newId,
updatedAt: new Date(),
}).where(eq(projectRevenueMapping.businessProjectKey, oldId));
}
return newId;
}

View File

@ -0,0 +1,90 @@
import { v4 as uuid } from 'uuid';
import { and, eq, isNotNull } from 'drizzle-orm';
import dayjs from 'dayjs';
import { db } from '../../../db/index';
import { projects, projectRevenueEvents } from '../../../db/schema';
import { getStrategyParams } from '../strategy-params';
export interface AmortizeResult {
insertedCount: number;
skippedCount: number;
projectStats: Record<string, number>;
}
/**
* 1 cron 调用:为所有 [] [ moat] insert
*
* :
* amount = V_asset / (N × 12)
* eventDate =
* refType = 'asset_amortization', refId = `${projectId}-${yyyy-MM}`
*
* 触发时机假设:在每月 1 cron ,asOf =
*/
export async function runAssetAmortization(asOf: Date = new Date()): Promise<AmortizeResult> {
const result: AmortizeResult = { insertedCount: 0, skippedCount: 0, projectStats: {} };
// 摊销期间 = 上月
const lastMonth = dayjs(asOf).subtract(1, 'month');
const periodKey = lastMonth.format('YYYY-MM');
const eventDate = lastMonth.endOf('month').toDate();
// 1. 找出所有资本护城河项目(category=moat 且 v_asset>0)
const moatProjects = await db.select().from(projects).where(and(
eq(projects.category, 'moat'),
isNotNull(projects.vAsset)
));
// 复合型包含 moat 也算入
const compositeProjects = await db.select().from(projects).where(and(
eq(projects.category, 'composite'),
isNotNull(projects.vAsset)
));
const compositeWithMoat = compositeProjects.filter(p => {
const strategies = (p.compositeStrategies as string[]) || [];
return Array.isArray(strategies) && strategies.includes('moat');
});
const targets = [...moatProjects, ...compositeWithMoat];
// 2. 对每个项目 insert(refId 唯一索引保证幂等,跑两次只 insert 第一次)
const now = new Date();
for (const project of targets) {
if (!project.vAsset || project.vAsset <= 0) continue;
const params = await getStrategyParams(project.category as any);
const amortYears = params.amortYears ?? 3;
const monthlyAmount = project.vAsset / (amortYears * 12);
const refId = `${project.id}-${periodKey}`;
// 先查是否已存在
const [existing] = await db.select().from(projectRevenueEvents).where(and(
eq(projectRevenueEvents.projectId, project.id),
eq(projectRevenueEvents.refType, 'asset_amortization'),
eq(projectRevenueEvents.refId, refId)
)).limit(1);
if (existing) {
result.skippedCount += 1;
continue;
}
await db.insert(projectRevenueEvents).values({
id: uuid(),
projectId: project.id,
eventDate,
revenueType: 'asset_value_add',
amount: monthlyAmount,
dataSource: 'calculated',
refType: 'asset_amortization',
refId,
channel: null,
notes: `资产摊销 ${periodKey}: V_asset=${project.vAsset} / (${amortYears}×12) = ${monthlyAmount.toFixed(2)}`,
createdBy: null,
createdAt: now,
});
result.insertedCount += 1;
result.projectStats[project.id] = (result.projectStats[project.id] || 0) + 1;
}
return result;
}

View File

@ -0,0 +1,215 @@
import { v4 as uuid } from 'uuid';
import { eq, inArray } from 'drizzle-orm';
import { db } from '../../../db/index';
import {
projectRevenueEvents,
projectRevenueMapping,
unmappedRevenueEvents,
} from '../../../db/schema';
import { config } from '../../../config';
// 对齐附录 A 的响应类型
interface RemoteEvent {
externalId: string;
businessProjectKey: string;
eventDate: string; // YYYY-MM-DD
amount: number;
currency: string;
revenueType: string; // 字符串,我们映射成 enum
channel?: string;
occurredAt?: string;
metadata?: Record<string, any>;
}
interface RemoteResponse {
date: string;
events: RemoteEvent[];
nextCursor: string | null;
totalCount: number;
}
export interface RevenueIngestResult {
insertedCount: number;
unmappedCount: number;
skippedCount: number;
fetchedCount: number;
errors: string[];
}
const REVENUE_TYPE_WHITELIST = new Set([
'direct_revenue', 'subscription', 'saved_cost', 'asset_value_add', 'refund', 'other',
]);
function normalizeRevenueType(t: string): typeof projectRevenueEvents.$inferInsert['revenueType'] {
if (REVENUE_TYPE_WHITELIST.has(t)) return t as any;
return 'other';
}
/**
* API , revenue_events () unmapped_revenue_events()
*
* :
* - cursor (,mock )
* - 5xx/429 退 3
* - 401/4xx ()
* - refType='revenue_api', refId=externalId
*/
export async function ingestRevenueForDate(dateStr: string): Promise<RevenueIngestResult> {
const result: RevenueIngestResult = {
insertedCount: 0,
unmappedCount: 0,
skippedCount: 0,
fetchedCount: 0,
errors: [],
};
// 1. 拉映射表
const mappings = await db.select().from(projectRevenueMapping);
const businessKeyToProject = new Map<string, string>();
for (const m of mappings) {
if (m.enabled) businessKeyToProject.set(m.businessProjectKey, m.projectId);
}
// 2. 分页拉数据
let cursor: string | null = null;
const allEvents: RemoteEvent[] = [];
while (true) {
let resp: RemoteResponse;
try {
resp = await fetchPageWithRetry(dateStr, cursor);
} catch (e) {
result.errors.push((e as Error).message);
return result;
}
allEvents.push(...resp.events);
if (!resp.nextCursor) break;
cursor = resp.nextCursor;
}
result.fetchedCount = allEvents.length;
if (allEvents.length === 0) return result;
// 3. 检查 externalId 去重
const externalIds = allEvents.map(e => e.externalId);
const existingRefIds = new Set<string>();
// 拉 revenue_events 中已有的 ref_id
const BATCH = 500;
for (let i = 0; i < externalIds.length; i += BATCH) {
const slice = externalIds.slice(i, i + BATCH);
const existing = await db.select({ refId: projectRevenueEvents.refId })
.from(projectRevenueEvents)
.where(inArray(projectRevenueEvents.refId, slice));
for (const e of existing) {
if (e.refId) existingRefIds.add(e.refId);
}
}
// 拉 unmapped_revenue_events 已有的 external_id
const existingUnmapped = new Set<string>();
for (let i = 0; i < externalIds.length; i += BATCH) {
const slice = externalIds.slice(i, i + BATCH);
const existing = await db.select({ externalId: unmappedRevenueEvents.externalId })
.from(unmappedRevenueEvents)
.where(inArray(unmappedRevenueEvents.externalId, slice));
for (const e of existing) {
existingUnmapped.add(e.externalId);
}
}
// 4. 分流插入
const now = new Date();
const toInsertMapped: typeof projectRevenueEvents.$inferInsert[] = [];
const toInsertUnmapped: typeof unmappedRevenueEvents.$inferInsert[] = [];
for (const ev of allEvents) {
if (existingRefIds.has(ev.externalId) || existingUnmapped.has(ev.externalId)) {
result.skippedCount += 1;
continue;
}
const projectId = businessKeyToProject.get(ev.businessProjectKey);
const eventDate = new Date(ev.eventDate + 'T00:00:00+08:00');
if (projectId) {
toInsertMapped.push({
id: uuid(),
projectId,
eventDate,
revenueType: normalizeRevenueType(ev.revenueType),
amount: ev.amount,
dataSource: config.MOCK_REVENUE_API ? 'mock' : 'api_pulled',
refType: 'revenue_api',
refId: ev.externalId,
channel: ev.channel || null,
notes: ev.metadata ? JSON.stringify(ev.metadata).slice(0, 500) : null,
createdBy: null,
createdAt: now,
});
} else {
toInsertUnmapped.push({
id: uuid(),
externalId: ev.externalId,
businessProjectKey: ev.businessProjectKey,
eventDate,
amount: ev.amount,
revenueType: ev.revenueType,
channel: ev.channel || null,
rawPayload: ev as any,
status: 'pending',
resolvedEventId: null,
createdAt: now,
});
}
}
if (toInsertMapped.length > 0) {
for (let i = 0; i < toInsertMapped.length; i += 200) {
await db.insert(projectRevenueEvents).values(toInsertMapped.slice(i, i + 200));
}
result.insertedCount = toInsertMapped.length;
}
if (toInsertUnmapped.length > 0) {
for (let i = 0; i < toInsertUnmapped.length; i += 200) {
await db.insert(unmappedRevenueEvents).values(toInsertUnmapped.slice(i, i + 200));
}
result.unmappedCount = toInsertUnmapped.length;
}
return result;
}
async function fetchPageWithRetry(date: string, cursor: string | null): Promise<RemoteResponse> {
const url = new URL(`${config.REVENUE_API_BASE_URL}/revenue/daily`);
url.searchParams.set('date', date);
if (cursor) url.searchParams.set('cursor', cursor);
const backoff = [10_000, 30_000, 90_000];
let lastErr: Error | null = null;
for (let attempt = 0; attempt <= backoff.length; attempt++) {
try {
const res = await fetch(url.toString(), {
headers: {
'Authorization': `Bearer ${config.REVENUE_API_KEY}`,
'Accept': 'application/json',
},
});
if (res.status === 200) {
return await res.json() as RemoteResponse;
}
if (res.status === 401 || (res.status >= 400 && res.status < 500 && res.status !== 429)) {
const body = await res.text().catch(() => '');
throw new Error(`Revenue API ${res.status}: ${body.slice(0, 200)}`);
}
// 5xx 或 429 → 重试
lastErr = new Error(`Revenue API ${res.status}, retrying...`);
} catch (e) {
lastErr = e as Error;
}
if (attempt < backoff.length) {
await new Promise(r => setTimeout(r, backoff[attempt]));
}
}
throw lastErr ?? new Error('Revenue API fetch failed after retries');
}

View File

@ -0,0 +1,68 @@
import { v4 as uuid } from 'uuid';
import dayjs from 'dayjs';
import { db } from '../../../db/index';
import { syncLogs } from '../../../db/schema';
import { ingestRevenueForDate } from './from-revenue-api';
import { runAssetAmortization } from './asset-amortizer';
/**
* ingest 入口:拉昨日数据, syncLog
*/
export async function runRevenueIngest(dateStr?: string): Promise<void> {
const date = dateStr ?? dayjs().subtract(1, 'day').format('YYYY-MM-DD');
const startedAt = Date.now();
try {
const r = await ingestRevenueForDate(date);
const elapsed = Date.now() - startedAt;
await db.insert(syncLogs).values({
id: uuid(),
source: 'roi_revenue_ingest',
status: r.errors.length === 0 ? 'success' : 'error',
message: `revenue ingest date=${date}: fetched=${r.fetchedCount} inserted=${r.insertedCount} unmapped=${r.unmappedCount} skipped=${r.skippedCount} elapsed=${elapsed}ms${r.errors.length > 0 ? ' errors=' + r.errors.join('|') : ''}`,
recordsProcessed: r.insertedCount + r.unmappedCount,
syncedAt: new Date(),
});
} catch (e) {
const msg = (e as Error).message;
console.error('[ROI-REVENUE-INGEST]', msg);
await db.insert(syncLogs).values({
id: uuid(),
source: 'roi_revenue_ingest',
status: 'error',
message: `revenue ingest date=${date} failed: ${msg}`,
recordsProcessed: 0,
syncedAt: new Date(),
}).catch(() => {});
}
}
/**
* 1 cron
*/
export async function runMonthlyAmortization(): Promise<void> {
const startedAt = Date.now();
try {
const r = await runAssetAmortization();
const elapsed = Date.now() - startedAt;
await db.insert(syncLogs).values({
id: uuid(),
source: 'roi_amortizer',
status: 'success',
message: `amortizer ok: inserted=${r.insertedCount} skipped=${r.skippedCount} elapsed=${elapsed}ms`,
recordsProcessed: r.insertedCount,
syncedAt: new Date(),
});
} catch (e) {
const msg = (e as Error).message;
console.error('[ROI-AMORTIZER]', msg);
await db.insert(syncLogs).values({
id: uuid(),
source: 'roi_amortizer',
status: 'error',
message: `amortizer failed: ${msg}`,
recordsProcessed: 0,
syncedAt: new Date(),
}).catch(() => {});
}
}

View File

@ -0,0 +1,145 @@
import { db } from '../../../db/index';
import { projects } from '../../../db/schema';
import type { RoiCategory } from '../types';
export interface MockRevenueEvent {
externalId: string;
businessProjectKey: string;
eventDate: string;
amount: number;
currency: string;
revenueType: 'direct_revenue' | 'subscription' | 'refund' | 'other';
channel: string;
occurredAt: string;
metadata?: Record<string, any>;
}
/** 字符串 hash 转 32-bit 整数(用作种子) */
function hashSeed(s: string): number {
let h = 2166136261;
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return h >>> 0;
}
/** 简单 LCG 随机数生成器,可复现 */
function makeRng(seed: number) {
let s = seed || 1;
return () => {
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
const CHANNELS = ['alipay', 'wechat', 'stripe', 'bank', 'offline'] as const;
interface CategoryProfile {
baseMin: number;
baseMax: number;
amountMin: number;
amountMax: number;
}
const PROFILE: Record<string, CategoryProfile> = {
cash_cow: { baseMin: 2, baseMax: 5, amountMin: 1000, amountMax: 10000 },
efficiency_tool: { baseMin: 1, baseMax: 2, amountMin: 500, amountMax: 3000 },
moat: { baseMin: 0, baseMax: 1, amountMin: 800, amountMax: 4000 },
composite: { baseMin: 1, baseMax: 3, amountMin: 800, amountMax: 5000 },
default: { baseMin: 1, baseMax: 2, amountMin: 500, amountMax: 3000 },
};
function getProfile(cat: RoiCategory | null | undefined): CategoryProfile {
if (!cat) return PROFILE.default;
return PROFILE[cat] ?? PROFILE.default;
}
function isWeekend(date: Date): boolean {
const d = date.getDay();
return d === 0 || d === 6;
}
function pad2(n: number): string {
return String(n).padStart(2, '0');
}
function dateOnly(d: Date): string {
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
}
function isoAt(d: Date, hour: number, min: number, sec: number): string {
return `${dateOnly(d)}T${pad2(hour)}:${pad2(min)}:${pad2(sec)}+08:00`;
}
/**
* mock
* date
*/
export async function generateMockRevenueForDate(dateStr: string): Promise<MockRevenueEvent[]> {
const date = new Date(dateStr + 'T00:00:00+08:00');
if (Number.isNaN(date.getTime())) {
throw new Error(`Invalid date: ${dateStr}`);
}
const allProjects = await db.select().from(projects);
const events: MockRevenueEvent[] = [];
const weekend = isWeekend(date);
const rng = makeRng(hashSeed(dateStr + 'devperf-mock'));
for (const project of allProjects) {
if (!project.identifier) continue; // 没有 identifier 跳过
const profile = getProfile(project.category as RoiCategory | null);
let count = profile.baseMin + Math.floor(rng() * (profile.baseMax - profile.baseMin + 1));
if (weekend) count = Math.ceil(count / 2);
for (let i = 0; i < count; i++) {
const isRefund = rng() < 0.05;
const baseAmount = profile.amountMin + Math.floor(rng() * (profile.amountMax - profile.amountMin));
const amount = isRefund ? -Math.floor(baseAmount * 0.3) : baseAmount;
const revenueType: MockRevenueEvent['revenueType'] = isRefund
? 'refund'
: (rng() < 0.3 ? 'subscription' : 'direct_revenue');
const channel = CHANNELS[Math.floor(rng() * CHANNELS.length)];
const hour = Math.floor(rng() * 24);
const minute = Math.floor(rng() * 60);
const second = Math.floor(rng() * 60);
events.push({
externalId: `MOCK-${dateStr.replace(/-/g, '')}-${project.identifier}-${String(i).padStart(3, '0')}`,
businessProjectKey: project.identifier,
eventDate: dateStr,
amount,
currency: 'CNY',
revenueType,
channel,
occurredAt: isoAt(date, hour, minute, second),
metadata: isRefund ? { kind: 'mock_refund' } : { kind: 'mock_revenue' },
});
}
}
// 按 occurredAt 排序,符合真实接口习惯
events.sort((a, b) => a.occurredAt.localeCompare(b.occurredAt));
return events;
}
export interface MockBusinessProject {
businessProjectKey: string;
name: string;
active: boolean;
}
/** 返回 mock 业务系统中的"项目清单",用于映射维护页面 */
export async function listMockBusinessProjects(): Promise<MockBusinessProject[]> {
const allProjects = await db.select().from(projects);
return allProjects
.filter(p => p.identifier)
.map(p => ({
businessProjectKey: p.identifier!,
name: p.name,
active: true,
}));
}

View File

@ -0,0 +1,49 @@
import { eq } from 'drizzle-orm';
import { db } from '../../db/index';
import { roiStrategies } from '../../db/schema';
import type { RoiCategory, StrategyParams } from './types';
const DEFAULTS: StrategyParams = {
hourlyRate: 400,
amortYears: 3,
commitHourCoef: 0.5,
taskHourCoef: 6,
};
let cache: Map<RoiCategory, StrategyParams> | null = null;
let cacheLoadedAt = 0;
const CACHE_TTL_MS = 60_000; // 1 分钟缓存,改完策略最多 1 分钟生效
async function loadCache(): Promise<Map<RoiCategory, StrategyParams>> {
const now = Date.now();
if (cache && now - cacheLoadedAt < CACHE_TTL_MS) return cache;
const rows = await db.select().from(roiStrategies);
const map = new Map<RoiCategory, StrategyParams>();
for (const row of rows) {
const p = (row.params as Partial<StrategyParams>) || {};
map.set(row.category, {
hourlyRate: p.hourlyRate ?? DEFAULTS.hourlyRate,
amortYears: p.amortYears ?? DEFAULTS.amortYears,
commitHourCoef: p.commitHourCoef ?? DEFAULTS.commitHourCoef,
taskHourCoef: p.taskHourCoef ?? DEFAULTS.taskHourCoef,
});
}
cache = map;
cacheLoadedAt = now;
return map;
}
/**
* (null) cash_cow
*/
export async function getStrategyParams(category: RoiCategory | null): Promise<StrategyParams> {
const map = await loadCache();
const cat: RoiCategory = category ?? 'cash_cow';
return map.get(cat) ?? DEFAULTS;
}
export function invalidateStrategyCache(): void {
cache = null;
cacheLoadedAt = 0;
}

View File

@ -0,0 +1,97 @@
import { and, eq, gte, lte, asc } from 'drizzle-orm';
import dayjs from 'dayjs';
import { db } from '../../db/index';
import { projects, projectCostEvents, projectRevenueEvents } from '../../db/schema';
export type Granularity = 'day' | 'week' | 'month' | 'year';
export interface TimeseriesBucket {
bucket: string; // YYYY-MM-DD (day/week) | YYYY-MM (month) | YYYY (year)
cost: number;
revenue: number;
net: number;
cumulativeCost: number;
cumulativeRevenue: number;
cumulativeRoi: number | null;
}
function bucketKey(date: Date, granularity: Granularity): string {
const d = dayjs(date);
switch (granularity) {
case 'day': return d.format('YYYY-MM-DD');
case 'week': return d.startOf('week').format('YYYY-MM-DD'); // 周一日期作为 key
case 'month': return d.format('YYYY-MM');
case 'year': return d.format('YYYY');
}
}
/**
*
* ROI 线
*/
export async function timeseries(
projectId: string,
from: Date,
to: Date,
granularity: Granularity
): Promise<TimeseriesBucket[]> {
// 截断到 launchedAt,跟 aggregate 保持一致
const [project] = await db.select().from(projects).where(eq(projects.id, projectId)).limit(1);
if (project?.launchedAt) {
const launchedAt = project.launchedAt instanceof Date ? project.launchedAt : new Date(project.launchedAt);
if (from < launchedAt) from = launchedAt;
}
const [costEvents, revenueEvents] = await Promise.all([
db.select().from(projectCostEvents).where(and(
eq(projectCostEvents.projectId, projectId),
gte(projectCostEvents.eventDate, from),
lte(projectCostEvents.eventDate, to)
)).orderBy(asc(projectCostEvents.eventDate)),
db.select().from(projectRevenueEvents).where(and(
eq(projectRevenueEvents.projectId, projectId),
gte(projectRevenueEvents.eventDate, from),
lte(projectRevenueEvents.eventDate, to)
)).orderBy(asc(projectRevenueEvents.eventDate)),
]);
// 收集所有桶 key,初始化为 0
const buckets = new Map<string, { cost: number; revenue: number }>();
for (const e of costEvents) {
const k = bucketKey(toDate(e.eventDate), granularity);
if (!buckets.has(k)) buckets.set(k, { cost: 0, revenue: 0 });
buckets.get(k)!.cost += e.amount;
}
for (const e of revenueEvents) {
const k = bucketKey(toDate(e.eventDate), granularity);
if (!buckets.has(k)) buckets.set(k, { cost: 0, revenue: 0 });
buckets.get(k)!.revenue += e.amount;
}
// 按 key 排序输出
const sortedKeys = Array.from(buckets.keys()).sort();
let cumCost = 0, cumRevenue = 0;
return sortedKeys.map(k => {
const { cost, revenue } = buckets.get(k)!;
cumCost += cost;
cumRevenue += revenue;
return {
bucket: k,
cost: round2(cost),
revenue: round2(revenue),
net: round2(revenue - cost),
cumulativeCost: round2(cumCost),
cumulativeRevenue: round2(cumRevenue),
cumulativeRoi: cumCost > 0 ? round2((cumRevenue - cumCost) / cumCost * 100) : null,
};
});
}
function toDate(v: any): Date {
return v instanceof Date ? v : new Date(v);
}
function round2(n: number): number {
return Math.round(n * 100) / 100;
}

View File

@ -0,0 +1,49 @@
// ROI 引擎共享类型定义
export type RoiCategory = 'cash_cow' | 'efficiency_tool' | 'moat' | 'composite';
export type CostType = 'dev_hours' | 'hardware_bom' | 'service_fee' | 'amortization' | 'other';
export type RevenueType = 'direct_revenue' | 'subscription' | 'saved_cost' | 'asset_value_add' | 'refund' | 'other';
export type Confidence = 'high' | 'medium' | 'low';
export interface CostBreakdown {
devHours: number;
hardwareBom: number;
serviceFee: number;
amortization: number;
other: number;
}
export interface RevenueBreakdown {
directRevenue: number;
subscription: number;
savedCost: number;
assetValueAdd: number;
refund: number;
other: number;
}
export interface AggregateResult {
projectId: string;
from: string; // YYYY-MM-DD
to: string; // YYYY-MM-DD
totalCost: number;
totalRevenue: number;
netProfit: number;
roiValue: number | null; // 百分比,80 = 80%。成本为 0 返回 null
confidence: Confidence;
bepDays: number | null; // 已回本 = 0;不可回本 = null;正数 = 预计还需天数
costBreakdown: CostBreakdown;
revenueBreakdown: RevenueBreakdown;
costEventCount: number;
revenueEventCount: number;
}
export interface StrategyParams {
hourlyRate: number;
amortYears?: number;
commitHourCoef: number;
taskHourCoef: number;
}

View File

@ -1,25 +1,105 @@
import { v4 as uuid } from 'uuid';
import { Cron } from 'croner';
import { syncGitea } from './sync-gitea';
import { analyzeCommitsForOKR } from '../services/okr-ai-sync';
import { runCostEventIngest } from '../services/roi/cost-ingest';
import { runRevenueIngest, runMonthlyAmortization } from '../services/roi/revenue-ingest';
import { runMonthlyDriverFactorsGeneration } from '../services/roi/ai-driver-writer';
import { db } from '../db/index';
import { syncLogs } from '../db/schema';
import { config } from '../config';
let giteaJob: Cron | null = null;
let revenueJob: Cron | null = null;
let amortizerJob: Cron | null = null;
let driverFactorsJob: Cron | null = null;
async function runSyncAndAnalyze() {
await syncGitea();
if (config.AI_ENABLED && config.AI_API_KEY) {
console.info('[SCHEDULER] AI OKR 分析开始...');
await analyzeCommitsForOKR().catch(e =>
console.error('[SCHEDULER] AI OKR 分析失败:', e)
);
} else {
const reason = !config.AI_ENABLED ? 'AI_ENABLED=false' : 'AI_API_KEY 为空';
console.warn(`[SCHEDULER] 跳过 AI 分析: ${reason}`);
await db.insert(syncLogs).values({
id: uuid(),
source: 'ai_okr',
status: 'error',
message: `AI 分析被跳过: ${reason} (AI_ENABLED=${config.AI_ENABLED}, AI_API_KEY length=${config.AI_API_KEY?.length || 0}, AI_MODEL=${config.AI_MODEL})`,
recordsProcessed: 0,
syncedAt: new Date(),
});
}
// ROI 成本事件 ingest:Git/Plane 同步完后跑,挂同一周期
console.info('[SCHEDULER] ROI 成本事件 ingest 开始...');
await runCostEventIngest().catch(e =>
console.error('[SCHEDULER] ROI 成本事件 ingest 失败:', e)
);
}
export function startScheduler(): void {
// 每天凌晨 2 点 + 晚上 7 点各同步一次
giteaJob = new Cron('0 2,19 * * *', async () => {
console.info('[SCHEDULER] Gitea 定时同步开始...');
await syncGitea();
await runSyncAndAnalyze();
});
console.info('[SCHEDULER] Gitea 自动同步已启动(每天 02:00 + 19:00');
// ROI 营收 ingest:每天 03:00 拉昨日营收
// 仅当 mock 启用 或 REVENUE_API_BASE_URL 不指向本地 mock 时才跑(避免对未挂载的端点请求拿 404)
const revenueApiReady = config.MOCK_REVENUE_API || !config.REVENUE_API_BASE_URL.includes('/mock');
if (revenueApiReady) {
revenueJob = new Cron('0 3 * * *', async () => {
console.info('[SCHEDULER] ROI 营收 ingest 开始...');
await runRevenueIngest().catch(e => console.error('[SCHEDULER] ROI 营收 ingest 失败:', e));
});
} else {
console.info('[SCHEDULER] 跳过营收 ingest cron: MOCK_REVENUE_API=false 且 REVENUE_API_BASE_URL 仍指向 mock');
}
// ROI 资产摊销:每月 1 号 01:00
amortizerJob = new Cron('0 1 1 * *', async () => {
console.info('[SCHEDULER] ROI 资产摊销开始...');
await runMonthlyAmortization().catch(e => console.error('[SCHEDULER] ROI 资产摊销失败:', e));
});
// ROI 驱动因子 AI:每月 1 号 03:00(摊销完后跑,数据齐备)
driverFactorsJob = new Cron('0 3 1 * *', async () => {
if (!config.AI_ENABLED || !config.AI_API_KEY) {
console.warn('[SCHEDULER] 跳过 ROI 驱动因子: AI 未启用');
return;
}
console.info('[SCHEDULER] ROI 驱动因子生成开始...');
await runMonthlyDriverFactorsGeneration().catch(e => console.error('[SCHEDULER] ROI 驱动因子失败:', e));
});
console.info(`[SCHEDULER] Gitea 自动同步已启动(每天 02:00 + 19:00 UTC, AI_ENABLED=${config.AI_ENABLED}, AI_API_KEY length=${config.AI_API_KEY?.length || 0}`);
console.info(`[SCHEDULER] ROI cron 已启动:营收 ingest(每天 03:00) + 资产摊销(每月 1 号 01:00) + AI 驱动因子(每月 1 号 03:00)`);
// 启动时立刻写一条诊断日志(不阻塞)
db.insert(syncLogs).values({
id: uuid(),
source: 'ai_okr',
status: 'success',
message: `[BOOT] Scheduler started. AI_ENABLED=${config.AI_ENABLED}, AI_API_KEY length=${config.AI_API_KEY?.length || 0}, AI_MODEL=${config.AI_MODEL}, AI_BASE_URL=${config.AI_BASE_URL}`,
recordsProcessed: 0,
syncedAt: new Date(),
}).catch(e => console.error('[SCHEDULER] 启动诊断日志写入失败:', e));
// 启动后 10 秒执行一次首次同步
setTimeout(async () => {
console.info('[SCHEDULER] 执行首次同步...');
await syncGitea().catch(e => console.error('[SCHEDULER] 首次同步失败:', e));
await runSyncAndAnalyze().catch(e => console.error('[SCHEDULER] 首次同步失败:', e));
}, 10000);
}
export function stopScheduler(): void {
giteaJob?.stop();
revenueJob?.stop();
amortizerJob?.stop();
driverFactorsJob?.stop();
console.info('[SCHEDULER] 已停止同步任务');
}

View File

@ -70,9 +70,16 @@ export async function syncGitea(): Promise<void> {
for (const repo of reposToSync) {
try {
// Sync commits
const commits = await giteaClient.getCommits(repo.owner, repo.name);
// Sync commits from all branches
const branches = await giteaClient.getBranches(repo.owner, repo.name);
const seenShas = new Set<string>();
for (const branch of branches) {
const commits = await giteaClient.getCommits(repo.owner, repo.name, undefined, branch.name);
for (const commit of commits) {
if (seenShas.has(commit.sha)) continue;
seenShas.add(commit.sha);
const existingCommit = await db.query.gitCommits.findFirst({
where: eq(gitCommits.sha, commit.sha),
});
@ -101,6 +108,7 @@ export async function syncGitea(): Promise<void> {
recordsProcessed++;
}
}
}
// Sync PRs
const prs = await giteaClient.getPullRequests(repo.owner, repo.name);
@ -143,7 +151,7 @@ export async function syncGitea(): Promise<void> {
recordsProcessed++;
}
console.info(`[SYNC] Repo ${repo.owner}/${repo.name}: synced`);
console.info(`[SYNC] Repo ${repo.owner}/${repo.name}: synced (${branches.length} branches)`);
} catch (repoErr) {
const msg = repoErr instanceof Error ? repoErr.message : 'Unknown error';
console.error(`[SYNC] Repo ${repo.owner}/${repo.name} failed:`, msg);

View File

@ -0,0 +1,86 @@
/**
* ROI
* DB ;/
*/
import { describe, it, expect } from 'bun:test';
import { evaluateConfidence } from '../../src/services/roi/confidence-evaluator';
import { bepFromTotals } from '../../src/services/roi/bep-calculator';
describe('confidence-evaluator', () => {
it('returns low when no events at all', () => {
expect(evaluateConfidence([], [])).toBe('low');
});
it('returns low when only cost, no revenue', () => {
expect(evaluateConfidence([{ dataSource: 'manual' }], [])).toBe('low');
});
it('returns low when all cost is auto-estimated', () => {
const costs = [
{ dataSource: 'auto_commits' },
{ dataSource: 'auto_tasks' },
];
const revenues = [{ dataSource: 'manual' }];
expect(evaluateConfidence(costs, revenues)).toBe('low');
});
it('returns high when 80%+ cost is plane_actual/manual + revenue exists', () => {
const costs = [
{ dataSource: 'plane_actual' },
{ dataSource: 'plane_actual' },
{ dataSource: 'manual' },
{ dataSource: 'manual' },
{ dataSource: 'auto_commits' }, // 1/5 = 20% 自动,刚好满足 80%+ 高质量
];
const revenues = [{ dataSource: 'api_pulled' }];
expect(evaluateConfidence(costs, revenues)).toBe('high');
});
it('returns medium when high-quality cost ratio is between 1% and 80%', () => {
const costs = [
{ dataSource: 'plane_actual' },
{ dataSource: 'auto_commits' },
{ dataSource: 'auto_commits' },
];
const revenues = [{ dataSource: 'manual' }];
// 1/3 = 33% 高质量,落在 medium 区间
expect(evaluateConfidence(costs, revenues)).toBe('medium');
});
it('returns high when all cost manual', () => {
const costs = [{ dataSource: 'manual' }, { dataSource: 'manual' }];
const revenues = [{ dataSource: 'manual' }];
expect(evaluateConfidence(costs, revenues)).toBe('high');
});
});
describe('bepFromTotals', () => {
it('returns 0 when already broken even (revenue >= cost)', () => {
expect(bepFromTotals(100000, 100000, 0, 0)).toBe(0);
expect(bepFromTotals(100000, 150000, 0, 0)).toBe(0);
});
it('returns null when recent net income is non-positive', () => {
// 累计亏 5w,近 30 天净产出为 0
expect(bepFromTotals(100000, 50000, 30000, 30000)).toBe(null);
// 近 30 天还在亏
expect(bepFromTotals(100000, 50000, 30000, 20000)).toBe(null);
});
it('returns positive days when on track to break even', () => {
// 总投入 100w,总产出 80w => 缺口 20w
// 近 30 天:成本 3w,收入 6w => 日均净 1000 元
// 预计天数 = 200000 / 1000 = 200 天
expect(bepFromTotals(1_000_000, 800_000, 30_000, 60_000)).toBe(200);
});
it('rounds up partial days', () => {
// 缺口 1000,日均净 300 => 3.33 天向上取整 = 4
expect(bepFromTotals(2000, 1000, 0, 9000)).toBe(4); // daily = 9000/30 = 300
});
it('respects custom windowDays', () => {
// 缺口 1000,近 10 天净 1000 => 日均 100 => 10 天回本
expect(bepFromTotals(2000, 1000, 0, 1000, 10)).toBe(10);
});
});

View File

@ -9,7 +9,7 @@ RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist/ /usr/share/nginx/html/
# SPA fallback: all routes to index.html
RUN printf 'server {\n listen 80;\n root /usr/share/nginx/html;\n index index.html;\n location / {\n try_files $uri $uri/ /index.html;\n }\n location /api/ {\n return 404;\n }\n}\n' > /etc/nginx/conf.d/default.conf
# Nginx config: gzip + cache + SPA fallback
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

35
frontend/nginx.conf Normal file
View File

@ -0,0 +1,35 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 256;
gzip_comp_level 6;
gzip_types
text/plain
text/css
text/javascript
application/javascript
application/json
application/xml
image/svg+xml;
# Static assets with hash in filename long cache
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache";
}
location /api/ {
return 404;
}
}

View File

@ -37,6 +37,10 @@ export function createProjectApi(data: { name: string; identifier: string }) {
return request.post('/api/projects', data);
}
export function updateProjectApi(id: string, data: { name?: string; identifier?: string }) {
return request.patch(`/api/projects/${id}`, data);
}
export function deleteProjectApi(id: string) {
return request.delete(`/api/projects/${id}`);
}
@ -65,3 +69,12 @@ export function triggerSyncApi(data?: { source?: string }) {
export function getSyncLogsApi(params?: { page?: number; pageSize?: number }) {
return request.get('/api/admin/sync/logs', { params });
}
// User Project Permissions (观察者项目权限)
export function getUserProjectsApi(userId: string) {
return request.get(`/api/admin/users/${userId}/projects`);
}
export function setUserProjectsApi(userId: string, projectIds: string[]) {
return request.put(`/api/admin/users/${userId}/projects`, { projectIds });
}

139
frontend/src/api/roi.ts Normal file
View File

@ -0,0 +1,139 @@
import request from './request';
export type RoiCategory = 'cash_cow' | 'efficiency_tool' | 'moat' | 'composite';
export type BizSystem = 'airhubs' | 'airflow' | 'aircore';
export type ProjectType = 'hardware' | 'software';
export type Confidence = 'high' | 'medium' | 'low';
export interface AggregateResult {
projectId: string;
from: string;
to: string;
totalCost: number;
totalRevenue: number;
netProfit: number;
roiValue: number | null;
confidence: Confidence;
bepDays: number | null;
costBreakdown: Record<string, number>;
revenueBreakdown: Record<string, number>;
costEventCount: number;
revenueEventCount: number;
}
export interface TimeseriesBucket {
bucket: string;
cost: number;
revenue: number;
net: number;
cumulativeCost: number;
cumulativeRevenue: number;
cumulativeRoi: number | null;
}
export interface DashboardResult {
from: string;
to: string;
summary: {
totalCost: number;
totalRevenue: number;
netProfit: number;
roiValue: number | null;
projectCount: number;
};
byCategory: Record<string, { totalCost: number; totalRevenue: number; netProfit: number; projectCount: number }>;
projects: Array<{
projectId: string;
name: string;
identifier: string;
category: RoiCategory | null;
totalCost: number;
totalRevenue: number;
roiValue: number | null;
confidence: Confidence;
}>;
}
export interface TagSuggestion {
suggestedCategory: RoiCategory;
suggestedBizSystem: BizSystem;
suggestedProjectType: ProjectType;
confidence: number;
reasoning: string;
}
export interface Strategy {
id: string;
category: RoiCategory;
name: string;
formulaKey: string;
params: {
hourlyRate: number;
amortYears?: number;
commitHourCoef: number;
taskHourCoef: number;
};
updatedAt: string;
}
export interface DriverFactor {
type: '现金流驱动' | '降本增效驱动' | '技术资产驱动';
text: string;
}
// ── 核心聚合 ──
export const aggregateRoi = (projectId: string, from: string, to: string) =>
request.get<{ code: number; data: AggregateResult }>(`/api/roi/aggregate`, { params: { projectId, from, to } });
export const timeseriesRoi = (projectId: string, from: string, to: string, granularity: 'day' | 'week' | 'month' | 'year' = 'month') =>
request.get<{ code: number; data: TimeseriesBucket[] }>(`/api/roi/timeseries`, { params: { projectId, from, to, granularity } });
export const fetchDashboard = (from: string, to: string) =>
request.get<{ code: number; data: DashboardResult }>(`/api/roi/dashboard`, { params: { from, to } });
// ── 事件流 ──
export const createCostEvent = (projectId: string, payload: any) =>
request.post(`/api/projects/${projectId}/cost-events`, payload);
export const createRevenueEvent = (projectId: string, payload: any) =>
request.post(`/api/projects/${projectId}/revenue-events`, payload);
export const listEvents = (projectId: string, type: 'cost' | 'revenue', from?: string, to?: string, limit = 100) =>
request.get(`/api/projects/${projectId}/events`, { params: { type, from, to, limit } });
export const deleteEvent = (projectId: string, eventId: string, type: 'cost' | 'revenue') =>
request.delete(`/api/projects/${projectId}/events/${eventId}`, { params: { type } });
// ── 策略 ──
export const fetchStrategies = () =>
request.get<{ code: number; data: Strategy[] }>(`/api/roi/strategies`);
export const updateStrategy = (id: string, params: any) =>
request.patch(`/api/roi/strategies/${id}`, { params });
// ── 打标 ──
export interface TagPayload {
category: RoiCategory;
compositeStrategies?: ('cash_cow' | 'efficiency_tool' | 'moat')[] | null;
bizSystem?: BizSystem | null;
projectType?: ProjectType | null;
ownerId?: string | null;
launchedAt?: string | null;
vAsset?: number | null;
tags?: string[] | null;
}
export const tagProject = (projectId: string, payload: TagPayload) =>
request.post(`/api/projects/${projectId}/tag`, payload);
export const suggestTag = (projectId: string) =>
request.post<{ code: number; data: TagSuggestion }>(`/api/projects/${projectId}/suggest-tag`, undefined, { timeout: 60000 });
// ── 项目映射 ──
export const listMapping = () => request.get(`/api/roi/mapping`);
export const createMapping = (payload: any) => request.post(`/api/roi/mapping`, payload);
export const deleteMapping = (id: string) => request.delete(`/api/roi/mapping/${id}`);
export const listUnmapped = () => request.get(`/api/roi/unmapped`);
// ── 驱动因子 ──
export const fetchDriverFactors = (projectId: string, periodKey?: string) =>
request.get(`/api/projects/${projectId}/driver-factors`, { params: { periodKey } });

View File

@ -18,7 +18,7 @@ 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 names = sorted.map((p) => p.name);
const values = sorted.map((p) => p.currentCycleProgress);
const bgValues = sorted.map(() => 100);
@ -31,7 +31,7 @@ const chartOptions = computed(() => {
const project = sorted[idx];
if (!project) return '';
return `
<strong>${project.identifier} ${project.name}</strong><br/>
<strong>${project.name}</strong><br/>
进度: ${project.currentCycleProgress}%<br/>
${project.completedPoints}/${project.totalPoints}
`;

View File

@ -70,6 +70,7 @@ onUnmounted(() => {
flex: 1;
padding: var(--space-6);
overflow-y: auto;
background: var(--color-bg);
}
/* Overlay backdrop for mobile sidebar */

View File

@ -4,12 +4,12 @@
* 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 { computed, ref, onMounted, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { NTag, NTooltip } from 'naive-ui';
import { useAuthStore } from '@/stores/auth';
import { useDashboardStore } from '@/stores/dashboard';
import { getOverviewApi } from '@/api/overview';
import { getAdminProjectsApi } from '@/api/admin';
const route = useRoute();
const router = useRouter();
@ -18,16 +18,67 @@ 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 }>>([]);
const projectList = ref<Array<{ projectId: string; name: string; identifier: string; bizSystem: string | null }>>([]);
// Load project list for sidebar sub-menu
onMounted(async () => {
async function loadProjectList() {
try {
const res = await getOverviewApi();
projectList.value = res.data.data.projectProgress || [];
const res = await getAdminProjectsApi();
const list = res.data.data || [];
projectList.value = list.map((p: any) => ({
projectId: p.id,
name: p.name,
identifier: p.identifier || '',
bizSystem: p.bizSystem || null,
}));
} catch {
// Silently fail - sidebar still works without project sub-menu
// Silently fail
}
}
// 线
const BIZ_GROUPS: Array<{ key: 'airhubs' | 'airflow' | 'aircore' | 'uncategorized'; label: string; color: string }> = [
{ key: 'airhubs', label: 'airhubs · 硬件与潮玩业务线', color: '#0D9668' },
{ key: 'airflow', label: 'airflow · 内容生成与效能线', color: '#3B5998' },
{ key: 'aircore', label: 'aircore · 底层技术基座', color: '#D4920A' },
{ key: 'uncategorized', label: '未分类', color: '#6B7280' },
];
// (,localStorage )
const groupOpenState = ref<Record<string, boolean>>(
(() => {
try { return JSON.parse(localStorage.getItem('sidebar-group-open') || '{}'); }
catch { return {}; }
})()
);
function isGroupOpen(key: string): boolean {
return groupOpenState.value[key] !== false; // true(undefined )
}
function toggleGroup(key: string) {
groupOpenState.value = { ...groupOpenState.value, [key]: !isGroupOpen(key) };
try { localStorage.setItem('sidebar-group-open', JSON.stringify(groupOpenState.value)); } catch {}
}
const projectGroups = computed(() => {
const map: Record<string, typeof projectList.value> = {
airhubs: [], airflow: [], aircore: [], uncategorized: [],
};
for (const p of projectList.value) {
const k = p.bizSystem || 'uncategorized';
if (!map[k]) map[k] = [];
map[k].push(p);
}
return BIZ_GROUPS
.map(g => ({ ...g, projects: map[g.key] }))
.filter(g => g.projects.length > 0);
});
onMounted(loadProjectList);
//
watch(() => route.path, (newPath) => {
if (newPath === '/projects') loadProjectList();
});
interface NavItem {
@ -51,6 +102,11 @@ const menuOptions = computed(() => {
items.push({ label: 'Git 活动', key: '/git', icon: 'git-branch' });
}
// ROI : admin
if (role === 'admin') {
items.push({ label: 'ROI 罗盘', key: '/roi', icon: 'trending-up' });
}
// B-17: Members nav item (admin/manager only)
if (role === 'admin' || role === 'manager') {
items.push({ label: '成员', key: '/members', icon: 'users' });
@ -71,6 +127,7 @@ const activeKey = computed(() => {
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('/roi')) return '/roi';
if (route.path.startsWith('/admin')) return '/admin';
return '/';
});
@ -158,20 +215,35 @@ const roleTagType = computed(() => {
</NTooltip>
</div>
<!-- B-17: Projects sub-menu -->
<!-- B-17: Projects sub-menu, grouped by bizSystem -->
<div
v-if="item.hasSubmenu && projectsExpanded && (!dashStore.sidebarCollapsed || dashStore.isMobile)"
class="submenu"
>
<template v-for="group in projectGroups" :key="group.key">
<div
v-for="proj in projectList"
class="submenu-group-title"
:class="{ 'group-collapsed': !isGroupOpen(group.key) }"
:style="{ borderLeftColor: group.color }"
@click="toggleGroup(group.key)"
>
<span class="group-dot" :style="{ background: group.color }"></span>
<span class="group-arrow">{{ isGroupOpen(group.key) ? '▾' : '▸' }}</span>
{{ group.label }}
<span class="group-count">{{ group.projects.length }}</span>
</div>
<template v-if="isGroupOpen(group.key)">
<div
v-for="proj in group.projects"
: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>
<span class="submenu-label">{{ proj.name }}</span>
</div>
</template>
</template>
<div v-if="!projectList.length" class="submenu-item submenu-empty">
暂无项目
</div>
@ -204,17 +276,18 @@ const roleTagType = computed(() => {
width: var(--sidebar-width);
height: 100vh;
background: var(--color-bg-sidebar);
color: #E5E7EB;
color: var(--color-text-onDark);
display: flex;
flex-direction: column;
position: fixed;
left: 0;
top: 0;
transition: width var(--duration-collapse) var(--ease-default),
transform 0.3s ease;
transition: width var(--duration-collapse) var(--ease-out),
transform var(--duration-medium) var(--ease-out);
z-index: var(--z-sticky);
overflow: hidden;
overflow-y: auto;
border-right: 1px solid oklch(0.25 0.012 230);
}
.sidebar.collapsed {
@ -262,20 +335,24 @@ const roleTagType = computed(() => {
.logo-icon {
width: 32px;
height: 32px;
background: var(--color-primary-hex);
border-radius: var(--radius-btn);
background: linear-gradient(135deg, var(--color-accent) 0%, var(--color-accent-hover) 100%);
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
font-weight: 800;
font-size: 12px;
color: white;
font-family: var(--font-display);
font-weight: var(--weight-semibold);
font-size: 13px;
color: var(--color-text-onDark);
flex-shrink: 0;
letter-spacing: -0.02em;
}
.logo-text {
font-weight: 700;
font-size: 16px;
font-family: var(--font-display);
font-weight: var(--weight-semibold);
font-size: var(--text-md);
letter-spacing: -0.01em;
white-space: nowrap;
}
@ -286,25 +363,31 @@ const roleTagType = computed(() => {
}
.nav-item {
padding: var(--space-3) var(--space-4);
margin-bottom: var(--space-1);
border-radius: var(--radius-btn);
padding: var(--space-2) var(--space-4);
margin-bottom: 2px;
border-radius: var(--radius-md);
cursor: pointer;
transition: background var(--duration-hover) var(--ease-default);
transition: background var(--duration-fast) var(--ease-out),
color var(--duration-fast) var(--ease-out);
position: relative;
white-space: nowrap;
display: flex;
align-items: center;
justify-content: space-between;
font-size: var(--text-sm);
font-weight: var(--weight-medium);
color: oklch(0.75 0.010 220);
}
.nav-item:hover {
background: rgba(255,255,255,0.08);
background: oklch(0.24 0.014 230);
color: var(--color-text-onDark);
}
.nav-item.active {
background: rgba(59,89,152,0.3);
border-left: 3px solid var(--color-primary-hex);
background: oklch(0.26 0.018 220);
color: var(--color-accent);
border-left: 2px solid var(--color-accent);
}
.nav-label {
@ -361,6 +444,51 @@ const roleTagType = computed(() => {
text-overflow: ellipsis;
}
/* 产品线分组标题 */
.submenu-group-title {
padding: 8px 12px 4px;
margin-top: 4px;
font-size: 11px;
font-weight: 600;
color: #9CA3AF;
letter-spacing: 0.3px;
border-left: 2px solid transparent;
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
user-select: none;
transition: color 0.15s, background 0.15s;
border-radius: 4px;
}
.submenu-group-title:hover {
color: #E5E7EB;
background: rgba(255,255,255,0.04);
}
.submenu-group-title.group-collapsed { opacity: 0.7; }
.group-arrow {
font-size: 10px;
width: 10px;
display: inline-flex;
justify-content: center;
color: #6B7280;
}
.group-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.group-count {
margin-left: auto;
font-size: 10px;
color: #6B7280;
background: rgba(255,255,255,0.06);
padding: 1px 6px;
border-radius: 8px;
font-weight: normal;
}
.nav-icon-only {
display: flex;
align-items: center;

View File

@ -0,0 +1,48 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useECharts, CHART_COLORS } from '@/composables/useECharts';
const props = defineProps<{
byCategory: Record<string, { totalCost: number; totalRevenue: number; netProfit: number; projectCount: number }>;
}>();
const CATEGORY_LABELS: Record<string, string> = {
cash_cow: '💰 现金牛',
efficiency_tool: '⚙️ 效能工具',
moat: '💎 资本护城河',
composite: '🚀 复合型',
uncategorized: '未打标',
};
const option = computed(() => {
const keys = Object.keys(props.byCategory);
const labels = keys.map(k => CATEGORY_LABELS[k] || k);
const costs = keys.map(k => props.byCategory[k].totalCost);
const revenues = keys.map(k => props.byCategory[k].totalRevenue);
const nets = keys.map(k => props.byCategory[k].netProfit);
return {
color: [CHART_COLORS[5], CHART_COLORS[1], CHART_COLORS[0]],
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
legend: { data: ['成本', '产出', '净利'], top: 0 },
grid: { left: 60, right: 20, top: 35, bottom: 30 },
xAxis: { type: 'category', data: labels, axisLabel: { fontSize: 11 } },
yAxis: { type: 'value', axisLabel: { formatter: (v: number) => v >= 10000 ? (v / 10000).toFixed(1) + '万' : v.toFixed(0) } },
series: [
{ name: '成本', type: 'bar', stack: 'cost', data: costs },
{ name: '产出', type: 'bar', stack: 'revenue', data: revenues },
{ name: '净利', type: 'bar', stack: 'net', data: nets, itemStyle: { opacity: 0.6 } },
],
};
});
const { chartRef } = useECharts(option);
</script>
<template>
<div ref="chartRef" class="category-stacked-bar"></div>
</template>
<style scoped>
.category-stacked-bar { width: 100%; height: 320px; }
</style>

View File

@ -0,0 +1,29 @@
<script setup lang="ts">
import { computed } from 'vue';
import { NTag } from 'naive-ui';
type Confidence = 'high' | 'medium' | 'low';
const props = defineProps<{
confidence: Confidence;
showLabel?: boolean;
}>();
const tagType = computed<'success' | 'warning' | 'error'>(() => {
if (props.confidence === 'high') return 'success';
if (props.confidence === 'medium') return 'warning';
return 'error';
});
const label = computed(() => {
if (props.confidence === 'high') return 'High';
if (props.confidence === 'medium') return 'Medium';
return 'Low';
});
</script>
<template>
<NTag :type="tagType" size="small" round>
<span v-if="showLabel !== false">置信度</span> {{ label }}
</NTag>
</template>

View File

@ -0,0 +1,117 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import {
NModal, NForm, NFormItem, NSelect, NDatePicker, NInputNumber, NInput, NButton, useMessage,
} from 'naive-ui';
import { createCostEvent, createRevenueEvent } from '@/api/roi';
const props = defineProps<{
show: boolean;
type: 'cost' | 'revenue';
projectId: string;
}>();
const emit = defineEmits<{ 'update:show': [v: boolean]; saved: [] }>();
const message = useMessage();
const saving = ref(false);
const dateTs = ref<number | null>(Date.now());
const form = ref({
costType: 'hardware_bom' as 'dev_hours' | 'hardware_bom' | 'service_fee' | 'amortization' | 'other',
revenueType: 'direct_revenue' as 'direct_revenue' | 'subscription' | 'saved_cost' | 'asset_value_add' | 'refund' | 'other',
amount: 0,
channel: '',
notes: '',
});
const COST_OPTIONS = [
{ label: '研发工时', value: 'dev_hours' },
{ label: '硬件 BOM', value: 'hardware_bom' },
{ label: '服务费/运维', value: 'service_fee' },
{ label: '摊销', value: 'amortization' },
{ label: '其他', value: 'other' },
];
const REVENUE_OPTIONS = [
{ label: '直接营收', value: 'direct_revenue' },
{ label: '订阅', value: 'subscription' },
{ label: '节约成本(效能工具)', value: 'saved_cost' },
{ label: '资产增值(护城河)', value: 'asset_value_add' },
{ label: '退款/冲账', value: 'refund' },
{ label: '其他', value: 'other' },
];
watch(() => props.show, (s) => {
if (s) {
dateTs.value = Date.now();
form.value = { costType: 'hardware_bom', revenueType: 'direct_revenue', amount: 0, channel: '', notes: '' };
}
});
function formatDate(ts: number | null): string {
if (ts === null) return '';
const d = new Date(ts);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
async function handleSave() {
if (dateTs.value === null) { message.warning('请选择日期'); return; }
if (props.type === 'cost' && form.value.amount < 0) { message.warning('成本金额必须 >= 0'); return; }
saving.value = true;
try {
const eventDate = formatDate(dateTs.value);
if (props.type === 'cost') {
await createCostEvent(props.projectId, {
eventDate,
costType: form.value.costType,
amount: form.value.amount,
notes: form.value.notes || undefined,
});
} else {
await createRevenueEvent(props.projectId, {
eventDate,
revenueType: form.value.revenueType,
amount: form.value.amount,
channel: form.value.channel || undefined,
notes: form.value.notes || undefined,
});
}
message.success('已保存');
emit('saved');
emit('update:show', false);
} catch (e: any) {
message.error('保存失败:' + (e?.response?.data?.message || e.message));
} finally {
saving.value = false;
}
}
</script>
<template>
<NModal :show="show" preset="card" :title="type === 'cost' ? '录入成本事件' : '录入产出事件'" style="width:500px"
@update:show="(v: boolean) => emit('update:show', v)">
<NForm label-placement="top" size="medium">
<NFormItem label="发生日期">
<NDatePicker v-model:value="dateTs" type="date" style="width:100%" />
</NFormItem>
<NFormItem v-if="type === 'cost'" label="成本类型">
<NSelect v-model:value="form.costType" :options="COST_OPTIONS" />
</NFormItem>
<NFormItem v-else label="产出类型">
<NSelect v-model:value="form.revenueType" :options="REVENUE_OPTIONS" />
</NFormItem>
<NFormItem :label="type === 'cost' ? '金额(元)' : '金额(元,退款用负数)'">
<NInputNumber v-model:value="form.amount" :min="type === 'cost' ? 0 : -1e8" :max="1e8" style="width:100%" />
</NFormItem>
<NFormItem v-if="type === 'revenue'" label="渠道(可选)">
<NInput v-model:value="form.channel" placeholder="alipay / wechat / stripe ..." />
</NFormItem>
<NFormItem label="备注(可选)">
<NInput v-model:value="form.notes" type="textarea" :autosize="{ minRows: 2, maxRows: 4 }" maxlength="500" />
</NFormItem>
<div style="display:flex;justify-content:flex-end;gap:8px">
<NButton @click="emit('update:show', false)">取消</NButton>
<NButton type="primary" :loading="saving" @click="handleSave">保存</NButton>
</div>
</NForm>
</NModal>
</template>

View File

@ -0,0 +1,254 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import {
NTag, NButton, NModal, NForm, NFormItem, NSelect, NCheckboxGroup, NCheckbox,
NDatePicker, NInputNumber, NAlert, NSpin, useMessage,
} from 'naive-ui';
import {
tagProject, suggestTag,
type RoiCategory, type BizSystem, type ProjectType, type TagPayload,
} from '@/api/roi';
const props = defineProps<{
projectId: string;
initialCategory?: RoiCategory | null;
initialCompositeStrategies?: string[] | null;
initialBizSystem?: BizSystem | null;
initialProjectType?: ProjectType | null;
initialLaunchedAt?: string | null;
initialVAsset?: number | null;
canEdit: boolean;
}>();
const emit = defineEmits<{ saved: [] }>();
const message = useMessage();
const open = ref(false);
const saving = ref(false);
const suggesting = ref(false);
interface AiSuggestion {
category: RoiCategory;
bizSystem: BizSystem;
projectType: ProjectType;
confidence: number;
reasoning: string;
}
const suggestion = ref<AiSuggestion | null>(null);
const form = ref<TagPayload>({
category: 'cash_cow',
compositeStrategies: null,
bizSystem: null,
projectType: null,
launchedAt: null,
vAsset: null,
});
const CATEGORY_META: Record<RoiCategory, { label: string; emoji: string; color: 'success' | 'info' | 'warning' | 'error' }> = {
cash_cow: { label: '现金牛', emoji: '💰', color: 'success' },
efficiency_tool: { label: '效能工具', emoji: '⚙️', color: 'info' },
moat: { label: '资本护城河', emoji: '💎', color: 'warning' },
composite: { label: '复合型', emoji: '🚀', color: 'error' },
};
const BIZ_META: Record<BizSystem, { label: string; sub: string }> = {
airhubs: { label: 'airhubs', sub: '硬件与潮玩业务线' },
airflow: { label: 'airflow', sub: '内容生成与效能线' },
aircore: { label: 'aircore', sub: '底层技术基座' },
};
const TYPE_LABEL: Record<ProjectType, string> = {
hardware: '硬件',
software: '软件',
};
const categoryOptions = (Object.keys(CATEGORY_META) as RoiCategory[]).map(k => ({
label: `${CATEGORY_META[k].emoji} ${CATEGORY_META[k].label}`,
value: k,
}));
const bizSystemOptions = (Object.keys(BIZ_META) as BizSystem[]).map(k => ({
label: `${BIZ_META[k].label}${BIZ_META[k].sub}`,
value: k,
}));
const projectTypeOptions = [
{ label: '🔧 硬件 (hardware)', value: 'hardware' },
{ label: '💻 软件 (software)', value: 'software' },
];
const subStrategyOptions = [
{ label: '💰 现金牛', value: 'cash_cow' },
{ label: '⚙️ 效能工具', value: 'efficiency_tool' },
{ label: '💎 资本护城河', value: 'moat' },
];
const needsAsset = computed(() => {
if (form.value.category === 'moat') return true;
if (form.value.category === 'composite' && (form.value.compositeStrategies || []).includes('moat')) return true;
return false;
});
const willRegenerateIdentifier = computed(() =>
!!(form.value.bizSystem && form.value.projectType) &&
(form.value.bizSystem !== props.initialBizSystem || form.value.projectType !== props.initialProjectType)
);
const launchedAtTs = ref<number | null>(null);
function openModal() {
form.value = {
category: props.initialCategory || 'cash_cow',
compositeStrategies: props.initialCompositeStrategies as any || null,
bizSystem: props.initialBizSystem || null,
projectType: props.initialProjectType || null,
launchedAt: props.initialLaunchedAt || null,
vAsset: props.initialVAsset || null,
};
launchedAtTs.value = props.initialLaunchedAt ? new Date(props.initialLaunchedAt).getTime() : null;
suggestion.value = null;
open.value = true;
}
watch(launchedAtTs, (ts) => {
if (ts === null) {
form.value.launchedAt = null;
} else {
const d = new Date(ts);
form.value.launchedAt = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
});
async function handleSuggest() {
suggesting.value = true;
try {
const res = await suggestTag(props.projectId);
const s = res.data.data;
suggestion.value = {
category: s.suggestedCategory,
bizSystem: s.suggestedBizSystem,
projectType: s.suggestedProjectType,
confidence: s.confidence,
reasoning: s.reasoning,
};
} catch (e: any) {
message.error('AI 推荐失败:' + (e?.response?.data?.message || e.message));
} finally {
suggesting.value = false;
}
}
function adoptSuggestion() {
if (!suggestion.value) return;
form.value.category = suggestion.value.category;
form.value.bizSystem = suggestion.value.bizSystem;
form.value.projectType = suggestion.value.projectType;
message.success('已采纳 AI 建议');
}
async function handleSave() {
if (needsAsset.value && (!form.value.vAsset || form.value.vAsset <= 0)) {
message.warning('资本护城河项目必须填写技术资产估值');
return;
}
saving.value = true;
try {
const res = await tagProject(props.projectId, form.value);
const newId = res.data?.data?.identifier;
if (newId && newId !== props.initialCategory) {
message.success(`已保存,新标识:${newId}`);
} else {
message.success('已保存');
}
open.value = false;
emit('saved');
} catch (e: any) {
message.error('保存失败:' + (e?.response?.data?.message || e.message));
} finally {
saving.value = false;
}
}
</script>
<template>
<div class="project-tag-selector">
<NTag v-if="initialCategory" :type="CATEGORY_META[initialCategory].color" round size="medium">
{{ CATEGORY_META[initialCategory].emoji }} {{ CATEGORY_META[initialCategory].label }}
</NTag>
<NTag v-else type="default" round size="medium">未打标</NTag>
<NButton v-if="canEdit" size="tiny" text type="primary" @click="openModal" style="margin-left:8px">
{{ initialCategory ? '编辑' : '+ 打标' }}
</NButton>
<NModal v-model:show="open" preset="card" title="项目商业定位打标" style="width:640px">
<NSpin :show="saving || suggesting">
<NForm label-placement="top" size="medium">
<NFormItem label="业务体系归属(决定项目标识前缀)">
<NSelect v-model:value="form.bizSystem" :options="bizSystemOptions" placeholder="选择 airhubs / airflow / aircore" clearable />
</NFormItem>
<NFormItem label="技术属性(硬件 / 软件)">
<NSelect v-model:value="form.projectType" :options="projectTypeOptions" placeholder="选择 hardware / software" clearable />
</NFormItem>
<NAlert v-if="willRegenerateIdentifier" type="info" style="margin:-4px 0 12px" :show-icon="false">
保存后将自动生成新标识(格式 {{ form.bizSystem }}-{{ form.projectType === 'hardware' ? 'hw' : 'sw' }}-XXX)
</NAlert>
<NFormItem label="ROI 分类标签">
<NSelect v-model:value="form.category" :options="categoryOptions" />
</NFormItem>
<NFormItem v-if="form.category === 'composite'" label="复合策略组合(至少选 2 个)">
<NCheckboxGroup v-model:value="form.compositeStrategies">
<div style="display:flex;gap:12px">
<NCheckbox v-for="opt in subStrategyOptions" :key="opt.value" :value="opt.value" :label="opt.label" />
</div>
</NCheckboxGroup>
</NFormItem>
<NFormItem label="立项日期(累计 ROI 起算点)">
<NDatePicker v-model:value="launchedAtTs" type="date" clearable style="width:100%" />
</NFormItem>
<NFormItem v-if="needsAsset" label="技术资产估值(元) — 资本护城河必填">
<NInputNumber v-model:value="form.vAsset" :min="0" :max="1e10" placeholder="如 360000" style="width:100%" />
</NFormItem>
<NAlert v-if="suggestion" type="info" style="margin:12px 0">
<div style="display:flex;justify-content:space-between;align-items:start;gap:12px">
<div style="flex:1">
<div style="font-weight:600;line-height:1.6">
AI 建议:
<NTag size="small" :type="CATEGORY_META[suggestion.category].color" round>{{ CATEGORY_META[suggestion.category].emoji }} {{ CATEGORY_META[suggestion.category].label }}</NTag>
<NTag size="small" type="info" round>{{ BIZ_META[suggestion.bizSystem].label }}</NTag>
<NTag size="small" type="default" round>{{ TYPE_LABEL[suggestion.projectType] }}</NTag>
<span style="color:var(--color-text-muted);margin-left:8px;font-weight:normal">置信度 {{ Math.round(suggestion.confidence * 100) }}%</span>
</div>
<div style="margin-top:6px;color:var(--color-text-muted);font-size:13px;line-height:1.5">{{ suggestion.reasoning }}</div>
</div>
<NButton size="small" type="primary" @click="adoptSuggestion">采纳</NButton>
</div>
</NAlert>
<div style="display:flex;justify-content:space-between;margin-top:16px">
<NButton @click="handleSuggest" :loading="suggesting">🤖 AI 推荐</NButton>
<div style="display:flex;gap:8px">
<NButton @click="open = false">取消</NButton>
<NButton type="primary" @click="handleSave" :loading="saving">保存</NButton>
</div>
</div>
</NForm>
</NSpin>
</NModal>
</div>
</template>
<style scoped>
.project-tag-selector {
display: inline-flex;
align-items: center;
gap: 4px;
}
</style>

View File

@ -0,0 +1,115 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useECharts, CHART_COLORS } from '@/composables/useECharts';
const props = defineProps<{
byCategory: Record<string, { totalRevenue: number }>;
}>();
const CATEGORY_LABELS: Record<string, string> = {
cash_cow: '现金牛',
efficiency_tool: '效能工具',
moat: '资本护城河',
composite: '复合型',
uncategorized: '未打标',
};
function fmtCurrency(n: number): string {
if (n >= 10000) return `¥${(n / 10000).toFixed(1)}`;
return `¥${Math.round(n).toLocaleString()}`;
}
const total = computed(() =>
Object.values(props.byCategory).reduce((s, v) => s + (v.totalRevenue > 0 ? v.totalRevenue : 0), 0)
);
const option = computed(() => {
const data = Object.entries(props.byCategory)
.filter(([, v]) => v.totalRevenue > 0)
.map(([k, v]) => ({ name: CATEGORY_LABELS[k] || k, value: Math.round(v.totalRevenue) }));
return {
color: CHART_COLORS,
textStyle: { fontFamily: "'Geist', 'PingFang SC', sans-serif", color: '#4d5258' },
tooltip: {
trigger: 'item',
backgroundColor: '#ffffff',
borderColor: '#dfe2e6',
borderWidth: 1,
textStyle: { color: '#2d3033', fontSize: 12 },
extraCssText: 'box-shadow: 0 8px 24px rgba(34,40,42,0.06); border-radius: 10px; padding: 8px 12px;',
formatter: (params: any) => `
<div style="font-weight:600;margin-bottom:4px">${params.name}</div>
<div style="font-family:'JetBrains Mono',monospace;font-size:13px">¥${params.value.toLocaleString()} <span style="color:#7a8085">(${params.percent}%)</span></div>
`,
},
legend: {
orient: 'horizontal',
bottom: 0,
left: 'center',
itemGap: 18,
itemWidth: 8,
itemHeight: 8,
icon: 'circle',
textStyle: { fontSize: 12, color: '#4d5258' },
},
graphic: [
{
type: 'text',
left: 'center',
top: '38%',
style: {
text: '总产出',
fill: '#7a8085',
fontSize: 11,
fontFamily: "'Geist', 'PingFang SC', sans-serif",
fontWeight: 500,
},
},
{
type: 'text',
left: 'center',
top: '46%',
style: {
text: fmtCurrency(total.value),
fill: '#2d3033',
fontSize: 22,
fontWeight: 600,
fontFamily: "'JetBrains Mono', monospace",
},
},
],
series: [{
type: 'pie',
radius: ['58%', '78%'],
center: ['50%', '45%'],
avoidLabelOverlap: true,
itemStyle: {
borderColor: '#ffffff',
borderWidth: 2,
},
label: { show: false },
labelLine: { show: false },
emphasis: {
scale: true,
scaleSize: 6,
itemStyle: {
shadowBlur: 12,
shadowColor: 'rgba(0,0,0,0.10)',
},
},
data,
}],
};
});
const { chartRef } = useECharts(option);
</script>
<template>
<div ref="chartRef" class="revenue-pie"></div>
</template>
<style scoped>
.revenue-pie { width: 100%; height: 320px; }
</style>

View File

@ -0,0 +1,56 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useECharts, CHART_COLORS } from '@/composables/useECharts';
import type { TimeseriesBucket } from '@/api/roi';
const props = defineProps<{
buckets: TimeseriesBucket[];
granularity: 'day' | 'week' | 'month' | 'year';
}>();
const option = computed(() => {
const labels = props.buckets.map(b => b.bucket);
const costs = props.buckets.map(b => b.cost);
const revenues = props.buckets.map(b => b.revenue);
const cumRoi = props.buckets.map(b => b.cumulativeRoi);
return {
color: CHART_COLORS,
tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } },
legend: { data: ['本期成本', '本期产出', '累计 ROI'], top: 0 },
grid: { left: 60, right: 70, top: 35, bottom: 30 },
xAxis: { type: 'category', data: labels, axisLabel: { fontSize: 11 } },
yAxis: [
{ type: 'value', name: '¥', position: 'left', axisLabel: { formatter: (v: number) => v >= 10000 ? (v / 10000).toFixed(1) + '万' : v.toFixed(0) } },
{ type: 'value', name: 'ROI %', position: 'right', axisLabel: { formatter: '{value}%' } },
],
series: [
{ name: '本期成本', type: 'bar', yAxisIndex: 0, data: costs, itemStyle: { color: CHART_COLORS[5] } },
{ name: '本期产出', type: 'bar', yAxisIndex: 0, data: revenues, itemStyle: { color: CHART_COLORS[1] } },
{
name: '累计 ROI',
type: 'line',
yAxisIndex: 1,
data: cumRoi,
smooth: true,
lineStyle: { width: 2 },
itemStyle: { color: CHART_COLORS[0] },
markLine: { silent: true, lineStyle: { color: '#888' }, data: [{ yAxis: 0, label: { formatter: '回本线' } }] },
},
],
};
});
const { chartRef } = useECharts(option);
</script>
<template>
<div ref="chartRef" class="roi-timeseries-chart"></div>
</template>
<style scoped>
.roi-timeseries-chart {
width: 100%;
height: 320px;
}
</style>

View File

@ -0,0 +1,123 @@
<script setup lang="ts">
import { ref, onMounted, h } from 'vue';
import {
NSpin, NButton, NDataTable, NModal, NForm, NFormItem, NInput, NSelect, NSwitch, NTag, useMessage,
} from 'naive-ui';
import { listMapping, createMapping, deleteMapping, listUnmapped } from '@/api/roi';
import request from '@/api/request';
const message = useMessage();
const loading = ref(true);
const mappings = ref<any[]>([]);
const unmapped = ref<any[]>([]);
const projectOptions = ref<{ label: string; value: string }[]>([]);
const showModal = ref(false);
const form = ref({ projectId: '', businessProjectKey: '', enabled: true, notes: '' });
async function load() {
loading.value = true;
try {
const [m, u, p] = await Promise.all([
listMapping(),
listUnmapped(),
request.get('/api/projects'),
]);
mappings.value = m.data.data || [];
unmapped.value = u.data.data || [];
projectOptions.value = (p.data.data || []).map((x: any) => ({
label: `${x.identifier || x.id} - ${x.name}`,
value: x.id,
}));
} finally { loading.value = false; }
}
onMounted(load);
async function handleCreate() {
if (!form.value.projectId || !form.value.businessProjectKey) {
message.warning('请填写所有必填项');
return;
}
try {
await createMapping(form.value);
message.success('已新增映射');
showModal.value = false;
form.value = { projectId: '', businessProjectKey: '', enabled: true, notes: '' };
await load();
} catch (e: any) {
message.error('新增失败:' + (e?.response?.data?.message || e.message));
}
}
async function handleDelete(id: string) {
if (!confirm('确认删除该映射?')) return;
await deleteMapping(id);
message.success('已删除');
await load();
}
const mappingColumns = [
{ title: '业务方 Key', key: 'businessProjectKey' },
{ title: 'DevPerf 项目', key: 'projectId' },
{ title: '启用', key: 'enabled', render: (row: any) => row.enabled ? '✅' : '⛔' },
{ title: '备注', key: 'notes' },
{ title: '操作', key: 'actions', render: (row: any) => h(NButton, {
size: 'tiny', type: 'error', onClick: () => handleDelete(row.id),
}, () => '删除') },
];
const unmappedColumns = [
{ title: '业务方 Key', key: 'businessProjectKey' },
{ title: '日期', key: 'eventDate', render: (row: any) => row.eventDate?.slice(0, 10) },
{ title: '金额', key: 'amount', render: (row: any) => `¥${Number(row.amount).toLocaleString()}` },
{ title: '类型', key: 'revenueType' },
{ title: '状态', key: 'status' },
];
</script>
<template>
<NSpin :show="loading">
<div style="margin-bottom:12px;color:var(--color-text-muted);font-size:13px">
把外部业务系统的"项目 key"映射到 DevPerf 项目新增映射后,未来抓到的营收数据自动归到对应项目;之前堆在"未映射"里的数据需手动处理
</div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
<strong>当前映射 ({{ mappings.length }})</strong>
<NButton type="primary" size="small" @click="showModal = true">+ 添加映射</NButton>
</div>
<div class="table-shell">
<NDataTable :columns="mappingColumns" :data="mappings" size="small" :bordered="false" />
</div>
<div style="margin-top:24px">
<strong style="color:var(--color-text-muted)"> 未映射的营收事件 ({{ unmapped.length }})</strong>
<div style="font-size:12px;color:var(--color-text-muted);margin:6px 0">
外部 API 拉到但未匹配到 DevPerf 项目的营收事件,先放在收容表里待处理新增对应映射后,后续数据会自动归类
</div>
<div class="table-shell">
<NDataTable :columns="unmappedColumns" :data="unmapped" size="small" :bordered="false" :max-height="300" />
</div>
</div>
<NModal v-model:show="showModal" preset="card" title="新增项目映射" style="width:500px">
<NForm label-placement="top">
<NFormItem label="业务方项目 Key(外部系统的 key)">
<NInput v-model:value="form.businessProjectKey" placeholder="如 PROD-A001" />
</NFormItem>
<NFormItem label="对应 DevPerf 项目">
<NSelect v-model:value="form.projectId" :options="projectOptions" filterable />
</NFormItem>
<NFormItem label="启用">
<NSwitch v-model:value="form.enabled" />
</NFormItem>
<NFormItem label="备注(可选)">
<NInput v-model:value="form.notes" type="textarea" :autosize="{ minRows: 2, maxRows: 3 }" />
</NFormItem>
<div style="display:flex;justify-content:flex-end;gap:8px">
<NButton @click="showModal = false">取消</NButton>
<NButton type="primary" @click="handleCreate">新增</NButton>
</div>
</NForm>
</NModal>
</NSpin>
</template>

View File

@ -0,0 +1,73 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import {
NSpin, NCard, NForm, NFormItem, NInputNumber, NButton, useMessage,
} from 'naive-ui';
import { fetchStrategies, updateStrategy, type Strategy } from '@/api/roi';
const message = useMessage();
const loading = ref(true);
const strategies = ref<Strategy[]>([]);
const saving = ref<Record<string, boolean>>({});
async function load() {
loading.value = true;
try {
const res = await fetchStrategies();
strategies.value = res.data.data;
} finally { loading.value = false; }
}
onMounted(load);
async function save(s: Strategy) {
saving.value[s.id] = true;
try {
await updateStrategy(s.id, s.params);
message.success('已保存:' + s.name);
} catch (e: any) {
message.error('保存失败:' + (e?.response?.data?.message || e.message));
} finally {
saving.value[s.id] = false;
}
}
const CATEGORY_LABELS: Record<string, string> = {
cash_cow: '💰 现金牛',
efficiency_tool: '⚙️ 效能工具',
moat: '💎 资本护城河',
composite: '🚀 复合型',
};
</script>
<template>
<NSpin :show="loading">
<div style="margin-bottom:12px;color:var(--color-text-muted);font-size:13px">
调整全局 ROI 计算参数修改后只影响新增的成本事件,历史数据用当时的 R_h 快照,不受影响
</div>
<div class="strategies-grid">
<NCard v-for="s in strategies" :key="s.id" size="small" :title="CATEGORY_LABELS[s.category] || s.category">
<NForm label-placement="left" label-width="160" size="small">
<NFormItem label="综合人时成本(¥/h)">
<NInputNumber v-model:value="s.params.hourlyRate" :min="0" :max="10000" style="width:120px" />
</NFormItem>
<NFormItem v-if="s.category === 'moat' || s.category === 'composite'" label="资产摊销年限(年)">
<NInputNumber v-model:value="s.params.amortYears" :min="1" :max="20" style="width:120px" />
</NFormItem>
<NFormItem label="每 commit 系数(h)">
<NInputNumber v-model:value="s.params.commitHourCoef" :min="0" :max="40" :step="0.1" style="width:120px" />
</NFormItem>
<NFormItem label="每 task 系数(h)">
<NInputNumber v-model:value="s.params.taskHourCoef" :min="0" :max="80" :step="0.5" style="width:120px" />
</NFormItem>
<NButton type="primary" size="small" :loading="saving[s.id]" @click="save(s)">保存</NButton>
</NForm>
</NCard>
</div>
</NSpin>
</template>
<style scoped>
.strategies-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; }
@media (max-width: 900px) { .strategies-grid { grid-template-columns: 1fr; } }
</style>

View File

@ -29,35 +29,40 @@ defineProps<{
<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);
border-radius: var(--radius-xl);
border: 1px solid var(--color-border-subtle);
padding: var(--space-6);
transition: border-color var(--duration-base) var(--ease-out),
box-shadow var(--duration-base) var(--ease-out);
}
.data-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
transform: translateY(-1px);
border-color: var(--color-border);
box-shadow: var(--shadow-sm);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: var(--space-4);
margin-bottom: var(--space-5);
padding-bottom: var(--space-3);
border-bottom: 1px solid var(--color-border-subtle);
}
.card-title {
font-size: 14px;
font-weight: 700;
font-family: var(--font-sans);
font-size: var(--text-md);
font-weight: var(--weight-semibold);
color: var(--color-text-primary);
letter-spacing: var(--tracking-tight);
margin: 0;
}
.card-subtitle {
font-size: 12px;
color: var(--color-text-secondary);
margin: 2px 0 0;
font-size: var(--text-sm);
color: var(--color-text-muted);
margin: var(--space-1) 0 0;
}
.card-body {

View File

@ -46,8 +46,10 @@ watch([selectedPeriod, selectedProjects], () => {
:options="projects || []"
placeholder="筛选项目"
multiple
filterable
clearable
style="width: 300px"
max-tag-count="responsive"
style="min-width: 200px; max-width: 400px"
/>
</div>
</template>

View File

@ -1,17 +1,57 @@
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 { GridComponent, TooltipComponent, LegendComponent, TitleComponent, DataZoomComponent, ToolboxComponent, VisualMapComponent, CalendarComponent, MarkLineComponent, GraphicComponent } from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers';
echarts.use([
BarChart, LineChart, PieChart, RadarChart, HeatmapChart, CustomChart,
GridComponent, TooltipComponent, LegendComponent, TitleComponent,
DataZoomComponent, ToolboxComponent, VisualMapComponent, CalendarComponent,
MarkLineComponent, GraphicComponent,
CanvasRenderer,
]);
export const CHART_COLORS = ['#3B5998', '#0D9668', '#D4920A', '#7C4DBA', '#2B8CA3', '#DC2626', '#8B5CF6', '#06B6D4'];
/**
* Editorial Data Console OKLCH 8
* Tailwind ,,
*/
export const CHART_COLORS = [
'#1f3a45', // 墨青 (主色)
'#317a5d', // 翠绿
'#c47918', // 琥珀
'#5a4d8c', // 紫罗兰
'#b13a25', // 朱砂
'#6e7635', // 橄榄
'#2d5d8c', // 钢蓝
'#955080', // 玫瑰
];
/** 默认图表配置 — 应用 editorial 风格 */
export const DEFAULT_CHART_THEME = {
textStyle: { fontFamily: "'Geist', 'PingFang SC', sans-serif", color: '#4d5258' },
legend: { textStyle: { fontSize: 12, color: '#4d5258' }, itemGap: 16 },
grid: { left: 56, right: 24, top: 40, bottom: 32, containLabel: true },
xAxis: {
axisLine: { lineStyle: { color: '#dfe2e6' } },
axisTick: { show: false },
axisLabel: { color: '#7a8085', fontSize: 11 },
splitLine: { show: false },
},
yAxis: {
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { color: '#7a8085', fontSize: 11 },
splitLine: { lineStyle: { color: '#eaeded', type: 'dashed' } },
},
tooltip: {
backgroundColor: '#ffffff',
borderColor: '#dfe2e6',
borderWidth: 1,
textStyle: { color: '#2d3033', fontSize: 12 },
extraCssText: 'box-shadow: 0 8px 24px rgba(34,40,42,0.06), 0 4px 8px rgba(34,40,42,0.04); border-radius: 10px;',
},
};
/**
* Broad chart options type to avoid strict ECharts type conflicts with

View File

@ -31,6 +31,18 @@ const router = createRouter({
name: 'ProjectDetail',
component: () => import('@/views/ProjectDetail.vue'),
},
{
path: 'projects/:id/roi',
name: 'ProjectRoiBoard',
component: () => import('@/views/ProjectRoiBoard.vue'),
meta: { roles: ['admin'] },
},
{
path: 'roi',
name: 'RoiDashboard',
component: () => import('@/views/RoiDashboard.vue'),
meta: { roles: ['admin'] },
},
// B-17 fix: added member list route
{
path: 'members',
@ -53,7 +65,7 @@ const router = createRouter({
path: 'git',
name: 'GitActivity',
component: () => import('@/views/GitActivity.vue'),
meta: { roles: ['admin', 'manager', 'developer'] },
meta: { roles: ['admin', 'manager', 'developer', 'viewer'] },
},
{
path: 'admin',

View File

@ -1,77 +1,181 @@
/* ============================================================
* DevPerf 2.0 设计令牌系统
* 审美方向: Editorial Data Console (Linear/Stripe 精致感 + 数据密度)
* AI : 不用 Inter/Roboto不用紫蓝渐变不用 glassmorphism
* 色彩: OKLCH 感知均匀, 中性色带 0.005-0.012 暖色调
* ============================================================ */
@import url('https://fonts.googleapis.com/css2?family=Newsreader:opsz,wght@6..72,400;6..72,500;6..72,600;6..72,700&family=Geist:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap');
: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);
/* ─────────── 色彩系统 (OKLCH) ─────────── */
/* 主色 — 深墨青(克制信任, 不是 Tailwind blue) */
--color-primary: oklch(0.32 0.05 200);
--color-primary-hover: oklch(0.27 0.05 200);
--color-primary-press: oklch(0.23 0.05 200);
--color-primary-soft: oklch(0.94 0.018 200);
/* Accent - Amber */
--color-accent: oklch(0.75 0.15 75);
--color-accent-hex: #D4920A;
/* 强调色 — 暖琥珀(数据高亮 / CTA) */
--color-accent: oklch(0.72 0.16 65);
--color-accent-hover: oklch(0.66 0.17 60);
--color-accent-soft: oklch(0.96 0.04 75);
/* Semantic */
--color-success: #0D9668;
--color-warning: #D4920A;
--color-error: #DC2626;
--color-info: #2B8CA3;
/* 语义色 */
--color-success: oklch(0.58 0.13 155);
--color-success-soft: oklch(0.95 0.04 155);
--color-warning: oklch(0.72 0.16 65);
--color-warning-soft: oklch(0.96 0.04 75);
--color-error: oklch(0.55 0.18 25);
--color-error-soft: oklch(0.96 0.03 25);
--color-info: oklch(0.52 0.11 240);
--color-info-soft: oklch(0.95 0.03 240);
/* Chart palette */
--chart-1: #3B5998;
--chart-2: #0D9668;
--chart-3: #D4920A;
--chart-4: #7C4DBA;
--chart-5: #2B8CA3;
/* 中性色阶(暖灰, chroma 0.005~0.012, 不是死灰) */
--color-bg: oklch(0.985 0.003 80); /* 页面背景 */
--color-bg-card: oklch(1.000 0.000 0); /* 卡片 */
--color-bg-subtle: oklch(0.965 0.004 80); /* 浅灰区块 */
--color-bg-hover: oklch(0.955 0.005 80); /* hover */
--color-bg-sidebar: oklch(0.18 0.012 230); /* 侧边栏深底带蓝绿 */
--color-bg-sidebar-2: oklch(0.21 0.013 230); /* 侧边栏次级 */
/* 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;
--color-border-subtle: oklch(0.935 0.006 80);
--color-border: oklch(0.88 0.008 80);
--color-border-strong: oklch(0.78 0.010 80);
/* 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;
--color-text-muted: oklch(0.58 0.009 80);
--color-text-secondary: oklch(0.42 0.011 80);
--color-text-primary: oklch(0.22 0.012 80);
--color-text-onDark: oklch(0.92 0.005 80);
--color-text-onDarkMuted:oklch(0.65 0.010 220);
/* Spacing */
/* 兼容老变量 (保持现有组件不破) */
--color-primary-hex: #1f3a45;
--color-accent-hex: #c47918;
--color-text-secondary-legacy: var(--color-text-secondary);
/* ─────────── 图表色板(OKLCH 衍生, 8 色, 中等饱和) ─────────── */
--chart-1: oklch(0.32 0.05 200); /* 墨青 */
--chart-2: oklch(0.58 0.13 155); /* 翠绿 */
--chart-3: oklch(0.72 0.16 65); /* 琥珀 */
--chart-4: oklch(0.50 0.15 280); /* 紫罗兰 */
--chart-5: oklch(0.55 0.18 25); /* 朱砂 */
--chart-6: oklch(0.62 0.13 105); /* 橄榄 */
--chart-7: oklch(0.50 0.10 220); /* 钢蓝 */
--chart-8: oklch(0.65 0.12 320); /* 玫瑰 */
/* ─────────── 字体 ─────────── */
/* Display: Newsreader 衬线(editorial 标题) */
--font-display: 'Newsreader', 'Source Serif 4', 'Songti SC', 'STSong', Georgia, serif;
/* Sans: Geist(Vercel 现代无衬线, 替代 Inter) + PingFang */
--font-sans: 'Geist', 'PingFang SC', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', sans-serif;
/* Mono: JetBrains Mono(等宽 KPI 数字) */
--font-mono: 'JetBrains Mono', 'SF Mono', 'Cascadia Code', Menlo, Consolas, monospace;
/* 兼容老变量 */
--font-heading: var(--font-sans);
--font-body: var(--font-sans);
--font-code: var(--font-mono);
/* ─────────── 字号比例尺(ratio 1.25, base 14px) ─────────── */
--text-xs: 11px; /* caption */
--text-sm: 12px; /* secondary */
--text-base: 14px; /* body */
--text-md: 16px; /* lead */
--text-lg: 18px; /* small heading */
--text-xl: 22px; /* section title */
--text-2xl: 28px; /* page title */
--text-3xl: 36px; /* KPI medium */
--text-4xl: 46px; /* KPI hero */
--text-display: clamp(24px, 1.6vw + 0.6rem, 32px); /* 中文友好的页头字号 */
/* ─────────── 行高 ─────────── */
--leading-tight: 1.2;
--leading-snug: 1.35;
--leading-normal: 1.55;
--leading-relaxed:1.7;
/* ─────────── 字重 ─────────── */
--weight-regular: 400;
--weight-medium: 500;
--weight-semibold: 600;
--weight-bold: 700;
/* ─────────── 字间距 ─────────── */
--tracking-tight: -0.02em;
--tracking-normal: 0;
--tracking-wide: 0.04em;
/* ─────────── 空间(4px 基础) ─────────── */
--space-0_5: 2px;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-7: 28px;
--space-8: 32px;
--space-10: 40px;
--space-12: 48px;
--space-14: 56px;
--space-16: 64px;
--space-20: 80px;
--space-24: 96px;
/* Border radius */
--radius-btn: 8px;
--radius-card: 12px;
--radius-modal: 16px;
--radius-pill: 9999px;
/* ─────────── 圆角 ─────────── */
--radius-xs: 4px;
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 10px;
--radius-xl: 14px;
--radius-2xl: 18px;
--radius-full: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;
/* 兼容老变量 */
--radius-btn: var(--radius-md);
--radius-card: var(--radius-xl);
--radius-modal: var(--radius-2xl);
--radius-pill: var(--radius-full);
/* Z-index */
/* ─────────── 阴影(多层柔和叠加) ─────────── */
--shadow-xs: 0 1px 1px oklch(0.22 0.01 80 / 0.03);
--shadow-sm: 0 1px 2px oklch(0.22 0.01 80 / 0.05), 0 1px 1px oklch(0.22 0.01 80 / 0.03);
--shadow-md: 0 2px 4px oklch(0.22 0.01 80 / 0.04), 0 4px 8px oklch(0.22 0.01 80 / 0.04), 0 1px 1px oklch(0.22 0.01 80 / 0.03);
--shadow-lg: 0 4px 8px oklch(0.22 0.01 80 / 0.04), 0 8px 24px oklch(0.22 0.01 80 / 0.06), 0 1px 1px oklch(0.22 0.01 80 / 0.03);
--shadow-xl: 0 8px 16px oklch(0.22 0.01 80 / 0.04), 0 16px 48px oklch(0.22 0.01 80 / 0.08), 0 1px 1px oklch(0.22 0.01 80 / 0.03);
--shadow-focus: 0 0 0 3px oklch(0.32 0.05 200 / 0.18);
--shadow-focus-error: 0 0 0 3px oklch(0.55 0.18 25 / 0.18);
/* ─────────── 缓动 + 时长(exponential easing, 反 bounce) ─────────── */
--ease-out: cubic-bezier(0.25, 1, 0.5, 1);
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
--ease-default: var(--ease-out); /* 老变量兼容 */
--ease-entrance: var(--ease-out-expo);
--duration-instant: 100ms;
--duration-fast: 150ms;
--duration-base: 200ms;
--duration-medium: 300ms;
--duration-slow: 500ms;
--duration-hover: var(--duration-fast);
--duration-entrance:var(--duration-slow);
--duration-collapse:var(--duration-medium);
/* ─────────── Z-index ─────────── */
--z-base: 1;
--z-dropdown: 100;
--z-sticky: 200;
--z-modal: 300;
--z-toast: 9999;
/* Sidebar */
/* ─────────── Layout ─────────── */
--sidebar-width: 240px;
--sidebar-collapsed-width: 64px;
--content-max: 1440px;
}
/* ─────────── Reset ─────────── */
*,
*::before,
*::after {
@ -87,58 +191,158 @@ html, body {
}
html {
font-family: var(--font-body);
font-size: 14px;
line-height: 1.6;
font-family: var(--font-sans);
font-size: var(--text-base);
line-height: var(--leading-normal);
color: var(--color-text-primary);
background-color: var(--color-bg);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
font-feature-settings: 'ss01', 'cv01', 'cv11';
}
body {
min-height: 100vh;
}
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-heading);
font-weight: 700;
line-height: 1.3;
/* 标题: 中文用 sans(PingFang)更协调, 衬线只用在英文 eyebrow */
h1, h2, h3 {
font-family: var(--font-sans);
font-weight: var(--weight-semibold);
line-height: var(--leading-tight);
letter-spacing: var(--tracking-tight);
color: var(--color-text-primary);
}
code, pre {
font-family: var(--font-code);
h4, h5, h6 {
font-family: var(--font-sans);
font-weight: var(--weight-semibold);
line-height: var(--leading-snug);
}
code, pre, kbd {
font-family: var(--font-mono);
font-feature-settings: 'zero', 'ss01';
}
/* 数字: tabular nums (KPI 用) */
.tabular-nums {
font-variant-numeric: tabular-nums;
font-family: var(--font-mono);
letter-spacing: -0.01em;
}
/* editorial 副标题 */
.eyebrow {
font-family: var(--font-sans);
font-size: var(--text-xs);
font-weight: var(--weight-medium);
text-transform: uppercase;
letter-spacing: var(--tracking-wide);
color: var(--color-text-muted);
}
a {
color: var(--color-primary-hex);
color: var(--color-primary);
text-decoration: none;
transition: color var(--duration-fast) var(--ease-out);
}
a:hover {
color: var(--color-primary-hover);
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 3px;
}
a:hover {
text-decoration: underline;
/* focus-visible(键盘用户) */
:focus-visible {
outline: none;
box-shadow: var(--shadow-focus);
border-radius: var(--radius-sm);
}
/* Scrollbar */
::-webkit-scrollbar {
width: 6px;
height: 6px;
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--color-border);
border-radius: 3px;
border-radius: var(--radius-full);
border: 2px solid transparent;
background-clip: padding-box;
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-text-muted);
background: var(--color-border-strong);
background-clip: padding-box;
}
/* Tabular figures for numbers */
.tabular-nums {
/* 减少动效偏好 */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
/* utility */
.surface { background: var(--color-bg-card); border: 1px solid var(--color-border-subtle); border-radius: var(--radius-xl); }
.hairline { border-bottom: 1px solid var(--color-border-subtle); }
/* 表格容器统一 wrapper:白底 + 边框 + 圆角,跟 list-card / DataCard 视觉一致 */
.table-shell {
background: var(--color-bg-card);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-xl);
overflow: hidden;
}
/* ============================================================
* 全局表格基线 (Naive UI NDataTable 统一样式)
* Editorial Data Console 风格:浅灰表头 + dashed 行分隔 + hover 高亮
* ============================================================ */
.n-data-table .n-data-table-th {
background: var(--color-bg-subtle) !important;
color: var(--color-text-secondary) !important;
font-family: var(--font-sans) !important;
font-size: 12px !important;
font-weight: var(--weight-medium) !important;
letter-spacing: 0.02em !important;
padding: 10px 16px !important;
border-bottom: 1px solid var(--color-border) !important;
border-right: none !important;
}
.n-data-table .n-data-table-td {
padding: 12px 16px !important;
border-bottom: 1px solid var(--color-border-subtle) !important;
border-right: none !important;
font-size: var(--text-sm);
color: var(--color-text-primary);
transition: background var(--duration-fast) var(--ease-out);
}
.n-data-table .n-data-table-tr:hover .n-data-table-td {
background: var(--color-bg-subtle) !important;
}
.n-data-table .n-data-table-tr:last-child .n-data-table-td {
border-bottom: none !important;
}
/* 数字单元格自动等宽 (用 .tabular-nums class 或 td 内 .tabular-nums span) */
.n-data-table .n-data-table-td .tabular-nums {
font-variant-numeric: tabular-nums;
}
/* 空状态贴齐主题 */
.n-data-table .n-data-table-empty {
padding: var(--space-10) var(--space-4) !important;
color: var(--color-text-muted);
}

View File

@ -1,27 +1,68 @@
import type { GlobalThemeOverrides } from 'naive-ui';
/**
* Naive UI global.css
* OKLCH(0.32 0.05 200) hex #1F3A45
*/
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',
// 主色板 (OKLCH 转近似 hex)
primaryColor: '#1f3a45',
primaryColorHover: '#173039',
primaryColorPressed: '#0f2730',
primaryColorSuppl: '#1f3a45',
// 语义色 (OKLCH 转近似 hex)
infoColor: '#2d5d8c',
infoColorHover: '#264f78',
successColor: '#317a5d',
successColorHover: '#2a6b51',
warningColor: '#c47918',
warningColorHover: '#a86715',
errorColor: '#b13a25',
errorColorHover: '#9c3220',
// 字体
fontFamily:
"'Geist', 'PingFang SC', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
fontFamilyMono:
"'JetBrains Mono', 'SF Mono', Menlo, Consolas, monospace",
// 文字色 (跟 OKLCH 中性色阶对齐)
textColorBase: '#2d3033',
textColor1: '#2d3033',
textColor2: '#4d5258',
textColor3: '#7a8085',
placeholderColor: '#a4a8ac',
// 卡片/分隔线
borderColor: '#dfe2e6',
dividerColor: '#e9ecef',
cardColor: '#ffffff',
bodyColor: '#fafbfb',
modalColor: '#ffffff',
// 圆角
borderRadius: '10px',
borderRadiusSmall: '6px',
// 字号
fontSize: '14px',
fontSizeSmall: '13px',
},
Button: {
borderRadiusMedium: '8px',
borderRadiusSmall: '6px',
borderRadiusLarge: '10px',
fontWeight: '500',
fontWeightStrong: '600',
paddingMedium: '0 14px',
},
Card: {
borderRadius: '12px',
borderRadius: '14px',
paddingMedium: '20px 24px',
color: '#ffffff',
borderColor: '#eaeded',
},
Dialog: {
borderRadius: '16px',
@ -31,8 +72,28 @@ export const naiveThemeOverrides: GlobalThemeOverrides = {
},
DataTable: {
borderRadius: '12px',
thColor: '#fafbfb',
thColorHover: '#f3f5f6',
thTextColor: '#4d5258',
thFontWeight: '600',
fontSize: '13px',
},
Tag: {
borderRadius: '6px',
fontWeightStrong: '500',
},
Tabs: {
tabFontSize: '14px',
tabFontWeightActive: '600',
},
Tooltip: {
borderRadius: '8px',
fontSize: '13px',
},
Modal: {
color: '#ffffff',
},
Drawer: {
color: '#ffffff',
},
};

View File

@ -1,7 +1,9 @@
<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 StrategiesPanel from '@/components/roi/admin/StrategiesPanel.vue';
import MappingPanel from '@/components/roi/admin/MappingPanel.vue';
import { getAdminUsersApi, createUserApi, deleteUserApi, getAuthorMappingsApi, createMappingApi, deleteMappingApi, getSyncLogsApi, triggerSyncApi, getAdminProjectsApi, setUserProjectsApi } from '@/api/admin';
import dayjs from 'dayjs';
const message = useMessage();
@ -58,23 +60,86 @@ function formatDate(value: unknown): string {
const userColumns = [
{ title: '姓名', key: 'displayName' },
{ title: '邮箱', key: 'email' },
{ title: '角色', key: 'role', width: 100 },
{
title: '角色', key: 'role', width: 100,
render: (row: any) => {
const roleMap: Record<string, string> = { admin: '管理员', manager: '经理', developer: '开发者', viewer: '观察者' };
return roleMap[row.role] || row.role;
},
},
{
title: '可查看项目', key: 'allowedProjects', width: 120,
render: (row: any) => {
if (row.role !== 'viewer' && row.role !== 'developer') return '-';
const count = (row.allowedProjectIds || []).length;
return count > 0 ? `${count} 个项目` : '未分配';
},
},
{
title: '创建时间', key: 'createdAt', width: 180,
render: (row: any) => formatDate(row.createdAt),
},
{
title: '操作', key: 'actions', width: 100,
title: '操作', key: 'actions', width: 180,
render: (row: any) => {
return h(
const buttons = [];
if (row.role === 'viewer' || row.role === 'developer') {
buttons.push(h(
NButton,
{ size: 'tiny', type: 'info', onClick: () => openProjectPermModal(row), style: 'margin-right: 8px' },
{ default: () => '分配项目' },
));
}
buttons.push(h(
NButton,
{ size: 'tiny', type: 'error', onClick: () => handleDeleteUser(row.id) },
{ default: () => '删除' },
);
));
return buttons;
},
},
];
// Project permissions ()
const showProjectPermModal = ref(false);
const permEditUserId = ref('');
const permEditUserName = ref('');
const permSelectedProjects = ref<string[]>([]);
const projectsLoading = ref(false);
const allProjects = ref<any[]>([]);
async function loadProjects() {
projectsLoading.value = true;
try {
const res = await getAdminProjectsApi();
allProjects.value = res.data.data;
} finally {
projectsLoading.value = false;
}
}
const projectOptions = computed(() =>
allProjects.value.map(p => ({ value: p.id, label: p.name }))
);
function openProjectPermModal(user: any) {
permEditUserId.value = user.id;
permEditUserName.value = user.displayName;
permSelectedProjects.value = user.allowedProjectIds || [];
showProjectPermModal.value = true;
}
async function handleSaveProjectPerms() {
try {
await setUserProjectsApi(permEditUserId.value, permSelectedProjects.value);
message.success('项目权限已更新');
showProjectPermModal.value = false;
loadUsers();
} catch (err: any) {
message.error(err.response?.data?.message || '更新项目权限失败');
}
}
// Mappings tab
const mappingsLoading = ref(false);
const mappingsData = ref<any[]>([]);
@ -211,6 +276,7 @@ onMounted(() => {
loadUsers();
loadMappings();
loadLogs();
loadProjects();
});
const roleOptions = [
@ -250,6 +316,14 @@ const roleOptions = [
<NDataTable :columns="logColumns" :data="logsData" :loading="logsLoading" :bordered="false" size="small" />
</div>
</NTabPane>
<NTabPane name="roi-strategies" tab="ROI 策略">
<StrategiesPanel />
</NTabPane>
<NTabPane name="roi-mapping" tab="ROI 项目映射">
<MappingPanel />
</NTabPane>
</NTabs>
<!-- 创建用户弹窗 -->
@ -273,6 +347,21 @@ const roleOptions = [
</NForm>
</NModal>
<!-- 分配项目权限弹窗 -->
<NModal v-model:show="showProjectPermModal" :title="`分配项目权限 - ${permEditUserName}`" preset="dialog" positive-text="保存" @positive-click="handleSaveProjectPerms">
<div style="margin-bottom: 12px; color: var(--color-text-tertiary); font-size: 13px;">
选择该用户可查看的项目未分配项目的用户将无法查看任何数据开发者自己创建的项目会自动获得权限
</div>
<NSelect
v-model:value="permSelectedProjects"
:options="projectOptions"
multiple
filterable
placeholder="选择可查看的项目"
max-tag-count="responsive"
/>
</NModal>
<!-- 添加映射弹窗 -->
<NModal v-model:show="showMappingModal" title="添加作者映射" preset="dialog" positive-text="创建" @positive-click="handleCreateMapping">
<NForm>
@ -298,5 +387,8 @@ const roleOptions = [
.table-responsive {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
background: var(--color-bg-card);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-xl);
}
</style>

View File

@ -3,18 +3,27 @@
* B-17 fix: New Member List page.
* Displays all team members with role badges and links to their detail pages.
*/
import { ref, onMounted } from 'vue';
import { ref, onMounted, h } from 'vue';
import { useRouter } from 'vue-router';
import { NSpin, NDataTable, NTag } from 'naive-ui';
import { NSpin, NDataTable, NTag, NButton, NModal, NForm, NFormItem, NInput, NSelect, useMessage } from 'naive-ui';
import { getMemberListApi } from '@/api/members';
import { updateUserApi } from '@/api/admin';
import DataCard from '@/components/shared/DataCard.vue';
import EmptyState from '@/components/shared/EmptyState.vue';
const router = useRouter();
const message = useMessage();
const loading = ref(true);
const members = ref<any[]>([]);
onMounted(async () => {
const userRole = (() => {
try { return JSON.parse(localStorage.getItem('user') || '{}').role || 'viewer'; }
catch { return 'viewer'; }
})();
const isAdmin = userRole === 'admin';
async function loadMembers() {
loading.value = true;
try {
const res = await getMemberListApi();
members.value = res.data.data || [];
@ -23,7 +32,47 @@ onMounted(async () => {
} finally {
loading.value = false;
}
}
onMounted(loadMembers);
//
const showEditModal = ref(false);
const editForm = ref({ id: '', displayName: '', email: '', role: '' });
function openEditModal(row: any, e: Event) {
e.stopPropagation();
editForm.value = { id: row.id, displayName: row.displayName, email: row.email, role: row.role };
showEditModal.value = true;
}
async function handleSaveEdit() {
if (!editForm.value.displayName) {
message.warning('姓名不能为空');
return;
}
try {
await updateUserApi(editForm.value.id, {
displayName: editForm.value.displayName,
email: editForm.value.email,
role: editForm.value.role,
});
message.success('成员信息已更新');
showEditModal.value = false;
loadMembers();
} catch (err: any) {
message.error(err.response?.data?.message || '更新失败');
}
}
const roleOptions = [
{ value: 'admin', label: '管理员' },
{ value: 'manager', label: '经理' },
{ value: 'developer', label: '开发者' },
{ value: 'viewer', label: '观察者' },
];
const roleLabels: Record<string, string> = { admin: '管理员', manager: '经理', developer: '开发者', viewer: '观察者' };
function roleTagType(role: string) {
if (role === 'admin') return 'info';
@ -51,9 +100,21 @@ const columns = [
type: roleTagType(row.role),
size: 'small',
round: true,
}, { default: () => row.role });
}, { default: () => roleLabels[row.role] || row.role });
},
},
...(isAdmin ? [{
title: '操作',
key: 'actions',
width: 80,
render: (row: any) => {
return h(NButton, {
size: 'tiny',
type: 'warning',
onClick: (e: Event) => openEditModal(row, e),
}, { default: () => '编辑' });
},
}] : []),
];
function handleRowClick(row: any) {
@ -61,11 +122,6 @@ function handleRowClick(row: any) {
}
</script>
<script lang="ts">
import { h } from 'vue';
export default {};
</script>
<template>
<div class="member-list-page">
<h2 style="margin-bottom: var(--space-5)">团队成员</h2>
@ -84,6 +140,21 @@ export default {};
</DataCard>
<EmptyState v-else-if="!loading" title="暂无成员" description="未找到团队成员。" />
</NSpin>
<!-- 编辑成员弹窗 -->
<NModal v-model:show="showEditModal" title="编辑成员" preset="dialog" positive-text="保存" @positive-click="handleSaveEdit">
<NForm>
<NFormItem label="姓名">
<NInput v-model:value="editForm.displayName" placeholder="请输入姓名" />
</NFormItem>
<NFormItem label="邮箱">
<NInput v-model:value="editForm.email" placeholder="请输入邮箱" />
</NFormItem>
<NFormItem label="角色">
<NSelect v-model:value="editForm.role" :options="roleOptions" />
</NFormItem>
</NForm>
</NModal>
</div>
</template>

View File

@ -42,7 +42,7 @@ async function loadData(filters?: { period?: string; projectIds?: string[] }) {
overviewData.value = overviewRes.data.data;
projectOptions.value = projectRes.data.data.map((p: any) => ({
value: p.id,
label: `${p.identifier || ''} ${p.name}`.trim(),
label: p.name,
}));
} catch (err) {
console.error('Failed to load overview:', err);
@ -167,7 +167,7 @@ function formatCommitTime(isoStr: string) {
<div class="urgent-kr-row">
<div class="urgent-kr-left">
<span class="urgent-kr-title" :class="{ 'title-done': kr.isCompleted, 'title-cancelled': kr.isCancelled }">{{ kr.title }}</span>
<span class="urgent-kr-meta">{{ kr.projectIdentifier }} · {{ kr.ownerName }} · {{ kr.endDate }}</span>
<span class="urgent-kr-meta">{{ kr.projectName }} · {{ kr.ownerName }} · {{ kr.endDate }}</span>
</div>
<div class="urgent-kr-right">
<div class="urgent-kr-bar" v-if="!kr.isCancelled">
@ -208,7 +208,7 @@ function formatCommitTime(isoStr: string) {
<span class="abnormal-title" :class="{ 'title-cancelled': kr.status === 'cancelled' }">{{ kr.title }}</span>
<span class="abnormal-badge" :class="'badge-' + kr.status">{{ kr.statusLabel }}</span>
</div>
<div class="abnormal-meta">{{ kr.projectIdentifier }} · {{ kr.ownerName }} · 截止 {{ kr.endDate }}</div>
<div class="abnormal-meta">{{ kr.projectName }} · {{ kr.ownerName }} · 截止 {{ kr.endDate }}</div>
<div v-if="kr.reason" class="abnormal-reason">{{ kr.reason }}</div>
</div>
<div class="abnormal-pct tabular-nums">{{ kr.progress }}%</div>

View File

@ -6,6 +6,7 @@ import { getProjectDetailApi } from '@/api/projects';
import { createObjectiveApi, createKeyResultApi, updateKeyResultApi, deleteObjectiveApi, deleteKeyResultApi, postponeKRApi, pauseKRApi, resumeKRApi, cancelKRApi, getKRLogsApi } from '@/api/okr';
import request from '@/api/request';
import DataCard from '@/components/shared/DataCard.vue';
import ProjectTagSelector from '@/components/roi/ProjectTagSelector.vue';
import { useECharts, CHART_COLORS } from '@/composables/useECharts';
import { useAuthStore } from '@/stores/auth';
import dayjs from 'dayjs';
@ -34,6 +35,10 @@ onMounted(loadData);
//
watch(() => route.params.id, () => loadData());
function goToRoiBoard() {
router.push(`/projects/${projectId.value}/roi`);
}
//
const allUsers = ref<any[]>([]);
const userOptions = computed(() => allUsers.value.map(u => ({ value: u.id, label: u.displayName })));
@ -324,9 +329,28 @@ function canEditObj(obj: any): boolean {
<!-- 项目标题 + 整体进度 -->
<div class="overall-progress">
<div style="display:flex;justify-content:space-between;align-items:center">
<h2 style="margin:0;font-size:18px">{{ data.project?.identifier }} - {{ data.project?.name }}</h2>
<div style="display:flex;align-items:center;gap:12px">
<h2 style="margin:0;font-size:18px">{{ data.project?.name }}</h2>
<ProjectTagSelector
v-if="authStore.isAdmin"
:project-id="projectId"
:initial-category="data.project?.category || null"
:initial-composite-strategies="data.project?.compositeStrategies || null"
:initial-biz-system="data.project?.bizSystem || null"
:initial-project-type="data.project?.projectType || null"
:initial-launched-at="data.project?.launchedAt || null"
:initial-v-asset="data.project?.vAsset || null"
:can-edit="true"
@saved="loadData"
/>
</div>
<div style="display:flex;align-items:center;gap:12px">
<NButton v-if="authStore.isAdmin" size="small" type="primary" ghost @click="goToRoiBoard">
💎 ROI 看板
</NButton>
<span class="tabular-nums" style="font-size:22px;font-weight:700;color:var(--color-primary-hex)" v-if="data.okr?.objectives?.length">{{ data.okr.overallProgress }}%</span>
</div>
</div>
<div v-if="data.okr?.objectives?.length" style="margin-top:8px">
<NProgress type="line" :percentage="clamp(data.okr.overallProgress)" :show-indicator="false" style="width:100%" />
</div>
@ -538,8 +562,8 @@ function canEditObj(obj: any): boolean {
<style scoped>
.overall-progress { background:var(--color-bg-card);border:1px solid var(--color-border);border-radius:var(--radius-card);padding:var(--space-4) var(--space-5);margin-bottom:var(--space-2); }
.grid-2 { display:grid;grid-template-columns:1fr 1fr;gap:var(--space-4); }
@media (max-width:768px) { .grid-2 { grid-template-columns:1fr; } }
.grid-2 { display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:var(--space-4);min-width:0; }
@media (max-width:768px) { .grid-2 { grid-template-columns:minmax(0,1fr); } }
.okr-list { display:flex;flex-direction:column;gap:var(--space-4); }
.obj-block { border-bottom:1px solid var(--color-border);padding-bottom:var(--space-3); }
@ -556,8 +580,8 @@ function canEditObj(obj: any): boolean {
/* 行1标题 + 标签 + 截止日期 + 菜单 */
.kr-line1 { display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:6px; }
.kr-title-area { display:flex;align-items:center;gap:6px;flex:1;min-width:0; }
.kr-name { font-size:13px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis; }
.kr-title-area { display:flex;align-items:flex-start;gap:6px;flex:1;min-width:0; }
.kr-name { font-size:13px;font-weight:500;line-height:1.5;overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;word-break:break-word; }
.kr-name-cancelled { text-decoration:line-through;color:var(--color-text-muted); }
.kr-meta-area { display:flex;align-items:center;gap:8px;flex-shrink:0; }
.kr-date { font-size:11px;color:var(--color-text-muted);white-space:nowrap; }

View File

@ -1,8 +1,8 @@
<script setup lang="ts">
import { ref, onMounted, h } from 'vue';
import { ref, computed, onMounted, h } from 'vue';
import { useRouter } from 'vue-router';
import { NSpin, NDataTable, NButton, NModal, NForm, NFormItem, NInput, NTag, NEmpty, useMessage } from 'naive-ui';
import { getAdminProjectsApi, createProjectApi, deleteProjectApi, bindRepoApi, getProjectReposApi, unbindRepoApi } from '@/api/admin';
import { NSpin, NDataTable, NButton, NModal, NForm, NFormItem, NInput, NSelect, NTag, NEmpty, useMessage } from 'naive-ui';
import { getAdminProjectsApi, createProjectApi, updateProjectApi, deleteProjectApi, bindRepoApi, getProjectReposApi, unbindRepoApi } from '@/api/admin';
import DataCard from '@/components/shared/DataCard.vue';
import EmptyState from '@/components/shared/EmptyState.vue';
@ -11,6 +11,53 @@ const message = useMessage();
const loading = ref(true);
const projects = ref<any[]>([]);
//
const selectedBiz = ref<string[]>([]); // =
const selectedType = ref<string[]>([]); // =
function toggleBiz(k: string) {
const i = selectedBiz.value.indexOf(k);
if (i >= 0) selectedBiz.value.splice(i, 1);
else selectedBiz.value.push(k);
}
function toggleType(k: string) {
const i = selectedType.value.indexOf(k);
if (i >= 0) selectedType.value.splice(i, 1);
else selectedType.value.push(k);
}
function clearFilters() {
selectedBiz.value = [];
selectedType.value = [];
}
// 线/
const bizCounts = computed(() => {
const c: Record<string, number> = { airhubs: 0, airflow: 0, aircore: 0, uncategorized: 0 };
for (const p of projects.value) c[p.bizSystem || 'uncategorized'] = (c[p.bizSystem || 'uncategorized'] || 0) + 1;
return c;
});
const typeCounts = computed(() => {
const c: Record<string, number> = { hardware: 0, software: 0, uncategorized: 0 };
for (const p of projects.value) c[p.projectType || 'uncategorized'] = (c[p.projectType || 'uncategorized'] || 0) + 1;
return c;
});
const filteredProjects = computed(() => {
return projects.value.filter(p => {
if (selectedBiz.value.length > 0 && !selectedBiz.value.includes(p.bizSystem || 'uncategorized')) return false;
if (selectedType.value.length > 0 && !selectedType.value.includes(p.projectType || 'uncategorized')) return false;
return true;
});
});
function chipColor(k: string): string {
const map: Record<string, string> = {
airhubs: '#0D9668', airflow: '#3B5998', aircore: '#D4920A',
hardware: '#7C4DBA', software: '#2B8CA3',
};
return map[k] || '#666';
}
const userRole = (() => {
try { return JSON.parse(localStorage.getItem('user') || '{}').role || 'viewer'; }
catch { return 'viewer'; }
@ -115,6 +162,33 @@ async function handleUnbindRepo(bindingId: string) {
}
}
//
const showEditModal = ref(false);
const editProject = ref({ id: '', name: '', identifier: '' });
function openEditModal(project: any) {
editProject.value = { id: project.id, name: project.name, identifier: project.identifier || '' };
showEditModal.value = true;
}
async function handleEditProject() {
if (!editProject.value.name) {
message.warning('项目名称不能为空');
return;
}
try {
await updateProjectApi(editProject.value.id, {
name: editProject.value.name,
identifier: editProject.value.identifier.toUpperCase(),
});
message.success('项目已更新');
showEditModal.value = false;
loadProjects();
} catch (err: any) {
message.error(err.response?.data?.message || '更新失败');
}
}
//
async function handleDelete(id: string, name: string) {
if (!confirm(`确定删除项目「${name}」吗?`)) return;
@ -137,52 +211,164 @@ function extractRepoName(raw: string) {
return cleaned;
}
// 线 meta( tokens )
const BIZ_META: Record<string, { label: string; dot: string }> = {
airhubs: { label: 'airhubs', dot: 'oklch(0.58 0.13 155)' }, // 绿
airflow: { label: 'airflow', dot: 'oklch(0.32 0.05 200)' }, //
aircore: { label: 'aircore', dot: 'oklch(0.72 0.16 65)' }, //
};
const TYPE_META: Record<string, { label: string }> = {
hardware: { label: '硬件' },
software: { label: '软件' },
};
// options
const bizOptions = computed(() => [
{ label: `airhubs · 硬件与潮玩 (${bizCounts.value.airhubs || 0})`, value: 'airhubs', disabled: !(bizCounts.value.airhubs > 0) },
{ label: `airflow · 内容与效能 (${bizCounts.value.airflow || 0})`, value: 'airflow', disabled: !(bizCounts.value.airflow > 0) },
{ label: `aircore · 技术基座 (${bizCounts.value.aircore || 0})`, value: 'aircore', disabled: !(bizCounts.value.aircore > 0) },
]);
const typeOptions = computed(() => [
{ label: `硬件 (${typeCounts.value.hardware || 0})`, value: 'hardware', disabled: !(typeCounts.value.hardware > 0) },
{ label: `软件 (${typeCounts.value.software || 0})`, value: 'software', disabled: !(typeCounts.value.software > 0) },
]);
//
const columns = [
{ title: '标识', key: 'identifier', width: 100 },
{ title: '项目名称', key: 'name', ellipsis: { tooltip: true } },
{
title: '产品线',
key: 'bizSystem',
width: 140,
render: (row: any) => {
if (!row.bizSystem) {
return h('span', { class: 'cell-muted' }, '—');
}
const biz = BIZ_META[row.bizSystem];
return h('div', { class: 'biz-cell' }, [
h('span', { class: 'biz-dot', style: { background: biz.dot } }),
h('span', { class: 'biz-name' }, biz.label),
]);
},
},
{
title: '类型',
key: 'projectType',
width: 100,
render: (row: any) => {
if (!row.projectType) return h('span', { class: 'cell-muted' }, '—');
const type = TYPE_META[row.projectType];
const typeClass = row.projectType === 'hardware' ? 'type-hw' : 'type-sw';
return h('span', { class: ['type-tag', typeClass] }, type.label);
},
},
{ title: '项目名称', key: 'name', ellipsis: { tooltip: true }, render: (row: any) =>
h('span', { class: 'cell-name' }, row.name)
},
{
title: '绑定仓库',
key: 'repos',
width: 160,
width: 200,
render: (row: any) => {
const repos = row.repos || [];
if (!repos.length) return h('span', { style: 'color:var(--color-text-muted);font-size:12px' }, '未绑定');
return h('div', { style: 'display:flex;flex-wrap:wrap;gap:4px' },
repos.map((r: any) => h(NTag, { size: 'small', type: 'info', round: true }, { default: () => extractRepoName(r.repoName) }))
if (!repos.length) return h('span', { class: 'cell-muted' }, '未绑定');
return h('div', { class: 'repo-cell' },
repos.map((r: any) => h('span', { class: 'repo-tag' }, extractRepoName(r.repoName)))
);
},
},
{
title: '操作',
title: '',
key: 'actions',
width: 200,
align: 'right' as const,
render: (row: any) => {
return h('div', { style: 'display:flex;gap:6px' }, [
h(NButton, { size: 'tiny', type: 'info', onClick: () => router.push(`/projects/${row.id}`) }, { default: () => '查看' }),
canCreate
? h(NButton, { size: 'tiny', type: 'default', onClick: () => openRepoModal(row) }, { default: () => '仓库' })
: null,
canCreate
? h(NButton, { size: 'tiny', type: 'error', onClick: () => handleDelete(row.id, row.name) }, { default: () => '删除' })
: null,
]);
const items: any[] = [
h('button', {
class: 'row-action',
onClick: (e: Event) => { e.stopPropagation(); router.push(`/projects/${row.id}`); }
}, '查看 →'),
];
if (canCreate) {
items.push(h('button', {
class: 'row-action',
onClick: (e: Event) => { e.stopPropagation(); openEditModal(row); }
}, '编辑'));
items.push(h('button', {
class: 'row-action',
onClick: (e: Event) => { e.stopPropagation(); openRepoModal(row); }
}, '仓库'));
}
if (userRole === 'admin') {
items.push(h('button', {
class: 'row-action row-action-danger',
onClick: (e: Event) => { e.stopPropagation(); handleDelete(row.id, row.name); }
}, '删除'));
}
return h('div', { class: 'action-cell' }, items);
},
},
];
function handleRowProps(row: any) {
return {
class: 'project-row',
style: { cursor: 'pointer' },
onClick: () => router.push(`/projects/${row.id}`),
};
}
</script>
<template>
<div class="project-list-page">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:var(--space-5)">
<h2>项目列表</h2>
<NButton v-if="canCreate" type="primary" @click="showCreateModal = true">创建项目</NButton>
</div>
<NSpin :show="loading">
<DataCard v-if="projects.length" title="全部项目" :subtitle="`${projects.length} 个项目`">
<NDataTable :columns="columns" :data="projects" :bordered="false" size="small" />
</DataCard>
<section v-if="projects.length" class="list-card">
<div class="list-card-header">
<div class="list-title-row">
<div class="title-block">
<h2 class="list-title">全部项目</h2>
<span class="list-subtitle">显示 <span class="tabular-nums">{{ filteredProjects.length }}</span> / <span class="tabular-nums">{{ projects.length }}</span> </span>
</div>
<div class="filter-bar">
<div class="filter-cell">
<label class="filter-label">产品线</label>
<NSelect
v-model:value="selectedBiz"
multiple
clearable
placeholder="全部"
:options="bizOptions"
size="small"
style="width: 220px"
/>
</div>
<div class="filter-cell">
<label class="filter-label">软硬件</label>
<NSelect
v-model:value="selectedType"
multiple
clearable
placeholder="全部"
:options="typeOptions"
size="small"
style="width: 160px"
/>
</div>
<NButton v-if="selectedBiz.length > 0 || selectedType.length > 0" size="small" text type="primary" @click="clearFilters">清空</NButton>
</div>
<NButton v-if="canCreate" type="primary" size="small" @click="showCreateModal = true">+ 创建项目</NButton>
</div>
</div>
<NDataTable
class="projects-table"
:columns="columns"
:data="filteredProjects"
:bordered="false"
:single-line="false"
size="medium"
:row-props="handleRowProps"
/>
</section>
<EmptyState v-else-if="!loading" title="暂无项目" description="点击「创建项目」开始添加。" />
</NSpin>
@ -204,6 +390,18 @@ const columns = [
</div>
</NModal>
<!-- 编辑项目弹窗 -->
<NModal v-model:show="showEditModal" title="编辑项目" preset="dialog" positive-text="保存" @positive-click="handleEditProject">
<NForm>
<NFormItem label="项目名称" required>
<NInput v-model:value="editProject.name" placeholder="项目名称" />
</NFormItem>
<NFormItem label="项目标识" required>
<NInput v-model:value="editProject.identifier" placeholder="大写英文缩写" />
</NFormItem>
</NForm>
</NModal>
<!-- 管理仓库弹窗 -->
<NModal v-model:show="showRepoModal" :title="`管理仓库 — ${editingProject?.name || ''}`" preset="dialog" :show-icon="false">
<div v-if="editingProject">
@ -238,5 +436,181 @@ const columns = [
</template>
<style scoped>
.project-list-page { max-width: 960px; }
.project-list-page {
padding: 0 0 clamp(var(--space-8), 4vw, var(--space-16));
width: 100%;
}
/* ─────── List card 容器 ─────── */
.list-card {
background: var(--color-bg-card);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-xl);
overflow: hidden;
}
.list-card-header {
padding: var(--space-5) var(--space-6) var(--space-3);
border-bottom: 1px solid var(--color-border-subtle);
}
.list-title-row {
display: flex;
align-items: flex-end;
gap: var(--space-6);
flex-wrap: wrap;
}
.title-block {
display: flex;
align-items: baseline;
gap: var(--space-3);
padding-bottom: 6px; /* 跟 select 底线对齐 */
}
.list-title {
font-family: var(--font-sans);
font-size: var(--text-lg);
font-weight: var(--weight-semibold);
color: var(--color-text-primary);
margin: 0;
}
.list-subtitle {
font-size: var(--text-sm);
color: var(--color-text-muted);
}
.filter-bar {
display: flex;
align-items: flex-end;
gap: var(--space-4);
flex: 1; /* 填充中间空间 */
justify-content: center;
flex-wrap: wrap;
}
.filter-cell {
display: flex;
flex-direction: column;
gap: 4px;
}
.filter-label {
font-size: var(--text-xs);
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
font-weight: var(--weight-medium);
}
.filter-row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.filter-label { font-size: 12px; color: var(--color-text-muted); min-width: 48px; }
.chip {
border: 1px solid var(--n-border-color, #e5e7eb);
background: transparent;
border-radius: 999px;
padding: 3px 10px;
font-size: 12px;
cursor: pointer;
user-select: none;
display: inline-flex;
align-items: center;
gap: 2px;
color: var(--color-text, #333);
transition: all 0.15s;
}
.chip:hover:not(.disabled) { border-color: var(--color-primary-hex, #3B5998); }
.chip.active { font-weight: 600; }
.chip.disabled { opacity: 0.35; cursor: not-allowed; }
.chip-count { margin-left: 4px; opacity: 0.7; font-size: 11px; font-weight: normal; }
/* 表格基线由 global.css 统一控制,这里只保留 hover 整行 cursor 提示 */
/* ─────── 单元格内容 ─────── */
:deep(.biz-cell) {
display: inline-flex;
align-items: center;
gap: var(--space-2);
font-size: var(--text-sm);
}
:deep(.biz-dot) {
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
}
:deep(.biz-name) {
color: var(--color-text-primary);
font-weight: var(--weight-medium);
}
:deep(.biz-sep) { color: var(--color-border); }
:deep(.biz-type) { color: var(--color-text-secondary); }
/* 类型 tag */
:deep(.type-tag) {
display: inline-block;
padding: 2px 10px;
border-radius: var(--radius-sm);
font-size: var(--text-xs);
font-weight: var(--weight-medium);
border: 1px solid;
}
:deep(.type-tag.type-hw) {
color: oklch(0.40 0.10 280);
background: oklch(0.97 0.02 280);
border-color: oklch(0.92 0.04 280);
}
:deep(.type-tag.type-sw) {
color: oklch(0.38 0.07 220);
background: oklch(0.97 0.012 220);
border-color: oklch(0.91 0.015 220);
}
:deep(.cell-name) {
color: var(--color-text-primary);
font-weight: var(--weight-medium);
font-size: var(--text-sm);
}
:deep(.cell-muted) {
color: var(--color-text-muted);
font-size: var(--text-sm);
font-style: italic;
}
:deep(.repo-cell) {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
:deep(.repo-tag) {
font-family: var(--font-mono);
font-size: 11px;
color: var(--color-text-secondary);
background: var(--color-bg-subtle);
border: 1px solid var(--color-border-subtle);
padding: 1px 8px;
border-radius: var(--radius-sm);
letter-spacing: -0.01em;
}
/* ─────── 操作按钮 (低调文本链接,默认全部显示) ─────── */
:deep(.action-cell) {
display: inline-flex;
align-items: center;
gap: var(--space-3);
}
:deep(.row-action) {
background: none;
border: none;
padding: 4px 0;
font-family: var(--font-sans);
font-size: var(--text-sm);
font-weight: var(--weight-medium);
color: var(--color-text-secondary);
cursor: pointer;
transition: color var(--duration-fast) var(--ease-out);
}
:deep(.row-action:hover) {
color: var(--color-primary);
}
:deep(.row-action-danger) {
color: var(--color-error);
opacity: 0.7;
}
:deep(.row-action-danger:hover) {
color: var(--color-error);
opacity: 1;
}
</style>

View File

@ -0,0 +1,505 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import {
NSpin, NButton, NSelect, NDatePicker, NTag, NEmpty, NCard, NTimeline, NTimelineItem, useMessage,
} from 'naive-ui';
import dayjs from 'dayjs';
import {
aggregateRoi, timeseriesRoi, listEvents, fetchDriverFactors,
type AggregateResult, type TimeseriesBucket, type DriverFactor,
} from '@/api/roi';
import request from '@/api/request';
import { useAuthStore } from '@/stores/auth';
import ConfidenceBadge from '@/components/roi/ConfidenceBadge.vue';
import RoiTimeSeriesChart from '@/components/roi/RoiTimeSeriesChart.vue';
import EventEntryModal from '@/components/roi/EventEntryModal.vue';
const route = useRoute();
const router = useRouter();
const authStore = useAuthStore();
const message = useMessage();
const projectId = computed(() => route.params.id as string);
const loading = ref(true);
const project = ref<any>(null);
//
const lifetimeAgg = ref<AggregateResult | null>(null);
const monthAgg = ref<AggregateResult | null>(null);
const ytdAgg = ref<AggregateResult | null>(null);
const tsBuckets = ref<TimeseriesBucket[]>([]);
const granularity = ref<'week' | 'month' | 'year'>('month');
const driverFactors = ref<DriverFactor[]>([]);
const recentCostEvents = ref<any[]>([]);
const recentRevenueEvents = ref<any[]>([]);
const entryModalType = ref<'cost' | 'revenue'>('revenue');
const showEntryModal = ref(false);
function todayStr(): string {
return dayjs().format('YYYY-MM-DD');
}
function monthStartStr(): string {
return dayjs().startOf('month').format('YYYY-MM-DD');
}
function yearStartStr(): string {
return dayjs().startOf('year').format('YYYY-MM-DD');
}
const launchedAtStr = computed(() => {
if (!project.value?.launchedAt) return null;
return dayjs(project.value.launchedAt).format('YYYY-MM-DD');
});
async function loadProject() {
const res = await request.get(`/api/projects/${projectId.value}`);
project.value = res.data.data.project;
}
async function loadAggregates() {
const today = todayStr();
// ROI launchedAt ; 1900-01-01 aggregator
const lifetimeFrom = launchedAtStr.value || '1900-01-01';
const [lifetime, month, ytd] = await Promise.all([
aggregateRoi(projectId.value, lifetimeFrom, today),
aggregateRoi(projectId.value, monthStartStr(), today),
aggregateRoi(projectId.value, yearStartStr(), today),
]);
lifetimeAgg.value = lifetime.data.data;
monthAgg.value = month.data.data;
ytdAgg.value = ytd.data.data;
}
async function loadTimeseries() {
const today = todayStr();
let from: string;
switch (granularity.value) {
case 'week': from = dayjs().subtract(26, 'week').startOf('week').format('YYYY-MM-DD'); break;
case 'month': from = dayjs().subtract(12, 'month').startOf('month').format('YYYY-MM-DD'); break;
case 'year': from = dayjs().subtract(5, 'year').startOf('year').format('YYYY-MM-DD'); break;
}
const res = await timeseriesRoi(projectId.value, from, today, granularity.value);
tsBuckets.value = res.data.data;
}
async function loadDriverFactors() {
try {
const res = await fetchDriverFactors(projectId.value);
const latest = (res.data.data || [])[0];
driverFactors.value = (latest?.factors as DriverFactor[]) || [];
} catch { driverFactors.value = []; }
}
async function loadRecentEvents() {
const [cost, revenue] = await Promise.all([
listEvents(projectId.value, 'cost', undefined, undefined, 30),
listEvents(projectId.value, 'revenue', undefined, undefined, 30),
]);
recentCostEvents.value = cost.data.data || [];
recentRevenueEvents.value = revenue.data.data || [];
}
async function loadAll() {
loading.value = true;
try {
await loadProject();
await Promise.all([loadAggregates(), loadTimeseries(), loadDriverFactors(), loadRecentEvents()]);
} catch (e: any) {
message.error('加载失败:' + (e?.response?.data?.message || e.message));
} finally {
loading.value = false;
}
}
onMounted(loadAll);
watch(() => projectId.value, loadAll);
watch(granularity, loadTimeseries);
function fmtCurrency(n: number | null | undefined): string {
if (n === null || n === undefined) return '—';
return `¥${Math.round(n).toLocaleString()}`;
}
function fmtPercent(n: number | null | undefined): string {
if (n === null || n === undefined) return '—';
return `${n.toFixed(1)}%`;
}
function roiColor(n: number | null | undefined): string {
if (n === null || n === undefined) return 'var(--color-text-muted)';
if (n >= 100) return 'var(--color-success)';
if (n >= 0) return 'var(--color-warning)';
return 'var(--color-error)';
}
function bepDisplay(): string {
const bep = lifetimeAgg.value?.bepDays;
if (bep === null || bep === undefined) return '按当前趋势暂无法回本';
if (bep === 0) return '已回本 ✓';
const months = Math.round(bep / 30 * 10) / 10;
return `预计 ${months} 月回本`;
}
const isBepWarn = computed(() => lifetimeAgg.value?.bepDays === null);
</script>
<template>
<div class="roi-board-page">
<NSpin :show="loading">
<!-- Editorial 页头 -->
<header class="board-header">
<div class="header-left">
<button class="back-link" @click="router.push(`/projects/${projectId}`)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
返回项目
</button>
<div class="eyebrow" v-if="project">ROI Board</div>
<h1 class="board-title" v-if="project">{{ project.name }}</h1>
<p class="board-lede" v-if="lifetimeAgg">立项以来 · 累计 ROI <span class="tabular-nums" :style="{ color: roiColor(lifetimeAgg.roiValue) }">{{ fmtPercent(lifetimeAgg.roiValue) }}</span> · {{ bepDisplay() }}</p>
</div>
<div class="header-actions" v-if="authStore.canEdit">
<button class="action-btn action-btn-secondary" @click="entryModalType = 'cost'; showEntryModal = true">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14M5 12h14"/></svg>
录入成本
</button>
<button class="action-btn action-btn-primary" @click="entryModalType = 'revenue'; showEntryModal = true">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14M5 12h14"/></svg>
录入产出
</button>
</div>
</header>
<!-- 顶部 4 张大卡片 -->
<div class="kpi-grid">
<article class="kpi-card">
<div class="kpi-label">历史总造价</div>
<div class="kpi-value tabular-nums">{{ fmtCurrency(lifetimeAgg?.totalCost) }}</div>
<div class="kpi-sub" v-if="lifetimeAgg"> {{ launchedAtStr || '立项' }} 至今</div>
</article>
<article class="kpi-card">
<div class="kpi-label">历史总产出</div>
<div class="kpi-value tabular-nums" style="color: var(--color-success)">{{ fmtCurrency(lifetimeAgg?.totalRevenue) }}</div>
<div class="kpi-sub" v-if="lifetimeAgg">净利 <span class="tabular-nums">{{ fmtCurrency(lifetimeAgg?.netProfit) }}</span></div>
</article>
<article class="kpi-card kpi-card-highlight">
<div class="kpi-label">累计 ROI</div>
<div class="kpi-value tabular-nums" :style="{ color: roiColor(lifetimeAgg?.roiValue) }">
{{ fmtPercent(lifetimeAgg?.roiValue) }}
</div>
<div class="kpi-sub kpi-sub-confidence" :class="{ 'kpi-sub-warn': isBepWarn }">
<ConfidenceBadge v-if="lifetimeAgg" :confidence="lifetimeAgg.confidence" :show-label="false" />
<span>{{ bepDisplay() }}</span>
</div>
</article>
<article class="kpi-card">
<div class="kpi-label">本月 ROI<span class="kpi-suffix">MTD</span></div>
<div class="kpi-value tabular-nums" :style="{ color: roiColor(monthAgg?.roiValue) }">
{{ fmtPercent(monthAgg?.roiValue) }}
</div>
<div class="kpi-sub" v-if="monthAgg">成本 <span class="tabular-nums">{{ fmtCurrency(monthAgg.totalCost) }}</span> · 产出 <span class="tabular-nums">{{ fmtCurrency(monthAgg.totalRevenue) }}</span></div>
</article>
</div>
<!-- YTD 摘要(单行替代 chip) -->
<div v-if="ytdAgg" class="ytd-strip">
<span class="ytd-label">YTD 本年</span>
<span class="ytd-metric"><span class="ytd-key">ROI</span><span class="ytd-num tabular-nums" :style="{ color: roiColor(ytdAgg.roiValue) }">{{ fmtPercent(ytdAgg.roiValue) }}</span></span>
<span class="ytd-sep"></span>
<span class="ytd-metric"><span class="ytd-key">成本</span><span class="ytd-num tabular-nums">{{ fmtCurrency(ytdAgg.totalCost) }}</span></span>
<span class="ytd-sep"></span>
<span class="ytd-metric"><span class="ytd-key">产出</span><span class="ytd-num tabular-nums">{{ fmtCurrency(ytdAgg.totalRevenue) }}</span></span>
</div>
<!-- 折线图 -->
<NCard size="small" style="margin-top:16px">
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center;width:100%">
<span>成本 vs 产出 趋势</span>
<NSelect v-model:value="granularity" :options="[
{ label: '周', value: 'week' },
{ label: '月', value: 'month' },
{ label: '年', value: 'year' },
]" style="width:90px" size="small" />
</div>
</template>
<RoiTimeSeriesChart v-if="tsBuckets.length > 0" :buckets="tsBuckets" :granularity="granularity" />
<NEmpty v-else description="暂无数据" />
</NCard>
<!-- 驱动因子(独占一行)-->
<NCard size="small" title="价值驱动因子(AI 解读)" style="margin-top:16px">
<div v-if="driverFactors.length > 0">
<div v-for="(f, i) in driverFactors" :key="i" class="factor-row">
<NTag :type="f.type === '现金流驱动' ? 'success' : f.type === '降本增效驱动' ? 'info' : 'warning'" size="small">{{ f.type }}</NTag>
<span style="margin-left:8px">{{ f.text }}</span>
</div>
</div>
<NEmpty v-else description="月度 AI 解读暂无(每月 1 号自动生成)" />
</NCard>
<!-- 事件流明细:成本 + 产出 并排 -->
<div class="two-col" style="margin-top:16px">
<NCard size="small">
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center;width:100%">
<span>💸 成本事件流</span>
<span class="event-count">{{ recentCostEvents.length }} </span>
</div>
</template>
<NTimeline v-if="recentCostEvents.length > 0">
<NTimelineItem
v-for="e in recentCostEvents.slice(0, 20)"
:key="e.id"
type="error"
:title="`${e.costType} · ${fmtCurrency(e.amount)}`"
:content="e.notes || ''"
:time="dayjs(e.eventDate).format('YYYY-MM-DD') + (e.dataSource ? ` · ${e.dataSource}` : '')"
/>
</NTimeline>
<NEmpty v-else description="暂无成本事件" />
</NCard>
<NCard size="small">
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center;width:100%">
<span>💰 产出事件流</span>
<span class="event-count">{{ recentRevenueEvents.length }} </span>
</div>
</template>
<NTimeline v-if="recentRevenueEvents.length > 0">
<NTimelineItem
v-for="e in recentRevenueEvents.slice(0, 20)"
:key="e.id"
:type="e.amount < 0 ? 'warning' : 'success'"
:title="`${e.revenueType} · ${fmtCurrency(e.amount)}`"
:content="e.notes || ''"
:time="dayjs(e.eventDate).format('YYYY-MM-DD') + (e.dataSource ? ` · ${e.dataSource}` : '')"
/>
</NTimeline>
<NEmpty v-else description="暂无产出事件" />
</NCard>
</div>
</NSpin>
<EventEntryModal v-model:show="showEntryModal" :type="entryModalType" :project-id="projectId" @saved="loadAll" />
</div>
</template>
<style scoped>
.roi-board-page {
padding: var(--space-2) 0 clamp(var(--space-8), 4vw, var(--space-16));
width: 100%;
}
/* ─────── Editorial 页头 ─────── */
.board-header {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: var(--space-6);
padding-bottom: var(--space-6);
margin-bottom: var(--space-8);
border-bottom: 1px solid var(--color-border-subtle);
}
.header-left { min-width: 0; flex: 1; }
.back-link {
background: none;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 4px 10px;
font-size: var(--text-sm);
color: var(--color-text-secondary);
cursor: pointer;
display: inline-flex;
align-items: center;
gap: var(--space-1);
transition: all var(--duration-fast) var(--ease-out);
margin-bottom: var(--space-3);
}
.back-link:hover { border-color: var(--color-border-strong); color: var(--color-text-primary); background: var(--color-bg-subtle); }
.eyebrow {
font-family: var(--font-sans);
font-size: var(--text-xs);
font-weight: var(--weight-medium);
text-transform: uppercase;
letter-spacing: var(--tracking-wide);
color: var(--color-text-muted);
margin-bottom: var(--space-2);
}
.board-title {
font-family: var(--font-sans);
font-size: var(--text-display);
font-weight: var(--weight-semibold);
letter-spacing: var(--tracking-tight);
color: var(--color-text-primary);
line-height: var(--leading-snug);
margin: 0;
}
.board-lede {
margin-top: var(--space-3);
font-size: var(--text-md);
color: var(--color-text-secondary);
line-height: var(--leading-normal);
}
.header-actions { display: flex; gap: var(--space-2); flex-shrink: 0; }
.action-btn {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-md);
font-family: var(--font-sans);
font-size: var(--text-sm);
font-weight: var(--weight-medium);
cursor: pointer;
transition: all var(--duration-fast) var(--ease-out);
border: 1px solid;
}
.action-btn-secondary {
background: var(--color-bg-card);
border-color: var(--color-border);
color: var(--color-text-primary);
}
.action-btn-secondary:hover { background: var(--color-bg-subtle); border-color: var(--color-border-strong); }
.action-btn-primary {
background: var(--color-primary);
border-color: var(--color-primary);
color: var(--color-text-onDark);
}
.action-btn-primary:hover { background: var(--color-primary-hover); border-color: var(--color-primary-hover); }
/* ─────── KPI 卡片 ─────── */
.kpi-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: clamp(var(--space-3), 1vw, var(--space-5));
}
@media (min-width: 1280px) {
.kpi-grid { grid-template-columns: 1.3fr 1fr 1.3fr 1fr; }
}
.kpi-card {
background: var(--color-bg-card);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-xl);
padding: var(--space-6);
transition: border-color var(--duration-base) var(--ease-out),
box-shadow var(--duration-base) var(--ease-out),
transform var(--duration-base) var(--ease-out);
}
.kpi-card:hover {
border-color: var(--color-border);
box-shadow: var(--shadow-md);
transform: translateY(-1px);
}
.kpi-card.kpi-card-highlight {
border-left: 3px solid var(--color-primary);
background: var(--color-bg-card);
}
.kpi-card.kpi-card-highlight .kpi-value {
font-size: clamp(40px, 3.6vw, 52px);
letter-spacing: -0.035em;
}
.kpi-label {
font-family: var(--font-sans);
font-size: var(--text-xs);
font-weight: var(--weight-medium);
text-transform: uppercase;
letter-spacing: var(--tracking-wide);
color: var(--color-text-muted);
display: flex;
align-items: center;
gap: var(--space-2);
}
.kpi-suffix {
font-size: 10px;
color: var(--color-text-muted);
padding: 1px 5px;
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-xs);
font-weight: var(--weight-regular);
}
.kpi-value {
font-family: var(--font-mono);
font-size: 32px;
font-weight: var(--weight-semibold);
letter-spacing: -0.025em;
line-height: 1;
margin-top: var(--space-3);
}
.kpi-sub {
font-size: var(--text-sm);
color: var(--color-text-muted);
margin-top: var(--space-4);
padding-top: var(--space-3);
border-top: 1px dashed var(--color-border-subtle);
line-height: var(--leading-snug);
}
.kpi-sub-confidence {
display: flex;
align-items: center;
gap: var(--space-2);
flex-wrap: wrap;
}
.kpi-sub-warn { color: var(--color-error); }
/* ─────── YTD 简单 strip(节奏化:小标签 + 大数字) ─────── */
.ytd-strip {
display: flex;
align-items: center;
gap: var(--space-4);
margin-top: var(--space-5);
padding: var(--space-3) var(--space-5);
background: var(--color-bg-subtle);
border-radius: var(--radius-md);
flex-wrap: wrap;
}
.ytd-label {
font-size: 10px;
font-weight: var(--weight-semibold);
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--color-text-muted);
}
.ytd-metric {
display: inline-flex;
gap: var(--space-2);
align-items: baseline;
}
.ytd-key {
font-size: var(--text-sm);
color: var(--color-text-muted);
}
.ytd-num {
font-size: 15px;
font-weight: var(--weight-semibold);
color: var(--color-text-primary);
}
.ytd-sep {
display: inline-block;
width: 1px;
height: 12px;
background: var(--color-border);
}
/* ─────── 通用 ─────── */
.two-col {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(380px, 1fr));
gap: clamp(var(--space-3), 1vw, var(--space-5));
}
.factor-row { margin: var(--space-2) 0; line-height: var(--leading-normal); display: flex; align-items: flex-start; gap: var(--space-2); }
.event-count {
font-size: var(--text-xs);
color: var(--color-text-muted);
background: var(--color-bg-subtle);
padding: 2px 10px;
border-radius: var(--radius-full);
font-variant-numeric: tabular-nums;
font-weight: var(--weight-medium);
}
/* auto-fit + minmax 已经处理窄屏自适应,只在很窄时调整 header 布局 */
@media (max-width: 640px) {
.board-header { flex-direction: column; align-items: flex-start; }
.header-actions { width: 100%; justify-content: flex-end; }
}
</style>

View File

@ -0,0 +1,488 @@
<script setup lang="ts">
import { h, ref, computed, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import {
NSpin, NCard, NSelect, NEmpty, NDataTable, NTag, NSwitch, NButton, useMessage,
} from 'naive-ui';
import dayjs from 'dayjs';
import { fetchDashboard, type DashboardResult } from '@/api/roi';
import ConfidenceBadge from '@/components/roi/ConfidenceBadge.vue';
import CategoryStackedBar from '@/components/roi/CategoryStackedBar.vue';
import RevenuePieChart from '@/components/roi/RevenuePieChart.vue';
const router = useRouter();
const message = useMessage();
const loading = ref(true);
type WindowKey = 'lifetime' | 'ytd' | 'mtd' | 'custom';
const windowKey = ref<WindowKey>('lifetime');
function rangeOf(key: WindowKey): { from: string; to: string } {
const today = dayjs().format('YYYY-MM-DD');
if (key === 'lifetime') return { from: '2020-01-01', to: today };
if (key === 'ytd') return { from: dayjs().startOf('year').format('YYYY-MM-DD'), to: today };
return { from: dayjs().startOf('month').format('YYYY-MM-DD'), to: today };
}
const lifetimeData = ref<DashboardResult | null>(null);
const ytdData = ref<DashboardResult | null>(null);
const mtdData = ref<DashboardResult | null>(null);
async function loadAll() {
loading.value = true;
try {
const [l, y, m] = await Promise.all([
fetchDashboard(rangeOf('lifetime').from, rangeOf('lifetime').to),
fetchDashboard(rangeOf('ytd').from, rangeOf('ytd').to),
fetchDashboard(rangeOf('mtd').from, rangeOf('mtd').to),
]);
lifetimeData.value = l.data.data;
ytdData.value = y.data.data;
mtdData.value = m.data.data;
} catch (e: any) {
message.error('加载失败:' + (e?.response?.data?.message || e.message));
} finally {
loading.value = false;
}
}
onMounted(loadAll);
const activeData = computed(() => {
if (windowKey.value === 'lifetime') return lifetimeData.value;
if (windowKey.value === 'ytd') return ytdData.value;
return mtdData.value;
});
function fmtCurrency(n: number | null | undefined): string {
if (n === null || n === undefined) return '—';
return `¥${Math.round(n).toLocaleString()}`;
}
function fmtPercent(n: number | null | undefined): string {
if (n === null || n === undefined) return '—';
return `${n.toFixed(1)}%`;
}
function roiColor(n: number | null | undefined): string {
if (n === null || n === undefined) return 'var(--color-text-muted)';
if (n >= 100) return '#0D9668';
if (n >= 0) return '#D4920A';
return '#DC2626';
}
const CATEGORY_META: Record<string, { emoji: string; label: string; color: 'success' | 'info' | 'warning' | 'error' | 'default' }> = {
cash_cow: { emoji: '💰', label: '现金牛', color: 'success' },
efficiency_tool: { emoji: '⚙️', label: '效能工具', color: 'info' },
moat: { emoji: '💎', label: '资本护城河', color: 'warning' },
composite: { emoji: '🚀', label: '复合型', color: 'error' },
uncategorized: { emoji: '◯', label: '未打标', color: 'default' },
};
//
const selectedCategories = ref<string[]>([]); // =
const hideZeroCost = ref(true);
function toggleCategory(cat: string) {
const i = selectedCategories.value.indexOf(cat);
if (i >= 0) selectedCategories.value.splice(i, 1);
else selectedCategories.value.push(cat);
}
function chipColor(t: string): string {
return ({ success: '#0D9668', info: '#3B5998', warning: '#D4920A', error: '#DC2626', default: '#666' } as Record<string, string>)[t] || '#666';
}
// ()
const categoryStats = computed(() => {
const stats: Record<string, { count: number; totalCost: number; totalRevenue: number }> = {};
for (const k of Object.keys(CATEGORY_META)) {
stats[k] = { count: 0, totalCost: 0, totalRevenue: 0 };
}
const items = activeData.value?.projects || [];
for (const p of items) {
const k = p.category || 'uncategorized';
if (!stats[k]) stats[k] = { count: 0, totalCost: 0, totalRevenue: 0 };
stats[k].count += 1;
stats[k].totalCost += p.totalCost;
stats[k].totalRevenue += p.totalRevenue;
}
return stats;
});
// /
const filteredProjects = computed(() => {
let items = (activeData.value?.projects || []).slice();
if (selectedCategories.value.length > 0) {
items = items.filter(p => selectedCategories.value.includes(p.category || 'uncategorized'));
}
if (hideZeroCost.value) {
items = items.filter(p => p.totalCost > 0);
}
return items;
});
const projectColumns = [
{
title: '项目', key: 'name',
render: (row: any) => row.name,
sorter: (a: any, b: any) => (a.name || '').localeCompare(b.name || ''),
},
{
title: '定位', key: 'category',
render: (row: any) => {
const meta = CATEGORY_META[row.category || 'uncategorized'];
return h(NTag, { type: meta.color, size: 'small', round: true }, () => `${meta.emoji} ${meta.label}`);
},
filterOptions: Object.entries(CATEGORY_META).map(([k, v]) => ({ label: `${v.emoji} ${v.label}`, value: k })),
filter: (value: any, row: any) => (row.category || 'uncategorized') === value,
},
{ title: '成本', key: 'totalCost', render: (row: any) => fmtCurrency(row.totalCost),
sorter: (a: any, b: any) => a.totalCost - b.totalCost },
{ title: '产出', key: 'totalRevenue', render: (row: any) => fmtCurrency(row.totalRevenue),
sorter: (a: any, b: any) => a.totalRevenue - b.totalRevenue },
{
title: 'ROI', key: 'roiValue',
render: (row: any) => h('span', { style: { color: roiColor(row.roiValue), fontWeight: 600 } }, fmtPercent(row.roiValue)),
sorter: (a: any, b: any) => (a.roiValue ?? -Infinity) - (b.roiValue ?? -Infinity),
sortOrder: 'descend' as const, // ROI
defaultSortOrder: 'descend' as const,
},
{ title: '置信度', key: 'confidence', render: (row: any) => h(ConfidenceBadge as any, { confidence: row.confidence, showLabel: false }) },
{
title: '操作', key: 'actions', render: (row: any) => h('a', {
style: { color: 'var(--color-primary-hex)', cursor: 'pointer' },
onClick: () => router.push(`/projects/${row.projectId}/roi`),
}, '查看 →'),
},
];
</script>
<template>
<div class="dashboard-page">
<NSpin :show="loading">
<!-- 3 ROI 大卡片 -->
<div class="kpi-grid">
<article class="kpi-card kpi-hero">
<div class="kpi-label">公司累计 ROI</div>
<div class="kpi-value-row">
<span class="kpi-value tabular-nums" :style="{ color: roiColor(lifetimeData?.summary.roiValue) }">
{{ fmtPercent(lifetimeData?.summary.roiValue) }}
</span>
</div>
<div class="kpi-meta" v-if="lifetimeData">
<span class="kpi-meta-row"><span class="kpi-meta-label">成本</span><span class="tabular-nums">{{ fmtCurrency(lifetimeData.summary.totalCost) }}</span></span>
<span class="kpi-meta-row"><span class="kpi-meta-label">产出</span><span class="tabular-nums">{{ fmtCurrency(lifetimeData.summary.totalRevenue) }}</span></span>
</div>
</article>
<article class="kpi-card">
<div class="kpi-label">本月 ROI<span class="kpi-suffix">MTD</span></div>
<div class="kpi-value-row">
<span class="kpi-value tabular-nums" :style="{ color: roiColor(mtdData?.summary.roiValue) }">
{{ fmtPercent(mtdData?.summary.roiValue) }}
</span>
</div>
<div class="kpi-meta" v-if="mtdData">
<span class="kpi-meta-row"><span class="kpi-meta-label">成本</span><span class="tabular-nums">{{ fmtCurrency(mtdData.summary.totalCost) }}</span></span>
<span class="kpi-meta-row"><span class="kpi-meta-label">产出</span><span class="tabular-nums">{{ fmtCurrency(mtdData.summary.totalRevenue) }}</span></span>
</div>
</article>
<article class="kpi-card">
<div class="kpi-label">本年 ROI<span class="kpi-suffix">YTD</span></div>
<div class="kpi-value-row">
<span class="kpi-value tabular-nums" :style="{ color: roiColor(ytdData?.summary.roiValue) }">
{{ fmtPercent(ytdData?.summary.roiValue) }}
</span>
</div>
<div class="kpi-meta" v-if="ytdData">
<span class="kpi-meta-row"><span class="kpi-meta-label">成本</span><span class="tabular-nums">{{ fmtCurrency(ytdData.summary.totalCost) }}</span></span>
<span class="kpi-meta-row"><span class="kpi-meta-label">产出</span><span class="tabular-nums">{{ fmtCurrency(ytdData.summary.totalRevenue) }}</span></span>
</div>
</article>
</div>
<!-- 切换时间窗口 -->
<div class="section-divider">
<div>
<div class="eyebrow">Business lines</div>
<h2 class="section-title">业务线分布</h2>
</div>
<NSelect v-model:value="windowKey" :options="[
{ label: '累计 (LTD)', value: 'lifetime' },
{ label: '本年 (YTD)', value: 'ytd' },
{ label: '本月 (MTD)', value: 'mtd' },
]" style="width:160px" size="small" />
</div>
<!-- 堆叠图 + 饼图 -->
<div class="charts-row">
<NCard size="small" title="各核心业务线 ROI 堆叠图">
<CategoryStackedBar v-if="activeData" :by-category="activeData.byCategory" />
<NEmpty v-else />
</NCard>
<NCard size="small" title="各业务线产出占比">
<RevenuePieChart v-if="activeData" :by-category="activeData.byCategory" />
<NEmpty v-else />
</NCard>
</div>
<!-- 项目明细表 -->
<NCard size="small" style="margin-top:16px">
<template #header>
<div class="table-header">
<div style="display:flex;align-items:center;gap:8px">
<span style="font-weight:600">项目明细</span>
<span class="result-count">显示 {{ filteredProjects.length }} / {{ activeData?.projects.length || 0 }} </span>
</div>
<div class="category-chips">
<button
v-for="(meta, k) in CATEGORY_META"
:key="k"
class="chip"
:class="{ active: selectedCategories.includes(k), disabled: (categoryStats[k]?.count || 0) === 0 }"
:style="selectedCategories.includes(k) ? { background: chipColor(meta.color), color: '#fff', borderColor: chipColor(meta.color) } : {}"
:disabled="(categoryStats[k]?.count || 0) === 0"
@click="toggleCategory(k)"
>
{{ meta.emoji }} {{ meta.label }}
<span class="chip-count">{{ categoryStats[k]?.count || 0 }}</span>
</button>
<NButton v-if="selectedCategories.length > 0" size="tiny" text type="primary" @click="selectedCategories = []">清空</NButton>
<span class="filter-divider">|</span>
<span class="filter-label">仅看有成本数据</span>
<NSwitch v-model:value="hideZeroCost" size="small" />
</div>
</div>
</template>
<NDataTable
v-if="activeData"
:columns="projectColumns"
:data="filteredProjects"
:max-height="500"
:row-key="(row: any) => row.projectId"
striped
/>
<NEmpty v-else />
</NCard>
</NSpin>
</div>
</template>
<style scoped>
.dashboard-page {
padding: var(--space-2) 0 clamp(var(--space-8), 4vw, var(--space-16));
width: 100%;
}
/* ─────── Editorial 页头 ─────── */
.page-header {
margin-bottom: var(--space-10);
padding-bottom: var(--space-6);
border-bottom: 1px solid var(--color-border-subtle);
}
.eyebrow {
font-family: var(--font-sans);
font-size: var(--text-xs);
font-weight: var(--weight-medium);
text-transform: uppercase;
letter-spacing: var(--tracking-wide);
color: var(--color-text-muted);
margin-bottom: var(--space-3);
}
.page-title {
font-family: var(--font-sans);
font-size: var(--text-display);
font-weight: var(--weight-semibold);
line-height: var(--leading-snug);
letter-spacing: var(--tracking-tight);
color: var(--color-text-primary);
font-variant-numeric: tabular-nums;
margin: 0;
}
.page-lede {
margin-top: var(--space-3);
font-size: var(--text-md);
color: var(--color-text-secondary);
line-height: 1.5;
max-width: 56ch;
}
/* ─────── KPI 卡片 (Editorial Data Console) ─────── */
.kpi-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: clamp(var(--space-3), 1vw, var(--space-5));
}
.kpi-card.kpi-hero {
grid-column: span 1;
}
@media (min-width: 1400px) {
.kpi-grid { grid-template-columns: 1.4fr 1fr 1fr; }
}
.kpi-card {
background: var(--color-bg-card);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-xl);
padding: var(--space-6) var(--space-7);
transition: border-color var(--duration-base) var(--ease-out),
box-shadow var(--duration-base) var(--ease-out),
transform var(--duration-base) var(--ease-out);
position: relative;
overflow: hidden;
}
.kpi-card::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(180deg, var(--color-bg-card) 0%, var(--color-bg-subtle) 100%);
opacity: 0;
transition: opacity var(--duration-base) var(--ease-out);
pointer-events: none;
}
.kpi-card:hover {
border-color: var(--color-border);
box-shadow: var(--shadow-md);
transform: translateY(-1px);
}
.kpi-card.kpi-hero {
border-left: 3px solid var(--color-primary);
}
.kpi-label {
font-family: var(--font-sans);
font-size: var(--text-xs);
font-weight: var(--weight-medium);
text-transform: uppercase;
letter-spacing: var(--tracking-wide);
color: var(--color-text-muted);
display: flex;
align-items: center;
gap: var(--space-2);
}
.kpi-suffix {
font-size: 10px;
color: var(--color-text-muted);
padding: 1px 6px;
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-xs);
letter-spacing: 0.05em;
}
.kpi-value-row {
margin-top: var(--space-3);
display: flex;
align-items: baseline;
gap: var(--space-3);
}
.kpi-value {
font-family: var(--font-mono);
font-size: var(--text-3xl);
font-weight: var(--weight-semibold);
letter-spacing: -0.025em;
line-height: 1;
}
.kpi-meta {
margin-top: var(--space-5);
display: flex;
flex-direction: column;
gap: var(--space-1);
padding-top: var(--space-3);
border-top: 1px dashed var(--color-border-subtle);
}
.kpi-meta-row {
display: flex;
justify-content: space-between;
font-size: var(--text-sm);
color: var(--color-text-secondary);
}
.kpi-meta-label {
color: var(--color-text-muted);
}
/* ─────── Section 分隔 ─────── */
.section-divider {
margin-top: var(--space-12);
margin-bottom: var(--space-5);
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: var(--space-4);
}
.section-title {
font-family: var(--font-sans);
font-size: var(--text-xl);
font-weight: var(--weight-semibold);
letter-spacing: var(--tracking-tight);
color: var(--color-text-primary);
margin: var(--space-2) 0 0 0;
}
.charts-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(420px, 1fr));
gap: clamp(var(--space-3), 1vw, var(--space-5));
}
/* ─────── 项目明细表 ─────── */
.table-header {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
gap: var(--space-4);
flex-wrap: wrap;
}
.category-chips {
display: flex;
align-items: center;
gap: var(--space-2);
flex-wrap: wrap;
}
.chip {
border: 1px solid var(--color-border);
background: var(--color-bg-card);
border-radius: var(--radius-full);
padding: 4px 12px;
font-family: var(--font-sans);
font-size: var(--text-sm);
font-weight: var(--weight-medium);
cursor: pointer;
user-select: none;
display: inline-flex;
align-items: center;
gap: var(--space-1);
color: var(--color-text-secondary);
transition: all var(--duration-fast) var(--ease-out);
}
.chip:hover:not(.disabled) {
border-color: var(--color-border-strong);
color: var(--color-text-primary);
background: var(--color-bg-subtle);
}
.chip.active { font-weight: var(--weight-semibold); box-shadow: var(--shadow-sm); }
.chip.disabled { opacity: 0.35; cursor: not-allowed; }
.chip-count {
font-family: var(--font-mono);
font-size: 10px;
color: var(--color-text-muted);
opacity: 1;
margin-left: 6px;
padding-left: 8px;
border-left: 1px solid currentColor;
line-height: 1;
font-variant-numeric: tabular-nums;
}
.chip.active .chip-count {
color: oklch(1 0 0 / 0.7);
}
.result-count {
font-size: var(--text-sm);
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
.filter-divider { color: var(--color-border); margin: 0 var(--space-1); user-select: none; }
.filter-label { font-size: var(--text-sm); color: var(--color-text-muted); }
/* 自适应:auto-fit + minmax 已经在窄屏自动单列, 这里仅微调 padding */
@media (max-width: 768px) {
.page-header { padding-bottom: var(--space-4); margin-bottom: var(--space-6); }
.section-divider { margin-top: var(--space-8); flex-wrap: wrap; }
}
</style>

View File

@ -48,6 +48,15 @@ spec:
# CORS
- name: CORS_ORIGINS
value: "https://devperf.airlabs.art"
# AI (豆包 Doubao / 火山引擎 Ark)
- name: AI_ENABLED
value: "true"
- name: AI_API_KEY
value: "846b6981-9954-4c58-bb39-63079393bdb8"
- name: AI_MODEL
value: "doubao-seed-2-0-pro-260215"
- name: AI_BASE_URL
value: "https://ark.cn-beijing.volces.com/api/v3"
livenessProbe:
httpGet:
path: /api/health

Binary file not shown.