Merge pull request 'feat(workflows): 新增科研团队洞察端到端工作流(research-team-insight)' (#249) from luwanzhou/gitlink-cli:feat/research-team-insight into master

This commit is contained in:
wbtiger 2026-07-14 22:32:36 +08:00
commit 8af07a94c1
30 changed files with 2769 additions and 0 deletions

View File

@ -0,0 +1,82 @@
# 科研团队洞察工作流research-team-insight
面向 GitLink 竞赛**子赛题三**的端到端自动化工作流示例Go 实现,与主仓库技术栈一致)。
从"一组成员"或"一个研究方向"出发,用 `gitlink-cli` 串联**用户发现 → 多人画像采集 → 聚合分析 →
可视化 → 报告归档**的完整闭环,自动产出团队**可视化报告HTML**、**洞察报告Markdown**与
**命令执行日志**。可视化为内联 SVG 的自包含 HTML能力雷达 + 协作网络图),浏览器直接打开。
## 命令链路≥3 个命令)
```
search +users (可选,按方向发现成员)
└─► profile +ability/+major/+role/+activity/+contribution + user +info (每位成员 ×6)
└─► 聚合分析(能力矩阵 / 学科覆盖 / 角色结构)
└─► 渲染团队 HTML能力雷达 + 协作网络图)
└─► 洞察报告 team-insight.md + command_log.json
```
> 底层通过 `gitlink-cli api GET /users/{login}/statistics/*` 采集(无需 `profile` 命令组也可运行)。
## 交付物
- `scripts/insight.go` — 工作流主程序(编排 + 采集 + 聚合)
- `scripts/render.go` — Markdown / HTML+SVG 渲染
- `scripts/insight_test.go` — 单元测试
- `scripts/run_demo.ps1` — 一键复现脚本
- `config/team.example.json` — 示例团队配置
- `examples/demo_outputs/` — 真实跑通的固定示例输出HTML / Markdown / 命令日志 / 原始数据)
- `docs/architecture.md` + `docs/assets/architecture.svg` — 架构与流程图
- `docs/runbook.md` — 运行手册与复现步骤
## 实现语言
主实现采用 **Go**,与 `gitlink-cli` 主仓库技术栈一致:复用根 `go.mod`,可被主仓库 CI
`go build` / `golangci-lint` / `go test`)直接覆盖;仅用标准库生成内联 SVG无第三方依赖。
## 快速开始
```bash
# 指定成员
go run ./scripts --members "yystopf,jiangtx,wangyue111" --team-name "课题组A" --out ./out
# 团队配置文件
go run ./scripts --config config/team.example.json --out ./out
# 按研究方向自动发现成员
go run ./scripts --keyword "联邦学习" --max 5 --out ./out
# 离线复现(用已采集数据,不访问网络)
go run ./scripts --config config/team.example.json --out ./out \
--no-fetch --data-root ./examples/demo_outputs/data
```
> **Windows 提示**`gitlink-cli` 为 npm 的 `.CMD` 包装,请用 `--cli-bin <路径>\gitlink-cli.CMD`
> 或设置环境变量 `GITLINK_CLI_BIN``scripts/run_demo.ps1` 已自动处理。
输出(`--out` 目录):
| 文件 | 说明 |
|------|------|
| `team-report.html` | 团队可视化(能力雷达、能力矩阵、共有/独有方向、协作网络图)——浏览器直接打开 |
| `team-insight.md` | 团队洞察报告(能力矩阵、学科覆盖、技术栈、角色结构、协作建议) |
| `command_log.json` | 完整命令执行日志(可复现、可审计) |
| `data/<login>/*.json` | 每位成员的原始画像数据 |
## 依赖
- Go 1.21+(仅标准库;复用主仓库 `go.mod`
- `gitlink-cli`(已登录;用 `api GET` 采集,无需 `profile` 命令组)
## 真实验证
`examples/demo_outputs/` 为对真实账号(@yystopf 何慧·国防科技大学、@jiangtx、@wangyue111
跑通的固定输出,共 19 条命令调用。工作流对瞬时 TLS 超时自动重试,并对镜像聚合账号在报告中
自动标注"分布偏泛"。详见 [`docs/runbook.md`](docs/runbook.md)。
## 与子赛题二/四的关系
- 子赛题四的 [`gitlink-research-profile`](../../../skills/gitlink-research-profile/SKILL.md) Skill 定义
"画像能力 + 单人/团队报告规范"。
- 本工作流(子赛题三)把该能力**串联成可复现的端到端解决方案**,加入用户发现、批量编排、聚合分析
与归档,产出团队级可视化与洞察成果。

View File

@ -0,0 +1,4 @@
{
"team_name": "gitlink-cli 贡献组",
"members": ["yystopf", "jiangtx", "wangyue111"]
}

View File

@ -0,0 +1,42 @@
# 架构与流程说明
![架构图](assets/architecture.svg)
## 设计目标
把分散的 GitLink 平台统计能力,编排成一个**可复现、可审计、零第三方依赖**的科研团队洞察流水线,
输出供科研评审/组队/选题直接使用的可视化与结构化成果。
## 五个阶段
| 阶段 | 动作 | 使用的命令 | 产物 |
|------|------|-----------|------|
| ① 成员发现 | 由方向关键词检索候选,或直接使用给定名单 | `gitlink-cli search +users -k`(可选) | 成员 login 列表 |
| ② 画像采集 | 对每位成员拉取 5 类统计 + 基础资料 | `api GET /users/{login}/statistics/{develop,major,role,activity}`、`/headmaps`、`user +info` | `data/<login>/*.json` |
| ③ 聚合分析 | 计算能力矩阵均值、学科共有/独有、技术栈、角色结构、最强成员 | `insight.go`(标准库) | 内存聚合结构 |
| ④ 可视化 | 渲染能力雷达(多成员叠加)+ 能力矩阵 + 共有/独有方向 + 协作网络图 | `render.go`(内联 SVG | `team-report.html` |
| ⑤ 归档 | 生成洞察报告与命令日志 | `insight.go` | `team-insight.md`、`command_log.json` |
## 关键设计
- **与主仓库技术栈一致**Go 实现,复用根 `go.mod`,可被主仓库 CI 直接覆盖(`go build`/`golangci-lint`/`go test`)。
- **零第三方依赖**:可视化用标准库拼装内联 SVG 生成自包含 HTML评审在任何环境用浏览器即可查看。
- **可复现**`--no-fetch --data-root` 可基于已归档数据离线重跑,产出一致;`command_log.json` 记录全部命令。
- **健壮性**:网络命令对瞬时 TLS 超时自动重试(间隔 3s最多 2 次)。
- **数据治理**:对持有大量镜像项目的平台/管理员账号(`user +info.mirror_projects_count`),报告自动标注"角色/学科分布偏泛",避免误读。
- **只读安全**:全流程不写任何远端数据。
## 输入 / 输出契约
- 输入:`--members`(逗号分隔)/ `--config` / `--keyword` 三选一。
- 每位成员数据目录需含 `develop/major/role/activity/contribution/user.json`(采集阶段自动生成)。
- 输出目录固定产出 `team-report.html` / `team-insight.md` / `command_log.json` / `data/`
## 代码结构
```
scripts/
├── insight.go # mainflag 解析、成员解析、采集编排、命令日志
├── render.go # 聚合渲染Markdown 报告 + HTML/SVG雷达图、协作网络图
└── insight_test.go # 单元测试(解析、聚合、渲染,全部离线)
```

View File

@ -0,0 +1,56 @@
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="300" viewBox="0 0 900 300" font-family="-apple-system,Segoe UI,Microsoft YaHei,sans-serif">
<rect width="900" height="300" fill="#f6f8fa"/>
<text x="450" y="32" text-anchor="middle" font-size="18" fill="#24292f" font-weight="bold">科研团队洞察工作流 · research-team-insight</text>
<g font-size="13" fill="#fff">
<rect x="24" y="70" width="150" height="80" rx="10" fill="#6f42c1"/>
<rect x="206" y="70" width="160" height="80" rx="10" fill="#2f6feb"/>
<rect x="398" y="70" width="150" height="80" rx="10" fill="#1f883d"/>
<rect x="580" y="70" width="150" height="80" rx="10" fill="#bf8700"/>
<rect x="762" y="70" width="114" height="80" rx="10" fill="#cf222e"/>
</g>
<g font-size="13" fill="#fff" text-anchor="middle">
<text x="99" y="104">① 成员发现</text>
<text x="286" y="104">② 画像采集</text>
<text x="473" y="104">③ 聚合分析</text>
<text x="655" y="104">④ 可视化</text>
<text x="819" y="104">⑤ 归档</text>
</g>
<g font-size="10.5" fill="#eef" text-anchor="middle">
<text x="99" y="126">search +users</text>
<text x="99" y="140">/ 成员名单</text>
<text x="286" y="124">profile +ability</text>
<text x="286" y="137">+major/+role</text>
<text x="286" y="150">+activity/+contribution</text>
<text x="473" y="126">能力矩阵</text>
<text x="473" y="140">学科/角色</text>
<text x="655" y="126">HTML 雷达图</text>
<text x="655" y="140">协作网络图</text>
<text x="819" y="124">insight.md</text>
<text x="819" y="138">command_log</text>
</g>
<g stroke="#9aa0a6" stroke-width="2" fill="none" marker-end="url(#a)">
<line x1="174" y1="110" x2="204" y2="110"/>
<line x1="366" y1="110" x2="396" y2="110"/>
<line x1="548" y1="110" x2="578" y2="110"/>
<line x1="730" y1="110" x2="760" y2="110"/>
</g>
<defs>
<marker id="a" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">
<path d="M0,0 L6,3 L0,6 Z" fill="#9aa0a6"/>
</marker>
</defs>
<rect x="206" y="190" width="342" height="56" rx="8" fill="#eef1f5" stroke="#d0d7de"/>
<text x="377" y="214" text-anchor="middle" font-size="12" fill="#57606a">GitLink 平台统计接口(只读)</text>
<text x="377" y="232" text-anchor="middle" font-size="11" fill="#8b949e">/api/users/{login}/statistics/* · /headmaps · user +info</text>
<line x1="286" y1="150" x2="286" y2="190" stroke="#c0c4cc" stroke-width="1.5" stroke-dasharray="4 3"/>
<rect x="580" y="190" width="296" height="56" rx="8" fill="#eef1f5" stroke="#d0d7de"/>
<text x="728" y="214" text-anchor="middle" font-size="12" fill="#57606a">Go 渲染器(内联 SVG</text>
<text x="728" y="232" text-anchor="middle" font-size="11" fill="#8b949e">标准库 · 自包含 HTML</text>
<line x1="655" y1="150" x2="655" y2="190" stroke="#c0c4cc" stroke-width="1.5" stroke-dasharray="4 3"/>
<text x="450" y="280" text-anchor="middle" font-size="11" fill="#8b949e">瞬时失败自动重试 · 镜像聚合账号自动标注 · 全程只读</text>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@ -0,0 +1,70 @@
# 运行手册Runbook
## 1. 前置条件
```bash
# 安装并登录 gitlink-cli
npm install -g @gitlink-ai/cli
gitlink-cli auth login # 或 export GITLINK_TOKEN=...
# Go 1.21+(复用主仓库 go.mod无需额外依赖
go version
```
## 2. 最短复现路径(离线,使用已归档数据)
无需网络,基于仓库内固定示例数据复跑,产物应与 `examples/demo_outputs/` 一致:
```bash
cd examples/workflows/research-team-insight
go run ./scripts --config config/team.example.json \
--out ./_repro --no-fetch --data-root ./examples/demo_outputs/data
# 打开 ./_repro/team-report.html 查看
```
## 3. 真实运行(在线采集)
```bash
cd examples/workflows/research-team-insight
# Linux / macOS
go run ./scripts --members "yystopf,jiangtx,wangyue111" \
--team-name "gitlink-cli 贡献组" --out ./out
# Windowsgitlink-cli 为 .CMD 包装)
go run ./scripts --members "yystopf,jiangtx,wangyue111" --out ./out `
--cli-bin "C:\nvm4w\nodejs\gitlink-cli.CMD"
# 或直接用一键脚本:
.\scripts\run_demo.ps1
```
按方向自动发现成员:
```bash
go run ./scripts --keyword "联邦学习" --max 5 --out ./out
```
## 4. 真实验证记录
- 验证对象:`@yystopf`(何慧·国防科技大学)、`@jiangtx`、`@wangyue111`
- 运行结果:退出码 0采集 3 人 × 6 接口 + 1 次渲染 = **19 条命令**(见 `examples/demo_outputs/command_log.json`
- 产物:
- `team-report.html`(能力雷达叠加 + 能力矩阵 + 协作网络图)
- `team-insight.md`(识别团队最强项=贡献度、短板=影响力;标注 @yystopf 为镜像聚合账号、分布偏泛)
- `command_log.json`
## 5. 测试
```bash
# 在主仓库根目录
go test ./examples/workflows/research-team-insight/scripts/
```
## 6. 常见问题
| 现象 | 处理 |
|------|------|
| Windows 上 `exec: "gitlink-cli": file not found` | `gitlink-cli` 为 npm `.CMD` 包装;用 `--cli-bin ...\gitlink-cli.CMD` 或设 `GITLINK_CLI_BIN`。程序对 `.cmd/.bat` 会自动用 `cmd /c` 包装 |
| `TLS handshake timeout` | 瞬时网络问题,自动重试 2 次(间隔 3s仍失败会记录到 `command_log.json` 并退出非零 |
| 某成员数据稀疏 / 学科为空 | 报告中如实标注,不臆造 |
| 某账号项目/学科数异常巨大 | 已结合 `user +info.mirror_projects_count` 自动标注"含镜像聚合,分布偏泛" |

View File

@ -0,0 +1,230 @@
[
{
"command": [
"gitlink-cli",
"api",
"GET",
"/users/yystopf/statistics/develop",
"--format",
"json"
],
"status": "ok",
"time": "2026-06-14T23:17:23+08:00"
},
{
"command": [
"gitlink-cli",
"api",
"GET",
"/users/yystopf/statistics/major",
"--format",
"json"
],
"status": "ok",
"time": "2026-06-14T23:17:29+08:00"
},
{
"command": [
"gitlink-cli",
"api",
"GET",
"/users/yystopf/statistics/role",
"--format",
"json"
],
"status": "ok",
"time": "2026-06-14T23:17:38+08:00"
},
{
"command": [
"gitlink-cli",
"api",
"GET",
"/users/yystopf/statistics/activity",
"--format",
"json"
],
"status": "ok",
"time": "2026-06-14T23:17:41+08:00"
},
{
"command": [
"gitlink-cli",
"api",
"GET",
"/users/yystopf/headmaps",
"--format",
"json"
],
"status": "ok",
"time": "2026-06-14T23:17:46+08:00"
},
{
"command": [
"gitlink-cli",
"user",
"+info",
"--login",
"yystopf",
"--format",
"json"
],
"status": "ok",
"time": "2026-06-14T23:17:55+08:00"
},
{
"command": [
"gitlink-cli",
"api",
"GET",
"/users/jiangtx/statistics/develop",
"--format",
"json"
],
"status": "ok",
"time": "2026-06-14T23:17:58+08:00"
},
{
"command": [
"gitlink-cli",
"api",
"GET",
"/users/jiangtx/statistics/major",
"--format",
"json"
],
"status": "ok",
"time": "2026-06-14T23:18:02+08:00"
},
{
"command": [
"gitlink-cli",
"api",
"GET",
"/users/jiangtx/statistics/role",
"--format",
"json"
],
"status": "ok",
"time": "2026-06-14T23:18:07+08:00"
},
{
"command": [
"gitlink-cli",
"api",
"GET",
"/users/jiangtx/statistics/activity",
"--format",
"json"
],
"status": "ok",
"time": "2026-06-14T23:18:15+08:00"
},
{
"command": [
"gitlink-cli",
"api",
"GET",
"/users/jiangtx/headmaps",
"--format",
"json"
],
"status": "ok",
"time": "2026-06-14T23:18:24+08:00"
},
{
"command": [
"gitlink-cli",
"user",
"+info",
"--login",
"jiangtx",
"--format",
"json"
],
"status": "ok",
"time": "2026-06-14T23:18:29+08:00"
},
{
"command": [
"gitlink-cli",
"api",
"GET",
"/users/wangyue111/statistics/develop",
"--format",
"json"
],
"status": "ok",
"time": "2026-06-14T23:18:32+08:00"
},
{
"command": [
"gitlink-cli",
"api",
"GET",
"/users/wangyue111/statistics/major",
"--format",
"json"
],
"status": "ok",
"time": "2026-06-14T23:18:34+08:00"
},
{
"command": [
"gitlink-cli",
"api",
"GET",
"/users/wangyue111/statistics/role",
"--format",
"json"
],
"status": "ok",
"time": "2026-06-14T23:18:37+08:00"
},
{
"command": [
"gitlink-cli",
"api",
"GET",
"/users/wangyue111/statistics/activity",
"--format",
"json"
],
"status": "ok",
"time": "2026-06-14T23:18:42+08:00"
},
{
"command": [
"gitlink-cli",
"api",
"GET",
"/users/wangyue111/headmaps",
"--format",
"json"
],
"status": "ok",
"time": "2026-06-14T23:18:46+08:00"
},
{
"command": [
"gitlink-cli",
"user",
"+info",
"--login",
"wangyue111",
"--format",
"json"
],
"status": "ok",
"time": "2026-06-14T23:18:49+08:00"
},
{
"command": [
"insight",
"render",
"team-report.html"
],
"status": "ok",
"time": "2026-06-14T23:18:49+08:00"
}
]

View File

@ -0,0 +1,45 @@
{
"ok": true,
"data": {
"commits_count": [
0,
0,
0,
0,
0,
0,
0,
0
],
"dates": [
"2026.06.07",
"2026.06.08",
"2026.06.09",
"2026.06.10",
"2026.06.11",
"2026.06.12",
"2026.06.13",
"2026.06.14"
],
"issues_count": [
0,
0,
0,
0,
0,
0,
0,
0
],
"pull_requests_count": [
0,
0,
0,
0,
0,
0,
0,
0
]
}
}

View File

@ -0,0 +1,32 @@
{
"ok": true,
"data": {
"headmaps": [
{
"contributions": 9,
"date": "2026-05-13"
},
{
"contributions": 1,
"date": "2026-06-01"
},
{
"contributions": 11,
"date": "2026-06-02"
},
{
"contributions": 21,
"date": "2026-06-03"
},
{
"contributions": 17,
"date": "2026-06-04"
},
{
"contributions": 2,
"date": "2026-06-05"
}
],
"total_contributions": 61
}
}

View File

@ -0,0 +1,25 @@
{
"ok": true,
"data": {
"platform": {
"activity": 99,
"contribution": 99,
"experience": 99,
"influence": 99,
"language": 98
},
"user": {
"activity": 60,
"contribution": 72,
"each_language_score": {
"Go": 71
},
"experience": 71,
"influence": 60,
"language": 66,
"languages_percent": {
"Go": 1
}
}
}
}

View File

@ -0,0 +1,8 @@
{
"ok": true,
"data": {
"categories": [
"其它"
]
}
}

View File

@ -0,0 +1,24 @@
{
"ok": true,
"data": {
"role": {
"developer": {
"count": 0,
"percent": 0
},
"manager": {
"count": 2,
"percent": 0.5
},
"owner": {
"count": 2,
"percent": 0.5
},
"reporter": {
"count": 0,
"percent": 0
}
},
"total_projects_count": 4
}
}

View File

@ -0,0 +1,36 @@
{
"ok": true,
"data": {
"admin": false,
"city": null,
"common_projects_count": 4,
"created_time": "2026-04-28 11:46",
"custom_department": null,
"description": null,
"email": null,
"gender": 1,
"grade": 0,
"image_url": "system/lets/letter_avatars/2/J/67_157_94/120.png",
"is_watch": false,
"login": "jiangtx",
"mirror_projects_count": 0,
"name": "jiangtx",
"province": null,
"real_name": "jiangtx",
"show_super_description": true,
"super_description": null,
"sync_mirror_projects_count": 0,
"undo_events": 0,
"undo_join_projects": 0,
"undo_messages": 0,
"undo_transfer_projects": 0,
"user_composes_count": 0,
"user_id": 148911,
"user_identity": "专业人士",
"user_org_count": 0,
"user_projects_count": 4,
"username": "jiangtx",
"watched_count": 0,
"watching_count": 0
}
}

View File

@ -0,0 +1,45 @@
{
"ok": true,
"data": {
"commits_count": [
0,
22,
46,
48,
29,
28,
8,
13
],
"dates": [
"2026.06.07",
"2026.06.08",
"2026.06.09",
"2026.06.10",
"2026.06.11",
"2026.06.12",
"2026.06.13",
"2026.06.14"
],
"issues_count": [
0,
0,
0,
0,
0,
0,
0,
0
],
"pull_requests_count": [
0,
2,
15,
11,
7,
4,
2,
2
]
}
}

View File

@ -0,0 +1,100 @@
{
"ok": true,
"data": {
"headmaps": [
{
"contributions": 7,
"date": "2026-05-11"
},
{
"contributions": 2,
"date": "2026-05-12"
},
{
"contributions": 12,
"date": "2026-05-13"
},
{
"contributions": 5,
"date": "2026-05-14"
},
{
"contributions": 161,
"date": "2026-05-17"
},
{
"contributions": 3,
"date": "2026-05-19"
},
{
"contributions": 9,
"date": "2026-05-20"
},
{
"contributions": 6,
"date": "2026-05-21"
},
{
"contributions": 31,
"date": "2026-05-24"
},
{
"contributions": 18,
"date": "2026-05-25"
},
{
"contributions": 35,
"date": "2026-05-27"
},
{
"contributions": 21,
"date": "2026-05-28"
},
{
"contributions": 9,
"date": "2026-06-02"
},
{
"contributions": 29,
"date": "2026-06-03"
},
{
"contributions": 20,
"date": "2026-06-04"
},
{
"contributions": 6,
"date": "2026-06-05"
},
{
"contributions": 22,
"date": "2026-06-08"
},
{
"contributions": 46,
"date": "2026-06-09"
},
{
"contributions": 48,
"date": "2026-06-10"
},
{
"contributions": 29,
"date": "2026-06-11"
},
{
"contributions": 28,
"date": "2026-06-12"
},
{
"contributions": 8,
"date": "2026-06-13"
},
{
"contributions": 14,
"date": "2026-06-14"
}
],
"total_contributions": 569
}
}

View File

@ -0,0 +1,31 @@
{
"ok": true,
"data": {
"platform": {
"activity": 99,
"contribution": 99,
"experience": 99,
"influence": 99,
"language": 98
},
"user": {
"activity": 62,
"contribution": 92,
"each_language_score": {
"C++": 75,
"Cpp": 66,
"Go": 66,
"Python": 66
},
"experience": 81,
"influence": 60,
"language": 77,
"languages_percent": {
"C++": 0.5,
"Cpp": 0.17,
"Go": 0.17,
"Python": 0.17
}
}
}
}

View File

@ -0,0 +1,6 @@
{
"ok": true,
"data": {
"categories": []
}
}

View File

@ -0,0 +1,24 @@
{
"ok": true,
"data": {
"role": {
"developer": {
"count": 0,
"percent": 0
},
"manager": {
"count": 0,
"percent": 0
},
"owner": {
"count": 6,
"percent": 1
},
"reporter": {
"count": 0,
"percent": 0
}
},
"total_projects_count": 6
}
}

View File

@ -0,0 +1,36 @@
{
"ok": true,
"data": {
"admin": false,
"city": null,
"common_projects_count": 6,
"created_time": "2026-04-19 15:38",
"custom_department": null,
"description": null,
"email": null,
"gender": null,
"grade": 0,
"image_url": "system/lets/letter_avatars/2/W/43_254_70/120.png",
"is_watch": false,
"login": "wangyue111",
"mirror_projects_count": 0,
"name": "wangyue111",
"province": null,
"real_name": "wangyue111",
"show_super_description": false,
"super_description": null,
"sync_mirror_projects_count": 0,
"undo_events": 0,
"undo_join_projects": 0,
"undo_messages": 0,
"undo_transfer_projects": 0,
"user_composes_count": 0,
"user_id": 148193,
"user_identity": "专业人士",
"user_org_count": 0,
"user_projects_count": 6,
"username": "wangyue111",
"watched_count": 0,
"watching_count": 0
}
}

View File

@ -0,0 +1,45 @@
{
"ok": true,
"data": {
"commits_count": [
0,
1,
0,
5,
0,
2,
0,
0
],
"dates": [
"2026.06.07",
"2026.06.08",
"2026.06.09",
"2026.06.10",
"2026.06.11",
"2026.06.12",
"2026.06.13",
"2026.06.14"
],
"issues_count": [
0,
0,
0,
0,
0,
1,
0,
0
],
"pull_requests_count": [
0,
0,
0,
0,
0,
0,
0,
0
]
}
}

View File

@ -0,0 +1,544 @@
{
"ok": true,
"data": {
"headmaps": [
{
"contributions": 80,
"date": "2025-06-16"
},
{
"contributions": 1,
"date": "2025-06-17"
},
{
"contributions": 28,
"date": "2025-06-18"
},
{
"contributions": 9,
"date": "2025-06-19"
},
{
"contributions": 7,
"date": "2025-06-20"
},
{
"contributions": 2,
"date": "2025-06-21"
},
{
"contributions": 3,
"date": "2025-06-23"
},
{
"contributions": 16,
"date": "2025-06-24"
},
{
"contributions": 12,
"date": "2025-06-25"
},
{
"contributions": 3,
"date": "2025-06-26"
},
{
"contributions": 2,
"date": "2025-06-27"
},
{
"contributions": 1,
"date": "2025-06-30"
},
{
"contributions": 5,
"date": "2025-07-01"
},
{
"contributions": 3,
"date": "2025-07-02"
},
{
"contributions": 2,
"date": "2025-07-03"
},
{
"contributions": 7,
"date": "2025-07-09"
},
{
"contributions": 7,
"date": "2025-07-10"
},
{
"contributions": 7,
"date": "2025-07-11"
},
{
"contributions": 6,
"date": "2025-07-14"
},
{
"contributions": 8,
"date": "2025-07-15"
},
{
"contributions": 1,
"date": "2025-07-16"
},
{
"contributions": 3,
"date": "2025-07-17"
},
{
"contributions": 7,
"date": "2025-07-18"
},
{
"contributions": 1,
"date": "2025-07-20"
},
{
"contributions": 3,
"date": "2025-07-21"
},
{
"contributions": 3,
"date": "2025-07-22"
},
{
"contributions": 15,
"date": "2025-07-24"
},
{
"contributions": 11,
"date": "2025-07-25"
},
{
"contributions": 11,
"date": "2025-07-28"
},
{
"contributions": 3,
"date": "2025-07-29"
},
{
"contributions": 11,
"date": "2025-07-30"
},
{
"contributions": 8,
"date": "2025-07-31"
},
{
"contributions": 10,
"date": "2025-08-01"
},
{
"contributions": 1,
"date": "2025-08-11"
},
{
"contributions": 1,
"date": "2025-08-22"
},
{
"contributions": 8,
"date": "2025-08-25"
},
{
"contributions": 4,
"date": "2025-08-27"
},
{
"contributions": 3,
"date": "2025-08-28"
},
{
"contributions": 3,
"date": "2025-08-29"
},
{
"contributions": 2,
"date": "2025-09-01"
},
{
"contributions": 2,
"date": "2025-09-02"
},
{
"contributions": 3,
"date": "2025-09-03"
},
{
"contributions": 3,
"date": "2025-09-04"
},
{
"contributions": 3,
"date": "2025-09-08"
},
{
"contributions": 7,
"date": "2025-09-10"
},
{
"contributions": 3,
"date": "2025-09-12"
},
{
"contributions": 5,
"date": "2025-09-19"
},
{
"contributions": 2,
"date": "2025-09-24"
},
{
"contributions": 2,
"date": "2025-09-26"
},
{
"contributions": 1,
"date": "2025-09-29"
},
{
"contributions": 4,
"date": "2025-10-13"
},
{
"contributions": 8,
"date": "2025-10-15"
},
{
"contributions": 3,
"date": "2025-10-16"
},
{
"contributions": 8,
"date": "2025-10-17"
},
{
"contributions": 6,
"date": "2025-10-22"
},
{
"contributions": 3,
"date": "2025-10-24"
},
{
"contributions": 4,
"date": "2025-10-27"
},
{
"contributions": 4,
"date": "2025-10-28"
},
{
"contributions": 2,
"date": "2025-10-30"
},
{
"contributions": 3,
"date": "2025-10-31"
},
{
"contributions": 9,
"date": "2025-11-04"
},
{
"contributions": 11,
"date": "2025-11-05"
},
{
"contributions": 4,
"date": "2025-11-06"
},
{
"contributions": 10,
"date": "2025-11-07"
},
{
"contributions": 5,
"date": "2025-11-10"
},
{
"contributions": 2,
"date": "2025-11-11"
},
{
"contributions": 1,
"date": "2025-11-13"
},
{
"contributions": 4,
"date": "2025-11-17"
},
{
"contributions": 1,
"date": "2025-11-18"
},
{
"contributions": 1,
"date": "2025-11-20"
},
{
"contributions": 1,
"date": "2025-11-24"
},
{
"contributions": 2,
"date": "2025-11-27"
},
{
"contributions": 6,
"date": "2025-12-01"
},
{
"contributions": 1,
"date": "2025-12-04"
},
{
"contributions": 5,
"date": "2025-12-09"
},
{
"contributions": 1,
"date": "2025-12-11"
},
{
"contributions": 1,
"date": "2025-12-25"
},
{
"contributions": 6,
"date": "2025-12-26"
},
{
"contributions": 2,
"date": "2025-12-27"
},
{
"contributions": 1,
"date": "2025-12-29"
},
{
"contributions": 11,
"date": "2025-12-31"
},
{
"contributions": 1,
"date": "2026-01-04"
},
{
"contributions": 3,
"date": "2026-01-05"
},
{
"contributions": 4,
"date": "2026-01-12"
},
{
"contributions": 2,
"date": "2026-01-13"
},
{
"contributions": 2,
"date": "2026-01-14"
},
{
"contributions": 5,
"date": "2026-01-15"
},
{
"contributions": 1,
"date": "2026-01-16"
},
{
"contributions": 1,
"date": "2026-01-18"
},
{
"contributions": 2,
"date": "2026-01-19"
},
{
"contributions": 2,
"date": "2026-01-20"
},
{
"contributions": 17,
"date": "2026-01-22"
},
{
"contributions": 3,
"date": "2026-01-23"
},
{
"contributions": 13,
"date": "2026-01-28"
},
{
"contributions": 5,
"date": "2026-01-29"
},
{
"contributions": 3,
"date": "2026-02-04"
},
{
"contributions": 3,
"date": "2026-02-27"
},
{
"contributions": 5,
"date": "2026-02-28"
},
{
"contributions": 6,
"date": "2026-03-11"
},
{
"contributions": 1,
"date": "2026-03-13"
},
{
"contributions": 2,
"date": "2026-03-20"
},
{
"contributions": 9,
"date": "2026-03-23"
},
{
"contributions": 8,
"date": "2026-03-24"
},
{
"contributions": 7,
"date": "2026-03-25"
},
{
"contributions": 3,
"date": "2026-03-26"
},
{
"contributions": 2,
"date": "2026-03-27"
},
{
"contributions": 8,
"date": "2026-03-30"
},
{
"contributions": 6,
"date": "2026-03-31"
},
{
"contributions": 2,
"date": "2026-04-01"
},
{
"contributions": 3,
"date": "2026-04-02"
},
{
"contributions": 5,
"date": "2026-04-03"
},
{
"contributions": 5,
"date": "2026-04-07"
},
{
"contributions": 3,
"date": "2026-04-08"
},
{
"contributions": 13,
"date": "2026-04-09"
},
{
"contributions": 1,
"date": "2026-04-10"
},
{
"contributions": 4,
"date": "2026-04-13"
},
{
"contributions": 9,
"date": "2026-04-22"
},
{
"contributions": 2,
"date": "2026-04-24"
},
{
"contributions": 3,
"date": "2026-04-27"
},
{
"contributions": 2,
"date": "2026-04-28"
},
{
"contributions": 1,
"date": "2026-04-29"
},
{
"contributions": 8,
"date": "2026-05-07"
},
{
"contributions": 2,
"date": "2026-05-08"
},
{
"contributions": 4,
"date": "2026-05-09"
},
{
"contributions": 1,
"date": "2026-05-12"
},
{
"contributions": 1,
"date": "2026-05-15"
},
{
"contributions": 1,
"date": "2026-05-22"
},
{
"contributions": 47,
"date": "2026-05-27"
},
{
"contributions": 3,
"date": "2026-05-29"
},
{
"contributions": 1,
"date": "2026-06-02"
},
{
"contributions": 5,
"date": "2026-06-05"
},
{
"contributions": 1,
"date": "2026-06-08"
},
{
"contributions": 5,
"date": "2026-06-10"
},
{
"contributions": 2,
"date": "2026-06-12"
}
],
"total_contributions": 737
}
}

View File

@ -0,0 +1,53 @@
{
"ok": true,
"data": {
"platform": {
"activity": 99,
"contribution": 99,
"experience": 99,
"influence": 99,
"language": 98
},
"user": {
"activity": 94,
"contribution": 97,
"each_language_score": {
"C": 75,
"C++": 71,
"CSS": 66,
"Go": 87,
"HTML": 81,
"JSX": 66,
"Java": 83,
"JavaScript": 80,
"Markdown": 66,
"Python": 80,
"Ruby": 75,
"Rust": 66,
"Shell": 80,
"Tsql": 66,
"Typescript": 75
},
"experience": 97,
"influence": 79,
"language": 90,
"languages_percent": {
"C": 0.05,
"C++": 0.04,
"CSS": 0.02,
"Go": 0.2,
"HTML": 0.11,
"JSX": 0.02,
"Java": 0.13,
"JavaScript": 0.09,
"Markdown": 0.02,
"Python": 0.09,
"Ruby": 0.05,
"Rust": 0.02,
"Shell": 0.09,
"Tsql": 0.02,
"Typescript": 0.05
}
}
}
}

View File

@ -0,0 +1,29 @@
{
"ok": true,
"data": {
"categories": [
"插件和扩展",
"程序开发",
"人工智能",
"其它",
"操作系统",
"大数据",
"DevOps/运维/网管",
"Web应用开发",
"物联网",
"应用工具",
"开发工具",
"游戏/娱乐",
"云计算和大数据",
"数据库",
"企业应用",
"数据存储",
"大气海洋环境",
"天文地球物理",
"工业设计制造",
"新能源新材料",
"生物医药健康",
"泛化工具"
]
}
}

View File

@ -0,0 +1,24 @@
{
"ok": true,
"data": {
"role": {
"developer": {
"count": 20,
"percent": 0
},
"manager": {
"count": 86,
"percent": 0
},
"owner": {
"count": 20719,
"percent": 0.99
},
"reporter": {
"count": 1,
"percent": 0
}
},
"total_projects_count": 20826
}
}

View File

@ -0,0 +1,36 @@
{
"ok": true,
"data": {
"admin": true,
"city": "北京",
"common_projects_count": 180,
"created_time": "2020-11-02 10:13",
"custom_department": "国防科技大学",
"description": "·",
"email": "yystopf@163.com",
"gender": 1,
"grade": 0,
"image_url": "images/avatars/User/85890?t=1742398112",
"is_watch": false,
"login": "yystopf",
"mirror_projects_count": 11453,
"name": "何慧",
"province": "北京",
"real_name": "何慧",
"show_super_description": true,
"super_description": null,
"sync_mirror_projects_count": 9126,
"undo_events": 0,
"undo_join_projects": 0,
"undo_messages": 0,
"undo_transfer_projects": 0,
"user_composes_count": 0,
"user_id": 85890,
"user_identity": "教授",
"user_org_count": 10,
"user_projects_count": 20759,
"username": "何慧",
"watched_count": 19,
"watching_count": 4
}
}

View File

@ -0,0 +1,41 @@
# 🔬 科研团队洞察报告gitlink-cli 贡献组
> 成员 3 人 数据来源gitlink-cli只读
## 一、能力矩阵0100
| 成员 | 影响力 | 贡献度 | 活跃度 | 项目经验 | 语言能力 |
|------|------|------|------|------|------|
| 何慧(@yystopf | 79 | 97 | 94 | 97 | 90 |
| jiangtx@jiangtx | 60 | 72 | 60 | 71 | 66 |
| wangyue111@wangyue111 | 60 | 92 | 62 | 81 | 77 |
| **团队均值** | **66** | **87** | **72** | **83** | **77** |
- 团队最强项:**贡献度**87短板**影响力**66良好
## 二、学科方向覆盖
- 覆盖方向总数:**22**
- 共有方向(团队共识):其它 ×2
- 独有方向个人特长DevOps/运维/网管, Web应用开发, 云计算和大数据, 人工智能, 企业应用, 大数据, 大气海洋环境, 天文地球物理, 工业设计制造, 应用工具, 开发工具, 插件和扩展, 操作系统, 数据存储, 数据库
## 三、技术栈覆盖
- 团队主力语言Go, C++, Python, Cpp, Java, HTML, JavaScript, Shell
## 四、角色结构
- 何慧:参与 20826 个项目Owner 20719 / Developer 20  含大量镜像,分布偏泛
- jiangtx参与 4 个项目Owner 2 / Developer 0
- wangyue111参与 6 个项目Owner 6 / Developer 0
## 五、协作与分工建议
- **核心建议**:何慧(@yystopf综合能力最强适合作为方向负责人。
- **团队聚焦**:在共有方向「其它」上成员重叠度最高,可作为协作主线。
- **互补利用**DevOps/运维/网管, Web应用开发, 云计算和大数据, 人工智能, 企业应用 等为个人独有方向,可作为跨学科延展点。
- **能力补强**:团队短板为「影响力」,建议引入该维度更强的协作者或专项提升。
---
*本报告由 research-team-insight 工作流自动生成链路search/profile → 聚合 → 可视化 → 归档。配套可视化见同目录 `team-report.html`。*

View File

@ -0,0 +1,22 @@
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>课题组科研画像 - gitlink-cli 贡献组</title><style>body{font-family:-apple-system,'Segoe UI','Microsoft YaHei',sans-serif;margin:0;background:#f6f8fa;color:#24292f}
.wrap{max-width:980px;margin:0 auto;padding:24px}
h1{font-size:22px;margin:0 0 4px}h2{font-size:16px;margin:18px 0 10px;border-left:4px solid #2f6feb;padding-left:8px}
.sub{color:#57606a;font-size:13px;margin-bottom:16px}
.card{background:#fff;border:1px solid #e6e8eb;border-radius:10px;padding:16px;margin-bottom:16px}
.grid{display:flex;gap:16px;flex-wrap:wrap}.grid>.card{flex:1;min-width:300px}
.tag{display:inline-block;background:#eaf1ff;color:#2f6feb;border-radius:12px;padding:3px 10px;margin:3px;font-size:12px}
.muted{color:#8b949e;font-size:12px}.legend{font-size:12px;color:#57606a;margin-top:8px}
table{border-collapse:collapse;width:100%}td,th{border:1px solid #eaecef;padding:6px 8px;text-align:left;font-size:13px}th{background:#f6f8fa}
.dot{display:inline-block;width:10px;height:10px;border-radius:50%;margin-right:6px;vertical-align:middle}</style></head><body><div class="wrap">
<h1>🔬 课题组科研画像gitlink-cli 贡献组</h1>
<div class="sub">成员 3 人 · 数据来源 gitlink-cli只读</div>
<div class="grid">
<div class="card"><h2>开发能力雷达</h2><svg viewBox="0 0 340 340" width="340" height="340" xmlns="http://www.w3.org/2000/svg"><circle cx="170" cy="170" r="30.0" fill="none" stroke="#e6e8eb"/><circle cx="170" cy="170" r="60.0" fill="none" stroke="#e6e8eb"/><circle cx="170" cy="170" r="90.0" fill="none" stroke="#e6e8eb"/><circle cx="170" cy="170" r="120.0" fill="none" stroke="#e6e8eb"/><line x1="170" y1="170" x2="170.0" y2="50.0" stroke="#e6e8eb"/><text x="170.0" y="34.0" text-anchor="middle" font-size="12" fill="#444">影响力</text><line x1="170" y1="170" x2="284.1" y2="132.9" stroke="#e6e8eb"/><text x="303.1" y="130.7" text-anchor="start" font-size="12" fill="#444">贡献度</text><line x1="170" y1="170" x2="240.5" y2="267.1" stroke="#e6e8eb"/><text x="252.3" y="287.3" text-anchor="start" font-size="12" fill="#444">活跃度</text><line x1="170" y1="170" x2="99.5" y2="267.1" stroke="#e6e8eb"/><text x="87.7" y="287.3" text-anchor="end" font-size="12" fill="#444">项目经验</text><line x1="170" y1="170" x2="55.9" y2="132.9" stroke="#e6e8eb"/><text x="36.9" y="130.7" text-anchor="end" font-size="12" fill="#444">语言能力</text><polygon points="170.0,75.2 280.7,134.0 236.3,261.3 101.6,264.2 67.3,136.6" fill="#2f6feb22" stroke="#2f6feb" stroke-width="2"/><polygon points="170.0,98.0 252.2,143.3 212.3,228.2 119.9,238.9 94.7,145.5" fill="#1f883d22" stroke="#1f883d" stroke-width="2"/><polygon points="170.0,98.0 275.0,135.9 213.7,230.2 112.9,248.6 82.1,141.4" fill="#bf870022" stroke="#bf8700" stroke-width="2"/></svg><div class="legend"><span class="dot" style="background:#2f6feb"></span>何慧&nbsp;&nbsp;<span class="dot" style="background:#1f883d"></span>jiangtx&nbsp;&nbsp;<span class="dot" style="background:#bf8700"></span>wangyue111&nbsp;&nbsp;</div></div>
<div class="card"><h2>能力矩阵0100</h2><table><tr><th>成员</th><th>影响力</th><th>贡献度</th><th>活跃度</th><th>项目经验</th><th>语言能力</th></tr><tr><td><span class="dot" style="background:#2f6feb"></span>何慧 <span class="muted">@yystopf</span></td><td>79</td><td>97</td><td>94</td><td>97</td><td>90</td></tr><tr><td><span class="dot" style="background:#1f883d"></span>jiangtx <span class="muted">@jiangtx</span></td><td>60</td><td>72</td><td>60</td><td>71</td><td>66</td></tr><tr><td><span class="dot" style="background:#bf8700"></span>wangyue111 <span class="muted">@wangyue111</span></td><td>60</td><td>92</td><td>62</td><td>81</td><td>77</td></tr><tr><td><b>团队均值</b></td><td><b>66</b></td><td><b>87</b></td><td><b>72</b></td><td><b>83</b></td><td><b>77</b></td></tr></table></div>
</div>
<div class="grid">
<div class="card"><h2>共有研究方向</h2><span class="tag">其它 ×2</span></div>
<div class="card"><h2>独有研究方向</h2><span class="tag">DevOps/运维/网管</span><span class="tag">Web应用开发</span><span class="tag">云计算和大数据</span><span class="tag">人工智能</span><span class="tag">企业应用</span><span class="tag">大数据</span><span class="tag">大气海洋环境</span><span class="tag">天文地球物理</span><span class="tag">工业设计制造</span><span class="tag">应用工具</span><span class="tag">开发工具</span><span class="tag">插件和扩展</span><span class="tag">操作系统</span><span class="tag">数据存储</span><span class="tag">数据库</span><span class="tag">新能源新材料</span><span class="tag">泛化工具</span><span class="tag">游戏/娱乐</span><span class="tag">物联网</span><span class="tag">生物医药健康</span><span class="tag">程序开发</span></div>
</div>
<div class="card"><h2>协作网络图(连线=共同方向数)</h2><svg viewBox="0 0 420 420" width="420" height="420" xmlns="http://www.w3.org/2000/svg"><line x1="210" y1="64" x2="336" y2="283" stroke="#9ab4e8" stroke-width="1.3" opacity="0.75"><title>yystopf ↔ jiangtx: 1 个共同方向</title></line><circle cx="210" cy="64" r="22" fill="#2f6feb"/><text x="210" y="68" text-anchor="middle" font-size="11" fill="#fff">何慧</text><circle cx="336" cy="283" r="22" fill="#1f883d"/><text x="336" y="287" text-anchor="middle" font-size="11" fill="#fff">jian</text><circle cx="84" cy="283" r="22" fill="#bf8700"/><text x="84" y="287" text-anchor="middle" font-size="11" fill="#fff">wang</text></svg></div>
<p class="muted" style="margin-top:24px">由 research-team-insight 工作流生成 · gitlink-cli profile只读</p></div></body></html>

View File

@ -0,0 +1,536 @@
// Command insight is an end-to-end research-team insight workflow (subtopic 3).
//
// It composes existing gitlink-cli commands to, for a set of members (or a
// research keyword), collect each member's GitLink platform statistics, aggregate
// them, and emit a team insight report (Markdown), a self-contained visual report
// (HTML with inline SVG: ability radar + collaboration network) and a command log.
//
// Chain: [search +users] -> profile statistics x N -> aggregate -> visualize -> archive.
// All operations are read-only.
package main
import (
"bytes"
"encoding/json"
"errors"
"flag"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
)
// ---------- data model ----------
type envelope struct {
Data json.RawMessage `json:"data"`
}
type abilityScores struct {
Influence int `json:"influence"`
Contribution int `json:"contribution"`
Activity int `json:"activity"`
Experience int `json:"experience"`
Language int `json:"language"`
}
func (a abilityScores) get(key string) int {
switch key {
case "influence":
return a.Influence
case "contribution":
return a.Contribution
case "activity":
return a.Activity
case "experience":
return a.Experience
case "language":
return a.Language
}
return 0
}
type developData struct {
Platform abilityScores `json:"platform"`
User struct {
abilityScores
LanguagesPercent map[string]float64 `json:"languages_percent"`
EachLanguageScore map[string]int `json:"each_language_score"`
} `json:"user"`
}
type majorData struct {
Categories []string `json:"categories"`
}
type roleEntry struct {
Count int `json:"count"`
Percent float64 `json:"percent"`
}
type roleData struct {
Role map[string]roleEntry `json:"role"`
TotalProjectsCount int `json:"total_projects_count"`
}
type activityData struct {
Dates []string `json:"dates"`
CommitsCount []int `json:"commits_count"`
IssuesCount []int `json:"issues_count"`
PullRequestsCount []int `json:"pull_requests_count"`
}
type headmapEntry struct {
Date string `json:"date"`
Contributions int `json:"contributions"`
}
type headmapsData struct {
TotalContributions int `json:"total_contributions"`
Headmaps []headmapEntry `json:"headmaps"`
}
type userInfo struct {
Login string `json:"login"`
Name string `json:"name"`
RealName string `json:"real_name"`
CustomDepartment string `json:"custom_department"`
City string `json:"city"`
Province string `json:"province"`
CreatedTime string `json:"created_time"`
CommonProjectsCount int `json:"common_projects_count"`
MirrorProjectsCount int `json:"mirror_projects_count"`
}
type subject struct {
Login string
Info userInfo
Dev developData
Major []string
Role roleData
Act activityData
Heat headmapsData
}
func (s subject) displayName() string {
if s.Info.RealName != "" {
return s.Info.RealName
}
if s.Info.Name != "" {
return s.Info.Name
}
return s.Login
}
var abilityDims = []struct{ Key, ZH string }{
{"influence", "影响力"},
{"contribution", "贡献度"},
{"activity", "活跃度"},
{"experience", "项目经验"},
{"language", "语言能力"},
}
// ---------- config / flags ----------
type options struct {
Config string
Members string
Keyword string
Max int
TeamName string
Out string
CLIBin string
NoFetch bool
DataRoot string
}
type teamConfig struct {
TeamName string `json:"team_name"`
Members []string `json:"members"`
}
// ---------- command execution + logging ----------
type commandResult struct {
Command []string `json:"command"`
Status string `json:"status"`
Time string `json:"time"`
Error string `json:"error,omitempty"`
}
var commandLog []commandResult
func runCLI(cliBin string, args []string, retries int) (string, error) {
display := append([]string{"gitlink-cli"}, args...)
var stdout, stderr bytes.Buffer
var lastErr error
for attempt := 0; attempt <= retries; attempt++ {
stdout.Reset()
stderr.Reset()
cmd := exec.Command(cliBin, args...) // #nosec G204 - cliBin is operator-provided
low := strings.ToLower(cliBin)
if strings.HasSuffix(low, ".cmd") || strings.HasSuffix(low, ".bat") {
cmd = exec.Command("cmd", append([]string{"/c", cliBin}, args...)...) // #nosec G204
}
cmd.Stdout = &stdout
cmd.Stderr = &stderr
lastErr = cmd.Run()
if lastErr == nil {
commandLog = append(commandLog, commandResult{Command: display, Status: "ok", Time: now()})
return stdout.String(), nil
}
if attempt < retries {
time.Sleep(3 * time.Second)
}
}
msg := strings.TrimSpace(stderr.String())
commandLog = append(commandLog, commandResult{Command: display, Status: "failed", Time: now(), Error: truncate(msg, 300)})
return "", fmt.Errorf("命令失败 gitlink-cli %s: %v: %s", strings.Join(args, " "), lastErr, msg)
}
func now() string { return time.Now().Format(time.RFC3339) }
func truncate(s string, n int) string {
if len(s) > n {
return s[:n]
}
return s
}
// ---------- fetch + load ----------
var statEndpoints = [][2]string{
{"develop.json", "statistics/develop"},
{"major.json", "statistics/major"},
{"role.json", "statistics/role"},
{"activity.json", "statistics/activity"},
{"contribution.json", "headmaps"},
}
func fetchMember(cliBin, login, dir string) error {
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
for _, ep := range statEndpoints {
out, err := runCLI(cliBin, []string{"api", "GET", fmt.Sprintf("/users/%s/%s", login, ep[1]), "--format", "json"}, 2)
if err != nil {
return err
}
if err := os.WriteFile(filepath.Join(dir, ep[0]), []byte(out), 0o644); err != nil {
return err
}
}
out, err := runCLI(cliBin, []string{"user", "+info", "--login", login, "--format", "json"}, 2)
if err != nil {
return err
}
return os.WriteFile(filepath.Join(dir, "user.json"), []byte(out), 0o644)
}
func parseInto(path string, v interface{}) error {
raw, err := os.ReadFile(path) // #nosec G304 - path derived from operator inputs
if err != nil {
return err
}
var env envelope
if err := json.Unmarshal(raw, &env); err != nil {
return fmt.Errorf("%s: %w", path, err)
}
payload := env.Data
if len(payload) == 0 {
payload = raw // tolerate already-unwrapped payloads
}
if err := json.Unmarshal(payload, v); err != nil {
return fmt.Errorf("%s: %w", path, err)
}
return nil
}
func loadSubject(dir string) (subject, error) {
s := subject{Login: filepath.Base(dir)}
if err := parseInto(filepath.Join(dir, "develop.json"), &s.Dev); err != nil {
return s, err
}
var mj majorData
if err := parseInto(filepath.Join(dir, "major.json"), &mj); err == nil {
s.Major = mj.Categories
}
_ = parseInto(filepath.Join(dir, "role.json"), &s.Role)
_ = parseInto(filepath.Join(dir, "activity.json"), &s.Act)
_ = parseInto(filepath.Join(dir, "contribution.json"), &s.Heat)
if err := parseInto(filepath.Join(dir, "user.json"), &s.Info); err == nil && s.Info.Login != "" {
s.Login = s.Info.Login
}
return s, nil
}
// ---------- aggregation helpers ----------
func teamAverages(subs []subject) map[string]int {
avg := map[string]int{}
if len(subs) == 0 {
return avg
}
for _, d := range abilityDims {
sum := 0
for _, s := range subs {
sum += s.Dev.User.get(d.Key)
}
avg[d.Key] = sum / len(subs)
}
return avg
}
func grade(v int) string {
switch {
case v >= 90:
return "卓越"
case v >= 75:
return "优秀"
case v >= 60:
return "良好"
case v >= 40:
return "一般"
default:
return "较弱"
}
}
// disciplineCoverage returns shared (count>1) and unique categories.
func disciplineCoverage(subs []subject) (shared map[string][]string, unique []string) {
all := map[string][]string{}
for _, s := range subs {
for _, c := range s.Major {
all[c] = append(all[c], s.Login)
}
}
shared = map[string][]string{}
for c, who := range all {
if len(who) > 1 {
shared[c] = who
} else {
unique = append(unique, c)
}
}
sort.Strings(unique)
return shared, unique
}
func topLanguages(subs []subject, n int) []string {
agg := map[string]float64{}
for _, s := range subs {
for lang, pct := range s.Dev.User.LanguagesPercent {
agg[lang] += pct
}
}
type kv struct {
k string
v float64
}
var list []kv
for k, v := range agg {
list = append(list, kv{k, v})
}
sort.Slice(list, func(i, j int) bool { return list[i].v > list[j].v })
var out []string
for i, e := range list {
if i >= n {
break
}
out = append(out, e.k)
}
return out
}
func activitySum(a activityData) (commits, issues, prs int) {
for _, v := range a.CommitsCount {
commits += v
}
for _, v := range a.IssuesCount {
issues += v
}
for _, v := range a.PullRequestsCount {
prs += v
}
return
}
func strongest(subs []subject) subject {
best := subs[0]
bestScore := -1
for _, s := range subs {
total := 0
for _, d := range abilityDims {
total += s.Dev.User.get(d.Key)
}
if total > bestScore {
bestScore = total
best = s
}
}
return best
}
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "错误:", err)
os.Exit(1)
}
}
func run() error {
opts := parseFlags()
members, teamName, err := resolveMembers(opts)
if err != nil {
return err
}
fmt.Printf("成员:%s\n", strings.Join(members, ", "))
dataRoot := opts.DataRoot
if dataRoot == "" {
dataRoot = filepath.Join(opts.Out, "data")
}
if err := os.MkdirAll(opts.Out, 0o755); err != nil {
return err
}
var subs []subject
for i, login := range members {
dir := filepath.Join(dataRoot, login)
if !opts.NoFetch {
fmt.Printf("[2/4] 采集画像 %d/%d%s\n", i+1, len(members), login)
if err := fetchMember(opts.CLIBin, login, dir); err != nil {
return err
}
}
s, err := loadSubject(dir)
if err != nil {
fmt.Printf(" ⚠️ 跳过 %s%v\n", login, err)
continue
}
subs = append(subs, s)
}
if len(subs) == 0 {
return errors.New("无可用成员数据")
}
fmt.Println("[3/4] 生成团队可视化报告与洞察报告…")
html := buildHTML(teamName, subs)
if err := os.WriteFile(filepath.Join(opts.Out, "team-report.html"), []byte(html), 0o644); err != nil {
return err
}
md := buildInsightMarkdown(teamName, subs)
if err := os.WriteFile(filepath.Join(opts.Out, "team-insight.md"), []byte(md), 0o644); err != nil {
return err
}
fmt.Println("[4/4] 写入命令日志…")
commandLog = append(commandLog, commandResult{Command: []string{"insight", "render", "team-report.html"}, Status: "ok", Time: now()})
logJSON, err := json.MarshalIndent(commandLog, "", " ")
if err != nil {
return err
}
if err := os.WriteFile(filepath.Join(opts.Out, "command_log.json"), logJSON, 0o644); err != nil {
return err
}
fmt.Printf("\n完成 输出目录:%s\n - team-report.html\n - team-insight.md\n - command_log.json%d 条)\n", opts.Out, len(commandLog))
return nil
}
func parseFlags() options {
var opts options
fs := flag.CommandLine
fs.StringVar(&opts.Config, "config", "", "团队配置 JSON{\"team_name\":..,\"members\":[..]}")
fs.StringVar(&opts.Members, "members", "", "成员 login逗号分隔")
fs.StringVar(&opts.Keyword, "keyword", "", "按研究方向发现成员search +users")
fs.IntVar(&opts.Max, "max", 5, "--keyword 模式下最多成员数")
fs.StringVar(&opts.TeamName, "team-name", "科研团队", "团队名称")
fs.StringVar(&opts.Out, "out", "./out", "输出目录")
fs.StringVar(&opts.CLIBin, "cli-bin", firstNonEmpty(os.Getenv("GITLINK_CLI_BIN"), "gitlink-cli"), "gitlink-cli 可执行文件路径")
fs.BoolVar(&opts.NoFetch, "no-fetch", false, "离线复现:不调用网络")
fs.StringVar(&opts.DataRoot, "data-root", "", "--no-fetch 时成员数据根目录(默认 <out>/data")
flag.Parse()
return opts
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if v != "" {
return v
}
}
return ""
}
func resolveMembers(opts options) ([]string, string, error) {
teamName := opts.TeamName
var members []string
if opts.Members != "" {
for _, m := range strings.Split(opts.Members, ",") {
if m = strings.TrimSpace(m); m != "" {
members = append(members, m)
}
}
}
if opts.Config != "" {
raw, err := os.ReadFile(opts.Config) // #nosec G304
if err != nil {
return nil, "", err
}
var cfg teamConfig
if err := json.Unmarshal(raw, &cfg); err != nil {
return nil, "", err
}
if len(members) == 0 {
members = cfg.Members
}
if cfg.TeamName != "" && opts.TeamName == "科研团队" {
teamName = cfg.TeamName
}
}
if len(members) == 0 && opts.Keyword != "" {
fmt.Printf("[1/4] 按方向「%s」发现成员…\n", opts.Keyword)
found, err := discoverMembers(opts.CLIBin, opts.Keyword, opts.Max)
if err != nil {
return nil, "", err
}
members = found
}
if len(members) == 0 {
return nil, "", errors.New("需要 --members、--config 或 --keyword")
}
return members, teamName, nil
}
func discoverMembers(cliBin, keyword string, limit int) ([]string, error) {
out, err := runCLI(cliBin, []string{"search", "+users", "-k", keyword, "--format", "json"}, 2)
if err != nil {
return nil, err
}
var env envelope
if err := json.Unmarshal([]byte(out), &env); err != nil {
return nil, err
}
var sd struct {
Users []userInfo `json:"users"`
}
_ = json.Unmarshal(env.Data, &sd)
var logins []string
for _, u := range sd.Users {
if u.Login != "" {
logins = append(logins, u.Login)
}
if len(logins) >= limit {
break
}
}
if len(logins) == 0 {
return nil, fmt.Errorf("方向「%s」未发现可用成员", keyword)
}
return logins, nil
}

View File

@ -0,0 +1,168 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
func writeFixture(t *testing.T, dir, name, body string) {
t.Helper()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
func makeMember(t *testing.T, root, login string, infl, contrib, lang int, cats string, mirror int) string {
dir := filepath.Join(root, login)
writeFixture(t, dir, "develop.json", `{"ok":true,"data":{"platform":{"influence":99,"contribution":99,"activity":99,"experience":99,"language":98},"user":{"influence":`+itoa(infl)+`,"contribution":`+itoa(contrib)+`,"activity":80,"experience":85,"language":`+itoa(lang)+`,"languages_percent":{"Go":0.5,"Python":0.3},"each_language_score":{"Go":88,"Python":80}}}}`)
writeFixture(t, dir, "major.json", `{"ok":true,"data":{"categories":[`+cats+`]}}`)
writeFixture(t, dir, "role.json", `{"ok":true,"data":{"role":{"owner":{"count":5,"percent":0.8},"developer":{"count":2,"percent":0.2},"manager":{"count":0,"percent":0},"reporter":{"count":0,"percent":0}},"total_projects_count":7}}`)
writeFixture(t, dir, "activity.json", `{"ok":true,"data":{"dates":["2026.06.13","2026.06.14"],"commits_count":[1,2],"issues_count":[0,1],"pull_requests_count":[0,0]}}`)
writeFixture(t, dir, "contribution.json", `{"ok":true,"data":{"total_contributions":123,"headmaps":[{"date":"2026-06-14","contributions":5}]}}`)
writeFixture(t, dir, "user.json", `{"ok":true,"data":{"login":"`+login+`","name":"`+login+`","real_name":"研究员`+login+`","custom_department":"某大学","mirror_projects_count":`+itoa(mirror)+`}}`)
return dir
}
func itoa(n int) string {
if n == 0 {
return "0"
}
neg := n < 0
if neg {
n = -n
}
var b []byte
for n > 0 {
b = append([]byte{byte('0' + n%10)}, b...)
n /= 10
}
if neg {
b = append([]byte{'-'}, b...)
}
return string(b)
}
func TestLoadSubject(t *testing.T) {
root := t.TempDir()
dir := makeMember(t, root, "alice", 70, 90, 66, `"人工智能","大数据"`, 0)
s, err := loadSubject(dir)
if err != nil {
t.Fatalf("loadSubject: %v", err)
}
if s.Login != "alice" {
t.Fatalf("login=%q", s.Login)
}
if s.Dev.User.Contribution != 90 {
t.Fatalf("contribution=%d want 90", s.Dev.User.Contribution)
}
if s.Dev.Platform.Influence != 99 {
t.Fatalf("platform influence=%d want 99", s.Dev.Platform.Influence)
}
if len(s.Major) != 2 || s.Major[0] != "人工智能" {
t.Fatalf("major=%v", s.Major)
}
if s.Role.TotalProjectsCount != 7 {
t.Fatalf("total projects=%d", s.Role.TotalProjectsCount)
}
if s.displayName() != "研究员alice" {
t.Fatalf("displayName=%q", s.displayName())
}
}
func TestAbilityGetAndAverages(t *testing.T) {
root := t.TempDir()
a, _ := loadSubject(makeMember(t, root, "a", 60, 80, 60, `"AI"`, 0))
b, _ := loadSubject(makeMember(t, root, "b", 80, 100, 80, `"AI"`, 0))
avg := teamAverages([]subject{a, b})
if avg["influence"] != 70 {
t.Fatalf("avg influence=%d want 70", avg["influence"])
}
if avg["contribution"] != 90 {
t.Fatalf("avg contribution=%d want 90", avg["contribution"])
}
if a.Dev.User.get("language") != 60 {
t.Fatalf("get language=%d", a.Dev.User.get("language"))
}
}
func TestDisciplineCoverage(t *testing.T) {
root := t.TempDir()
a, _ := loadSubject(makeMember(t, root, "a", 60, 60, 60, `"AI","大数据"`, 0))
b, _ := loadSubject(makeMember(t, root, "b", 60, 60, 60, `"AI","物联网"`, 0))
shared, unique := disciplineCoverage([]subject{a, b})
if _, ok := shared["AI"]; !ok {
t.Fatalf("expected AI shared, got %v", shared)
}
found := map[string]bool{}
for _, u := range unique {
found[u] = true
}
if !found["大数据"] || !found["物联网"] {
t.Fatalf("unique=%v", unique)
}
}
func TestGradeAndActivitySum(t *testing.T) {
cases := map[int]string{95: "卓越", 80: "优秀", 65: "良好", 45: "一般", 10: "较弱"}
for v, want := range cases {
if got := grade(v); got != want {
t.Fatalf("grade(%d)=%s want %s", v, got, want)
}
}
c, i, p := activitySum(activityData{CommitsCount: []int{1, 2}, IssuesCount: []int{0, 3}, PullRequestsCount: []int{1}})
if c != 3 || i != 3 || p != 1 {
t.Fatalf("activitySum=%d,%d,%d", c, i, p)
}
}
func TestStrongestAndTopLanguages(t *testing.T) {
root := t.TempDir()
a, _ := loadSubject(makeMember(t, root, "weak", 50, 50, 50, `"AI"`, 0))
b, _ := loadSubject(makeMember(t, root, "strong", 90, 90, 90, `"AI"`, 0))
if strongest([]subject{a, b}).Login != "strong" {
t.Fatal("expected strong to be strongest")
}
langs := topLanguages([]subject{a, b}, 5)
if len(langs) == 0 || langs[0] != "Go" {
t.Fatalf("top languages=%v", langs)
}
}
func TestBuildMarkdownAndHTML(t *testing.T) {
root := t.TempDir()
a, _ := loadSubject(makeMember(t, root, "alice", 70, 95, 66, `"人工智能","大数据"`, 0))
b, _ := loadSubject(makeMember(t, root, "bob", 60, 72, 77, `"人工智能","物联网"`, 9000))
subs := []subject{a, b}
md := buildInsightMarkdown("测试组", subs)
for _, want := range []string{"科研团队洞察报告:测试组", "能力矩阵", "团队均值", "学科方向覆盖", "协作与分工建议", "含大量镜像"} {
if !strings.Contains(md, want) {
t.Fatalf("markdown missing %q", want)
}
}
htmlOut := buildHTML("测试组", subs)
for _, want := range []string{"<svg", "<polygon", "<line", "能力矩阵", "协作网络图", "课题组科研画像"} {
if !strings.Contains(htmlOut, want) {
t.Fatalf("html missing %q", want)
}
}
}
func TestParseIntoUnwrapped(t *testing.T) {
// tolerate payload without the {"data":..} envelope
dir := t.TempDir()
writeFixture(t, dir, "major.json", `{"categories":["X"]}`)
var mj majorData
if err := parseInto(filepath.Join(dir, "major.json"), &mj); err != nil {
t.Fatalf("parseInto: %v", err)
}
if len(mj.Categories) != 1 || mj.Categories[0] != "X" {
t.Fatalf("categories=%v", mj.Categories)
}
}

View File

@ -0,0 +1,348 @@
package main
import (
"fmt"
"html"
"math"
"sort"
"strings"
)
var palette = []string{"#2f6feb", "#1f883d", "#bf8700", "#cf222e", "#6f42c1", "#0969da"}
// ---------- Markdown insight ----------
func buildInsightMarkdown(team string, subs []subject) string {
var b strings.Builder
n := len(subs)
avg := teamAverages(subs)
fmt.Fprintf(&b, "# 🔬 科研团队洞察报告:%s\n\n", team)
fmt.Fprintf(&b, "> 成员 %d 人 数据来源gitlink-cli只读\n\n", n)
b.WriteString("## 一、能力矩阵0100\n\n")
b.WriteString("| 成员 |")
for _, d := range abilityDims {
fmt.Fprintf(&b, " %s |", d.ZH)
}
b.WriteString("\n|------|")
for range abilityDims {
b.WriteString("------|")
}
b.WriteString("\n")
for _, s := range subs {
fmt.Fprintf(&b, "| %s@%s |", s.displayName(), s.Login)
for _, d := range abilityDims {
fmt.Fprintf(&b, " %d |", s.Dev.User.get(d.Key))
}
b.WriteString("\n")
}
b.WriteString("| **团队均值** |")
for _, d := range abilityDims {
fmt.Fprintf(&b, " **%d** |", avg[d.Key])
}
b.WriteString("\n\n")
// strongest / weakest dimension
type dimAvg struct {
zh string
v int
}
var das []dimAvg
for _, d := range abilityDims {
das = append(das, dimAvg{d.ZH, avg[d.Key]})
}
sort.Slice(das, func(i, j int) bool { return das[i].v > das[j].v })
fmt.Fprintf(&b, "- 团队最强项:**%s**%d短板**%s**%d%s\n\n",
das[0].zh, das[0].v, das[len(das)-1].zh, das[len(das)-1].v, grade(das[len(das)-1].v))
// disciplines
shared, unique := disciplineCoverage(subs)
b.WriteString("## 二、学科方向覆盖\n\n")
fmt.Fprintf(&b, "- 覆盖方向总数:**%d**\n", len(shared)+len(unique))
b.WriteString("- 共有方向(团队共识):" + sharedSummary(shared, 10) + "\n")
b.WriteString("- 独有方向(个人特长):" + joinLimit(unique, 15) + "\n\n")
// languages
b.WriteString("## 三、技术栈覆盖\n\n")
langs := topLanguages(subs, 8)
b.WriteString("- 团队主力语言:" + emptyOr(strings.Join(langs, ", "), "无") + "\n\n")
// roles
b.WriteString("## 四、角色结构\n\n")
for _, s := range subs {
owner := s.Role.Role["owner"].Count
dev := s.Role.Role["developer"].Count
note := ""
if s.Info.MirrorProjectsCount > 100 {
note = " ⚠️ 含大量镜像,分布偏泛"
}
fmt.Fprintf(&b, "- %s参与 %s 个项目Owner %d / Developer %d%s\n",
s.displayName(), dashIfZero(s.Role.TotalProjectsCount), owner, dev, note)
}
b.WriteString("\n")
// suggestions
b.WriteString("## 五、协作与分工建议\n\n")
lead := strongest(subs)
fmt.Fprintf(&b, "- **核心建议**%s@%s综合能力最强适合作为方向负责人。\n", lead.displayName(), lead.Login)
if top := topShared(shared); top != "" {
fmt.Fprintf(&b, "- **团队聚焦**:在共有方向「%s」上成员重叠度最高可作为协作主线。\n", top)
}
if len(unique) > 0 {
fmt.Fprintf(&b, "- **互补利用**%s 等为个人独有方向,可作为跨学科延展点。\n", joinLimit(unique, 5))
}
fmt.Fprintf(&b, "- **能力补强**:团队短板为「%s」建议引入该维度更强的协作者或专项提升。\n\n", das[len(das)-1].zh)
b.WriteString("---\n\n*本报告由 research-team-insight 工作流自动生成链路search/profile → 聚合 → 可视化 → 归档。配套可视化见同目录 `team-report.html`。*\n")
return b.String()
}
// ---------- HTML report ----------
func buildHTML(team string, subs []subject) string {
avg := teamAverages(subs)
shared, unique := disciplineCoverage(subs)
var matrix strings.Builder
matrix.WriteString("<tr><th>成员</th>")
for _, d := range abilityDims {
fmt.Fprintf(&matrix, "<th>%s</th>", d.ZH)
}
matrix.WriteString("</tr>")
for i, s := range subs {
color := palette[i%len(palette)]
fmt.Fprintf(&matrix, `<tr><td><span class="dot" style="background:%s"></span>%s <span class="muted">@%s</span></td>`,
color, html.EscapeString(s.displayName()), html.EscapeString(s.Login))
for _, d := range abilityDims {
fmt.Fprintf(&matrix, "<td>%d</td>", s.Dev.User.get(d.Key))
}
matrix.WriteString("</tr>")
}
matrix.WriteString("<tr><td><b>团队均值</b></td>")
for _, d := range abilityDims {
fmt.Fprintf(&matrix, "<td><b>%d</b></td>", avg[d.Key])
}
matrix.WriteString("</tr>")
sharedTags := tagsHTML(sharedTagList(shared))
uniqueTags := tagsHTML(unique)
body := fmt.Sprintf(`
<h1>🔬 课题组科研画像%s</h1>
<div class="sub">成员 %d · 数据来源 gitlink-cli只读</div>
<div class="grid">
<div class="card"><h2>开发能力雷达</h2>%s%s</div>
<div class="card"><h2>能力矩阵0100</h2><table>%s</table></div>
</div>
<div class="grid">
<div class="card"><h2>共有研究方向</h2>%s</div>
<div class="card"><h2>独有研究方向</h2>%s</div>
</div>
<div class="card"><h2>协作网络图连线=共同方向数</h2>%s</div>
`,
html.EscapeString(team), len(subs),
radarSVG(subs), radarLegend(subs),
matrix.String(), sharedTags, uniqueTags, networkSVG(subs))
return page(fmt.Sprintf("课题组科研画像 - %s", team), body)
}
func page(title, body string) string {
css := `body{font-family:-apple-system,'Segoe UI','Microsoft YaHei',sans-serif;margin:0;background:#f6f8fa;color:#24292f}
.wrap{max-width:980px;margin:0 auto;padding:24px}
h1{font-size:22px;margin:0 0 4px}h2{font-size:16px;margin:18px 0 10px;border-left:4px solid #2f6feb;padding-left:8px}
.sub{color:#57606a;font-size:13px;margin-bottom:16px}
.card{background:#fff;border:1px solid #e6e8eb;border-radius:10px;padding:16px;margin-bottom:16px}
.grid{display:flex;gap:16px;flex-wrap:wrap}.grid>.card{flex:1;min-width:300px}
.tag{display:inline-block;background:#eaf1ff;color:#2f6feb;border-radius:12px;padding:3px 10px;margin:3px;font-size:12px}
.muted{color:#8b949e;font-size:12px}.legend{font-size:12px;color:#57606a;margin-top:8px}
table{border-collapse:collapse;width:100%}td,th{border:1px solid #eaecef;padding:6px 8px;text-align:left;font-size:13px}th{background:#f6f8fa}
.dot{display:inline-block;width:10px;height:10px;border-radius:50%;margin-right:6px;vertical-align:middle}`
return fmt.Sprintf(`<!doctype html><html lang="zh-CN"><head><meta charset="utf-8">`+
`<meta name="viewport" content="width=device-width,initial-scale=1"><title>%s</title><style>%s</style></head>`+
`<body><div class="wrap">%s<p class="muted" style="margin-top:24px">由 research-team-insight 工作流生成 · gitlink-cli profile只读</p></div></body></html>`,
html.EscapeString(title), css, body)
}
// ---------- SVG ----------
func radarSVG(subs []subject) string {
const size = 340.0
cx, cy := size/2, size/2
r := size/2 - 50
n := len(abilityDims)
var sb strings.Builder
fmt.Fprintf(&sb, `<svg viewBox="0 0 %.0f %.0f" width="%.0f" height="%.0f" xmlns="http://www.w3.org/2000/svg">`, size, size, size, size)
for k := 1; k <= 4; k++ {
fmt.Fprintf(&sb, `<circle cx="%.0f" cy="%.0f" r="%.1f" fill="none" stroke="#e6e8eb"/>`, cx, cy, r*float64(k)/4)
}
for i, d := range abilityDims {
ang := -math.Pi/2 + 2*math.Pi*float64(i)/float64(n)
x, y := cx+r*math.Cos(ang), cy+r*math.Sin(ang)
fmt.Fprintf(&sb, `<line x1="%.0f" y1="%.0f" x2="%.1f" y2="%.1f" stroke="#e6e8eb"/>`, cx, cy, x, y)
lx, ly := cx+(r+20)*math.Cos(ang), cy+(r+20)*math.Sin(ang)
anchor := "middle"
if math.Cos(ang) > 0.3 {
anchor = "start"
} else if math.Cos(ang) < -0.3 {
anchor = "end"
}
fmt.Fprintf(&sb, `<text x="%.1f" y="%.1f" text-anchor="%s" font-size="12" fill="#444">%s</text>`, lx, ly+4, anchor, d.ZH)
}
for i, s := range subs {
color := palette[i%len(palette)]
var pts strings.Builder
for j, d := range abilityDims {
ang := -math.Pi/2 + 2*math.Pi*float64(j)/float64(n)
rr := r * float64(clamp(s.Dev.User.get(d.Key), 0, 100)) / 100
if j > 0 {
pts.WriteString(" ")
}
fmt.Fprintf(&pts, "%.1f,%.1f", cx+rr*math.Cos(ang), cy+rr*math.Sin(ang))
}
fmt.Fprintf(&sb, `<polygon points="%s" fill="%s22" stroke="%s" stroke-width="2"/>`, pts.String(), color, color)
}
sb.WriteString("</svg>")
return sb.String()
}
func radarLegend(subs []subject) string {
var sb strings.Builder
sb.WriteString(`<div class="legend">`)
for i, s := range subs {
color := palette[i%len(palette)]
fmt.Fprintf(&sb, `<span class="dot" style="background:%s"></span>%s&nbsp;&nbsp;`, color, html.EscapeString(s.displayName()))
}
sb.WriteString("</div>")
return sb.String()
}
func networkSVG(subs []subject) string {
n := len(subs)
const size = 420.0
if n < 2 {
return `<p class="muted">成员不足 2 人,无协作网络。</p>`
}
cx, cy := size/2, size/2
r := size/2 - 64
type pt struct{ x, y float64 }
pos := make([]pt, n)
for i := range subs {
ang := -math.Pi/2 + 2*math.Pi*float64(i)/float64(n)
pos[i] = pt{cx + r*math.Cos(ang), cy + r*math.Sin(ang)}
}
var sb strings.Builder
fmt.Fprintf(&sb, `<svg viewBox="0 0 %.0f %.0f" width="%.0f" height="%.0f" xmlns="http://www.w3.org/2000/svg">`, size, size, size, size)
for i := 0; i < n; i++ {
for j := i + 1; j < n; j++ {
shared := sharedCount(subs[i].Major, subs[j].Major)
if shared > 0 {
w := math.Min(6, 1+float64(shared)/3)
fmt.Fprintf(&sb, `<line x1="%.0f" y1="%.0f" x2="%.0f" y2="%.0f" stroke="#9ab4e8" stroke-width="%.1f" opacity="0.75"><title>%s ↔ %s: %d 个共同方向</title></line>`,
pos[i].x, pos[i].y, pos[j].x, pos[j].y, w, html.EscapeString(subs[i].Login), html.EscapeString(subs[j].Login), shared)
}
}
}
for i, s := range subs {
color := palette[i%len(palette)]
label := []rune(s.displayName())
if len(label) > 4 {
label = label[:4]
}
fmt.Fprintf(&sb, `<circle cx="%.0f" cy="%.0f" r="22" fill="%s"/><text x="%.0f" y="%.0f" text-anchor="middle" font-size="11" fill="#fff">%s</text>`,
pos[i].x, pos[i].y, color, pos[i].x, pos[i].y+4, html.EscapeString(string(label)))
}
sb.WriteString("</svg>")
return sb.String()
}
// ---------- small helpers ----------
func clamp(v, lo, hi int) int {
if v < lo {
return lo
}
if v > hi {
return hi
}
return v
}
func sharedCount(a, b []string) int {
set := map[string]bool{}
for _, x := range a {
set[x] = true
}
c := 0
for _, y := range b {
if set[y] {
c++
}
}
return c
}
func tagsHTML(items []string) string {
if len(items) == 0 {
return `<span class="muted">无</span>`
}
var sb strings.Builder
for _, it := range items {
fmt.Fprintf(&sb, `<span class="tag">%s</span>`, html.EscapeString(it))
}
return sb.String()
}
func sharedTagList(shared map[string][]string) []string {
type kv struct {
k string
n int
}
var list []kv
for c, who := range shared {
list = append(list, kv{c, len(who)})
}
sort.Slice(list, func(i, j int) bool { return list[i].n > list[j].n })
var out []string
for _, e := range list {
out = append(out, fmt.Sprintf("%s ×%d", e.k, e.n))
}
return out
}
func sharedSummary(shared map[string][]string, limit int) string {
list := sharedTagList(shared)
return emptyOr(joinLimit(list, limit), "无")
}
func topShared(shared map[string][]string) string {
best, bestN := "", 0
for c, who := range shared {
if len(who) > bestN {
bestN, best = len(who), c
}
}
return best
}
func joinLimit(items []string, limit int) string {
if len(items) > limit {
items = items[:limit]
}
return strings.Join(items, ", ")
}
func emptyOr(s, fallback string) string {
if strings.TrimSpace(s) == "" {
return fallback
}
return s
}
func dashIfZero(n int) string {
if n == 0 {
return "—"
}
return fmt.Sprintf("%d", n)
}

View File

@ -0,0 +1,27 @@
<#
一键复现脚本运行科研团队洞察工作流并生成示例输出
用法: .\scripts\run_demo.ps1 [-Out <dir>] [-NoFetch]
-NoFetch 使用 examples/demo_outputs/data 下已采集数据离线复现
#>
param(
[string]$Out = "./examples/demo_outputs",
[switch]$NoFetch
)
$ErrorActionPreference = "Stop"
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
Set-Location (Join-Path $here "..")
# 解析 gitlink-cli 可执行文件Windows 上为 .CMD 包装)
$cliBin = "gitlink-cli"
$src = (Get-Command gitlink-cli -ErrorAction SilentlyContinue).Source
if ($src -and $src.ToLower().EndsWith(".ps1")) { $cliBin = ($src -replace '\.ps1$', '.CMD') }
$args = @("run", "./scripts",
"--config", "config/team.example.json",
"--out", $Out,
"--cli-bin", $cliBin)
if ($NoFetch) { $args += @("--no-fetch", "--data-root", (Join-Path $Out "data")) }
Write-Host "运行go $($args -join ' ')"
& go @args