Compare commits
No commits in common. "airlabs" and "main" have entirely different histories.
@ -70,14 +70,12 @@ jobs:
|
||||
|
||||
- name: Setup Kubectl
|
||||
run: |
|
||||
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
|
||||
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
|
||||
fi
|
||||
kubectl version --client
|
||||
|
||||
@ -86,9 +84,6 @@ 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
|
||||
@ -135,5 +130,4 @@ jobs:
|
||||
run: |
|
||||
docker container prune -f
|
||||
docker image prune -f
|
||||
echo "Disk usage:"
|
||||
df -h / | tail -1
|
||||
docker builder prune -a -f
|
||||
|
||||
@ -1,421 +0,0 @@
|
||||
# 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`
|
||||
|
||||
使用豆包(火山引擎 Ark)API,原生 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 提交人的 userId(AI 返回 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 |
|
||||
@ -1,14 +0,0 @@
|
||||
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`);
|
||||
@ -1,20 +0,0 @@
|
||||
-- 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;
|
||||
@ -8,20 +8,6 @@
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -58,22 +58,12 @@ export async function getRepos(): Promise<GiteaRepo[]> {
|
||||
return Array.isArray(data) ? data : [];
|
||||
}
|
||||
|
||||
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[]> {
|
||||
export async function getCommits(owner: string, repo: string, since?: string): Promise<GiteaCommit[]> {
|
||||
// 分页拉取所有 commit(每页 50,最多 10 页 = 500 条)
|
||||
const all: GiteaCommit[] = [];
|
||||
for (let page = 1; page <= 10; page++) {
|
||||
let path = `/repos/${owner}/${repo}/commits?limit=50&page=${page}`;
|
||||
if (since) path += `&since=${since}`;
|
||||
if (branch) path += `&sha=${encodeURIComponent(branch)}`;
|
||||
const data = await giteaGet<GiteaCommit[]>(path);
|
||||
if (!Array.isArray(data) || data.length === 0) break;
|
||||
all.push(...data);
|
||||
|
||||
@ -23,12 +23,6 @@ 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: 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'),
|
||||
});
|
||||
|
||||
function loadConfig() {
|
||||
|
||||
@ -154,7 +154,6 @@ 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) => ({
|
||||
@ -171,13 +170,11 @@ 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 / completed
|
||||
status: varchar('status', { length: 100 }).default('active'), // active / paused / cancelled
|
||||
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) => ({
|
||||
@ -221,34 +218,12 @@ 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', 'ai_okr']).notNull(),
|
||||
source: mysqlEnum('source', ['plane', 'gitea']).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),
|
||||
}));
|
||||
|
||||
@ -20,7 +20,7 @@ const app = new Hono();
|
||||
|
||||
// Global middleware
|
||||
app.use('*', cors({
|
||||
origin: ['http://localhost:5173', 'http://localhost:3201', 'https://devperf.airlabs.art'],
|
||||
origin: ['http://localhost:5173', 'http://localhost:3201'],
|
||||
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
allowHeaders: ['Content-Type', 'Authorization'],
|
||||
}));
|
||||
@ -71,5 +71,4 @@ console.info(`DevPerf Dashboard API starting on port ${port}`);
|
||||
export default {
|
||||
port,
|
||||
fetch: app.fetch,
|
||||
idleTimeout: 120, // AI 分析可能耗时较长
|
||||
};
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
import { Hono } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { z } from 'zod';
|
||||
import { eq, desc } from 'drizzle-orm';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { db } from '../db/index';
|
||||
import { users, authorMappings, syncLogs, projects, projectRepos, gitCommits, gitPRs, userProjectPermissions, objectives, keyResults, krLogs } from '../db/schema';
|
||||
import { users, authorMappings, syncLogs, projects, projectRepos, gitCommits, gitPRs } from '../db/schema';
|
||||
import { requireRole } from '../middleware/role';
|
||||
import { AppError } from '../middleware/error-handler';
|
||||
|
||||
@ -18,12 +18,6 @@ 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 => ({
|
||||
@ -33,7 +27,6 @@ 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',
|
||||
@ -154,41 +147,8 @@ 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' });
|
||||
});
|
||||
@ -289,49 +249,6 @@ 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) => {
|
||||
@ -349,7 +266,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(desc(syncLogs.syncedAt));
|
||||
const allLogs = await db.select().from(syncLogs).orderBy(syncLogs.syncedAt);
|
||||
const total = allLogs.length;
|
||||
const items = allLogs.slice((page - 1) * pageSize, page * pageSize);
|
||||
|
||||
|
||||
@ -1,9 +1,8 @@
|
||||
import { Hono } from 'hono';
|
||||
import { db } from '../db/index';
|
||||
import { gitCommits, gitPRs, users, projectRepos } from '../db/schema';
|
||||
import { eq, and, gte, desc, inArray } from 'drizzle-orm';
|
||||
import { gitCommits, gitPRs, users } from '../db/schema';
|
||||
import { eq, and, gte, desc } from 'drizzle-orm';
|
||||
import { AppError } from '../middleware/error-handler';
|
||||
import { getAllowedProjectIds } from '../services/permissions';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
export const gitRoutes = new Hono();
|
||||
@ -14,25 +13,8 @@ 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') {
|
||||
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' });
|
||||
}
|
||||
throw new AppError(40103, 'Insufficient permissions', 403);
|
||||
}
|
||||
|
||||
let targetUserId: string | undefined;
|
||||
@ -48,12 +30,7 @@ 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));
|
||||
let commits = await commitQuery;
|
||||
|
||||
// 观察者:过滤到分配项目的仓库
|
||||
if (allowedRepoNames) {
|
||||
commits = commits.filter(c => allowedRepoNames!.has(c.repoName));
|
||||
}
|
||||
const commits = await commitQuery;
|
||||
|
||||
// Heatmap(按天)
|
||||
const dayMap: Record<string, { commits: number; additions: number; deletions: number }> = {};
|
||||
@ -72,10 +49,7 @@ gitRoutes.get('/git/activity', async (c) => {
|
||||
const heatmap = Object.entries(dayMap).map(([date, data]) => ({ date, ...data }));
|
||||
|
||||
// 统计指标(替代原来的 PR 指标)
|
||||
let allCommits = await db.select().from(gitCommits);
|
||||
if (allowedRepoNames) {
|
||||
allCommits = allCommits.filter(c => allowedRepoNames!.has(c.repoName));
|
||||
}
|
||||
const allCommits = await db.select().from(gitCommits);
|
||||
const thisMonthStart = dayjs().startOf('month').toDate();
|
||||
const thisMonthCommits = allCommits.filter(c => dayjs(c.committedAt).isAfter(thisMonthStart));
|
||||
const activeContributors = new Set(allCommits.filter(c => c.userId).map(c => c.userId)).size;
|
||||
|
||||
@ -2,26 +2,14 @@ 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' });
|
||||
});
|
||||
|
||||
@ -150,24 +138,6 @@ 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'),
|
||||
|
||||
@ -1,36 +1,16 @@
|
||||
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, inArray } from 'drizzle-orm';
|
||||
import { getAllowedProjectIds } from '../services/permissions';
|
||||
import { eq, desc, gte } from 'drizzle-orm';
|
||||
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(allowedRepos?: Set<string>) {
|
||||
const recent = await db.select().from(gitCommits).orderBy(desc(gitCommits.committedAt)).limit(50);
|
||||
async function getRecentCommits() {
|
||||
const recent = await db.select().from(gitCommits).orderBy(desc(gitCommits.committedAt)).limit(15);
|
||||
const allUsers = await db.select().from(users);
|
||||
const userMap = new Map(allUsers.map(u => [u.id, u.displayName]));
|
||||
let filtered = recent;
|
||||
if (allowedRepos) {
|
||||
filtered = recent.filter(c => allowedRepos.has(c.repoName));
|
||||
}
|
||||
return filtered.slice(0, 15).map(c => ({
|
||||
return recent.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,
|
||||
@ -40,39 +20,15 @@ async function getRecentCommits(allowedRepos?: Set<string>) {
|
||||
}
|
||||
|
||||
overviewRoutes.get('/overview', async (c) => {
|
||||
const user = c.get('user');
|
||||
const period = c.req.query('period');
|
||||
let projectIds = c.req.query('projectIds')?.split(',').filter(Boolean) || [];
|
||||
|
||||
// 观察者:强制限定到已分配的项目
|
||||
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));
|
||||
}
|
||||
const projectIds = c.req.query('projectIds')?.split(',').filter(Boolean) || [];
|
||||
|
||||
// 1. 各项目 OKR 整体进度(替代 Sprint 交付率)
|
||||
const allProjects = await db.select().from(projects);
|
||||
const projectOKRProgress: { projectId: string; name: string; identifier: string; progress: number; objectiveCount: number }[] = [];
|
||||
|
||||
for (const proj of allProjects) {
|
||||
if ((mustFilterByProject || projectIds.length > 0) && !projectIds.includes(proj.id)) continue;
|
||||
if (projectIds.length > 0 && !projectIds.includes(proj.id)) continue;
|
||||
let projObjectives = await db.select().from(objectives)
|
||||
.where(eq(objectives.projectId, proj.id));
|
||||
if (period) {
|
||||
@ -93,7 +49,7 @@ overviewRoutes.get('/overview', async (c) => {
|
||||
// 2. KR 完成状态分布(替代任务状态分布)
|
||||
const allKRs = await db.select().from(keyResults);
|
||||
let filteredKRs = allKRs;
|
||||
if (mustFilterByProject || projectIds.length > 0) {
|
||||
if (projectIds.length > 0) {
|
||||
const projObjIds = new Set(
|
||||
(await db.select().from(objectives))
|
||||
.filter(o => o.projectId && projectIds.includes(o.projectId))
|
||||
@ -125,15 +81,10 @@ overviewRoutes.get('/overview', async (c) => {
|
||||
|
||||
// 4. Weekly Code Activity (last 12 weeks)
|
||||
const twelveWeeksAgo = dayjs().subtract(12, 'week').startOf('week').toDate();
|
||||
let commits = await db.select().from(gitCommits)
|
||||
const commits = await db.select().from(gitCommits)
|
||||
.where(gte(gitCommits.committedAt, twelveWeeksAgo));
|
||||
let prs = await db.select().from(gitPRs)
|
||||
const prs = await db.select().from(gitPRs)
|
||||
.where(gte(gitPRs.createdAt, twelveWeeksAgo));
|
||||
// 观察者:按项目绑定仓库过滤
|
||||
if (allowedRepos) {
|
||||
commits = commits.filter(c => allowedRepos.has(c.repoName));
|
||||
prs = prs.filter(p => allowedRepos.has(p.repoName));
|
||||
}
|
||||
const allUsers = await db.select().from(users);
|
||||
|
||||
const weekMap: Record<string, Record<string, { commits: number; prs: number }>> = {};
|
||||
@ -175,7 +126,7 @@ overviewRoutes.get('/overview', async (c) => {
|
||||
? await db.select().from(objectives).where(eq(objectives.period, period))
|
||||
: await db.select().from(objectives);
|
||||
|
||||
if (mustFilterByProject || projectIds.length > 0) {
|
||||
if (projectIds.length > 0) {
|
||||
allObjectives = allObjectives.filter(o => o.projectId && projectIds.includes(o.projectId));
|
||||
}
|
||||
|
||||
@ -207,12 +158,8 @@ overviewRoutes.get('/overview', async (c) => {
|
||||
const weekStart = dayjs().startOf('week').add(weekOffset, 'week');
|
||||
const weekEnd = dayjs().endOf('week').add(weekOffset, 'week');
|
||||
|
||||
let allKRsRaw = await db.select().from(keyResults);
|
||||
const allKRsRaw = await db.select().from(keyResults);
|
||||
const allObjsMap = new Map((await db.select().from(objectives)).map(o => [o.id, o]));
|
||||
// 观察者:过滤到已分配项目的 KR
|
||||
if (allowedObjIds) {
|
||||
allKRsRaw = allKRsRaw.filter(kr => allowedObjIds!.has(kr.objectiveId));
|
||||
}
|
||||
|
||||
const thisWeekKRs = allKRsRaw.filter(kr => {
|
||||
if (!kr.endDate) return false;
|
||||
@ -356,7 +303,7 @@ overviewRoutes.get('/overview', async (c) => {
|
||||
weekLabel: weekStart.format('MM/DD') + ' ~ ' + weekEnd.format('MM/DD'),
|
||||
},
|
||||
overdueKRs: overdueList,
|
||||
recentCommits: await getRecentCommits(allowedRepos),
|
||||
recentCommits: await getRecentCommits(),
|
||||
},
|
||||
message: 'success',
|
||||
});
|
||||
|
||||
@ -3,23 +3,17 @@ 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, userProjectPermissions } from '../db/schema';
|
||||
import { eq, and, desc, gte, inArray } from 'drizzle-orm';
|
||||
import { projects, sprintSnapshots, milestones, taskSnapshots, gitCommits, gitPRs, users, objectives, keyResults, projectRepos, krLogs } from '../db/schema';
|
||||
import { eq, and, desc, gte } 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 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));
|
||||
}
|
||||
const allProjects = await db.select().from(projects);
|
||||
return c.json({
|
||||
code: 0,
|
||||
data: allProjects.map(p => ({
|
||||
@ -44,7 +38,6 @@ 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();
|
||||
@ -56,17 +49,6 @@ 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);
|
||||
}
|
||||
);
|
||||
@ -76,60 +58,11 @@ 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));
|
||||
// 清理 OKR:KR 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');
|
||||
@ -166,13 +99,6 @@ 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),
|
||||
@ -243,47 +169,32 @@ projectRoutes.get('/projects/:id', async (c) => {
|
||||
})),
|
||||
};
|
||||
|
||||
// OKR for this project (batch queries to avoid N+1)
|
||||
// OKR for this project
|
||||
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 = krsByObj.get(obj.id) || [];
|
||||
const krs = await db.select().from(keyResults)
|
||||
.where(eq(keyResults.objectiveId, obj.id));
|
||||
const owner = obj.ownerId
|
||||
? await db.query.users.findFirst({ where: eq(users.id, obj.ownerId) })
|
||||
: null;
|
||||
okrData.push({
|
||||
id: obj.id,
|
||||
title: obj.title,
|
||||
ownerId: obj.ownerId || null,
|
||||
ownerName: obj.ownerId ? (userMap.get(obj.ownerId) || '未指定') : '未指定',
|
||||
ownerName: owner?.displayName || '未指定',
|
||||
period: obj.period,
|
||||
startDate: obj.startDate || null,
|
||||
endDate: obj.endDate || null,
|
||||
progress: obj.progress || 0,
|
||||
keyResults: krs.map(kr => {
|
||||
const logs = logsByKR.get(kr.id) || [];
|
||||
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);
|
||||
const wasPostponed = logs.some(l => l.action === 'postponed');
|
||||
const lastPostponeReason = logs.find(l => l.action === 'postponed')?.detail || null;
|
||||
return {
|
||||
@ -302,7 +213,7 @@ projectRoutes.get('/projects/:id', async (c) => {
|
||||
? Math.round(((kr.currentValue || 0) / kr.targetValue) * 100)
|
||||
: 0,
|
||||
};
|
||||
}),
|
||||
})),
|
||||
});
|
||||
totalOKRProgress += obj.progress || 0;
|
||||
}
|
||||
@ -323,13 +234,15 @@ projectRoutes.get('/projects/:id', async (c) => {
|
||||
return cleaned;
|
||||
}));
|
||||
|
||||
// 获取该项目绑定仓库的 Git 数据(按仓库名过滤,避免全表扫描)
|
||||
const boundRepoNamesList = Array.from(boundRepoNames);
|
||||
const recentCommits = boundRepoNamesList.length > 0
|
||||
? await db.select().from(gitCommits).where(inArray(gitCommits.repoName, boundRepoNamesList))
|
||||
// 获取该项目所有 Git 数据(不限时间范围)
|
||||
const allCommits = await db.select().from(gitCommits);
|
||||
const allPRs = await db.select().from(gitPRs);
|
||||
|
||||
const recentCommits = boundRepoNames.size > 0
|
||||
? allCommits.filter(c => boundRepoNames.has(c.repoName))
|
||||
: [];
|
||||
const recentPRs = boundRepoNamesList.length > 0
|
||||
? await db.select().from(gitPRs).where(inArray(gitPRs.repoName, boundRepoNamesList))
|
||||
const recentPRs = boundRepoNames.size > 0
|
||||
? allPRs.filter(p => boundRepoNames.has(p.repoName))
|
||||
: [];
|
||||
|
||||
const weeklyTrend: { weekStart: string; commits: number; prs: number }[] = [];
|
||||
|
||||
@ -1,78 +0,0 @@
|
||||
import { config } from '../config';
|
||||
|
||||
/**
|
||||
* 调用豆包(火山引擎 Ark)LLM 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)}`);
|
||||
}
|
||||
}
|
||||
@ -1,578 +0,0 @@
|
||||
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();
|
||||
|
||||
const groups = await gatherUnanalyzedCommits();
|
||||
|
||||
if (groups.length === 0) {
|
||||
console.info('[AI-OKR] No unanalyzed commits found, skipping');
|
||||
return { totalCommits: 0, reposProcessed: 0, actionsExecuted: 0, summaries: [] };
|
||||
}
|
||||
|
||||
let totalCommits = 0;
|
||||
let actionsExecuted = 0;
|
||||
const summaries: { repo: string; summary: string }[] = [];
|
||||
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 记录 sync log
|
||||
const elapsed = Date.now() - startTime;
|
||||
await db.insert(syncLogs).values({
|
||||
id: uuid(),
|
||||
source: 'ai_okr',
|
||||
status: 'success',
|
||||
message: `${dryRun ? '[DRY RUN] ' : ''}Analyzed ${totalCommits} commits from ${groups.length} repos, executed ${actionsExecuted} actions in ${elapsed}ms`,
|
||||
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 };
|
||||
}
|
||||
@ -27,7 +27,6 @@ export async function getOKRByPeriod(period?: string) {
|
||||
id: obj.id,
|
||||
title: obj.title,
|
||||
ownerName: owner?.displayName || '未指定',
|
||||
projectId: obj.projectId || null,
|
||||
projectName: project?.name || '未关联项目',
|
||||
period: obj.period,
|
||||
startDate: obj.startDate || null,
|
||||
@ -68,7 +67,7 @@ export async function getOKRByPeriod(period?: string) {
|
||||
/**
|
||||
* 从日期自动推算所属季度,例如 2026-04-15 → "2026-Q2"
|
||||
*/
|
||||
export function dateToPeriod(dateStr: string): string {
|
||||
function dateToPeriod(dateStr: string): string {
|
||||
const d = new Date(dateStr);
|
||||
const year = d.getFullYear();
|
||||
const q = Math.ceil((d.getMonth() + 1) / 3);
|
||||
@ -256,17 +255,14 @@ async function addKRLog(krId: string, action: string, detail: string | null, ope
|
||||
});
|
||||
}
|
||||
|
||||
export async function recalcObjectiveProgress(objectiveId: string) {
|
||||
async function recalcObjectiveProgress(objectiveId: string) {
|
||||
const allKRs = await db.select().from(keyResults)
|
||||
.where(eq(keyResults.objectiveId, objectiveId));
|
||||
// 只算 active 和 completed 的 KR,暂停和取消的不计入
|
||||
// 只算 active 和已完成的 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) => {
|
||||
// completed 状态按 100% 计入
|
||||
const progress = k.status === 'completed'
|
||||
? 100
|
||||
: (k.targetValue > 0 ? ((k.currentValue || 0) / k.targetValue) * 100 : 0);
|
||||
const progress = 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;
|
||||
|
||||
@ -1,21 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@ -1,25 +1,13 @@
|
||||
import { Cron } from 'croner';
|
||||
import { syncGitea } from './sync-gitea';
|
||||
import { analyzeCommitsForOKR } from '../services/okr-ai-sync';
|
||||
import { config } from '../config';
|
||||
|
||||
let giteaJob: 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function startScheduler(): void {
|
||||
// 每天凌晨 2 点 + 晚上 7 点各同步一次
|
||||
giteaJob = new Cron('0 2,19 * * *', async () => {
|
||||
console.info('[SCHEDULER] Gitea 定时同步开始...');
|
||||
await runSyncAndAnalyze();
|
||||
await syncGitea();
|
||||
});
|
||||
|
||||
console.info('[SCHEDULER] Gitea 自动同步已启动(每天 02:00 + 19:00)');
|
||||
@ -27,7 +15,7 @@ export function startScheduler(): void {
|
||||
// 启动后 10 秒执行一次首次同步
|
||||
setTimeout(async () => {
|
||||
console.info('[SCHEDULER] 执行首次同步...');
|
||||
await runSyncAndAnalyze().catch(e => console.error('[SCHEDULER] 首次同步失败:', e));
|
||||
await syncGitea().catch(e => console.error('[SCHEDULER] 首次同步失败:', e));
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
|
||||
@ -70,16 +70,9 @@ export async function syncGitea(): Promise<void> {
|
||||
|
||||
for (const repo of reposToSync) {
|
||||
try {
|
||||
// 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);
|
||||
// Sync commits
|
||||
const commits = await giteaClient.getCommits(repo.owner, repo.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),
|
||||
});
|
||||
@ -108,7 +101,6 @@ export async function syncGitea(): Promise<void> {
|
||||
recordsProcessed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sync PRs
|
||||
const prs = await giteaClient.getPullRequests(repo.owner, repo.name);
|
||||
@ -151,7 +143,7 @@ export async function syncGitea(): Promise<void> {
|
||||
recordsProcessed++;
|
||||
}
|
||||
|
||||
console.info(`[SYNC] Repo ${repo.owner}/${repo.name}: synced (${branches.length} branches)`);
|
||||
console.info(`[SYNC] Repo ${repo.owner}/${repo.name}: synced`);
|
||||
} catch (repoErr) {
|
||||
const msg = repoErr instanceof Error ? repoErr.message : 'Unknown error';
|
||||
console.error(`[SYNC] Repo ${repo.owner}/${repo.name} failed:`, msg);
|
||||
|
||||
@ -9,7 +9,7 @@ RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY --from=builder /app/dist/ /usr/share/nginx/html/
|
||||
# Nginx config: gzip + cache + SPA fallback
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
# 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
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
|
||||
@ -1,35 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -37,10 +37,6 @@ 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}`);
|
||||
}
|
||||
@ -69,12 +65,3 @@ 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 });
|
||||
}
|
||||
|
||||
@ -18,7 +18,7 @@ const chartOptions = computed(() => {
|
||||
const sorted = [...props.projects].sort(
|
||||
(a, b) => b.currentCycleProgress - a.currentCycleProgress,
|
||||
);
|
||||
const names = sorted.map((p) => p.name);
|
||||
const names = sorted.map((p) => `${p.identifier} ${p.name}`.trim());
|
||||
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.name}</strong><br/>
|
||||
<strong>${project.identifier} ${project.name}</strong><br/>
|
||||
进度: ${project.currentCycleProgress}%<br/>
|
||||
${project.completedPoints}/${project.totalPoints} 点
|
||||
`;
|
||||
|
||||
@ -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, watch } from 'vue';
|
||||
import { computed, ref, onMounted } 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 { getAdminProjectsApi } from '@/api/admin';
|
||||
import { getOverviewApi } from '@/api/overview';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@ -20,21 +20,14 @@ const dashStore = useDashboardStore();
|
||||
const projectsExpanded = ref(false);
|
||||
const projectList = ref<Array<{ projectId: string; name: string; identifier: string }>>([]);
|
||||
|
||||
async function loadProjectList() {
|
||||
// Load project list for sidebar sub-menu
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await getAdminProjectsApi();
|
||||
const list = res.data.data || [];
|
||||
projectList.value = list.map((p: any) => ({ projectId: p.id, name: p.name, identifier: p.identifier || '' }));
|
||||
const res = await getOverviewApi();
|
||||
projectList.value = res.data.data.projectProgress || [];
|
||||
} catch {
|
||||
// Silently fail
|
||||
// Silently fail - sidebar still works without project sub-menu
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadProjectList);
|
||||
|
||||
// 路由变化时刷新项目列表(如从项目列表页返回时)
|
||||
watch(() => route.path, (newPath) => {
|
||||
if (newPath === '/projects') loadProjectList();
|
||||
});
|
||||
|
||||
interface NavItem {
|
||||
@ -177,7 +170,7 @@ const roleTagType = computed(() => {
|
||||
:class="{ active: route.path === `/projects/${proj.projectId}` }"
|
||||
@click="handleProjectSelect(proj.projectId)"
|
||||
>
|
||||
<span class="submenu-label">{{ proj.name }}</span>
|
||||
<span class="submenu-label">{{ proj.identifier || '' }} {{ proj.name }}</span>
|
||||
</div>
|
||||
<div v-if="!projectList.length" class="submenu-item submenu-empty">
|
||||
暂无项目
|
||||
|
||||
@ -46,10 +46,8 @@ watch([selectedPeriod, selectedProjects], () => {
|
||||
:options="projects || []"
|
||||
placeholder="筛选项目"
|
||||
multiple
|
||||
filterable
|
||||
clearable
|
||||
max-tag-count="responsive"
|
||||
style="min-width: 200px; max-width: 400px"
|
||||
style="width: 300px"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -53,7 +53,7 @@ const router = createRouter({
|
||||
path: 'git',
|
||||
name: 'GitActivity',
|
||||
component: () => import('@/views/GitActivity.vue'),
|
||||
meta: { roles: ['admin', 'manager', 'developer', 'viewer'] },
|
||||
meta: { roles: ['admin', 'manager', 'developer'] },
|
||||
},
|
||||
{
|
||||
path: 'admin',
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<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, getAdminProjectsApi, setUserProjectsApi } from '@/api/admin';
|
||||
import { getAdminUsersApi, createUserApi, deleteUserApi, getAuthorMappingsApi, createMappingApi, deleteMappingApi, getSyncLogsApi, triggerSyncApi } from '@/api/admin';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const message = useMessage();
|
||||
@ -58,86 +58,23 @@ function formatDate(value: unknown): string {
|
||||
const userColumns = [
|
||||
{ title: '姓名', key: 'displayName' },
|
||||
{ title: '邮箱', key: 'email' },
|
||||
{
|
||||
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: 'role', width: 100 },
|
||||
{
|
||||
title: '创建时间', key: 'createdAt', width: 180,
|
||||
render: (row: any) => formatDate(row.createdAt),
|
||||
},
|
||||
{
|
||||
title: '操作', key: 'actions', width: 180,
|
||||
title: '操作', key: 'actions', width: 100,
|
||||
render: (row: any) => {
|
||||
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(
|
||||
return 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[]>([]);
|
||||
@ -274,7 +211,6 @@ onMounted(() => {
|
||||
loadUsers();
|
||||
loadMappings();
|
||||
loadLogs();
|
||||
loadProjects();
|
||||
});
|
||||
|
||||
const roleOptions = [
|
||||
@ -337,21 +273,6 @@ 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>
|
||||
|
||||
@ -3,27 +3,18 @@
|
||||
* B-17 fix: New Member List page.
|
||||
* Displays all team members with role badges and links to their detail pages.
|
||||
*/
|
||||
import { ref, onMounted, h } from 'vue';
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { NSpin, NDataTable, NTag, NButton, NModal, NForm, NFormItem, NInput, NSelect, useMessage } from 'naive-ui';
|
||||
import { NSpin, NDataTable, NTag } 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[]>([]);
|
||||
|
||||
const userRole = (() => {
|
||||
try { return JSON.parse(localStorage.getItem('user') || '{}').role || 'viewer'; }
|
||||
catch { return 'viewer'; }
|
||||
})();
|
||||
const isAdmin = userRole === 'admin';
|
||||
|
||||
async function loadMembers() {
|
||||
loading.value = true;
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await getMemberListApi();
|
||||
members.value = res.data.data || [];
|
||||
@ -32,47 +23,7 @@ async function loadMembers() {
|
||||
} 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';
|
||||
@ -100,21 +51,9 @@ const columns = [
|
||||
type: roleTagType(row.role),
|
||||
size: 'small',
|
||||
round: true,
|
||||
}, { default: () => roleLabels[row.role] || row.role });
|
||||
}, { default: () => 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) {
|
||||
@ -122,6 +61,11 @@ 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>
|
||||
@ -140,21 +84,6 @@ function handleRowClick(row: any) {
|
||||
</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>
|
||||
|
||||
|
||||
@ -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.name,
|
||||
label: `${p.identifier || ''} ${p.name}`.trim(),
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error('Failed to load overview:', err);
|
||||
|
||||
@ -538,8 +538,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: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); } }
|
||||
.grid-2 { display:grid;grid-template-columns:1fr 1fr;gap:var(--space-4); }
|
||||
@media (max-width:768px) { .grid-2 { grid-template-columns: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 +556,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: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-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-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; }
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
import { ref, 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, updateProjectApi, deleteProjectApi, bindRepoApi, getProjectReposApi, unbindRepoApi } from '@/api/admin';
|
||||
import { getAdminProjectsApi, createProjectApi, deleteProjectApi, bindRepoApi, getProjectReposApi, unbindRepoApi } from '@/api/admin';
|
||||
import DataCard from '@/components/shared/DataCard.vue';
|
||||
import EmptyState from '@/components/shared/EmptyState.vue';
|
||||
|
||||
@ -115,33 +115,6 @@ 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;
|
||||
@ -183,17 +156,14 @@ const columns = [
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 250,
|
||||
width: 200,
|
||||
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: 'warning', onClick: () => openEditModal(row) }, { default: () => '编辑' })
|
||||
: null,
|
||||
canCreate
|
||||
? h(NButton, { size: 'tiny', type: 'default', onClick: () => openRepoModal(row) }, { default: () => '仓库' })
|
||||
: null,
|
||||
userRole === 'admin'
|
||||
canCreate
|
||||
? h(NButton, { size: 'tiny', type: 'error', onClick: () => handleDelete(row.id, row.name) }, { default: () => '删除' })
|
||||
: null,
|
||||
]);
|
||||
@ -234,18 +204,6 @@ 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">
|
||||
|
||||
@ -48,15 +48,6 @@ 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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user