From 90707005ed26bbd6b498fad911846b4c694a31d0 Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Fri, 13 Feb 2026 18:36:44 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20V2=E5=8A=9F=E8=83=BD=E5=A2=9E=E5=BC=BA?= =?UTF-8?q?=20=E2=80=94=20=E9=87=8C=E7=A8=8B=E7=A2=91=E7=B3=BB=E7=BB=9F+?= =?UTF-8?q?=E5=9C=86=E7=8E=AF=E8=BF=9B=E5=BA=A6=E5=9B=BE+=E6=8D=9F?= =?UTF-8?q?=E8=80=97=E4=BF=AE=E5=A4=8D+AI=E6=9C=8D=E5=8A=A1+=E6=8A=A5?= =?UTF-8?q?=E5=91=8A=E7=B3=BB=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 项目详情页三阶段里程碑管理(前期/制作/后期) - 制作卡片改用180px ECharts圆环进度图+右侧数据列表 - 修复损耗率双重计算bug(测试秒数不再重复计入超产) - 新增飞书推送服务、豆包AI风险分析、APScheduler定时报告 - 项目列表页增强(筛选/排序/批量操作/废弃功能) - 成员详情页产出时间轴+效率对比 - 成本页固定开支管理 Co-Authored-By: Claude Opus 4.6 --- AirLabs AI 能力需求文档.md | 335 +++++++++++++++++ AirLabs Project PRD.md | 57 ++- backend/airlabs.db | Bin 114688 -> 122880 bytes backend/calculations.py | 30 +- backend/config.py | 15 + backend/main.py | 62 +++- backend/models.py | 67 +++- backend/requirements.txt | 4 + backend/routers/costs.py | 22 +- backend/routers/dashboard.py | 12 +- backend/routers/projects.py | 163 ++++++++- backend/routers/reports.py | 107 ++++++ backend/routers/submissions.py | 7 +- backend/schemas.py | 22 ++ backend/services/__init__.py | 0 backend/services/ai_service.py | 96 +++++ backend/services/feishu_service.py | 146 ++++++++ backend/services/report_service.py | 507 ++++++++++++++++++++++++++ backend/services/scheduler_service.py | 78 ++++ frontend/src/api/index.js | 4 + frontend/src/components/Layout.vue | 4 +- frontend/src/router/index.js | 12 +- frontend/src/views/Costs.vue | 31 +- frontend/src/views/Dashboard.vue | 47 +++ frontend/src/views/MemberDetail.vue | 99 +++-- frontend/src/views/ProjectDetail.vue | 396 +++++++++++++++++++- frontend/src/views/Projects.vue | 101 ++++- frontend/src/views/Users.vue | 15 +- 之前的上下文.md | 181 +++++++++ 项目总结文档.md | 307 ++++++++++++++++ 30 files changed, 2820 insertions(+), 107 deletions(-) create mode 100644 AirLabs AI 能力需求文档.md create mode 100644 backend/routers/reports.py create mode 100644 backend/services/__init__.py create mode 100644 backend/services/ai_service.py create mode 100644 backend/services/feishu_service.py create mode 100644 backend/services/report_service.py create mode 100644 backend/services/scheduler_service.py create mode 100644 之前的上下文.md create mode 100644 项目总结文档.md diff --git a/AirLabs AI 能力需求文档.md b/AirLabs AI 能力需求文档.md new file mode 100644 index 0000000..cbbac35 --- /dev/null +++ b/AirLabs AI 能力需求文档.md @@ -0,0 +1,335 @@ +# AirLabs · AI 能力需求文档 + +> V2 功能模块 | 更新日期:2026-02-13 + +--- + +## 1. 功能概述 + +为 AirLabs 项目管理系统接入 AI 能力,实现: + +1. **自动报告生成** — 系统定时汇总项目、产出、成本数据,由 AI 大模型生成自然语言总结 +2. **飞书私聊推送** — 通过飞书自建应用将报告以卡片消息形式私聊推送给指定管理人员 +3. **项目风险预警** — 基于规则引擎 + AI 分析,识别项目风险并在仪表盘展示 + +**本版本不包含**: +- 前端 AI 聊天助手页面 +- 自然语言数据问答 +- 按权限分级推送(所有接收人看到相同完整报告) + +以上功能列入后续迭代计划。 + +--- + +## 2. AI 模型选型 + +| 项目 | 选型 | +|------|------| +| 模型 | 豆包(Doubao)— 字节跳动旗下大语言模型 | +| 平台 | 火山引擎 ARK | +| 模型版本 | doubao-seed-1-8-251228 | +| API 协议 | OpenAI 兼容(使用 openai Python SDK) | +| API 地址 | https://ark.cn-beijing.volces.com/api/v3 | + +### 调用方式 + +```python +from openai import OpenAI + +client = OpenAI( + api_key="", + base_url="https://ark.cn-beijing.volces.com/api/v3" +) + +response = client.chat.completions.create( + model="doubao-seed-1-8-251228", + messages=[ + {"role": "system", "content": "你是 AirLabs 项目管理助手..."}, + {"role": "user", "content": "<数据上下文>"} + ] +) +``` + +### 降级策略 + +- API Key 未配置 → 报告仅包含数据模板,不含 AI 摘要 +- API 调用失败 → 自动降级为纯数据报告,记录错误日志 +- 超时设置:单次调用 30 秒超时 + +--- + +## 3. 自动报告功能 + +### 3.1 报告类型与推送时间 + +| 报告类型 | 推送时间 | 数据范围 | +|----------|----------|----------| +| 日报 | 每天 20:00 | 当天数据 | +| 周报 | 每周五 20:00 | 本周一至周五 | +| 月报 | 每月1日 10:00 | 上月完整数据 | + +时区:Asia/Shanghai(北京时间) + +### 3.2 日报内容 + +``` +📊 AirLabs 日报 — 2026-02-13 + +【今日概览】 +• 进行中项目:X 个 +• 今日提交:X 人次,总产出 Xm Xs +• 今日未提交:张三、李四 + +【各项目进展】 +• 项目A:进度 XX%,今日产出 Xs +• 项目B:进度 XX%,今日产出 Xs + +【风险提醒】 +⚠️ 项目C:距截止仅剩 X 天,进度仅 XX% + +【AI 点评】 +(豆包生成的自然语言总结) +``` + +### 3.3 周报内容 + +``` +📋 AirLabs 周报 — 第X周(MM/DD - MM/DD) + +【项目进展】 +• 项目A:周初 XX% → 周末 XX%,本周产出 Xs +• 项目B:周初 XX% → 周末 XX%,本周产出 Xs + +【团队产出】 +• 本周总产出:Xm Xs +• 人均日产出:Xs +• 效率最高:XXX(日均 Xs) + +【成本概览】 +• 本周人力成本:¥X +• 本周 AI 工具支出:¥X +• 本周外包支出:¥X + +【损耗排行】 +• 项目A:损耗率 XX% +• 项目B:损耗率 XX% + +【AI 分析与建议】 +(豆包生成的深度分析) +``` + +### 3.4 月报内容 + +``` +📈 AirLabs 月报 — 2026年X月 + +【月度总览】 +• 进行中项目:X 个 +• 本月完成项目:X 个 +• 月度总产出:Xm Xs +• 月度总成本:¥X + +【各项目成本明细】 +| 项目 | 人力 | AI工具 | 外包 | 固定开支 | 总成本 | +| ... | + +【盈亏概览】(仅客户正式项目) +• 项目A:回款 ¥X,成本 ¥X,利润 ¥X +• 总利润率:XX% + +【月度损耗】 +• 总损耗:Xs(损耗率 XX%) +• 测试损耗:Xs +• 超产损耗:Xs + +【人均产出统计】 +• 月度人均产出:Xs +• 较上月变化:+XX% / -XX% + +【AI 深度分析】 +(豆包生成的月度总结、趋势分析、改进建议) +``` + +--- + +## 4. 飞书推送机制 + +### 4.1 接入方式 + +- **类型**:飞书自建应用(非 Webhook 群机器人) +- **能力**:私聊发送消息给指定个人 +- **消息格式**:飞书交互式卡片(Interactive Card) + +### 4.2 所需权限 + +| 权限 | 权限标识 | 用途 | +|------|----------|------| +| 以应用身份发消息 | im:message:send_as_bot | 发送私聊消息 | +| 通过手机号获取用户ID | contact:user.id:readonly | 查找接收人 | + +### 4.3 接收人配置 + +通过 `.env` 文件配置接收人手机号,逗号分隔: + +``` +REPORT_RECEIVERS=18002277047,13811803069,13636518028,13811126887 +``` + +| 姓名 | 手机号 | +|------|--------| +| 沈海川 | 18002277047 | +| 李海 | 13811803069 | +| 曾恺 | 13636518028 | +| 黄雪婷 | 13811126887 | + +### 4.4 推送流程 + +``` +定时触发 / 手动触发 + ↓ +report_service 汇总数据库数据 + ↓ +ai_service 调用豆包生成摘要 + ↓ +组装飞书卡片 markdown + ↓ +feishu_service 获取 tenant_access_token + ↓ +通过手机号查询每个接收人的 user_id + ↓ +逐个发送私聊卡片消息 +``` + +--- + +## 5. 项目风险预警 + +### 5.1 风险评估维度 + +| 维度 | 规则 | 风险等级 | +|------|------|----------| +| 进度风险 | 实际进度 < 预期进度(按时间线性估算) | 中/高 | +| 超期风险 | 距截止不足 7 天且进度 < 80% | 高 | +| 损耗风险 | 损耗率 > 50% | 中;> 80% 高 | +| 产出停滞 | 近 7 天无提交或产出骤降 > 50% | 高 | + +### 5.2 展示位置 + +- **仪表盘**:新增风险预警卡片区域,展示所有中/高风险项目 +- **日报**:风险提醒段落 +- **周报/月报**:风险汇总段落 + +### 5.3 AI 增强 + +对标记为中/高风险的项目,调用豆包生成: +- 风险原因分析 +- 改进建议 +- 预估影响 + +--- + +## 6. 技术架构 + +``` +┌──────────────────────────────────────────────────┐ +│ FastAPI 后端 │ +│ │ +│ ┌─────────────┐ ┌────────────────────┐ │ +│ │ APScheduler │──→│ report_service.py │ │ +│ │ 定时调度 │ │ 数据汇总 │ │ +│ │ • 日报 20:00 │ │ ↓ │ │ +│ │ • 周报 五20:00│ │ ai_service.py │ │ +│ │ • 月报 1日10 │ │ 调用豆包 AI │ │ +│ └─────────────┘ │ ↓ │ │ +│ │ feishu_service.py │ │ +│ ┌─────────────┐ │ 飞书私聊推送 │ │ +│ │ reports.py │──→│ │ │ +│ │ 手动触发 API │ └────────────────────┘ │ +│ └─────────────┘ │ +│ │ +│ ┌─────────────┐ ┌────────────────────┐ │ +│ │ dashboard.py │──→│ ai_service.py │ │ +│ │ 仪表盘 API │ │ 风险预警分析 │ │ +│ └─────────────┘ └────────────────────┘ │ +└──────────────────────────────────────────────────┘ + │ │ + ▼ ▼ +┌──────────────┐ ┌──────────────┐ +│ SQLite 数据库 │ │ 豆包 AI API │ +│ airlabs.db │ │ ARK 平台 │ +└──────────────┘ └──────────────┘ + │ + ▼ + ┌──────────────┐ + │ 飞书开放平台 │ + │ 私聊消息推送 │ + └──────────────┘ +``` + +### 新增依赖 + +| 包名 | 用途 | +|------|------| +| openai | 调用豆包 AI(OpenAI 兼容协议) | +| httpx | 异步 HTTP 请求(飞书 API) | +| apscheduler | 定时任务调度 | +| python-dotenv | 读取 .env 配置 | + +--- + +## 7. 新增文件说明 + +| 文件路径 | 用途 | +|----------|------| +| backend/.env | 环境变量:API Key、飞书凭证、接收人 | +| backend/services/__init__.py | services 包初始化 | +| backend/services/ai_service.py | 豆包模型调用封装 | +| backend/services/report_service.py | 报告数据汇总 + AI 摘要 | +| backend/services/feishu_service.py | 飞书应用消息推送 | +| backend/services/scheduler_service.py | APScheduler 定时任务 | +| backend/routers/reports.py | 手动触发报告 API | + +--- + +## 8. API 接口 + +| 方法 | 路径 | 说明 | 权限 | +|------|------|------|------| +| POST | /api/reports/daily | 手动触发日报 | 超级管理员 | +| POST | /api/reports/weekly | 手动触发周报 | 超级管理员 | +| POST | /api/reports/monthly | 手动触发月报 | 超级管理员 | + +--- + +## 9. 后续迭代计划 + +| 优先级 | 功能 | 说明 | +|--------|------|------| +| P0 | AI 智能问答助手 | 前端聊天页面,自然语言查询系统数据 | +| P1 | 按权限分级推送 | 不同角色收到不同内容的报告 | +| P1 | 报告历史存储 | 保存历史报告,支持回看 | +| P2 | 飞书交互式操作 | 在飞书卡片上直接查看详情、跳转系统 | +| P2 | 团队效率 AI 分析 | AI 深度分析成员效率趋势和分配建议 | + +--- + +## 10. 配置清单 + +### 已获取 + +| 配置项 | 值 | +|--------|-----| +| 豆包 API Key | `846b6981-9954-4c58-bb39-63079393bdb8` | +| 豆包模型 | `doubao-seed-1-8-251228` | +| 飞书 App ID | `cli_a90478156bf85bd7` | +| 飞书 App Secret | `87N2nnx6Yv56TPjl2GraLdKOjFiGOSGp` | +| 接收人 | 沈海川、李海、曾恺、黄雪婷 | + +### 飞书应用已开通权限 + +- `im:message:send_as_bot` — 以应用身份发消息 +- `contact:user.id:readonly` — 通过手机号获取用户 ID + +--- + +*本文档为 AirLabs V2 AI 能力的完整需求说明,配合主 PRD 使用。* diff --git a/AirLabs Project PRD.md b/AirLabs Project PRD.md index d5520b2..fd21357 100644 --- a/AirLabs Project PRD.md +++ b/AirLabs Project PRD.md @@ -581,7 +581,62 @@ V1 不做逐条通过率标记(太复杂),采用**人均基准对比法** --- +## 17. AI 能力(V2) + +### 17.1 概述 + +V2 接入 AI 大模型能力,为管理层提供自动化报告和智能分析: + +- **AI 模型**:豆包(Doubao)— 字节跳动大语言模型,火山引擎 ARK 平台 +- **报告推送**:飞书自建应用,私聊推送给指定管理人员 +- **定时调度**:APScheduler 集成 + +### 17.2 自动报告 + +| 报告类型 | 推送时间 | 数据范围 | +|----------|----------|----------| +| 日报 | 每天 20:00 | 当天数据 | +| 周报 | 每周五 20:00 | 本周一至周五 | +| 月报 | 每月1日 10:00 | 上月完整数据 | + +报告内容由系统汇总数据 + AI 生成自然语言摘要与建议。 + +### 17.3 项目风险预警 + +基于规则引擎自动识别风险项目,在仪表盘展示预警: + +| 风险维度 | 判定规则 | +|----------|----------| +| 超期风险 | 距截止 ≤7天且进度 <80% | +| 进度风险 | 实际进度严重落后于时间线 | +| 损耗风险 | 损耗率 >50% | +| 产出停滞 | 近7天无提交 | + +### 17.4 后续迭代(V3) + +- AI 智能问答助手(自然语言查询系统数据) +- 按权限分级推送(不同角色看到不同内容) +- 团队效率 AI 深度分析 + +--- + +## 18. 飞书集成 + +### 接入方式 +- 飞书自建应用(非 Webhook 群机器人) +- 私聊发送消息给指定管理人员 + +### 所需权限 +- `im:message:send_as_bot` — 以应用身份发消息 +- `contact:user.id:readonly` — 通过手机号获取用户 ID + +### 消息格式 +- 飞书交互式卡片(Interactive Card) +- 内容为 markdown 格式 + +--- + ## 最终定义 -> **这是一个让内容生产过程保持自由, +> **这是一个让内容生产过程保持自由, > 但让项目进度、成本与结果无法模糊的系统。** diff --git a/backend/airlabs.db b/backend/airlabs.db index 7ac369e0400c96ef663706bc0017e0e0280fdcbd..2cb3e0d6278e8f10a5c20bc4d4fcbc7707925239 100644 GIT binary patch delta 3188 zcmd5;TW}NC8Q!xONh@jh7-U-r%hJj?6G&~b7fD_zy&!CbdP0!v8q!>C+0r<|7-2~U zW?Hhcaln~L2IwhqI-w=bgybQK(^dhdWCA3QNgR?)m@=JCJMCaQCJ)UM4;>zQb|u-u zHq*Bz8cDPJo&W#N|DE&wXSYx=7p|I5HmrM`AP5isRX)yBEed%2kA2f{oedC-=@w!6 z2Y=m`<@-#xioTi~Cd<(*VX1@;$p#2{gpy?@DltZne+$za}s78WHL}nCwh!{ak z!!SD*r7VOiK5j$rxkpi~v1Vh%3@go%NTU;OH%$=a11RDi@#+^ib4+8Eh4&{GPhVM> znO-~a8~%fYqR^SmvZklTiFmxPA-1qbqSP{d;*!bZ85=h@HqcUF|=(n zpzWU!1=Jt?m40!g_^;?URuX((yteaGpyfRr;T&VUKGtHfuu391oZjCo_cu#&^TWx| zR%I~SF_Jjgxiz~t|BF5Sdv=V*{9-Qfv%%n?l$8^Qhn`U$iFd>w8}81x@BXRMnKriU z7#7&o9q-uP-qRW1wd58>5S54`MFN4m+nCkmKnu}0h@lUoZmW2xks}(%7z_?ZyA80> zYK29@$sZ!%ZDi?Ei`%>M5VWI-I1$q)g8vjledi!(LVwG6)-x0U z9uEgw2%6^zo`WwG?FRr4Gk_z)_iq9|0DJ@$Md!3#4yjefRu&`1*DvZHbmZB0u{DW4 zBx+A`S&}0j)RS1?^P#Y|!@8^r6}0+473gi$5LZCvtJg`cQ1l6}+0nQErpck9;<4Fh z`#l^vOTgE;_qj)HM{G50&@yXIoBqPwPtQ;fk+b+zeTxhq+F*A6NCIPAdVex?IDNSH zKsK{CHIVNePVY9L~MSEgGPjvgv*qXdnq*ymhaVqelhpk@J#nH%Ta@o?k-hs^U0sWwznhpYI zQ~4&@o5%|tn6jtykywjhWpkME0cm72Y29*hCII&QlG?~jPMQAz z6q1?5tF@DXj}1Wrus)v`01~+BrZT?=f}gY#6PBH^Xh*C&kC$wDla{XpF&qiDMS{}c z-l>ZhK<7pO1@I-?&DqWq%{GCuPup|wOZXQ24J^PR*aaW7P1pozx5fFt^5^-p{84_` zzTNKNe{B0FZ|4@cFSzsES#FZcaZhs(aemIlQT7G<7q)A-%+IV^tIa|HmxGN0a_;#z z=g+*|)g7xUM#7Z@BSG1(hQe|C?ClCyU17YqC(SobOY0FiS7<7V~`!MJ*MkHCm zEGDtOQWABql0uOH79sgnNtVJvy0sQ(7@8?L zR5hTs;4_moCRmdmpEBWyqWYC!n--82(vKh3tigXT`cg*7uLjz}qFURm*I$SK==tA` zFHZltEB^BydgCSx+-IOSugrWseQxpenYq{B)5oaVrlpLv1h*RO{JLMpuZbX9U1gXVlWt%nO3__TWWc2Ej^`p#UH{=9}+1C ztGiiWgDN>L3kD52RccCN9Og=Vm6r9UGRQ7VsbjGJu<Cr~jE%?Q&++}O2sB~F4q=;&mvf}h+_y7U#z}xU!cnjWye}^|v{na1hr{Q%Tn9$~H GH~tIVM8nYl delta 1057 zcmZoTz~0cnK0#X0jDdkc28dxmbfS(iquIuUh5Uj*Ay#fi2EKW`nLMj`%(xjhD=NI_ zVrepB<(SMWojbW!(p)t;zqrJzEHkxSNk>5m%ttuNuWE)u}6(>*d51PEwQI)e=hL=Gc zEF$IazuC-bIu{eO>E`o3XBg{20b^lhYHVd>!oa{7B??l-$i%_HX`GW;oT*}zq+)2K z5}cM4T4`vSnv_>=5tNvm7#`#q;_aVRmR#u(WfYm}ALt(Ho0S!jTAt&lXIz>cY3iDl zQ}}Fd-_zN%1A_crLY+fA{r#BT0+3?^Vrf0lR)(h_*9t>yt;$HuOD|5$gWBv8V3cf; z>k<~?To~Z)lxd=$n;z(1s$Zn<;}&FGo}2DyP-T{7>2K;E<`e4UQEZuJXu^1d0oE#kWK#wM-=o*^r|e3u+Z~# z&UXpVH_puUOUZC@OANE{_4RVM^iC?v56sUos(jJV_-yWegio>9$;H2!L5#1Qfxm*U zkMAdc2LCj^9emIEEuGGJ*`8EK}z*ML{8yJm&Vk=ph(zB*- O-^S> dict: """ 计算项目损耗 返回: {test_waste, overproduction_waste, total_waste, waste_rate, target_seconds} + 废弃项目:全部产出直接记为损耗 """ + from models import ProjectStatus project = db.query(Project).filter(Project.id == project_id).first() if not project: return {} target = project.target_total_seconds - # 测试损耗:工作类型为"测试"的全部秒数 - test_waste = db.query(sa_func.sum(Submission.total_seconds)).filter( - Submission.project_id == project_id, - Submission.work_type == WorkType.TEST, - ).scalar() or 0 - # 全部有秒数的提交总量 total_submitted = db.query(sa_func.sum(Submission.total_seconds)).filter( Submission.project_id == project_id, Submission.total_seconds > 0, ).scalar() or 0 - # 超产损耗 - overproduction_waste = max(0, total_submitted - target) - - total_waste = test_waste + overproduction_waste - waste_rate = round(total_waste / target * 100, 1) if target > 0 else 0 + # 废弃项目:全部产出记为损耗 + if project.status == ProjectStatus.ABANDONED: + total_waste = total_submitted + test_waste = 0.0 + overproduction_waste = total_submitted + waste_rate = 100.0 if total_submitted > 0 else 0.0 + else: + # 测试损耗:工作类型为"测试"的全部秒数 + test_waste = db.query(sa_func.sum(Submission.total_seconds)).filter( + Submission.project_id == project_id, + Submission.work_type == WorkType.TEST, + ).scalar() or 0 + # 超产损耗(仅计算生产性提交超出目标的部分,排除测试秒数避免双重计数) + production_submitted = total_submitted - test_waste + overproduction_waste = max(0, production_submitted - target) + total_waste = test_waste + overproduction_waste + waste_rate = round(total_waste / target * 100, 1) if target > 0 else 0 return { "target_seconds": target, diff --git a/backend/config.py b/backend/config.py index e4aeb22..b4e5c18 100644 --- a/backend/config.py +++ b/backend/config.py @@ -1,5 +1,8 @@ """应用配置""" import os +from dotenv import load_dotenv + +load_dotenv() # 数据库 DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./airlabs.db") @@ -14,3 +17,15 @@ CORS_ORIGINS = os.getenv("CORS_ORIGINS", "*").split(",") # 成本计算 WORKING_DAYS_PER_MONTH = 22 + +# 豆包 AI(火山引擎 ARK) +ARK_API_KEY = os.getenv("ARK_API_KEY", "") +ARK_MODEL = os.getenv("ARK_MODEL", "doubao-seed-1-8-251228") +ARK_BASE_URL = os.getenv("ARK_BASE_URL", "https://ark.cn-beijing.volces.com/api/v3") + +# 飞书自建应用 +FEISHU_APP_ID = os.getenv("FEISHU_APP_ID", "") +FEISHU_APP_SECRET = os.getenv("FEISHU_APP_SECRET", "") + +# 报告接收人手机号 +REPORT_RECEIVERS = [p.strip() for p in os.getenv("REPORT_RECEIVERS", "").split(",") if p.strip()] diff --git a/backend/main.py b/backend/main.py index 28754ea..65bb580 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,12 +1,22 @@ """AirLabs Project —— 主入口""" +from dotenv import load_dotenv +load_dotenv() + from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from fastapi.responses import FileResponse from database import engine, Base -from models import User, Role, PhaseGroup, BUILTIN_ROLES +from models import ( + User, Role, PhaseGroup, BUILTIN_ROLES, COST_PERM_MIGRATION, + Project, ProjectMilestone, DEFAULT_MILESTONES +) from auth import hash_password import os +import logging + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) # 创建所有表 Base.metadata.create_all(bind=engine) @@ -31,6 +41,7 @@ from routers.submissions import router as submissions_router from routers.costs import router as costs_router from routers.dashboard import router as dashboard_router from routers.roles import router as roles_router +from routers.reports import router as reports_router app.include_router(auth_router) app.include_router(users_router) @@ -39,6 +50,7 @@ app.include_router(submissions_router) app.include_router(costs_router) app.include_router(dashboard_router) app.include_router(roles_router) +app.include_router(reports_router) # 前端静态文件 frontend_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "frontend", "dist") @@ -53,6 +65,21 @@ if os.path.exists(frontend_dir): return FileResponse(os.path.join(frontend_dir, "index.html")) +@app.on_event("startup") +async def start_scheduler(): + """启动定时任务调度器""" + from services.scheduler_service import setup_scheduler + setup_scheduler() + + +@app.on_event("shutdown") +async def stop_scheduler(): + """关闭定时任务调度器""" + from services.scheduler_service import scheduler + scheduler.shutdown(wait=False) + logger.info("[定时任务] 已关闭") + + @app.on_event("startup") def init_roles_and_admin(): """首次启动时创建内置角色和默认管理员""" @@ -73,6 +100,39 @@ def init_roles_and_admin(): print(f"[OK] created role: {role_name}") db.commit() + # 迁移旧成本权限 → 细分权限 + old_cost_perms = set(COST_PERM_MIGRATION.keys()) + for role in db.query(Role).all(): + perms = list(role.permissions or []) + changed = False + for old_perm, new_perms in COST_PERM_MIGRATION.items(): + if old_perm in perms: + perms.remove(old_perm) + for np in new_perms: + if np not in perms: + perms.append(np) + changed = True + if changed: + role.permissions = perms + print(f"[MIGRATE] upgraded cost permissions for role: {role.name}") + db.commit() + + # 为已有项目补充默认里程碑 + for proj in db.query(Project).all(): + has_ms = db.query(ProjectMilestone).filter( + ProjectMilestone.project_id == proj.id + ).first() + if not has_ms: + for ms in DEFAULT_MILESTONES: + db.add(ProjectMilestone( + project_id=proj.id, + name=ms["name"], + phase=PhaseGroup(ms["phase"]), + sort_order=ms.get("sort_order", 0), + )) + print(f"[MIGRATE] added default milestones for project: {proj.name}") + db.commit() + # 创建默认管理员(关联超级管理员角色) admin_role = db.query(Role).filter(Role.name == "超级管理员").first() if admin_role and not db.query(User).filter(User.username == "admin").first(): diff --git a/backend/models.py b/backend/models.py index c091d64..11975e1 100644 --- a/backend/models.py +++ b/backend/models.py @@ -23,10 +23,18 @@ ALL_PERMISSIONS = [ # 内容提交 ("submission:view", "查看提交记录", "内容提交"), ("submission:create", "新增提交", "内容提交"), - # 成本管理 - ("cost:view", "查看成本", "成本管理"), - ("cost:create", "录入成本", "成本管理"), - ("cost:delete", "删除成本", "成本管理"), + # 成本管理 —— 按类型细分 + ("cost_ai:view", "查看AI工具成本", "成本管理"), + ("cost_ai:create", "录入AI工具成本", "成本管理"), + ("cost_ai:delete", "删除AI工具成本", "成本管理"), + ("cost_outsource:view", "查看外包成本", "成本管理"), + ("cost_outsource:create", "录入外包成本", "成本管理"), + ("cost_outsource:delete", "删除外包成本", "成本管理"), + ("cost_overhead:view", "查看固定开支", "成本管理"), + ("cost_overhead:create", "录入固定开支", "成本管理"), + ("cost_overhead:delete", "删除固定开支", "成本管理"), + ("cost_labor:view", "查看人力调整", "成本管理"), + ("cost_labor:create", "录入人力调整", "成本管理"), # 用户与角色 ("user:view", "查看用户列表", "用户与角色"), ("user:manage", "管理用户", "用户与角色"), @@ -38,6 +46,16 @@ ALL_PERMISSIONS = [ PERMISSION_KEYS = [p[0] for p in ALL_PERMISSIONS] +# 成本查看权限集合(用于判断是否有任一成本查看权限) +COST_VIEW_PERMS = ["cost_ai:view", "cost_outsource:view", "cost_overhead:view", "cost_labor:view"] + +# 旧权限 → 新权限映射(用于数据库迁移) +COST_PERM_MIGRATION = { + "cost:view": ["cost_ai:view", "cost_outsource:view", "cost_overhead:view", "cost_labor:view"], + "cost:create": ["cost_ai:create", "cost_outsource:create", "cost_overhead:create", "cost_labor:create"], + "cost:delete": ["cost_ai:delete", "cost_outsource:delete", "cost_overhead:delete"], +} + # 内置角色定义 BUILTIN_ROLES = { "超级管理员": { @@ -50,7 +68,10 @@ BUILTIN_ROLES = { "dashboard:view", "project:view", "project:create", "project:edit", "project:complete", "submission:view", "submission:create", - "cost:view", "cost:create", "cost:delete", + "cost_ai:view", "cost_ai:create", "cost_ai:delete", + "cost_outsource:view", "cost_outsource:create", "cost_outsource:delete", + "cost_overhead:view", "cost_overhead:create", "cost_overhead:delete", + "cost_labor:view", "cost_labor:create", "user:view", "settlement:view", "efficiency:view", ], @@ -60,7 +81,7 @@ BUILTIN_ROLES = { "permissions": [ "project:view", "project:create", "submission:view", "submission:create", - "cost:view", "cost:create", + "cost_ai:view", "cost_ai:create", "efficiency:view", ], }, @@ -86,6 +107,7 @@ class ProjectType(str, enum.Enum): class ProjectStatus(str, enum.Enum): IN_PROGRESS = "制作中" COMPLETED = "已完成" + ABANDONED = "废弃" class PhaseGroup(str, enum.Enum): @@ -219,6 +241,7 @@ class Project(Base): submissions = relationship("Submission", back_populates="project") outsource_costs = relationship("OutsourceCost", back_populates="project") ai_tool_allocations = relationship("AIToolCostAllocation", back_populates="project") + milestones = relationship("ProjectMilestone", back_populates="project", cascade="all, delete-orphan") @property def target_total_seconds(self): @@ -341,3 +364,35 @@ class OverheadCost(Base): note = Column(Text, nullable=True) recorded_by = Column(Integer, ForeignKey("users.id"), nullable=False) created_at = Column(DateTime, server_default=func.now()) + + +# ──────────────────────────── 项目里程碑 ──────────────────────────── + +class ProjectMilestone(Base): + __tablename__ = "project_milestones" + + id = Column(Integer, primary_key=True, index=True) + project_id = Column(Integer, ForeignKey("projects.id"), nullable=False) + name = Column(String(100), nullable=False) + phase = Column(SAEnum(PhaseGroup), nullable=False) + is_completed = Column(Integer, nullable=False, default=0) # 0/1 + completed_at = Column(DateTime, nullable=True) + sort_order = Column(Integer, nullable=False, default=0) + + project = relationship("Project", back_populates="milestones") + + +# 默认里程碑模板 +DEFAULT_MILESTONES = [ + # 前期 + {"name": "策划案", "phase": "前期", "sort_order": 1}, + {"name": "剧本", "phase": "前期", "sort_order": 2}, + {"name": "分镜", "phase": "前期", "sort_order": 3}, + {"name": "人设图", "phase": "前期", "sort_order": 4}, + {"name": "场景图", "phase": "前期", "sort_order": 5}, + # 后期 + {"name": "配音", "phase": "后期", "sort_order": 1}, + {"name": "音效", "phase": "后期", "sort_order": 2}, + {"name": "修补镜头", "phase": "后期", "sort_order": 3}, + {"name": "杂项", "phase": "后期", "sort_order": 4}, +] diff --git a/backend/requirements.txt b/backend/requirements.txt index 9fd390c..2568228 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -6,3 +6,7 @@ python-jose[cryptography] passlib[bcrypt] bcrypt==4.0.1 python-multipart +openai +httpx +apscheduler +python-dotenv diff --git a/backend/routers/costs.py b/backend/routers/costs.py index a9d4b8b..4fcfca2 100644 --- a/backend/routers/costs.py +++ b/backend/routers/costs.py @@ -23,7 +23,7 @@ router = APIRouter(prefix="/api/costs", tags=["成本管理"]) @router.get("/ai-tools", response_model=List[AIToolCostOut]) def list_ai_tool_costs( db: Session = Depends(get_db), - current_user: User = Depends(require_permission("cost:view")) + current_user: User = Depends(require_permission("cost_ai:view")) ): costs = db.query(AIToolCost).order_by(AIToolCost.record_date.desc()).all() return [ @@ -45,7 +45,7 @@ def list_ai_tool_costs( def create_ai_tool_cost( req: AIToolCostCreate, db: Session = Depends(get_db), - current_user: User = Depends(require_permission("cost:create")) + current_user: User = Depends(require_permission("cost_ai:create")) ): cost = AIToolCost( tool_name=req.tool_name, @@ -85,7 +85,7 @@ def create_ai_tool_cost( def delete_ai_tool_cost( cost_id: int, db: Session = Depends(get_db), - current_user: User = Depends(require_permission("cost:delete")) + current_user: User = Depends(require_permission("cost_ai:delete")) ): cost = db.query(AIToolCost).filter(AIToolCost.id == cost_id).first() if not cost: @@ -102,7 +102,7 @@ def delete_ai_tool_cost( def list_outsource_costs( project_id: Optional[int] = Query(None), db: Session = Depends(get_db), - current_user: User = Depends(require_permission("cost:view")) + current_user: User = Depends(require_permission("cost_outsource:view")) ): q = db.query(OutsourceCost) if project_id: @@ -124,7 +124,7 @@ def list_outsource_costs( def create_outsource_cost( req: OutsourceCostCreate, db: Session = Depends(get_db), - current_user: User = Depends(require_permission("cost:create")) + current_user: User = Depends(require_permission("cost_outsource:create")) ): cost = OutsourceCost( project_id=req.project_id, @@ -151,7 +151,7 @@ def create_outsource_cost( def delete_outsource_cost( cost_id: int, db: Session = Depends(get_db), - current_user: User = Depends(require_permission("cost:delete")) + current_user: User = Depends(require_permission("cost_outsource:delete")) ): cost = db.query(OutsourceCost).filter(OutsourceCost.id == cost_id).first() if not cost: @@ -167,7 +167,7 @@ def delete_outsource_cost( def create_cost_override( req: CostOverrideCreate, db: Session = Depends(get_db), - current_user: User = Depends(require_permission("cost:create")) + current_user: User = Depends(require_permission("cost_labor:create")) ): override = CostOverride( user_id=req.user_id, @@ -187,7 +187,7 @@ def list_cost_overrides( user_id: Optional[int] = Query(None), project_id: Optional[int] = Query(None), db: Session = Depends(get_db), - current_user: User = Depends(require_permission("cost:view")) + current_user: User = Depends(require_permission("cost_labor:view")) ): q = db.query(CostOverride) if user_id: @@ -211,7 +211,7 @@ def list_cost_overrides( @router.get("/overhead", response_model=List[OverheadCostOut]) def list_overhead_costs( db: Session = Depends(get_db), - current_user: User = Depends(require_permission("cost:view")) + current_user: User = Depends(require_permission("cost_overhead:view")) ): costs = db.query(OverheadCost).order_by(OverheadCost.record_month.desc()).all() return [ @@ -232,7 +232,7 @@ def list_overhead_costs( def create_overhead_cost( req: OverheadCostCreate, db: Session = Depends(get_db), - current_user: User = Depends(require_permission("cost:create")) + current_user: User = Depends(require_permission("cost_overhead:create")) ): cost = OverheadCost( cost_type=OverheadCostType(req.cost_type), @@ -259,7 +259,7 @@ def create_overhead_cost( def delete_overhead_cost( cost_id: int, db: Session = Depends(get_db), - current_user: User = Depends(require_permission("cost:delete")) + current_user: User = Depends(require_permission("cost_overhead:delete")) ): cost = db.query(OverheadCost).filter(OverheadCost.id == cost_id).first() if not cost: diff --git a/backend/routers/dashboard.py b/backend/routers/dashboard.py index 39424f3..ea1c1d7 100644 --- a/backend/routers/dashboard.py +++ b/backend/routers/dashboard.py @@ -28,6 +28,7 @@ def get_dashboard( # 项目概览 active = db.query(Project).filter(Project.status == ProjectStatus.IN_PROGRESS).all() completed = db.query(Project).filter(Project.status == ProjectStatus.COMPLETED).all() + abandoned = db.query(Project).filter(Project.status == ProjectStatus.ABANDONED).all() # 当月日期范围 today = date.today() @@ -91,11 +92,11 @@ def get_dashboard( "estimated_completion_date": str(p.estimated_completion_date) if p.estimated_completion_date else None, }) - # 损耗排行 + # 损耗排行(含废弃项目,废弃项目全部产出记为损耗) waste_ranking = [] total_waste_seconds_all = 0.0 total_target_seconds_all = 0.0 - for p in active + completed: + for p in active + completed + abandoned: w = calc_waste_for_project(p.id, db) total_waste_seconds_all += w.get("total_waste_seconds", 0) total_target_seconds_all += p.target_total_seconds or 0 @@ -140,7 +141,7 @@ def get_dashboard( total_ai_all = 0.0 total_outsource_all = 0.0 total_overhead_all = 0.0 - for p in active + completed: + for p in active + completed + abandoned: total_labor_all += calc_labor_cost_for_project(p.id, db) total_ai_all += calc_ai_tool_cost_for_project(p.id, db) total_outsource_all += calc_outsource_cost_for_project(p.id, db) @@ -203,6 +204,10 @@ def get_dashboard( "profit_by_project": profit_by_project, } + # ── 风险预警 ── + from services.report_service import analyze_project_risks + risk_alerts = analyze_project_risks(db) + return { "active_projects": len(active), "completed_projects": len(completed), @@ -216,6 +221,7 @@ def get_dashboard( "waste_ranking": waste_ranking, "settled_projects": settled, "profitability": profitability, + "risk_alerts": risk_alerts, # 图表数据 "daily_trend": daily_trend, "cost_breakdown": cost_breakdown, diff --git a/backend/routers/projects.py b/backend/routers/projects.py index dfad0f7..c3c9d13 100644 --- a/backend/routers/projects.py +++ b/backend/routers/projects.py @@ -3,12 +3,17 @@ from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.orm import Session from sqlalchemy import func as sa_func from typing import List, Optional +from datetime import datetime from database import get_db from models import ( User, Project, Submission, ProjectType, - ProjectStatus, PhaseGroup, WorkType + ProjectStatus, PhaseGroup, WorkType, + ProjectMilestone, DEFAULT_MILESTONES +) +from schemas import ( + ProjectCreate, ProjectUpdate, ProjectOut, + MilestoneOut, MilestoneCreate ) -from schemas import ProjectCreate, ProjectUpdate, ProjectOut from auth import get_current_user, require_permission router = APIRouter(prefix="/api/projects", tags=["项目管理"]) @@ -25,17 +30,60 @@ def enrich_project(p: Project, db: Session) -> ProjectOut: target = p.target_total_seconds progress = round(total_secs / target * 100, 1) if target > 0 else 0 - # 损耗 = 测试损耗 + 超产损耗 + # 损耗 = 测试损耗 + 超产损耗(排除测试秒数避免双重计数) test_secs = db.query(sa_func.sum(Submission.total_seconds)).filter( Submission.project_id == p.id, Submission.work_type == WorkType.TEST ).scalar() or 0 - overproduction = max(0, total_secs - target) + production_secs = total_secs - test_secs + overproduction = max(0, production_secs - target) waste = test_secs + overproduction waste_rate = round(waste / target * 100, 1) if target > 0 else 0 leader_name = p.leader.name if p.leader else None + # 里程碑数据 + ms_rows = db.query(ProjectMilestone).filter( + ProjectMilestone.project_id == p.id + ).order_by(ProjectMilestone.phase, ProjectMilestone.sort_order).all() + + milestones_out = [ + MilestoneOut( + id=m.id, name=m.name, + phase=m.phase.value if hasattr(m.phase, 'value') else m.phase, + is_completed=bool(m.is_completed), + completed_at=m.completed_at, + sort_order=m.sort_order, + ) + for m in ms_rows + ] + + # 阶段摘要 + pre_ms = [m for m in ms_rows if (m.phase.value if hasattr(m.phase, 'value') else m.phase) == "前期"] + post_ms = [m for m in ms_rows if (m.phase.value if hasattr(m.phase, 'value') else m.phase) == "后期"] + pre_completed = sum(1 for m in pre_ms if m.is_completed) + post_completed = sum(1 for m in post_ms if m.is_completed) + + phase_summary = { + "pre": {"total": len(pre_ms), "completed": pre_completed}, + "production": { + "progress_percent": progress, + "submitted_seconds": round(total_secs, 1), + "target_seconds": target, + }, + "post": {"total": len(post_ms), "completed": post_completed}, + } + + # 自动推断当前阶段 + if len(pre_ms) > 0 and pre_completed < len(pre_ms): + current_stage = "前期" + elif progress < 100: + current_stage = "制作" + elif len(post_ms) > 0 and post_completed < len(post_ms): + current_stage = "后期" + else: + current_stage = "已完成" + return ProjectOut( id=p.id, name=p.name, project_type=p.project_type.value if hasattr(p.project_type, 'value') else p.project_type, @@ -53,6 +101,9 @@ def enrich_project(p: Project, db: Session) -> ProjectOut: progress_percent=progress, waste_seconds=round(waste, 1), waste_rate=waste_rate, + milestones=milestones_out, + phase_summary=phase_summary, + current_stage=current_stage, ) @@ -89,6 +140,17 @@ def create_project( contract_amount=req.contract_amount, ) db.add(project) + db.flush() + + # 创建里程碑 + ms_list = req.milestones if req.milestones else DEFAULT_MILESTONES + for ms in ms_list: + db.add(ProjectMilestone( + project_id=project.id, + name=ms["name"], + phase=PhaseGroup(ms["phase"]), + sort_order=ms.get("sort_order", 0), + )) db.commit() db.refresh(project) return enrich_project(project, db) @@ -121,7 +183,10 @@ def update_project( if req.project_type is not None: p.project_type = ProjectType(req.project_type) if req.status is not None: - p.status = ProjectStatus(req.status) + new_status = ProjectStatus(req.status) + p.status = new_status + if new_status in (ProjectStatus.IN_PROGRESS, ProjectStatus.ABANDONED): + p.actual_completion_date = None if req.leader_id is not None: p.leader_id = req.leader_id if req.current_phase is not None: @@ -161,6 +226,7 @@ def delete_project( db.query(OutsourceCost).filter(OutsourceCost.project_id == project_id).delete() db.query(AIToolCostAllocation).filter(AIToolCostAllocation.project_id == project_id).delete() db.query(CostOverride).filter(CostOverride.project_id == project_id).delete() + db.query(ProjectMilestone).filter(ProjectMilestone.project_id == project_id).delete() db.delete(p) db.commit() return {"message": "项目已删除"} @@ -176,8 +242,91 @@ def complete_project( p = db.query(Project).filter(Project.id == project_id).first() if not p: raise HTTPException(status_code=404, detail="项目不存在") - from datetime import date + from datetime import date as date_today p.status = ProjectStatus.COMPLETED - p.actual_completion_date = date.today() + p.actual_completion_date = date_today.today() db.commit() return {"message": "项目已标记为完成", "project_id": project_id} + + +# ──────────────────── 里程碑管理 ──────────────────── + +@router.get("/{project_id}/milestones", response_model=List[MilestoneOut]) +def list_milestones( + project_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + ms = db.query(ProjectMilestone).filter( + ProjectMilestone.project_id == project_id + ).order_by(ProjectMilestone.phase, ProjectMilestone.sort_order).all() + return [ + MilestoneOut( + id=m.id, name=m.name, + phase=m.phase.value if hasattr(m.phase, 'value') else m.phase, + is_completed=bool(m.is_completed), + completed_at=m.completed_at, + sort_order=m.sort_order, + ) + for m in ms + ] + + +@router.post("/{project_id}/milestones", response_model=MilestoneOut) +def add_milestone( + project_id: int, + req: MilestoneCreate, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("project:edit")) +): + p = db.query(Project).filter(Project.id == project_id).first() + if not p: + raise HTTPException(status_code=404, detail="项目不存在") + # 找到同阶段最大 sort_order + max_order = db.query(sa_func.max(ProjectMilestone.sort_order)).filter( + ProjectMilestone.project_id == project_id, + ProjectMilestone.phase == PhaseGroup(req.phase), + ).scalar() or 0 + m = ProjectMilestone( + project_id=project_id, + name=req.name, + phase=PhaseGroup(req.phase), + sort_order=max_order + 1, + ) + db.add(m) + db.commit() + db.refresh(m) + return MilestoneOut( + id=m.id, name=m.name, + phase=m.phase.value, is_completed=False, + completed_at=None, sort_order=m.sort_order, + ) + + +@router.put("/milestones/{milestone_id}/toggle") +def toggle_milestone( + milestone_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("project:edit")) +): + m = db.query(ProjectMilestone).filter(ProjectMilestone.id == milestone_id).first() + if not m: + raise HTTPException(status_code=404, detail="里程碑不存在") + m.is_completed = 0 if m.is_completed else 1 + m.completed_at = datetime.now() if m.is_completed else None + db.commit() + return {"id": m.id, "is_completed": bool(m.is_completed)} + + +@router.delete("/milestones/{milestone_id}") +def delete_milestone( + milestone_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("project:edit")) +): + m = db.query(ProjectMilestone).filter(ProjectMilestone.id == milestone_id).first() + if not m: + raise HTTPException(status_code=404, detail="里程碑不存在") + db.delete(m) + db.commit() + return {"message": "已删除"} diff --git a/backend/routers/reports.py b/backend/routers/reports.py new file mode 100644 index 0000000..7d702cb --- /dev/null +++ b/backend/routers/reports.py @@ -0,0 +1,107 @@ +"""AI 报告路由 —— 手动触发报告生成与飞书推送""" +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session +from database import get_db +from models import User +from auth import require_permission + +router = APIRouter(prefix="/api/reports", tags=["AI报告"]) + + +@router.post("/daily") +async def trigger_daily_report( + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("dashboard:view")), +): + """手动触发日报生成并推送飞书""" + from services.report_service import generate_daily_report + from services.feishu_service import feishu + + report = generate_daily_report(db) + push_result = await feishu.send_report_to_all(report["title"], report["content"]) + + return { + "message": "日报生成并推送完成", + "title": report["title"], + "content": report["content"], + "push_result": push_result, + } + + +@router.post("/weekly") +async def trigger_weekly_report( + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("dashboard:view")), +): + """手动触发周报生成并推送飞书""" + from services.report_service import generate_weekly_report + from services.feishu_service import feishu + + report = generate_weekly_report(db) + push_result = await feishu.send_report_to_all(report["title"], report["content"]) + + return { + "message": "周报生成并推送完成", + "title": report["title"], + "content": report["content"], + "push_result": push_result, + } + + +@router.post("/monthly") +async def trigger_monthly_report( + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("dashboard:view")), +): + """手动触发月报生成并推送飞书""" + from services.report_service import generate_monthly_report + from services.feishu_service import feishu + + report = generate_monthly_report(db) + push_result = await feishu.send_report_to_all(report["title"], report["content"]) + + return { + "message": "月报生成并推送完成", + "title": report["title"], + "content": report["content"], + "push_result": push_result, + } + + +@router.post("/preview/{report_type}") +async def preview_report( + report_type: str, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("dashboard:view")), +): + """预览报告内容(不推送飞书)""" + from services.report_service import ( + generate_daily_report, generate_weekly_report, generate_monthly_report, + ) + + generators = { + "daily": generate_daily_report, + "weekly": generate_weekly_report, + "monthly": generate_monthly_report, + } + + generator = generators.get(report_type) + if not generator: + return {"error": f"不支持的报告类型: {report_type},可选: daily, weekly, monthly"} + + report = generator(db) + return { + "title": report["title"], + "content": report["content"], + "data": report.get("data"), + } + + +@router.get("/risks") +def get_project_risks( + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("dashboard:view")), +): + """获取当前所有项目风险预警""" + from services.report_service import analyze_project_risks + return analyze_project_risks(db) diff --git a/backend/routers/submissions.py b/backend/routers/submissions.py index 6d78605..fb86a63 100644 --- a/backend/routers/submissions.py +++ b/backend/routers/submissions.py @@ -44,8 +44,11 @@ def list_submissions( current_user: User = Depends(get_current_user) ): q = db.query(Submission) - # 没有 user:view 权限的只能看自己的 - if not current_user.has_permission("user:view"): + # 查看项目内提交时,所有人都可见(方便横向对比) + # 全局提交列表时,没有 user:view 权限只能看自己的 + if project_id: + pass # 项目内提交不做用户过滤 + elif not current_user.has_permission("user:view"): q = q.filter(Submission.user_id == current_user.id) elif user_id: q = q.filter(Submission.user_id == user_id) diff --git a/backend/schemas.py b/backend/schemas.py index c90c0c1..73361da 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -61,6 +61,23 @@ class UserOut(BaseModel): # ──────────────────────────── 项目 ──────────────────────────── +class MilestoneOut(BaseModel): + id: int + name: str + phase: str + is_completed: bool + completed_at: Optional[datetime] = None + sort_order: int + + class Config: + from_attributes = True + + +class MilestoneCreate(BaseModel): + name: str + phase: str # 前期/后期 + + class ProjectCreate(BaseModel): name: str project_type: str @@ -70,6 +87,7 @@ class ProjectCreate(BaseModel): episode_count: int estimated_completion_date: Optional[date] = None contract_amount: Optional[float] = None + milestones: Optional[List[dict]] = None # [{"name", "phase", "sort_order"}] class ProjectUpdate(BaseModel): @@ -105,6 +123,10 @@ class ProjectOut(BaseModel): progress_percent: Optional[float] = 0 waste_seconds: Optional[float] = 0 waste_rate: Optional[float] = 0 + # 里程碑 + milestones: Optional[List[MilestoneOut]] = [] + phase_summary: Optional[dict] = None + current_stage: Optional[str] = None class Config: from_attributes = True diff --git a/backend/services/__init__.py b/backend/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/services/ai_service.py b/backend/services/ai_service.py new file mode 100644 index 0000000..6573baf --- /dev/null +++ b/backend/services/ai_service.py @@ -0,0 +1,96 @@ +"""豆包大模型服务 —— 通过火山引擎 ARK 平台调用""" +import logging +from config import ARK_API_KEY, ARK_MODEL, ARK_BASE_URL + +logger = logging.getLogger(__name__) + +# 延迟初始化,避免无 Key 时报错 +_client = None + + +def _get_client(): + global _client + if _client is None and ARK_API_KEY: + from openai import OpenAI + _client = OpenAI(api_key=ARK_API_KEY, base_url=ARK_BASE_URL) + return _client + + +def generate_report_summary(data_context: str, report_type: str) -> str: + """ + 调用豆包生成报告摘要 + :param data_context: 数据库汇总数据(纯文本) + :param report_type: daily / weekly / monthly + :return: AI 生成的 markdown 摘要,失败时返回空字符串 + """ + client = _get_client() + if not client: + logger.warning("豆包 AI 未配置 API Key,跳过摘要生成") + return "" + + type_labels = {"daily": "日报", "weekly": "周报", "monthly": "月报"} + label = type_labels.get(report_type, "报告") + + system_prompt = ( + "你是 AirLabs 动画团队的项目管理助手。" + "请根据提供的数据,用简洁的中文生成一段项目管理{label}总结。" + "要求:\n" + "1. 语言简练专业,适合管理层阅读\n" + "2. 先总结关键数据,再给出分析和建议\n" + "3. 如果有风险项目,重点提醒\n" + "4. 使用 markdown 格式\n" + "5. 总字数控制在 300 字以内" + ).format(label=label) + + try: + response = client.chat.completions.create( + model=ARK_MODEL, + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": f"以下是{label}数据,请生成总结:\n\n{data_context}"}, + ], + temperature=0.7, + max_tokens=1024, + timeout=30, + ) + return response.choices[0].message.content.strip() + except Exception as e: + logger.error(f"豆包 AI 调用失败: {e}") + return "" + + +def generate_risk_analysis(project_data: str) -> str: + """ + 调用豆包分析项目风险 + :param project_data: 项目数据文本 + :return: AI 生成的风险分析,失败时返回空字符串 + """ + client = _get_client() + if not client: + return "" + + system_prompt = ( + "你是 AirLabs 动画团队的项目风险分析专家。" + "请根据项目数据,分析风险原因并给出改进建议。" + "要求:\n" + "1. 分析要具体,基于数据说话\n" + "2. 建议要可执行\n" + "3. 使用中文,简练专业\n" + "4. 总字数控制在 150 字以内" + ) + + try: + response = client.chat.completions.create( + model=ARK_MODEL, + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": f"项目数据:\n\n{project_data}"}, + ], + temperature=0.7, + max_tokens=512, + timeout=30, + ) + return response.choices[0].message.content.strip() + except Exception as e: + logger.error(f"豆包 AI 风险分析失败: {e}") + return "" diff --git a/backend/services/feishu_service.py b/backend/services/feishu_service.py new file mode 100644 index 0000000..35dc64e --- /dev/null +++ b/backend/services/feishu_service.py @@ -0,0 +1,146 @@ +"""飞书自建应用消息推送服务""" +import time +import json +import logging +import httpx +from config import FEISHU_APP_ID, FEISHU_APP_SECRET, REPORT_RECEIVERS + +logger = logging.getLogger(__name__) + +FEISHU_BASE = "https://open.feishu.cn/open-apis" + + +class FeishuService: + def __init__(self): + self.app_id = FEISHU_APP_ID + self.app_secret = FEISHU_APP_SECRET + self._tenant_token: str = "" + self._token_expires: float = 0 + self._user_id_cache: dict[str, str] = {} + + async def _get_tenant_token(self) -> str: + """获取 tenant_access_token(有效期 2 小时,自动缓存)""" + if self._tenant_token and time.time() < self._token_expires: + return self._tenant_token + + if not self.app_id or not self.app_secret: + logger.warning("飞书 App ID/Secret 未配置") + return "" + + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.post( + f"{FEISHU_BASE}/auth/v3/tenant_access_token/internal", + json={ + "app_id": self.app_id, + "app_secret": self.app_secret, + }, + ) + data = resp.json() + + if data.get("code") != 0: + logger.error(f"获取飞书 token 失败: {data}") + return "" + + self._tenant_token = data["tenant_access_token"] + self._token_expires = time.time() + data.get("expire", 7200) - 60 + logger.info("飞书 tenant_access_token 获取成功") + return self._tenant_token + + async def get_user_id_by_mobile(self, mobile: str) -> str: + """通过手机号查飞书 user_id""" + if mobile in self._user_id_cache: + return self._user_id_cache[mobile] + + token = await self._get_tenant_token() + if not token: + return "" + + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.post( + f"{FEISHU_BASE}/contact/v3/users/batch_get_id", + headers={"Authorization": f"Bearer {token}"}, + json={"mobiles": [mobile]}, + params={"user_id_type": "open_id"}, + ) + data = resp.json() + + if data.get("code") != 0: + logger.error(f"查询用户 {mobile} 失败: {data}") + return "" + + user_list = data.get("data", {}).get("user_list", []) + if user_list and user_list[0].get("user_id"): + uid = user_list[0]["user_id"] + self._user_id_cache[mobile] = uid + return uid + + logger.warning(f"未找到手机号 {mobile} 对应的飞书用户") + return "" + + async def send_card_message(self, user_id: str, title: str, content: str): + """发送飞书交互式卡片消息给个人""" + token = await self._get_tenant_token() + if not token: + return False + + card = { + "header": { + "title": {"tag": "plain_text", "content": title}, + "template": "blue", + }, + "elements": [ + {"tag": "markdown", "content": content}, + ], + } + + payload = { + "receive_id": user_id, + "msg_type": "interactive", + "content": json.dumps(card), + } + + async with httpx.AsyncClient(timeout=15) as client: + resp = await client.post( + f"{FEISHU_BASE}/im/v1/messages", + headers={"Authorization": f"Bearer {token}"}, + params={"receive_id_type": "open_id"}, + json=payload, + ) + data = resp.json() + + if data.get("code") != 0: + logger.error(f"发送消息给 {user_id} 失败: {data}") + return False + + logger.info(f"飞书消息发送成功: {user_id}") + return True + + async def send_report_to_all(self, title: str, content: str) -> dict: + """ + 给所有配置的接收人发送报告 + 返回 {"success": [...], "failed": [...]} + """ + results = {"success": [], "failed": []} + + if not REPORT_RECEIVERS: + logger.warning("未配置报告接收人") + return results + + for mobile in REPORT_RECEIVERS: + user_id = await self.get_user_id_by_mobile(mobile) + if not user_id: + results["failed"].append({"mobile": mobile, "reason": "未找到用户"}) + continue + + ok = await self.send_card_message(user_id, title, content) + if ok: + results["success"].append(mobile) + else: + results["failed"].append({"mobile": mobile, "reason": "发送失败"}) + + logger.info(f"报告推送完成: 成功 {len(results['success'])},失败 {len(results['failed'])}") + return results + + +# 全局单例 +feishu = FeishuService() diff --git a/backend/services/report_service.py b/backend/services/report_service.py new file mode 100644 index 0000000..f905e37 --- /dev/null +++ b/backend/services/report_service.py @@ -0,0 +1,507 @@ +"""报告生成服务 —— 汇总数据库数据 + 调用 AI 生成摘要""" +import logging +from datetime import date, timedelta +from sqlalchemy.orm import Session +from sqlalchemy import func as sa_func + +from models import ( + User, Project, Submission, AIToolCost, + ProjectStatus, WorkType, +) +from calculations import ( + calc_waste_for_project, + calc_labor_cost_for_project, + calc_ai_tool_cost_for_project, + calc_outsource_cost_for_project, + calc_overhead_cost_for_project, + calc_team_efficiency, + calc_project_settlement, +) +from services.ai_service import generate_report_summary + +logger = logging.getLogger(__name__) + + +def _fmt_seconds(secs: float) -> str: + """秒数格式化为 Xm Xs""" + if secs <= 0: + return "0s" + m = int(secs) // 60 + s = int(secs) % 60 + if m > 0: + return f"{m}m {s}s" if s > 0 else f"{m}m" + return f"{s}s" + + +def _fmt_money(amount: float) -> str: + """金额格式化""" + if amount >= 10000: + return f"¥{amount/10000:.1f}万" + return f"¥{amount:,.0f}" + + +# ──────────────────────────── 日报 ──────────────────────────── + +def generate_daily_report(db: Session) -> dict: + """ + 生成日报 + 返回 {"title": str, "content": str, "data": dict} + """ + today = date.today() + + # 今日提交 + today_subs = db.query(Submission).filter( + Submission.submit_date == today + ).all() + + today_submitter_ids = set(s.user_id for s in today_subs) + today_total_secs = sum(s.total_seconds for s in today_subs if s.total_seconds > 0) + + # 所有活跃用户(有提交记录的) + all_active_user_ids = set( + uid for (uid,) in db.query(Submission.user_id).distinct().all() + ) + not_submitted = [] + for uid in all_active_user_ids: + if uid not in today_submitter_ids: + user = db.query(User).filter(User.id == uid).first() + if user and user.is_active: + not_submitted.append(user.name) + + # 进行中项目 + active_projects = db.query(Project).filter( + Project.status == ProjectStatus.IN_PROGRESS + ).all() + + project_lines = [] + risk_lines = [] + for p in active_projects: + waste = calc_waste_for_project(p.id, db) + total_secs = waste.get("total_submitted_seconds", 0) + target = p.target_total_seconds + progress = round(total_secs / target * 100, 1) if target > 0 else 0 + + # 今日该项目产出 + proj_today_secs = sum( + s.total_seconds for s in today_subs + if s.project_id == p.id and s.total_seconds > 0 + ) + + project_lines.append( + f"- **{p.name}**:进度 {progress}%,今日产出 {_fmt_seconds(proj_today_secs)}" + ) + + # 风险检测 + if p.estimated_completion_date: + days_left = (p.estimated_completion_date - today).days + if days_left < 0: + risk_lines.append(f"- **{p.name}**:已超期 {-days_left} 天,进度 {progress}%") + elif days_left <= 7 and progress < 80: + risk_lines.append(f"- **{p.name}**:距截止仅剩 {days_left} 天,进度仅 {progress}%") + + # 组装数据上下文(供 AI 使用) + data_context = ( + f"日期:{today}\n" + f"进行中项目:{len(active_projects)} 个\n" + f"今日提交人次:{len(today_submitter_ids)}\n" + f"今日总产出:{_fmt_seconds(today_total_secs)}\n" + f"今日未提交人员:{', '.join(not_submitted) if not_submitted else '无'}\n" + f"各项目情况:\n" + "\n".join(project_lines) + "\n" + f"风险项目:\n" + ("\n".join(risk_lines) if risk_lines else "无") + ) + + # 调用 AI 生成摘要 + ai_summary = generate_report_summary(data_context, "daily") + + # 组装飞书 markdown 内容 + title = f"AirLabs 日报 — {today}" + lines = [ + f"**【今日概览】**", + f"- 进行中项目:{len(active_projects)} 个", + f"- 今日提交:{len(today_submitter_ids)} 人次,总产出 {_fmt_seconds(today_total_secs)}", + ] + if not_submitted: + lines.append(f"- 今日未提交:{', '.join(not_submitted)}") + + lines.append("") + lines.append("**【各项目进展】**") + lines.extend(project_lines if project_lines else ["- 暂无进行中项目"]) + + if risk_lines: + lines.append("") + lines.append("**【风险提醒】**") + lines.extend(risk_lines) + + if ai_summary: + lines.append("") + lines.append("**【AI 点评】**") + lines.append(ai_summary) + + content = "\n".join(lines) + + return {"title": title, "content": content, "data": {"date": str(today)}} + + +# ──────────────────────────── 周报 ──────────────────────────── + +def generate_weekly_report(db: Session) -> dict: + """生成周报(本周一到当天的数据)""" + today = date.today() + # 本周一 + monday = today - timedelta(days=today.weekday()) + + # 本周提交 + week_subs = db.query(Submission).filter( + Submission.submit_date >= monday, + Submission.submit_date <= today, + ).all() + + week_submitter_ids = set(s.user_id for s in week_subs) + week_total_secs = sum(s.total_seconds for s in week_subs if s.total_seconds > 0) + working_days = min((today - monday).days + 1, 5) + avg_daily = round(week_total_secs / max(1, len(week_submitter_ids)) / max(1, working_days), 1) + + # 进行中项目 + active_projects = db.query(Project).filter( + Project.status == ProjectStatus.IN_PROGRESS + ).all() + + # 各项目周报数据 + project_lines = [] + for p in active_projects: + waste = calc_waste_for_project(p.id, db) + total_secs = waste.get("total_submitted_seconds", 0) + target = p.target_total_seconds + progress = round(total_secs / target * 100, 1) if target > 0 else 0 + + proj_week_secs = sum( + s.total_seconds for s in week_subs + if s.project_id == p.id and s.total_seconds > 0 + ) + project_lines.append( + f"- **{p.name}**:当前进度 {progress}%,本周产出 {_fmt_seconds(proj_week_secs)}" + ) + + # 本周成本(简化:统计提交人的日成本) + week_labor = 0.0 + processed = set() + for s in week_subs: + key = (s.user_id, s.submit_date) + if key not in processed: + processed.add(key) + user = db.query(User).filter(User.id == s.user_id).first() + if user: + week_labor += user.daily_cost + + week_ai_cost = db.query(sa_func.sum(AIToolCost.amount)).filter( + AIToolCost.record_date >= monday, + AIToolCost.record_date <= today, + ).scalar() or 0 + + # 损耗排行 + waste_ranking = [] + for p in active_projects: + w = calc_waste_for_project(p.id, db) + if w.get("total_waste_seconds", 0) > 0: + waste_ranking.append({ + "name": p.name, + "rate": w["waste_rate"], + }) + waste_ranking.sort(key=lambda x: x["rate"], reverse=True) + + # 效率排行(找产出最高的人) + user_week_secs = {} + for s in week_subs: + if s.total_seconds > 0: + user_week_secs[s.user_id] = user_week_secs.get(s.user_id, 0) + s.total_seconds + + top_producer = None + if user_week_secs: + top_uid = max(user_week_secs, key=user_week_secs.get) + top_user = db.query(User).filter(User.id == top_uid).first() + if top_user: + top_daily = round(user_week_secs[top_uid] / max(1, working_days), 1) + top_producer = f"{top_user.name}(日均 {_fmt_seconds(top_daily)})" + + # AI 数据上下文 + data_context = ( + f"周期:{monday} ~ {today}\n" + f"进行中项目:{len(active_projects)} 个\n" + f"本周总产出:{_fmt_seconds(week_total_secs)}\n" + f"人均日产出:{_fmt_seconds(avg_daily)}\n" + f"效率最高:{top_producer or '暂无'}\n" + f"本周人力成本:{_fmt_money(week_labor)}\n" + f"本周AI工具成本:{_fmt_money(week_ai_cost)}\n" + f"各项目:\n" + "\n".join(project_lines) + "\n" + f"损耗排行:\n" + "\n".join( + f"- {w['name']}:{w['rate']}%" for w in waste_ranking[:5] + ) if waste_ranking else "损耗排行:无" + ) + + ai_summary = generate_report_summary(data_context, "weekly") + + # 组装内容 + title = f"AirLabs 周报 — 第{today.isocalendar()[1]}周({monday} ~ {today})" + lines = [ + "**【项目进展】**", + ] + lines.extend(project_lines if project_lines else ["- 暂无进行中项目"]) + + lines.append("") + lines.append("**【团队产出】**") + lines.append(f"- 本周总产出:{_fmt_seconds(week_total_secs)}") + lines.append(f"- 人均日产出:{_fmt_seconds(avg_daily)}") + if top_producer: + lines.append(f"- 效率最高:{top_producer}") + + lines.append("") + lines.append("**【成本概览】**") + lines.append(f"- 本周人力成本:{_fmt_money(week_labor)}") + lines.append(f"- 本周 AI 工具支出:{_fmt_money(week_ai_cost)}") + + if waste_ranking: + lines.append("") + lines.append("**【损耗排行】**") + for w in waste_ranking[:5]: + lines.append(f"- {w['name']}:损耗率 {w['rate']}%") + + if ai_summary: + lines.append("") + lines.append("**【AI 分析与建议】**") + lines.append(ai_summary) + + content = "\n".join(lines) + return {"title": title, "content": content, "data": {"week_start": str(monday), "week_end": str(today)}} + + +# ──────────────────────────── 月报 ──────────────────────────── + +def generate_monthly_report(db: Session) -> dict: + """生成月报(上月完整数据,在每月1号调用)""" + today = date.today() + # 上月日期范围 + first_of_this_month = today.replace(day=1) + last_of_prev_month = first_of_this_month - timedelta(days=1) + first_of_prev_month = last_of_prev_month.replace(day=1) + + month_label = f"{last_of_prev_month.year}年{last_of_prev_month.month}月" + + # 上月提交 + month_subs = db.query(Submission).filter( + Submission.submit_date >= first_of_prev_month, + Submission.submit_date <= last_of_prev_month, + ).all() + + month_total_secs = sum(s.total_seconds for s in month_subs if s.total_seconds > 0) + month_submitters = set(s.user_id for s in month_subs) + + # 所有项目(含进行中和上月完成的) + all_projects = db.query(Project).filter( + Project.status.in_([ProjectStatus.IN_PROGRESS, ProjectStatus.COMPLETED]) + ).all() + + # 上月完成的项目 + completed_this_month = [ + p for p in all_projects + if p.status == ProjectStatus.COMPLETED + and p.actual_completion_date + and first_of_prev_month <= p.actual_completion_date <= last_of_prev_month + ] + + active_projects = [p for p in all_projects if p.status == ProjectStatus.IN_PROGRESS] + + # 各项目成本 + project_cost_lines = [] + total_all_cost = 0.0 + for p in active_projects + completed_this_month: + labor = calc_labor_cost_for_project(p.id, db) + ai_tool = calc_ai_tool_cost_for_project(p.id, db) + outsource = calc_outsource_cost_for_project(p.id, db) + overhead = calc_overhead_cost_for_project(p.id, db) + total = labor + ai_tool + outsource + overhead + total_all_cost += total + project_cost_lines.append( + f"- **{p.name}**:人力 {_fmt_money(labor)} / AI工具 {_fmt_money(ai_tool)} / " + f"外包 {_fmt_money(outsource)} / 固定 {_fmt_money(overhead)} → 总计 {_fmt_money(total)}" + ) + + # 盈亏概览(已结算的客户正式项目) + profit_lines = [] + total_profit = 0.0 + total_contract = 0.0 + for p in completed_this_month: + settlement = calc_project_settlement(p.id, db) + if settlement.get("contract_amount"): + pl = settlement.get("profit_loss", 0) + total_profit += pl + total_contract += settlement["contract_amount"] + sign = "+" if pl >= 0 else "" + profit_lines.append( + f"- **{p.name}**:回款 {_fmt_money(settlement['contract_amount'])}," + f"成本 {_fmt_money(settlement['total_cost'])},利润 {sign}{_fmt_money(pl)}" + ) + + # 损耗汇总 + total_waste_secs = 0.0 + total_target_secs = 0.0 + for p in active_projects + completed_this_month: + w = calc_waste_for_project(p.id, db) + total_waste_secs += w.get("total_waste_seconds", 0) + total_target_secs += p.target_total_seconds or 0 + waste_rate = round(total_waste_secs / total_target_secs * 100, 1) if total_target_secs > 0 else 0 + + # 人均产出 + working_days_month = 22 + avg_per_person = round(month_total_secs / max(1, len(month_submitters)), 1) + + # AI 数据上下文 + data_context = ( + f"月份:{month_label}\n" + f"进行中项目:{len(active_projects)} 个\n" + f"本月完成项目:{len(completed_this_month)} 个\n" + f"月度总产出:{_fmt_seconds(month_total_secs)}\n" + f"月度总成本:{_fmt_money(total_all_cost)}\n" + f"总损耗率:{waste_rate}%\n" + f"参与人数:{len(month_submitters)}\n" + f"人均产出:{_fmt_seconds(avg_per_person)}\n" + f"各项目成本:\n" + "\n".join(project_cost_lines) + "\n" + f"盈亏:\n" + ("\n".join(profit_lines) if profit_lines else "本月无结算项目") + ) + + ai_summary = generate_report_summary(data_context, "monthly") + + # 组装内容 + title = f"AirLabs 月报 — {month_label}" + lines = [ + "**【月度总览】**", + f"- 进行中项目:{len(active_projects)} 个", + f"- 本月完成项目:{len(completed_this_month)} 个", + f"- 月度总产出:{_fmt_seconds(month_total_secs)}", + f"- 月度总成本:{_fmt_money(total_all_cost)}", + ] + + if project_cost_lines: + lines.append("") + lines.append("**【各项目成本明细】**") + lines.extend(project_cost_lines) + + if profit_lines: + lines.append("") + lines.append("**【盈亏概览】**") + lines.extend(profit_lines) + if total_contract > 0: + profit_rate = round(total_profit / total_contract * 100, 1) + lines.append(f"- 总利润率:{profit_rate}%") + + lines.append("") + lines.append("**【月度损耗】**") + lines.append(f"- 总损耗:{_fmt_seconds(total_waste_secs)}(损耗率 {waste_rate}%)") + + lines.append("") + lines.append("**【人均产出】**") + lines.append(f"- 参与人数:{len(month_submitters)} 人") + lines.append(f"- 月度人均产出:{_fmt_seconds(avg_per_person)}") + + if ai_summary: + lines.append("") + lines.append("**【AI 深度分析】**") + lines.append(ai_summary) + + content = "\n".join(lines) + return { + "title": title, + "content": content, + "data": {"month": month_label, "start": str(first_of_prev_month), "end": str(last_of_prev_month)}, + } + + +# ──────────────────────────── 风险预警 ──────────────────────────── + +def analyze_project_risks(db: Session) -> list: + """ + 分析所有进行中项目的风险,返回风险列表 + 纯规则引擎判断,不依赖 AI + """ + today = date.today() + active_projects = db.query(Project).filter( + Project.status == ProjectStatus.IN_PROGRESS + ).all() + + risks = [] + for p in active_projects: + waste = calc_waste_for_project(p.id, db) + total_secs = waste.get("total_submitted_seconds", 0) + target = p.target_total_seconds + progress = round(total_secs / target * 100, 1) if target > 0 else 0 + + risk_factors = [] + risk_level = "low" + + # 1. 超期检测 + if p.estimated_completion_date: + days_left = (p.estimated_completion_date - today).days + if days_left < 0: + risk_factors.append(f"已超期 {-days_left} 天") + risk_level = "high" + elif days_left <= 7 and progress < 80: + risk_factors.append(f"距截止仅剩 {days_left} 天,进度仅 {progress}%") + risk_level = "high" + elif days_left <= 14 and progress < 60: + risk_factors.append(f"距截止 {days_left} 天,进度 {progress}% 偏低") + risk_level = "medium" + + # 进度落后于时间线 + if p.estimated_completion_date and days_left > 0: + # 估算已用天数 + if hasattr(p, 'created_at') and p.created_at: + created = p.created_at.date() if hasattr(p.created_at, 'date') else p.created_at + total_days = (p.estimated_completion_date - created).days + elapsed_days = (today - created).days + if total_days > 0: + expected_progress = round(elapsed_days / total_days * 100, 1) + if progress < expected_progress * 0.7: + risk_factors.append( + f"预期进度 {expected_progress}%,实际 {progress}%,严重落后" + ) + risk_level = "high" + elif progress < expected_progress * 0.85: + risk_factors.append( + f"预期进度 {expected_progress}%,实际 {progress}%" + ) + if risk_level != "high": + risk_level = "medium" + + # 2. 损耗率检测 + waste_rate = waste.get("waste_rate", 0) + if waste_rate > 80: + risk_factors.append(f"损耗率 {waste_rate}%,严重偏高") + risk_level = "high" + elif waste_rate > 50: + risk_factors.append(f"损耗率 {waste_rate}%,偏高") + if risk_level != "high": + risk_level = "medium" + + # 3. 近7天无提交 + week_ago = today - timedelta(days=7) + recent_subs = db.query(Submission).filter( + Submission.project_id == p.id, + Submission.submit_date >= week_ago, + ).count() + if recent_subs == 0 and total_secs > 0: + risk_factors.append("近 7 天无提交,产出停滞") + if risk_level != "high": + risk_level = "medium" + + if risk_factors: + risks.append({ + "project_id": p.id, + "project_name": p.name, + "risk_level": risk_level, + "progress": progress, + "risk_factors": risk_factors, + }) + + # 高风险排前面 + level_order = {"high": 0, "medium": 1, "low": 2} + risks.sort(key=lambda x: level_order.get(x["risk_level"], 99)) + return risks diff --git a/backend/services/scheduler_service.py b/backend/services/scheduler_service.py new file mode 100644 index 0000000..21c2028 --- /dev/null +++ b/backend/services/scheduler_service.py @@ -0,0 +1,78 @@ +"""APScheduler 定时任务 —— 自动生成报告并推送飞书""" +import asyncio +import logging +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from database import SessionLocal + +logger = logging.getLogger(__name__) + +scheduler = AsyncIOScheduler(timezone="Asia/Shanghai") + + +async def _run_report_job(report_type: str): + """通用报告任务执行器""" + from services.report_service import ( + generate_daily_report, generate_weekly_report, generate_monthly_report, + ) + from services.feishu_service import feishu + + logger.info(f"[定时任务] 开始生成{report_type}...") + db = SessionLocal() + try: + if report_type == "日报": + result = generate_daily_report(db) + elif report_type == "周报": + result = generate_weekly_report(db) + elif report_type == "月报": + result = generate_monthly_report(db) + else: + logger.error(f"未知报告类型: {report_type}") + return + + logger.info(f"[定时任务] {report_type}生成完成,开始推送飞书...") + push_result = await feishu.send_report_to_all(result["title"], result["content"]) + logger.info(f"[定时任务] {report_type}推送完成: {push_result}") + + except Exception as e: + logger.error(f"[定时任务] {report_type}生成/推送失败: {e}", exc_info=True) + finally: + db.close() + + +async def daily_report_job(): + await _run_report_job("日报") + + +async def weekly_report_job(): + await _run_report_job("周报") + + +async def monthly_report_job(): + await _run_report_job("月报") + + +def setup_scheduler(): + """配置并启动定时任务""" + # 日报:每天 20:00 + scheduler.add_job( + daily_report_job, "cron", + hour=20, minute=0, + id="daily_report", replace_existing=True, + ) + # 周报:每周五 20:00 + scheduler.add_job( + weekly_report_job, "cron", + day_of_week="fri", hour=20, minute=0, + id="weekly_report", replace_existing=True, + ) + # 月报:每月1日 10:00 + scheduler.add_job( + monthly_report_job, "cron", + day=1, hour=10, minute=0, + id="monthly_report", replace_existing=True, + ) + + scheduler.start() + logger.info( + "[定时任务] 已启动 — 日报:每天20:00 | 周报:周五20:00 | 月报:每月1日10:00" + ) diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index eb38324..da9f5e4 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -63,6 +63,10 @@ export const projectApi = { complete: (id) => api.post(`/projects/${id}/complete`), settlement: (id) => api.get(`/projects/${id}/settlement`), efficiency: (id) => api.get(`/projects/${id}/efficiency`), + milestones: (id) => api.get(`/projects/${id}/milestones`), + addMilestone: (id, data) => api.post(`/projects/${id}/milestones`, data), + toggleMilestone: (milestoneId) => api.put(`/projects/milestones/${milestoneId}/toggle`), + deleteMilestone: (milestoneId) => api.delete(`/projects/milestones/${milestoneId}`), } // ── 内容提交 ── diff --git a/frontend/src/components/Layout.vue b/frontend/src/components/Layout.vue index 93d370a..bec14c3 100644 --- a/frontend/src/components/Layout.vue +++ b/frontend/src/components/Layout.vue @@ -19,7 +19,7 @@ :to="item.path" class="nav-item" :class="{ active: isActive(item.path) }" - v-show="!item.perm || authStore.hasPermission(item.perm)" + v-show="!item.perm || (Array.isArray(item.perm) ? item.perm.some(p => authStore.hasPermission(p)) : authStore.hasPermission(item.perm))" > {{ item.label }} @@ -72,7 +72,7 @@ const menuItems = [ { path: '/dashboard', label: '仪表盘', icon: 'Odometer', perm: 'dashboard:view' }, { path: '/projects', label: '项目管理', icon: 'FolderOpened', perm: 'project:view' }, { path: '/submissions', label: '内容提交', icon: 'EditPen', perm: 'submission:view' }, - { path: '/costs', label: '成本管理', icon: 'Money', perm: 'cost:view' }, + { path: '/costs', label: '成本管理', icon: 'Money', perm: ['cost_ai:view', 'cost_outsource:view', 'cost_overhead:view', 'cost_labor:view'] }, { path: '/users', label: '用户管理', icon: 'User', perm: 'user:manage' }, { path: '/roles', label: '角色管理', icon: 'Lock', perm: 'role:manage' }, ] diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index c39436c..da83176 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -11,7 +11,7 @@ const routes = [ { path: 'projects', name: 'Projects', component: () => import('../views/Projects.vue'), meta: { perm: 'project:view' } }, { path: 'projects/:id', name: 'ProjectDetail', component: () => import('../views/ProjectDetail.vue'), meta: { perm: 'project:view' } }, { path: 'submissions', name: 'Submissions', component: () => import('../views/Submissions.vue'), meta: { perm: 'submission:view' } }, - { path: 'costs', name: 'Costs', component: () => import('../views/Costs.vue'), meta: { perm: 'cost:view' } }, + { path: 'costs', name: 'Costs', component: () => import('../views/Costs.vue'), meta: { perm: ['cost_ai:view', 'cost_outsource:view', 'cost_overhead:view', 'cost_labor:view'] } }, { path: 'users', name: 'Users', component: () => import('../views/Users.vue'), meta: { perm: 'user:manage' } }, { path: 'users/:id/detail', name: 'MemberDetail', component: () => import('../views/MemberDetail.vue'), meta: { perm: 'user:view' } }, { path: 'roles', name: 'Roles', component: () => import('../views/Roles.vue'), meta: { perm: 'role:manage' } }, @@ -57,9 +57,13 @@ router.beforeEach(async (to, from, next) => { } // 权限校验:如果路由要求特定权限,且用户没有,跳到第一个有权限的页面 - if (to.meta.perm && !authStore.hasPermission(to.meta.perm)) { - // 找到第一个有权限的页面 - const fallback = routes[1].children.find(r => !r.meta?.perm || authStore.hasPermission(r.meta.perm)) + const checkPerm = (perm) => { + if (!perm) return true + if (Array.isArray(perm)) return perm.some(p => authStore.hasPermission(p)) + return authStore.hasPermission(perm) + } + if (to.meta.perm && !checkPerm(to.meta.perm)) { + const fallback = routes[1].children.find(r => checkPerm(r.meta?.perm)) next(fallback ? '/' + fallback.path : '/login') return } diff --git a/frontend/src/views/Costs.vue b/frontend/src/views/Costs.vue index 89b5bf9..3b0cc2e 100644 --- a/frontend/src/views/Costs.vue +++ b/frontend/src/views/Costs.vue @@ -4,9 +4,9 @@ - +
- 新增 + 新增
@@ -16,16 +16,16 @@ - +
- +
- 新增 + 新增
@@ -39,16 +39,16 @@ - +
- +
- 新增 + 新增
@@ -57,7 +57,7 @@ - + @@ -165,7 +165,13 @@ import { useAuthStore } from '../stores/auth' import { ElMessage, ElMessageBox } from 'element-plus' const authStore = useAuthStore() -const activeTab = ref('ai') + +// 默认选中第一个有权限的 tab +const tabOrder = ['ai', 'outsource', 'overhead'] +const tabPermMap = { ai: 'cost_ai:view', outsource: 'cost_outsource:view', overhead: 'cost_overhead:view' } +const defaultTab = tabOrder.find(t => authStore.hasPermission(tabPermMap[t])) || 'ai' +const activeTab = ref(defaultTab) + const loadingAI = ref(false) const loadingOut = ref(false) const loadingOH = ref(false) @@ -211,7 +217,10 @@ async function deleteOH(id) { } onMounted(async () => { - loadAI(); loadOut(); loadOH() + // 仅加载有权限的成本数据 + if (authStore.hasPermission('cost_ai:view')) loadAI() + if (authStore.hasPermission('cost_outsource:view')) loadOut() + if (authStore.hasPermission('cost_overhead:view')) loadOH() try { projects.value = await projectApi.list({}) } catch {} }) diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue index ecc0193..68f43d5 100644 --- a/frontend/src/views/Dashboard.vue +++ b/frontend/src/views/Dashboard.vue @@ -65,6 +65,33 @@ + +
+
+ + + 风险预警 + + {{ data.risk_alerts.length }} 个项目存在风险 +
+
+
+
+ {{ risk.project_name }} + + {{ risk.risk_level === 'high' ? '高风险' : '中风险' }} + + 进度 {{ risk.progress }}% +
+
+ {{ factor }} +
+
+
+
+
@@ -395,4 +422,24 @@ onUnmounted(() => { .profit-text { font-weight: 600; color: #34C759; } .profit-text.loss { color: #FF3B30; } .text-muted { color: var(--text-secondary); } + +/* 风险预警 */ +.risk-card { margin-bottom: 16px; } +.risk-item { + padding: 14px 0; border-bottom: 1px solid var(--border-light); + cursor: pointer; margin: 0 -20px; padding-left: 20px; padding-right: 20px; + transition: background 0.15s; +} +.risk-item:last-child { border-bottom: none; } +.risk-item:hover { background: #FFFBF5; } +.risk-item.high { border-left: 3px solid #FF3B30; } +.risk-item.medium { border-left: 3px solid #FF9500; } +.risk-header { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; } +.risk-name { font-size: 14px; font-weight: 600; color: var(--text-primary); } +.risk-progress { font-size: 12px; color: var(--text-secondary); margin-left: auto; } +.risk-factors { display: flex; flex-wrap: wrap; gap: 6px; } +.risk-factor { + font-size: 12px; color: #8B572A; background: #FFF8F0; + padding: 2px 8px; border-radius: 4px; +} diff --git a/frontend/src/views/MemberDetail.vue b/frontend/src/views/MemberDetail.vue index 06ac636..803197e 100644 --- a/frontend/src/views/MemberDetail.vue +++ b/frontend/src/views/MemberDetail.vue @@ -42,15 +42,24 @@
近 90 天提交热力图
-
+
-
-
-
+
近 90 天内有 {{ activeDays }} 天有提交
+
+
+ {{ l }} +
+
+
+
+
+
+ {{ m.label }} +
@@ -70,16 +79,16 @@
按项目统计
-
+
{{ pg.projectName }} {{ pg.submissions.length }} 次提交 总产出 {{ formatSecs(pg.totalSecs) }}
- +
-
+
@@ -105,7 +114,7 @@ diff --git a/frontend/src/views/Users.vue b/frontend/src/views/Users.vue index b9ab7c0..7f17982 100644 --- a/frontend/src/views/Users.vue +++ b/frontend/src/views/Users.vue @@ -5,7 +5,7 @@ 新增用户
- +