feat: V2功能增强 — 里程碑系统+圆环进度图+损耗修复+AI服务+报告系统
- 项目详情页三阶段里程碑管理(前期/制作/后期) - 制作卡片改用180px ECharts圆环进度图+右侧数据列表 - 修复损耗率双重计算bug(测试秒数不再重复计入超产) - 新增飞书推送服务、豆包AI风险分析、APScheduler定时报告 - 项目列表页增强(筛选/排序/批量操作/废弃功能) - 成员详情页产出时间轴+效率对比 - 成本页固定开支管理 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
0dbe00a8b4
commit
90707005ed
335
AirLabs AI 能力需求文档.md
Normal file
335
AirLabs AI 能力需求文档.md
Normal file
@ -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="<ARK_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 使用。*
|
||||||
@ -581,6 +581,61 @@ 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 格式
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 最终定义
|
## 最终定义
|
||||||
|
|
||||||
> **这是一个让内容生产过程保持自由,
|
> **这是一个让内容生产过程保持自由,
|
||||||
|
|||||||
Binary file not shown.
@ -173,30 +173,38 @@ def calc_waste_for_project(project_id: int, db: Session) -> dict:
|
|||||||
"""
|
"""
|
||||||
计算项目损耗
|
计算项目损耗
|
||||||
返回: {test_waste, overproduction_waste, total_waste, waste_rate, target_seconds}
|
返回: {test_waste, overproduction_waste, total_waste, waste_rate, target_seconds}
|
||||||
|
废弃项目:全部产出直接记为损耗
|
||||||
"""
|
"""
|
||||||
|
from models import ProjectStatus
|
||||||
project = db.query(Project).filter(Project.id == project_id).first()
|
project = db.query(Project).filter(Project.id == project_id).first()
|
||||||
if not project:
|
if not project:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
target = project.target_total_seconds
|
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(
|
total_submitted = db.query(sa_func.sum(Submission.total_seconds)).filter(
|
||||||
Submission.project_id == project_id,
|
Submission.project_id == project_id,
|
||||||
Submission.total_seconds > 0,
|
Submission.total_seconds > 0,
|
||||||
).scalar() or 0
|
).scalar() or 0
|
||||||
|
|
||||||
# 超产损耗
|
# 废弃项目:全部产出记为损耗
|
||||||
overproduction_waste = max(0, total_submitted - target)
|
if project.status == ProjectStatus.ABANDONED:
|
||||||
|
total_waste = total_submitted
|
||||||
total_waste = test_waste + overproduction_waste
|
test_waste = 0.0
|
||||||
waste_rate = round(total_waste / target * 100, 1) if target > 0 else 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 {
|
return {
|
||||||
"target_seconds": target,
|
"target_seconds": target,
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
"""应用配置"""
|
"""应用配置"""
|
||||||
import os
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
# 数据库
|
# 数据库
|
||||||
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./airlabs.db")
|
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
|
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()]
|
||||||
|
|||||||
@ -1,12 +1,22 @@
|
|||||||
"""AirLabs Project —— 主入口"""
|
"""AirLabs Project —— 主入口"""
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from database import engine, Base
|
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
|
from auth import hash_password
|
||||||
import os
|
import os
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# 创建所有表
|
# 创建所有表
|
||||||
Base.metadata.create_all(bind=engine)
|
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.costs import router as costs_router
|
||||||
from routers.dashboard import router as dashboard_router
|
from routers.dashboard import router as dashboard_router
|
||||||
from routers.roles import router as roles_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(auth_router)
|
||||||
app.include_router(users_router)
|
app.include_router(users_router)
|
||||||
@ -39,6 +50,7 @@ app.include_router(submissions_router)
|
|||||||
app.include_router(costs_router)
|
app.include_router(costs_router)
|
||||||
app.include_router(dashboard_router)
|
app.include_router(dashboard_router)
|
||||||
app.include_router(roles_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")
|
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"))
|
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")
|
@app.on_event("startup")
|
||||||
def init_roles_and_admin():
|
def init_roles_and_admin():
|
||||||
"""首次启动时创建内置角色和默认管理员"""
|
"""首次启动时创建内置角色和默认管理员"""
|
||||||
@ -73,6 +100,39 @@ def init_roles_and_admin():
|
|||||||
print(f"[OK] created role: {role_name}")
|
print(f"[OK] created role: {role_name}")
|
||||||
db.commit()
|
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()
|
admin_role = db.query(Role).filter(Role.name == "超级管理员").first()
|
||||||
if admin_role and not db.query(User).filter(User.username == "admin").first():
|
if admin_role and not db.query(User).filter(User.username == "admin").first():
|
||||||
|
|||||||
@ -23,10 +23,18 @@ ALL_PERMISSIONS = [
|
|||||||
# 内容提交
|
# 内容提交
|
||||||
("submission:view", "查看提交记录", "内容提交"),
|
("submission:view", "查看提交记录", "内容提交"),
|
||||||
("submission:create", "新增提交", "内容提交"),
|
("submission:create", "新增提交", "内容提交"),
|
||||||
# 成本管理
|
# 成本管理 —— 按类型细分
|
||||||
("cost:view", "查看成本", "成本管理"),
|
("cost_ai:view", "查看AI工具成本", "成本管理"),
|
||||||
("cost:create", "录入成本", "成本管理"),
|
("cost_ai:create", "录入AI工具成本", "成本管理"),
|
||||||
("cost:delete", "删除成本", "成本管理"),
|
("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:view", "查看用户列表", "用户与角色"),
|
||||||
("user:manage", "管理用户", "用户与角色"),
|
("user:manage", "管理用户", "用户与角色"),
|
||||||
@ -38,6 +46,16 @@ ALL_PERMISSIONS = [
|
|||||||
|
|
||||||
PERMISSION_KEYS = [p[0] for p in 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 = {
|
BUILTIN_ROLES = {
|
||||||
"超级管理员": {
|
"超级管理员": {
|
||||||
@ -50,7 +68,10 @@ BUILTIN_ROLES = {
|
|||||||
"dashboard:view",
|
"dashboard:view",
|
||||||
"project:view", "project:create", "project:edit", "project:complete",
|
"project:view", "project:create", "project:edit", "project:complete",
|
||||||
"submission:view", "submission:create",
|
"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",
|
"user:view",
|
||||||
"settlement:view", "efficiency:view",
|
"settlement:view", "efficiency:view",
|
||||||
],
|
],
|
||||||
@ -60,7 +81,7 @@ BUILTIN_ROLES = {
|
|||||||
"permissions": [
|
"permissions": [
|
||||||
"project:view", "project:create",
|
"project:view", "project:create",
|
||||||
"submission:view", "submission:create",
|
"submission:view", "submission:create",
|
||||||
"cost:view", "cost:create",
|
"cost_ai:view", "cost_ai:create",
|
||||||
"efficiency:view",
|
"efficiency:view",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@ -86,6 +107,7 @@ class ProjectType(str, enum.Enum):
|
|||||||
class ProjectStatus(str, enum.Enum):
|
class ProjectStatus(str, enum.Enum):
|
||||||
IN_PROGRESS = "制作中"
|
IN_PROGRESS = "制作中"
|
||||||
COMPLETED = "已完成"
|
COMPLETED = "已完成"
|
||||||
|
ABANDONED = "废弃"
|
||||||
|
|
||||||
|
|
||||||
class PhaseGroup(str, enum.Enum):
|
class PhaseGroup(str, enum.Enum):
|
||||||
@ -219,6 +241,7 @@ class Project(Base):
|
|||||||
submissions = relationship("Submission", back_populates="project")
|
submissions = relationship("Submission", back_populates="project")
|
||||||
outsource_costs = relationship("OutsourceCost", back_populates="project")
|
outsource_costs = relationship("OutsourceCost", back_populates="project")
|
||||||
ai_tool_allocations = relationship("AIToolCostAllocation", back_populates="project")
|
ai_tool_allocations = relationship("AIToolCostAllocation", back_populates="project")
|
||||||
|
milestones = relationship("ProjectMilestone", back_populates="project", cascade="all, delete-orphan")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def target_total_seconds(self):
|
def target_total_seconds(self):
|
||||||
@ -341,3 +364,35 @@ class OverheadCost(Base):
|
|||||||
note = Column(Text, nullable=True)
|
note = Column(Text, nullable=True)
|
||||||
recorded_by = Column(Integer, ForeignKey("users.id"), nullable=False)
|
recorded_by = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
created_at = Column(DateTime, server_default=func.now())
|
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},
|
||||||
|
]
|
||||||
|
|||||||
@ -6,3 +6,7 @@ python-jose[cryptography]
|
|||||||
passlib[bcrypt]
|
passlib[bcrypt]
|
||||||
bcrypt==4.0.1
|
bcrypt==4.0.1
|
||||||
python-multipart
|
python-multipart
|
||||||
|
openai
|
||||||
|
httpx
|
||||||
|
apscheduler
|
||||||
|
python-dotenv
|
||||||
|
|||||||
@ -23,7 +23,7 @@ router = APIRouter(prefix="/api/costs", tags=["成本管理"])
|
|||||||
@router.get("/ai-tools", response_model=List[AIToolCostOut])
|
@router.get("/ai-tools", response_model=List[AIToolCostOut])
|
||||||
def list_ai_tool_costs(
|
def list_ai_tool_costs(
|
||||||
db: Session = Depends(get_db),
|
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()
|
costs = db.query(AIToolCost).order_by(AIToolCost.record_date.desc()).all()
|
||||||
return [
|
return [
|
||||||
@ -45,7 +45,7 @@ def list_ai_tool_costs(
|
|||||||
def create_ai_tool_cost(
|
def create_ai_tool_cost(
|
||||||
req: AIToolCostCreate,
|
req: AIToolCostCreate,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(require_permission("cost:create"))
|
current_user: User = Depends(require_permission("cost_ai:create"))
|
||||||
):
|
):
|
||||||
cost = AIToolCost(
|
cost = AIToolCost(
|
||||||
tool_name=req.tool_name,
|
tool_name=req.tool_name,
|
||||||
@ -85,7 +85,7 @@ def create_ai_tool_cost(
|
|||||||
def delete_ai_tool_cost(
|
def delete_ai_tool_cost(
|
||||||
cost_id: int,
|
cost_id: int,
|
||||||
db: Session = Depends(get_db),
|
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()
|
cost = db.query(AIToolCost).filter(AIToolCost.id == cost_id).first()
|
||||||
if not cost:
|
if not cost:
|
||||||
@ -102,7 +102,7 @@ def delete_ai_tool_cost(
|
|||||||
def list_outsource_costs(
|
def list_outsource_costs(
|
||||||
project_id: Optional[int] = Query(None),
|
project_id: Optional[int] = Query(None),
|
||||||
db: Session = Depends(get_db),
|
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)
|
q = db.query(OutsourceCost)
|
||||||
if project_id:
|
if project_id:
|
||||||
@ -124,7 +124,7 @@ def list_outsource_costs(
|
|||||||
def create_outsource_cost(
|
def create_outsource_cost(
|
||||||
req: OutsourceCostCreate,
|
req: OutsourceCostCreate,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(require_permission("cost:create"))
|
current_user: User = Depends(require_permission("cost_outsource:create"))
|
||||||
):
|
):
|
||||||
cost = OutsourceCost(
|
cost = OutsourceCost(
|
||||||
project_id=req.project_id,
|
project_id=req.project_id,
|
||||||
@ -151,7 +151,7 @@ def create_outsource_cost(
|
|||||||
def delete_outsource_cost(
|
def delete_outsource_cost(
|
||||||
cost_id: int,
|
cost_id: int,
|
||||||
db: Session = Depends(get_db),
|
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()
|
cost = db.query(OutsourceCost).filter(OutsourceCost.id == cost_id).first()
|
||||||
if not cost:
|
if not cost:
|
||||||
@ -167,7 +167,7 @@ def delete_outsource_cost(
|
|||||||
def create_cost_override(
|
def create_cost_override(
|
||||||
req: CostOverrideCreate,
|
req: CostOverrideCreate,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(require_permission("cost:create"))
|
current_user: User = Depends(require_permission("cost_labor:create"))
|
||||||
):
|
):
|
||||||
override = CostOverride(
|
override = CostOverride(
|
||||||
user_id=req.user_id,
|
user_id=req.user_id,
|
||||||
@ -187,7 +187,7 @@ def list_cost_overrides(
|
|||||||
user_id: Optional[int] = Query(None),
|
user_id: Optional[int] = Query(None),
|
||||||
project_id: Optional[int] = Query(None),
|
project_id: Optional[int] = Query(None),
|
||||||
db: Session = Depends(get_db),
|
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)
|
q = db.query(CostOverride)
|
||||||
if user_id:
|
if user_id:
|
||||||
@ -211,7 +211,7 @@ def list_cost_overrides(
|
|||||||
@router.get("/overhead", response_model=List[OverheadCostOut])
|
@router.get("/overhead", response_model=List[OverheadCostOut])
|
||||||
def list_overhead_costs(
|
def list_overhead_costs(
|
||||||
db: Session = Depends(get_db),
|
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()
|
costs = db.query(OverheadCost).order_by(OverheadCost.record_month.desc()).all()
|
||||||
return [
|
return [
|
||||||
@ -232,7 +232,7 @@ def list_overhead_costs(
|
|||||||
def create_overhead_cost(
|
def create_overhead_cost(
|
||||||
req: OverheadCostCreate,
|
req: OverheadCostCreate,
|
||||||
db: Session = Depends(get_db),
|
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 = OverheadCost(
|
||||||
cost_type=OverheadCostType(req.cost_type),
|
cost_type=OverheadCostType(req.cost_type),
|
||||||
@ -259,7 +259,7 @@ def create_overhead_cost(
|
|||||||
def delete_overhead_cost(
|
def delete_overhead_cost(
|
||||||
cost_id: int,
|
cost_id: int,
|
||||||
db: Session = Depends(get_db),
|
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()
|
cost = db.query(OverheadCost).filter(OverheadCost.id == cost_id).first()
|
||||||
if not cost:
|
if not cost:
|
||||||
|
|||||||
@ -28,6 +28,7 @@ def get_dashboard(
|
|||||||
# 项目概览
|
# 项目概览
|
||||||
active = db.query(Project).filter(Project.status == ProjectStatus.IN_PROGRESS).all()
|
active = db.query(Project).filter(Project.status == ProjectStatus.IN_PROGRESS).all()
|
||||||
completed = db.query(Project).filter(Project.status == ProjectStatus.COMPLETED).all()
|
completed = db.query(Project).filter(Project.status == ProjectStatus.COMPLETED).all()
|
||||||
|
abandoned = db.query(Project).filter(Project.status == ProjectStatus.ABANDONED).all()
|
||||||
|
|
||||||
# 当月日期范围
|
# 当月日期范围
|
||||||
today = date.today()
|
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,
|
"estimated_completion_date": str(p.estimated_completion_date) if p.estimated_completion_date else None,
|
||||||
})
|
})
|
||||||
|
|
||||||
# 损耗排行
|
# 损耗排行(含废弃项目,废弃项目全部产出记为损耗)
|
||||||
waste_ranking = []
|
waste_ranking = []
|
||||||
total_waste_seconds_all = 0.0
|
total_waste_seconds_all = 0.0
|
||||||
total_target_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)
|
w = calc_waste_for_project(p.id, db)
|
||||||
total_waste_seconds_all += w.get("total_waste_seconds", 0)
|
total_waste_seconds_all += w.get("total_waste_seconds", 0)
|
||||||
total_target_seconds_all += p.target_total_seconds or 0
|
total_target_seconds_all += p.target_total_seconds or 0
|
||||||
@ -140,7 +141,7 @@ def get_dashboard(
|
|||||||
total_ai_all = 0.0
|
total_ai_all = 0.0
|
||||||
total_outsource_all = 0.0
|
total_outsource_all = 0.0
|
||||||
total_overhead_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_labor_all += calc_labor_cost_for_project(p.id, db)
|
||||||
total_ai_all += calc_ai_tool_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)
|
total_outsource_all += calc_outsource_cost_for_project(p.id, db)
|
||||||
@ -203,6 +204,10 @@ def get_dashboard(
|
|||||||
"profit_by_project": profit_by_project,
|
"profit_by_project": profit_by_project,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ── 风险预警 ──
|
||||||
|
from services.report_service import analyze_project_risks
|
||||||
|
risk_alerts = analyze_project_risks(db)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"active_projects": len(active),
|
"active_projects": len(active),
|
||||||
"completed_projects": len(completed),
|
"completed_projects": len(completed),
|
||||||
@ -216,6 +221,7 @@ def get_dashboard(
|
|||||||
"waste_ranking": waste_ranking,
|
"waste_ranking": waste_ranking,
|
||||||
"settled_projects": settled,
|
"settled_projects": settled,
|
||||||
"profitability": profitability,
|
"profitability": profitability,
|
||||||
|
"risk_alerts": risk_alerts,
|
||||||
# 图表数据
|
# 图表数据
|
||||||
"daily_trend": daily_trend,
|
"daily_trend": daily_trend,
|
||||||
"cost_breakdown": cost_breakdown,
|
"cost_breakdown": cost_breakdown,
|
||||||
|
|||||||
@ -3,12 +3,17 @@ from fastapi import APIRouter, Depends, HTTPException, Query
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import func as sa_func
|
from sqlalchemy import func as sa_func
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
from datetime import datetime
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from models import (
|
from models import (
|
||||||
User, Project, Submission, ProjectType,
|
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
|
from auth import get_current_user, require_permission
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/projects", tags=["项目管理"])
|
router = APIRouter(prefix="/api/projects", tags=["项目管理"])
|
||||||
@ -25,17 +30,60 @@ def enrich_project(p: Project, db: Session) -> ProjectOut:
|
|||||||
target = p.target_total_seconds
|
target = p.target_total_seconds
|
||||||
progress = round(total_secs / target * 100, 1) if target > 0 else 0
|
progress = round(total_secs / target * 100, 1) if target > 0 else 0
|
||||||
|
|
||||||
# 损耗 = 测试损耗 + 超产损耗
|
# 损耗 = 测试损耗 + 超产损耗(排除测试秒数避免双重计数)
|
||||||
test_secs = db.query(sa_func.sum(Submission.total_seconds)).filter(
|
test_secs = db.query(sa_func.sum(Submission.total_seconds)).filter(
|
||||||
Submission.project_id == p.id,
|
Submission.project_id == p.id,
|
||||||
Submission.work_type == WorkType.TEST
|
Submission.work_type == WorkType.TEST
|
||||||
).scalar() or 0
|
).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 = test_secs + overproduction
|
||||||
waste_rate = round(waste / target * 100, 1) if target > 0 else 0
|
waste_rate = round(waste / target * 100, 1) if target > 0 else 0
|
||||||
|
|
||||||
leader_name = p.leader.name if p.leader else None
|
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(
|
return ProjectOut(
|
||||||
id=p.id, name=p.name,
|
id=p.id, name=p.name,
|
||||||
project_type=p.project_type.value if hasattr(p.project_type, 'value') else p.project_type,
|
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,
|
progress_percent=progress,
|
||||||
waste_seconds=round(waste, 1),
|
waste_seconds=round(waste, 1),
|
||||||
waste_rate=waste_rate,
|
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,
|
contract_amount=req.contract_amount,
|
||||||
)
|
)
|
||||||
db.add(project)
|
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.commit()
|
||||||
db.refresh(project)
|
db.refresh(project)
|
||||||
return enrich_project(project, db)
|
return enrich_project(project, db)
|
||||||
@ -121,7 +183,10 @@ def update_project(
|
|||||||
if req.project_type is not None:
|
if req.project_type is not None:
|
||||||
p.project_type = ProjectType(req.project_type)
|
p.project_type = ProjectType(req.project_type)
|
||||||
if req.status is not None:
|
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:
|
if req.leader_id is not None:
|
||||||
p.leader_id = req.leader_id
|
p.leader_id = req.leader_id
|
||||||
if req.current_phase is not None:
|
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(OutsourceCost).filter(OutsourceCost.project_id == project_id).delete()
|
||||||
db.query(AIToolCostAllocation).filter(AIToolCostAllocation.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(CostOverride).filter(CostOverride.project_id == project_id).delete()
|
||||||
|
db.query(ProjectMilestone).filter(ProjectMilestone.project_id == project_id).delete()
|
||||||
db.delete(p)
|
db.delete(p)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"message": "项目已删除"}
|
return {"message": "项目已删除"}
|
||||||
@ -176,8 +242,91 @@ def complete_project(
|
|||||||
p = db.query(Project).filter(Project.id == project_id).first()
|
p = db.query(Project).filter(Project.id == project_id).first()
|
||||||
if not p:
|
if not p:
|
||||||
raise HTTPException(status_code=404, detail="项目不存在")
|
raise HTTPException(status_code=404, detail="项目不存在")
|
||||||
from datetime import date
|
from datetime import date as date_today
|
||||||
p.status = ProjectStatus.COMPLETED
|
p.status = ProjectStatus.COMPLETED
|
||||||
p.actual_completion_date = date.today()
|
p.actual_completion_date = date_today.today()
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"message": "项目已标记为完成", "project_id": project_id}
|
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": "已删除"}
|
||||||
|
|||||||
107
backend/routers/reports.py
Normal file
107
backend/routers/reports.py
Normal file
@ -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)
|
||||||
@ -44,8 +44,11 @@ def list_submissions(
|
|||||||
current_user: User = Depends(get_current_user)
|
current_user: User = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
q = db.query(Submission)
|
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)
|
q = q.filter(Submission.user_id == current_user.id)
|
||||||
elif user_id:
|
elif user_id:
|
||||||
q = q.filter(Submission.user_id == user_id)
|
q = q.filter(Submission.user_id == user_id)
|
||||||
|
|||||||
@ -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):
|
class ProjectCreate(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
project_type: str
|
project_type: str
|
||||||
@ -70,6 +87,7 @@ class ProjectCreate(BaseModel):
|
|||||||
episode_count: int
|
episode_count: int
|
||||||
estimated_completion_date: Optional[date] = None
|
estimated_completion_date: Optional[date] = None
|
||||||
contract_amount: Optional[float] = None
|
contract_amount: Optional[float] = None
|
||||||
|
milestones: Optional[List[dict]] = None # [{"name", "phase", "sort_order"}]
|
||||||
|
|
||||||
|
|
||||||
class ProjectUpdate(BaseModel):
|
class ProjectUpdate(BaseModel):
|
||||||
@ -105,6 +123,10 @@ class ProjectOut(BaseModel):
|
|||||||
progress_percent: Optional[float] = 0
|
progress_percent: Optional[float] = 0
|
||||||
waste_seconds: Optional[float] = 0
|
waste_seconds: Optional[float] = 0
|
||||||
waste_rate: Optional[float] = 0
|
waste_rate: Optional[float] = 0
|
||||||
|
# 里程碑
|
||||||
|
milestones: Optional[List[MilestoneOut]] = []
|
||||||
|
phase_summary: Optional[dict] = None
|
||||||
|
current_stage: Optional[str] = None
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|||||||
0
backend/services/__init__.py
Normal file
0
backend/services/__init__.py
Normal file
96
backend/services/ai_service.py
Normal file
96
backend/services/ai_service.py
Normal file
@ -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 ""
|
||||||
146
backend/services/feishu_service.py
Normal file
146
backend/services/feishu_service.py
Normal file
@ -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()
|
||||||
507
backend/services/report_service.py
Normal file
507
backend/services/report_service.py
Normal file
@ -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
|
||||||
78
backend/services/scheduler_service.py
Normal file
78
backend/services/scheduler_service.py
Normal file
@ -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"
|
||||||
|
)
|
||||||
@ -63,6 +63,10 @@ export const projectApi = {
|
|||||||
complete: (id) => api.post(`/projects/${id}/complete`),
|
complete: (id) => api.post(`/projects/${id}/complete`),
|
||||||
settlement: (id) => api.get(`/projects/${id}/settlement`),
|
settlement: (id) => api.get(`/projects/${id}/settlement`),
|
||||||
efficiency: (id) => api.get(`/projects/${id}/efficiency`),
|
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}`),
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 内容提交 ──
|
// ── 内容提交 ──
|
||||||
|
|||||||
@ -19,7 +19,7 @@
|
|||||||
:to="item.path"
|
:to="item.path"
|
||||||
class="nav-item"
|
class="nav-item"
|
||||||
:class="{ active: isActive(item.path) }"
|
: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))"
|
||||||
>
|
>
|
||||||
<el-icon :size="18"><component :is="item.icon" /></el-icon>
|
<el-icon :size="18"><component :is="item.icon" /></el-icon>
|
||||||
<span v-show="!isCollapsed" class="nav-label">{{ item.label }}</span>
|
<span v-show="!isCollapsed" class="nav-label">{{ item.label }}</span>
|
||||||
@ -72,7 +72,7 @@ const menuItems = [
|
|||||||
{ path: '/dashboard', label: '仪表盘', icon: 'Odometer', perm: 'dashboard:view' },
|
{ path: '/dashboard', label: '仪表盘', icon: 'Odometer', perm: 'dashboard:view' },
|
||||||
{ path: '/projects', label: '项目管理', icon: 'FolderOpened', perm: 'project:view' },
|
{ path: '/projects', label: '项目管理', icon: 'FolderOpened', perm: 'project:view' },
|
||||||
{ path: '/submissions', label: '内容提交', icon: 'EditPen', perm: 'submission: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: '/users', label: '用户管理', icon: 'User', perm: 'user:manage' },
|
||||||
{ path: '/roles', label: '角色管理', icon: 'Lock', perm: 'role:manage' },
|
{ path: '/roles', label: '角色管理', icon: 'Lock', perm: 'role:manage' },
|
||||||
]
|
]
|
||||||
|
|||||||
@ -11,7 +11,7 @@ const routes = [
|
|||||||
{ path: 'projects', name: 'Projects', component: () => import('../views/Projects.vue'), meta: { perm: 'project:view' } },
|
{ 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: '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: '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', 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: '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' } },
|
{ 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 checkPerm = (perm) => {
|
||||||
// 找到第一个有权限的页面
|
if (!perm) return true
|
||||||
const fallback = routes[1].children.find(r => !r.meta?.perm || authStore.hasPermission(r.meta.perm))
|
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')
|
next(fallback ? '/' + fallback.path : '/login')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,9 +4,9 @@
|
|||||||
|
|
||||||
<el-tabs v-model="activeTab">
|
<el-tabs v-model="activeTab">
|
||||||
<!-- AI 工具成本 -->
|
<!-- AI 工具成本 -->
|
||||||
<el-tab-pane label="AI 工具成本" name="ai">
|
<el-tab-pane v-if="authStore.hasPermission('cost_ai:view')" label="AI 工具成本" name="ai">
|
||||||
<div class="tab-header">
|
<div class="tab-header">
|
||||||
<el-button type="primary" size="small" @click="showAIForm = true"><el-icon><Plus /></el-icon> 新增</el-button>
|
<el-button v-if="authStore.hasPermission('cost_ai:create')" type="primary" size="small" @click="showAIForm = true"><el-icon><Plus /></el-icon> 新增</el-button>
|
||||||
</div>
|
</div>
|
||||||
<el-table :data="aiCosts" v-loading="loadingAI" stripe size="small">
|
<el-table :data="aiCosts" v-loading="loadingAI" stripe size="small">
|
||||||
<el-table-column prop="tool_name" label="工具名称" width="140" />
|
<el-table-column prop="tool_name" label="工具名称" width="140" />
|
||||||
@ -16,16 +16,16 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="allocation_type" label="归属方式" width="120" />
|
<el-table-column prop="allocation_type" label="归属方式" width="120" />
|
||||||
<el-table-column prop="record_date" label="录入日期" width="110" />
|
<el-table-column prop="record_date" label="录入日期" width="110" />
|
||||||
<el-table-column label="操作" width="80" v-if="authStore.hasPermission('cost:delete')">
|
<el-table-column label="操作" width="80" v-if="authStore.hasPermission('cost_ai:delete')">
|
||||||
<template #default="{row}"><el-button text type="danger" size="small" @click="deleteAI(row.id)">删除</el-button></template>
|
<template #default="{row}"><el-button text type="danger" size="small" @click="deleteAI(row.id)">删除</el-button></template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
|
||||||
<!-- 外包成本 -->
|
<!-- 外包成本 -->
|
||||||
<el-tab-pane label="外包成本" name="outsource">
|
<el-tab-pane v-if="authStore.hasPermission('cost_outsource:view')" label="外包成本" name="outsource">
|
||||||
<div class="tab-header">
|
<div class="tab-header">
|
||||||
<el-button type="primary" size="small" @click="showOutForm = true"><el-icon><Plus /></el-icon> 新增</el-button>
|
<el-button v-if="authStore.hasPermission('cost_outsource:create')" type="primary" size="small" @click="showOutForm = true"><el-icon><Plus /></el-icon> 新增</el-button>
|
||||||
</div>
|
</div>
|
||||||
<el-table :data="outCosts" v-loading="loadingOut" stripe size="small">
|
<el-table :data="outCosts" v-loading="loadingOut" stripe size="small">
|
||||||
<el-table-column label="项目" width="160">
|
<el-table-column label="项目" width="160">
|
||||||
@ -39,16 +39,16 @@
|
|||||||
<template #default="{row}">¥{{ row.amount.toLocaleString() }}</template>
|
<template #default="{row}">¥{{ row.amount.toLocaleString() }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="record_date" label="录入日期" width="110" />
|
<el-table-column prop="record_date" label="录入日期" width="110" />
|
||||||
<el-table-column label="操作" width="80" v-if="authStore.hasPermission('cost:delete')">
|
<el-table-column label="操作" width="80" v-if="authStore.hasPermission('cost_outsource:delete')">
|
||||||
<template #default="{row}"><el-button text type="danger" size="small" @click="deleteOut(row.id)">删除</el-button></template>
|
<template #default="{row}"><el-button text type="danger" size="small" @click="deleteOut(row.id)">删除</el-button></template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
|
||||||
<!-- 固定开支 -->
|
<!-- 固定开支 -->
|
||||||
<el-tab-pane label="固定开支" name="overhead">
|
<el-tab-pane v-if="authStore.hasPermission('cost_overhead:view')" label="固定开支" name="overhead">
|
||||||
<div class="tab-header">
|
<div class="tab-header">
|
||||||
<el-button type="primary" size="small" @click="showOHForm = true"><el-icon><Plus /></el-icon> 新增</el-button>
|
<el-button v-if="authStore.hasPermission('cost_overhead:create')" type="primary" size="small" @click="showOHForm = true"><el-icon><Plus /></el-icon> 新增</el-button>
|
||||||
</div>
|
</div>
|
||||||
<el-table :data="overheadCosts" v-loading="loadingOH" stripe size="small">
|
<el-table :data="overheadCosts" v-loading="loadingOH" stripe size="small">
|
||||||
<el-table-column prop="cost_type" label="费用类型" width="140" />
|
<el-table-column prop="cost_type" label="费用类型" width="140" />
|
||||||
@ -57,7 +57,7 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="record_month" label="所属月份" width="110" />
|
<el-table-column prop="record_month" label="所属月份" width="110" />
|
||||||
<el-table-column prop="note" label="备注" show-overflow-tooltip />
|
<el-table-column prop="note" label="备注" show-overflow-tooltip />
|
||||||
<el-table-column label="操作" width="80" v-if="authStore.hasPermission('cost:delete')">
|
<el-table-column label="操作" width="80" v-if="authStore.hasPermission('cost_overhead:delete')">
|
||||||
<template #default="{row}"><el-button text type="danger" size="small" @click="deleteOH(row.id)">删除</el-button></template>
|
<template #default="{row}"><el-button text type="danger" size="small" @click="deleteOH(row.id)">删除</el-button></template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
@ -165,7 +165,13 @@ import { useAuthStore } from '../stores/auth'
|
|||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
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 loadingAI = ref(false)
|
||||||
const loadingOut = ref(false)
|
const loadingOut = ref(false)
|
||||||
const loadingOH = ref(false)
|
const loadingOH = ref(false)
|
||||||
@ -211,7 +217,10 @@ async function deleteOH(id) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
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 {}
|
try { projects.value = await projectApi.list({}) } catch {}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -65,6 +65,33 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 风险预警 -->
|
||||||
|
<div class="card risk-card" v-if="data.risk_alerts?.length">
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="card-title">
|
||||||
|
<el-icon :size="16" style="color:#FF9500;margin-right:6px;vertical-align:-2px"><WarnTriangleFilled /></el-icon>
|
||||||
|
风险预警
|
||||||
|
</span>
|
||||||
|
<span class="card-count">{{ data.risk_alerts.length }} 个项目存在风险</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div v-for="risk in data.risk_alerts" :key="risk.project_id"
|
||||||
|
class="risk-item" :class="risk.risk_level"
|
||||||
|
@click="$router.push(`/projects/${risk.project_id}`)">
|
||||||
|
<div class="risk-header">
|
||||||
|
<span class="risk-name">{{ risk.project_name }}</span>
|
||||||
|
<el-tag size="small" :type="risk.risk_level === 'high' ? 'danger' : 'warning'" effect="dark">
|
||||||
|
{{ risk.risk_level === 'high' ? '高风险' : '中风险' }}
|
||||||
|
</el-tag>
|
||||||
|
<span class="risk-progress">进度 {{ risk.progress }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="risk-factors">
|
||||||
|
<span v-for="(factor, i) in risk.risk_factors" :key="i" class="risk-factor">{{ factor }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 图表行:产出趋势 + 成本构成 -->
|
<!-- 图表行:产出趋势 + 成本构成 -->
|
||||||
<div class="chart-row">
|
<div class="chart-row">
|
||||||
<div class="card chart-card wide">
|
<div class="card chart-card wide">
|
||||||
@ -395,4 +422,24 @@ onUnmounted(() => {
|
|||||||
.profit-text { font-weight: 600; color: #34C759; }
|
.profit-text { font-weight: 600; color: #34C759; }
|
||||||
.profit-text.loss { color: #FF3B30; }
|
.profit-text.loss { color: #FF3B30; }
|
||||||
.text-muted { color: var(--text-secondary); }
|
.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;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -42,15 +42,24 @@
|
|||||||
<!-- 90天日历热力图 -->
|
<!-- 90天日历热力图 -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header"><span class="card-title">近 90 天提交热力图</span></div>
|
<div class="card-header"><span class="card-title">近 90 天提交热力图</span></div>
|
||||||
<div class="card-body">
|
<div class="card-body heatmap-body">
|
||||||
<div class="calendar-heatmap" v-if="calendarData.length">
|
<div class="calendar-heatmap" v-if="calendarData.length">
|
||||||
<div class="calendar-row">
|
<div class="heatmap-summary">近 90 天内有 <strong>{{ activeDays }}</strong> 天有提交</div>
|
||||||
<div v-for="week in calendarWeeks" :key="week[0]" class="calendar-week">
|
<div class="calendar-wrapper">
|
||||||
<div v-for="day in week" :key="day.date" class="calendar-day"
|
<div class="weekday-labels">
|
||||||
:class="getDayClass(day)" :title="getDayTitle(day)">
|
<span v-for="l in ['日','一','二','三','四','五','六']" :key="l">{{ l }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="calendar-grid">
|
||||||
|
<div v-for="(week, wi) in calendarWeeks" :key="wi" class="calendar-week">
|
||||||
|
<div v-for="(day, di) in week" :key="`${wi}-${di}`" class="calendar-day"
|
||||||
|
:class="getDayClass(day)" :title="getDayTitle(day)">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="month-labels" v-if="monthLabels.length">
|
||||||
|
<span v-for="(m, i) in monthLabels" :key="i" class="month-label" :style="{ left: m.offset + 'px' }">{{ m.label }}</span>
|
||||||
|
</div>
|
||||||
<div class="calendar-legend">
|
<div class="calendar-legend">
|
||||||
<span class="legend-text">少</span>
|
<span class="legend-text">少</span>
|
||||||
<span class="calendar-day level-0"></span>
|
<span class="calendar-day level-0"></span>
|
||||||
@ -70,16 +79,16 @@
|
|||||||
<div class="card-header"><span class="card-title">按项目统计</span></div>
|
<div class="card-header"><span class="card-title">按项目统计</span></div>
|
||||||
<div class="card-body" v-if="projectGroups.length">
|
<div class="card-body" v-if="projectGroups.length">
|
||||||
<div v-for="pg in projectGroups" :key="pg.projectId" class="project-group">
|
<div v-for="pg in projectGroups" :key="pg.projectId" class="project-group">
|
||||||
<div class="project-group-header" @click="pg.expanded = !pg.expanded">
|
<div class="project-group-header" @click="toggleProjectExpand(pg.projectId)">
|
||||||
<div class="project-group-info">
|
<div class="project-group-info">
|
||||||
<span class="project-group-name">{{ pg.projectName }}</span>
|
<span class="project-group-name">{{ pg.projectName }}</span>
|
||||||
<el-tag size="small" type="info">{{ pg.submissions.length }} 次提交</el-tag>
|
<el-tag size="small" type="info">{{ pg.submissions.length }} 次提交</el-tag>
|
||||||
<span class="project-group-secs">总产出 {{ formatSecs(pg.totalSecs) }}</span>
|
<span class="project-group-secs">总产出 {{ formatSecs(pg.totalSecs) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<el-icon :class="{ rotated: pg.expanded }"><ArrowRight /></el-icon>
|
<el-icon :class="{ rotated: expandedProjectIds.has(pg.projectId) }"><ArrowRight /></el-icon>
|
||||||
</div>
|
</div>
|
||||||
<transition name="expand">
|
<transition name="expand">
|
||||||
<div v-if="pg.expanded" class="project-group-body">
|
<div v-if="expandedProjectIds.has(pg.projectId)" class="project-group-body">
|
||||||
<el-table :data="pg.submissions" size="small">
|
<el-table :data="pg.submissions" size="small">
|
||||||
<el-table-column prop="submit_date" label="日期" width="110" />
|
<el-table-column prop="submit_date" label="日期" width="110" />
|
||||||
<el-table-column label="工作类型" width="80">
|
<el-table-column label="工作类型" width="80">
|
||||||
@ -105,7 +114,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, reactive, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { userApi, submissionApi } from '../api'
|
import { userApi, submissionApi } from '../api'
|
||||||
|
|
||||||
@ -113,6 +122,7 @@ const route = useRoute()
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const member = ref({})
|
const member = ref({})
|
||||||
const allSubmissions = ref([])
|
const allSubmissions = ref([])
|
||||||
|
const expandedProjectIds = ref(new Set())
|
||||||
|
|
||||||
const totalOutputSecs = computed(() => allSubmissions.value.reduce((s, r) => s + (r.total_seconds || 0), 0))
|
const totalOutputSecs = computed(() => allSubmissions.value.reduce((s, r) => s + (r.total_seconds || 0), 0))
|
||||||
|
|
||||||
@ -151,7 +161,6 @@ const calendarWeeks = computed(() => {
|
|||||||
const weeks = []
|
const weeks = []
|
||||||
const data = calendarData.value
|
const data = calendarData.value
|
||||||
let week = []
|
let week = []
|
||||||
// 补齐第一周的空位
|
|
||||||
const firstDay = new Date(data[0]?.date).getDay()
|
const firstDay = new Date(data[0]?.date).getDay()
|
||||||
for (let i = 0; i < firstDay; i++) week.push({ date: '', secs: 0, count: 0, empty: true })
|
for (let i = 0; i < firstDay; i++) week.push({ date: '', secs: 0, count: 0, empty: true })
|
||||||
data.forEach(d => {
|
data.forEach(d => {
|
||||||
@ -162,6 +171,26 @@ const calendarWeeks = computed(() => {
|
|||||||
return weeks
|
return weeks
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 月份标签:每月首次出现的月份
|
||||||
|
const cellWidth = 22
|
||||||
|
const monthLabels = computed(() => {
|
||||||
|
const data = calendarData.value
|
||||||
|
if (!data.length) return []
|
||||||
|
const firstDate = new Date(data[0].date)
|
||||||
|
const result = []
|
||||||
|
let prevMonth = -1
|
||||||
|
for (let i = 0; i < data.length; i++) {
|
||||||
|
if (data[i].empty) continue
|
||||||
|
const dt = new Date(data[i].date)
|
||||||
|
if (dt.getMonth() !== prevMonth) {
|
||||||
|
prevMonth = dt.getMonth()
|
||||||
|
const weekIdx = Math.floor((i + firstDate.getDay()) / 7)
|
||||||
|
result.push({ label: `${dt.getMonth() + 1}月`, offset: weekIdx * cellWidth })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
function getDayClass(day) {
|
function getDayClass(day) {
|
||||||
if (day.empty) return 'empty'
|
if (day.empty) return 'empty'
|
||||||
if (day.count === 0) return 'level-0'
|
if (day.count === 0) return 'level-0'
|
||||||
@ -182,13 +211,12 @@ const projectGroups = computed(() => {
|
|||||||
const groups = {}
|
const groups = {}
|
||||||
allSubmissions.value.forEach(s => {
|
allSubmissions.value.forEach(s => {
|
||||||
if (!groups[s.project_id]) {
|
if (!groups[s.project_id]) {
|
||||||
groups[s.project_id] = reactive({
|
groups[s.project_id] = {
|
||||||
projectId: s.project_id,
|
projectId: s.project_id,
|
||||||
projectName: s.project_name,
|
projectName: s.project_name,
|
||||||
submissions: [],
|
submissions: [],
|
||||||
totalSecs: 0,
|
totalSecs: 0,
|
||||||
expanded: false,
|
}
|
||||||
})
|
|
||||||
}
|
}
|
||||||
groups[s.project_id].submissions.push(s)
|
groups[s.project_id].submissions.push(s)
|
||||||
groups[s.project_id].totalSecs += s.total_seconds || 0
|
groups[s.project_id].totalSecs += s.total_seconds || 0
|
||||||
@ -196,6 +224,13 @@ const projectGroups = computed(() => {
|
|||||||
return Object.values(groups).sort((a, b) => b.totalSecs - a.totalSecs)
|
return Object.values(groups).sort((a, b) => b.totalSecs - a.totalSecs)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function toggleProjectExpand(projectId) {
|
||||||
|
const next = new Set(expandedProjectIds.value)
|
||||||
|
if (next.has(projectId)) next.delete(projectId)
|
||||||
|
else next.add(projectId)
|
||||||
|
expandedProjectIds.value = next
|
||||||
|
}
|
||||||
|
|
||||||
function formatSecs(s) {
|
function formatSecs(s) {
|
||||||
if (!s) return '0秒'
|
if (!s) return '0秒'
|
||||||
const abs = Math.abs(s)
|
const abs = Math.abs(s)
|
||||||
@ -242,23 +277,43 @@ onMounted(load)
|
|||||||
.card-body { padding: 20px; }
|
.card-body { padding: 20px; }
|
||||||
|
|
||||||
/* 日历热力图 */
|
/* 日历热力图 */
|
||||||
.calendar-heatmap { overflow-x: auto; }
|
.heatmap-body { min-height: 160px; }
|
||||||
.calendar-row { display: flex; gap: 3px; }
|
.heatmap-summary {
|
||||||
.calendar-week { display: flex; flex-direction: column; gap: 3px; }
|
font-size: 13px; color: var(--text-secondary); margin-bottom: 12px;
|
||||||
.calendar-day {
|
|
||||||
width: 12px; height: 12px; border-radius: 2px; background: #EBEDF0;
|
|
||||||
}
|
}
|
||||||
.calendar-day.empty { background: transparent; }
|
.heatmap-summary strong { color: var(--text-primary); font-weight: 600; }
|
||||||
|
.calendar-heatmap { width: 100%; }
|
||||||
|
.calendar-wrapper { display: flex; gap: 6px; align-items: flex-start; margin-bottom: 8px; }
|
||||||
|
.weekday-labels {
|
||||||
|
display: flex; flex-direction: column; gap: 4px; flex-shrink: 0; width: 14px;
|
||||||
|
font-size: 11px; color: var(--text-secondary); line-height: 18px;
|
||||||
|
}
|
||||||
|
.weekday-labels span { height: 18px; text-align: center; }
|
||||||
|
.calendar-grid {
|
||||||
|
display: flex; gap: 4px; justify-content: flex-start;
|
||||||
|
}
|
||||||
|
.month-labels {
|
||||||
|
position: relative; height: 16px; margin-top: 4px; margin-left: 20px;
|
||||||
|
font-size: 11px; color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.month-label { position: absolute; }
|
||||||
|
.calendar-week { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.calendar-day {
|
||||||
|
width: 18px; height: 18px; border-radius: 3px; background: #EBEDF0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.calendar-day.empty { background: transparent; min-width: 18px; min-height: 18px; }
|
||||||
.calendar-day.level-0 { background: #EBEDF0; }
|
.calendar-day.level-0 { background: #EBEDF0; }
|
||||||
.calendar-day.level-1 { background: #9be9a8; }
|
.calendar-day.level-1 { background: #9be9a8; }
|
||||||
.calendar-day.level-2 { background: #40c463; }
|
.calendar-day.level-2 { background: #40c463; }
|
||||||
.calendar-day.level-3 { background: #30a14e; }
|
.calendar-day.level-3 { background: #30a14e; }
|
||||||
.calendar-day.level-4 { background: #216e39; }
|
.calendar-day.level-4 { background: #216e39; }
|
||||||
|
.calendar-day:hover:not(.empty) { outline: 2px solid #30a14e; outline-offset: 1px; }
|
||||||
.calendar-legend {
|
.calendar-legend {
|
||||||
display: flex; align-items: center; gap: 3px; margin-top: 8px;
|
display: flex; align-items: center; gap: 4px; margin-top: 12px;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end; font-size: 12px;
|
||||||
}
|
}
|
||||||
.legend-text { font-size: 11px; color: var(--text-secondary); margin: 0 4px; }
|
.legend-text { color: var(--text-secondary); margin: 0 6px; }
|
||||||
|
|
||||||
/* 项目分组 */
|
/* 项目分组 */
|
||||||
.project-group { border-bottom: 1px solid #f0f1f2; }
|
.project-group { border-bottom: 1px solid #f0f1f2; }
|
||||||
|
|||||||
@ -5,12 +5,14 @@
|
|||||||
<el-button text @click="$router.push('/projects')" class="back-btn"><el-icon><ArrowLeft /></el-icon></el-button>
|
<el-button text @click="$router.push('/projects')" class="back-btn"><el-icon><ArrowLeft /></el-icon></el-button>
|
||||||
<h2>{{ project.name }}</h2>
|
<h2>{{ project.name }}</h2>
|
||||||
<el-tag :type="typeTagMap[project.project_type]" size="small">{{ project.project_type }}</el-tag>
|
<el-tag :type="typeTagMap[project.project_type]" size="small">{{ project.project_type }}</el-tag>
|
||||||
<el-tag :type="project.status === '已完成' ? 'success' : 'info'" size="small">{{ project.status }}</el-tag>
|
<el-tag :type="project.status === '已完成' ? 'success' : project.status === '废弃' ? 'danger' : 'info'" size="small">{{ project.status }}</el-tag>
|
||||||
</div>
|
</div>
|
||||||
<el-space>
|
<el-space>
|
||||||
|
<el-button v-if="authStore.hasPermission('project:edit')" @click="openEdit">编辑项目</el-button>
|
||||||
<el-button v-if="authStore.hasPermission('project:delete')" type="danger" text @click="handleDelete">删除项目</el-button>
|
<el-button v-if="authStore.hasPermission('project:delete')" type="danger" text @click="handleDelete">删除项目</el-button>
|
||||||
<el-button v-if="authStore.hasPermission('project:complete') && project.status === '制作中'" type="danger" plain @click="handleComplete">确认完成</el-button>
|
<el-button v-if="authStore.hasPermission('project:complete') && project.status === '制作中'" type="danger" plain @click="handleComplete">确认完成</el-button>
|
||||||
<el-button v-if="authStore.hasPermission('settlement:view') && project.status === '已完成'" type="primary" @click="$router.push(`/settlement/${project.id}`)">查看结算</el-button>
|
<el-button v-if="authStore.hasPermission('settlement:view') && project.status === '已完成'" type="primary" @click="$router.push(`/settlement/${project.id}`)">查看结算</el-button>
|
||||||
|
<el-button v-if="authStore.hasPermission('project:edit') && project.status === '制作中'" type="warning" plain @click="handleAbandon">标记废弃</el-button>
|
||||||
</el-space>
|
</el-space>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -55,8 +57,8 @@
|
|||||||
<div class="stat-value">{{ formatSecs(project.total_submitted_seconds) }}</div>
|
<div class="stat-value">{{ formatSecs(project.total_submitted_seconds) }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-label">完成进度</div>
|
<div class="stat-label">当前阶段</div>
|
||||||
<div class="stat-value" :style="{color: project.progress_percent > 100 ? '#FF9500' : '#3370FF'}">{{ project.progress_percent }}%</div>
|
<div class="stat-value" :style="{color: stageColor}">{{ project.current_stage || '—' }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-label">损耗率</div>
|
<div class="stat-label">损耗率</div>
|
||||||
@ -64,7 +66,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 项目进度时间轴 -->
|
<!-- 里程碑进度 -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<span class="card-title">项目进度</span>
|
<span class="card-title">项目进度</span>
|
||||||
@ -73,14 +75,112 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<!-- 产出进度条 -->
|
<!-- 三阶段分段进度条 -->
|
||||||
<div class="progress-section">
|
<div class="milestone-pipeline" v-if="project.phase_summary">
|
||||||
<div class="progress-label-row">
|
<div class="segmented-bar">
|
||||||
<span>产出进度</span>
|
<div class="seg-group" style="flex:2">
|
||||||
<span :style="{color: progressColor, fontWeight: 600}">{{ project.progress_percent }}%</span>
|
<div class="seg-track">
|
||||||
|
<div class="seg-fill pre" :style="{width: prePercent + '%'}"></div>
|
||||||
|
</div>
|
||||||
|
<div class="seg-info">
|
||||||
|
<span class="seg-name" :class="{active: project.current_stage === '前期'}">前期</span>
|
||||||
|
<span class="seg-stat">{{ phasePre.completed }}/{{ phasePre.total }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="seg-group" style="flex:5">
|
||||||
|
<div class="seg-track">
|
||||||
|
<div class="seg-fill production" :style="{width: Math.min(project.progress_percent, 100) + '%'}"></div>
|
||||||
|
</div>
|
||||||
|
<div class="seg-info">
|
||||||
|
<span class="seg-name" :class="{active: project.current_stage === '制作'}">制作</span>
|
||||||
|
<span class="seg-stat">{{ project.progress_percent }}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="seg-group" style="flex:2">
|
||||||
|
<div class="seg-track">
|
||||||
|
<div class="seg-fill post" :style="{width: postPercent + '%'}"></div>
|
||||||
|
</div>
|
||||||
|
<div class="seg-info">
|
||||||
|
<span class="seg-name" :class="{active: project.current_stage === '后期'}">后期</span>
|
||||||
|
<span class="seg-stat">{{ phasePost.completed }}/{{ phasePost.total }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 三列里程碑清单 -->
|
||||||
|
<div class="milestone-columns">
|
||||||
|
<!-- 前期 -->
|
||||||
|
<div class="milestone-col">
|
||||||
|
<div class="milestone-col-header">前期</div>
|
||||||
|
<div v-for="m in preMilestones" :key="m.id" class="milestone-item">
|
||||||
|
<el-checkbox
|
||||||
|
:model-value="m.is_completed"
|
||||||
|
@change="toggleMilestone(m)"
|
||||||
|
:disabled="!authStore.hasPermission('project:edit')"
|
||||||
|
/>
|
||||||
|
<span class="milestone-name" :class="{ completed: m.is_completed }">{{ m.name }}</span>
|
||||||
|
<el-button v-if="authStore.hasPermission('project:edit')" text type="danger" size="small" class="milestone-del"
|
||||||
|
@click="deleteMilestone(m.id)"><el-icon size="12"><Close /></el-icon></el-button>
|
||||||
|
</div>
|
||||||
|
<div v-if="authStore.hasPermission('project:edit')" class="milestone-add">
|
||||||
|
<el-input v-model="newMilestone.pre" size="small" placeholder="添加前期里程碑" @keyup.enter="addMilestone('前期', 'pre')">
|
||||||
|
<template #append>
|
||||||
|
<el-button @click="addMilestone('前期', 'pre')"><el-icon><Plus /></el-icon></el-button>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 制作 -->
|
||||||
|
<div class="milestone-col production-col">
|
||||||
|
<div class="milestone-col-header">制作</div>
|
||||||
|
<div class="production-ring-layout">
|
||||||
|
<div ref="progressChartRef" style="width:180px;height:180px;flex-shrink:0"></div>
|
||||||
|
<div class="production-info">
|
||||||
|
<div class="prod-info-row">
|
||||||
|
<span class="prod-info-label">已产出</span>
|
||||||
|
<span class="prod-info-value">{{ formatSecs(project.total_submitted_seconds) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="prod-info-row">
|
||||||
|
<span class="prod-info-label">目标</span>
|
||||||
|
<span class="prod-info-value">{{ formatSecs(project.target_total_seconds) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="prod-info-row">
|
||||||
|
<span class="prod-info-label">损耗</span>
|
||||||
|
<span class="prod-info-value" :style="{color: project.waste_rate > 30 ? '#FF3B30' : 'inherit'}">{{ formatSecs(project.waste_seconds) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="prod-info-row">
|
||||||
|
<span class="prod-info-label">损耗率</span>
|
||||||
|
<span class="prod-info-value" :style="{color: project.waste_rate > 30 ? '#FF3B30' : 'inherit'}">{{ project.waste_rate }}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 后期 -->
|
||||||
|
<div class="milestone-col">
|
||||||
|
<div class="milestone-col-header">后期</div>
|
||||||
|
<div v-for="m in postMilestones" :key="m.id" class="milestone-item">
|
||||||
|
<el-checkbox
|
||||||
|
:model-value="m.is_completed"
|
||||||
|
@change="toggleMilestone(m)"
|
||||||
|
:disabled="!authStore.hasPermission('project:edit')"
|
||||||
|
/>
|
||||||
|
<span class="milestone-name" :class="{ completed: m.is_completed }">{{ m.name }}</span>
|
||||||
|
<el-button v-if="authStore.hasPermission('project:edit')" text type="danger" size="small" class="milestone-del"
|
||||||
|
@click="deleteMilestone(m.id)"><el-icon size="12"><Close /></el-icon></el-button>
|
||||||
|
</div>
|
||||||
|
<div v-if="authStore.hasPermission('project:edit')" class="milestone-add">
|
||||||
|
<el-input v-model="newMilestone.post" size="small" placeholder="添加后期里程碑" @keyup.enter="addMilestone('后期', 'post')">
|
||||||
|
<template #append>
|
||||||
|
<el-button @click="addMilestone('后期', 'post')"><el-icon><Plus /></el-icon></el-button>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<el-progress :percentage="Math.min(project.progress_percent, 100)" :stroke-width="8" :show-text="false" :color="progressColor" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 时间轴 -->
|
<!-- 时间轴 -->
|
||||||
<div class="timeline-section" v-if="project.created_at && project.estimated_completion_date">
|
<div class="timeline-section" v-if="project.created_at && project.estimated_completion_date">
|
||||||
<div class="progress-label-row">
|
<div class="progress-label-row">
|
||||||
@ -208,6 +308,63 @@
|
|||||||
</div>
|
</div>
|
||||||
</el-drawer>
|
</el-drawer>
|
||||||
|
|
||||||
|
<!-- 编辑项目对话框 -->
|
||||||
|
<el-dialog v-model="showEdit" title="编辑项目" width="560px" destroy-on-close>
|
||||||
|
<el-form :model="editForm" label-width="110px" label-position="left">
|
||||||
|
<el-form-item label="项目名称" required>
|
||||||
|
<el-input v-model="editForm.name" placeholder="输入项目名称" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="项目类型" required>
|
||||||
|
<el-select v-model="editForm.project_type" style="width:100%">
|
||||||
|
<el-option v-for="t in projectTypes" :key="t" :label="t" :value="t" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="项目状态">
|
||||||
|
<el-select v-model="editForm.status" style="width:100%">
|
||||||
|
<el-option label="制作中" value="制作中" />
|
||||||
|
<el-option label="已完成" value="已完成" />
|
||||||
|
<el-option label="废弃" value="废弃" />
|
||||||
|
</el-select>
|
||||||
|
<div class="field-hint" style="margin-top:4px">可将已完成项目改回制作中,误触确认完成时可在此恢复</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="负责人">
|
||||||
|
<el-select v-model="editForm.leader_id" placeholder="选择负责人" clearable style="width:100%">
|
||||||
|
<el-option v-for="u in users" :key="u.id" :label="u.name" :value="u.id" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="当前阶段">
|
||||||
|
<el-select v-model="editForm.current_phase" style="width:100%">
|
||||||
|
<el-option label="前期" value="前期" />
|
||||||
|
<el-option label="制作" value="制作" />
|
||||||
|
<el-option label="后期" value="后期" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="单集时长" required>
|
||||||
|
<div class="inline-field">
|
||||||
|
<el-input-number v-model="editForm.episode_duration_minutes" :min="0.1" :step="0.5" style="width:140px" />
|
||||||
|
<span class="field-unit">分钟/集</span>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="集数" required>
|
||||||
|
<div class="inline-field">
|
||||||
|
<el-input-number v-model="editForm.episode_count" :min="1" style="width:140px" />
|
||||||
|
<span class="field-unit">集</span>
|
||||||
|
<span class="field-hint">目标总时长 = {{ editForm.episode_duration_minutes }} 分 × {{ editForm.episode_count }} 集 = {{ (editForm.episode_duration_minutes * editForm.episode_count).toFixed(1) }} 分钟</span>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="预估完成日期">
|
||||||
|
<el-date-picker v-model="editForm.estimated_completion_date" value-format="YYYY-MM-DD" placeholder="选择预计交付日期" style="width:100%" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item v-if="editForm.project_type === '客户正式项目'" label="合同金额">
|
||||||
|
<el-input-number v-model="editForm.contract_amount" :min="0" :step="10000" :controls="false" placeholder="甲方合同总金额(元)" style="width:100%" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="showEdit = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="editing" @click="handleSaveEdit">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
<!-- 提交记录 -->
|
<!-- 提交记录 -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
@ -236,11 +393,12 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, reactive, onMounted } from 'vue'
|
import { ref, computed, reactive, onMounted, onUnmounted, nextTick, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { projectApi, submissionApi } from '../api'
|
import { projectApi, submissionApi, userApi } from '../api'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import * as echarts from 'echarts'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@ -249,9 +407,101 @@ const loading = ref(false)
|
|||||||
const project = ref({})
|
const project = ref({})
|
||||||
const submissions = ref([])
|
const submissions = ref([])
|
||||||
const efficiency = ref([])
|
const efficiency = ref([])
|
||||||
|
const users = ref([])
|
||||||
|
const showEdit = ref(false)
|
||||||
|
const editing = ref(false)
|
||||||
|
const projectTypes = ['客户正式项目', '客户测试项目', '内部原创项目', '内部测试项目']
|
||||||
const typeTagMap = { '客户正式项目': 'success', '客户测试项目': 'warning', '内部原创项目': '', '内部测试项目': 'info' }
|
const typeTagMap = { '客户正式项目': 'success', '客户测试项目': 'warning', '内部原创项目': '', '内部测试项目': 'info' }
|
||||||
|
|
||||||
|
// ── 圆环进度图 ──
|
||||||
|
const progressChartRef = ref(null)
|
||||||
|
let progressChart = null
|
||||||
|
|
||||||
|
function initProgressChart() {
|
||||||
|
if (!progressChartRef.value) return
|
||||||
|
const p = project.value
|
||||||
|
const pct = p.progress_percent || 0
|
||||||
|
const isOver = pct > 100
|
||||||
|
|
||||||
|
if (progressChart) progressChart.dispose()
|
||||||
|
progressChart = echarts.init(progressChartRef.value)
|
||||||
|
|
||||||
|
progressChart.setOption({
|
||||||
|
series: [{
|
||||||
|
type: 'pie',
|
||||||
|
radius: ['55%', '78%'],
|
||||||
|
center: ['50%', '50%'],
|
||||||
|
silent: true,
|
||||||
|
label: { show: false },
|
||||||
|
data: [
|
||||||
|
{ value: Math.min(pct, 100), itemStyle: { color: isOver ? '#FF9500' : '#3370FF' } },
|
||||||
|
{ value: Math.max(0, 100 - pct), itemStyle: { color: '#E5E6EB' } },
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
graphic: [{
|
||||||
|
type: 'text',
|
||||||
|
left: 'center', top: 'center',
|
||||||
|
style: {
|
||||||
|
text: pct + '%',
|
||||||
|
fontSize: 22, fontWeight: 700,
|
||||||
|
fill: isOver ? '#FF9500' : '#3370FF',
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleProgressResize() { progressChart?.resize() }
|
||||||
|
|
||||||
|
const editForm = reactive({
|
||||||
|
name: '', project_type: '客户正式项目', status: '制作中', leader_id: null, current_phase: '制作',
|
||||||
|
episode_duration_minutes: 5, episode_count: 1,
|
||||||
|
estimated_completion_date: null, contract_amount: null,
|
||||||
|
})
|
||||||
|
|
||||||
const progressColor = computed(() => project.value.progress_percent > 100 ? '#FF9500' : '#3370FF')
|
const progressColor = computed(() => project.value.progress_percent > 100 ? '#FF9500' : '#3370FF')
|
||||||
|
const stageColor = computed(() => {
|
||||||
|
const s = project.value.current_stage
|
||||||
|
if (s === '已完成') return '#34C759'
|
||||||
|
if (s === '前期') return '#8F959E'
|
||||||
|
if (s === '制作') return '#3370FF'
|
||||||
|
if (s === '后期') return '#FF9500'
|
||||||
|
return 'var(--text-primary)'
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 里程碑 ──
|
||||||
|
const phasePre = computed(() => project.value.phase_summary?.pre || { total: 0, completed: 0 })
|
||||||
|
const phasePost = computed(() => project.value.phase_summary?.post || { total: 0, completed: 0 })
|
||||||
|
const prePercent = computed(() => phasePre.value.total > 0 ? Math.round(phasePre.value.completed / phasePre.value.total * 100) : 0)
|
||||||
|
const postPercent = computed(() => phasePost.value.total > 0 ? Math.round(phasePost.value.completed / phasePost.value.total * 100) : 0)
|
||||||
|
const preMilestones = computed(() => (project.value.milestones || []).filter(m => m.phase === '前期'))
|
||||||
|
const postMilestones = computed(() => (project.value.milestones || []).filter(m => m.phase === '后期'))
|
||||||
|
const newMilestone = reactive({ pre: '', post: '' })
|
||||||
|
|
||||||
|
async function toggleMilestone(m) {
|
||||||
|
try {
|
||||||
|
await projectApi.toggleMilestone(m.id)
|
||||||
|
load()
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addMilestone(phase, key) {
|
||||||
|
const name = newMilestone[key]?.trim()
|
||||||
|
if (!name) return
|
||||||
|
try {
|
||||||
|
await projectApi.addMilestone(route.params.id, { name, phase })
|
||||||
|
newMilestone[key] = ''
|
||||||
|
load()
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteMilestone(id) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('确认删除此里程碑?', '删除', { type: 'warning' })
|
||||||
|
await projectApi.deleteMilestone(id)
|
||||||
|
load()
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
// ── 时间轴计算 ──
|
// ── 时间轴计算 ──
|
||||||
const todayStr = new Date().toISOString().slice(0, 10)
|
const todayStr = new Date().toISOString().slice(0, 10)
|
||||||
@ -402,6 +652,36 @@ function formatSecs(s) {
|
|||||||
return m > 0 ? `${sign}${m}分${sec > 0 ? sec + '秒' : ''}` : `${sign}${sec}秒`
|
return m > 0 ? `${sign}${m}分${sec > 0 ? sec + '秒' : ''}` : `${sign}${sec}秒`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openEdit() {
|
||||||
|
const p = project.value
|
||||||
|
if (authStore.hasPermission('user:view') && !users.value.length) {
|
||||||
|
try { users.value = await userApi.list() } catch {}
|
||||||
|
}
|
||||||
|
Object.assign(editForm, {
|
||||||
|
name: p.name, project_type: p.project_type, status: p.status || '制作中', leader_id: p.leader_id,
|
||||||
|
current_phase: p.current_phase || '制作',
|
||||||
|
episode_duration_minutes: p.episode_duration_minutes, episode_count: p.episode_count,
|
||||||
|
estimated_completion_date: p.estimated_completion_date, contract_amount: p.contract_amount,
|
||||||
|
})
|
||||||
|
showEdit.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveEdit() {
|
||||||
|
if (!editForm.name) { ElMessage.warning('请输入项目名称'); return }
|
||||||
|
editing.value = true
|
||||||
|
try {
|
||||||
|
await projectApi.update(route.params.id, {
|
||||||
|
name: editForm.name, project_type: editForm.project_type, status: editForm.status, leader_id: editForm.leader_id,
|
||||||
|
current_phase: editForm.current_phase,
|
||||||
|
episode_duration_minutes: editForm.episode_duration_minutes, episode_count: editForm.episode_count,
|
||||||
|
estimated_completion_date: editForm.estimated_completion_date, contract_amount: editForm.contract_amount,
|
||||||
|
})
|
||||||
|
ElMessage.success('项目已更新')
|
||||||
|
showEdit.value = false
|
||||||
|
load()
|
||||||
|
} finally { editing.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
@ -411,6 +691,12 @@ async function load() {
|
|||||||
if (authStore.hasPermission('efficiency:view')) {
|
if (authStore.hasPermission('efficiency:view')) {
|
||||||
try { efficiency.value = await projectApi.efficiency(id) } catch {}
|
try { efficiency.value = await projectApi.efficiency(id) } catch {}
|
||||||
}
|
}
|
||||||
|
if (authStore.hasPermission('user:view') && !users.value.length) {
|
||||||
|
try { users.value = await userApi.list() } catch {}
|
||||||
|
}
|
||||||
|
await nextTick()
|
||||||
|
initProgressChart()
|
||||||
|
window.addEventListener('resize', handleProgressResize)
|
||||||
} finally { loading.value = false }
|
} finally { loading.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -429,14 +715,32 @@ async function handleDelete() {
|
|||||||
|
|
||||||
async function handleComplete() {
|
async function handleComplete() {
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm('确认将此项目标记为完成并进行结算?此操作不可撤销。', '确认完成', { type: 'warning' })
|
await ElMessageBox.confirm('确认将此项目标记为完成并进行结算?', '确认完成', { type: 'warning' })
|
||||||
await projectApi.complete(route.params.id)
|
await projectApi.complete(route.params.id)
|
||||||
ElMessage.success('项目已完成')
|
ElMessage.success('项目已完成')
|
||||||
load()
|
load()
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleAbandon() {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
'确定将此项目标记为废弃?项目停止制作,全部产出将记为损耗。可在编辑中改回制作中。',
|
||||||
|
'标记废弃',
|
||||||
|
{ type: 'warning', confirmButtonText: '确定废弃', cancelButtonText: '取消' }
|
||||||
|
)
|
||||||
|
await projectApi.update(route.params.id, { status: '废弃' })
|
||||||
|
ElMessage.success('项目已标记为废弃')
|
||||||
|
load()
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(load)
|
onMounted(load)
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('resize', handleProgressResize)
|
||||||
|
progressChart?.dispose()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@ -484,15 +788,77 @@ onMounted(load)
|
|||||||
background: var(--bg-hover); padding: 2px 8px; border-radius: 4px; display: inline-block;
|
background: var(--bg-hover); padding: 2px 8px; border-radius: 4px; display: inline-block;
|
||||||
}
|
}
|
||||||
.rate-badge.danger { background: #FFE8E7; color: #FF3B30; }
|
.rate-badge.danger { background: #FFE8E7; color: #FF3B30; }
|
||||||
|
.inline-field { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.field-unit { font-size: 13px; color: var(--text-secondary); white-space: nowrap; }
|
||||||
|
.field-hint { font-size: 12px; color: var(--text-placeholder, #C0C4CC); margin-left: 4px; }
|
||||||
|
|
||||||
|
/* 分段进度条 */
|
||||||
|
.milestone-pipeline { margin-bottom: 24px; }
|
||||||
|
.segmented-bar { display: flex; gap: 6px; margin-bottom: 24px; }
|
||||||
|
.seg-group { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.seg-track {
|
||||||
|
height: 8px; background: #E5E6EB; border-radius: 4px;
|
||||||
|
overflow: hidden; position: relative;
|
||||||
|
}
|
||||||
|
.seg-fill {
|
||||||
|
height: 100%; border-radius: 4px; transition: width 0.4s ease;
|
||||||
|
}
|
||||||
|
.seg-fill.pre { background: #8F959E; }
|
||||||
|
.seg-fill.production { background: #3370FF; }
|
||||||
|
.seg-fill.post { background: #FF9500; }
|
||||||
|
.seg-info {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
padding: 0 2px;
|
||||||
|
}
|
||||||
|
.seg-name {
|
||||||
|
font-size: 12px; font-weight: 500; color: var(--text-secondary);
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
.seg-name.active { color: #3370FF; font-weight: 600; }
|
||||||
|
.seg-stat { font-size: 12px; color: var(--text-placeholder, #C0C4CC); font-weight: 600; }
|
||||||
|
|
||||||
|
.milestone-columns {
|
||||||
|
display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 20px;
|
||||||
|
}
|
||||||
|
.milestone-col {
|
||||||
|
background: #F7F8FA; border-radius: 8px; padding: 12px 14px;
|
||||||
|
}
|
||||||
|
.milestone-col-header {
|
||||||
|
font-size: 13px; font-weight: 600; color: var(--text-primary);
|
||||||
|
margin-bottom: 12px; padding-bottom: 8px; border-bottom: 1px solid var(--border-light, #f0f1f2);
|
||||||
|
}
|
||||||
|
.milestone-item {
|
||||||
|
display: flex; align-items: center; gap: 6px; padding: 4px 0;
|
||||||
|
}
|
||||||
|
.milestone-name { font-size: 13px; color: var(--text-primary); flex: 1; }
|
||||||
|
.milestone-name.completed { color: var(--text-placeholder, #C0C4CC); text-decoration: line-through; }
|
||||||
|
.milestone-del { opacity: 0; transition: opacity 0.15s; padding: 2px !important; }
|
||||||
|
.milestone-item:hover .milestone-del { opacity: 1; }
|
||||||
|
.milestone-add { margin-top: 8px; }
|
||||||
|
|
||||||
|
/* 制作阶段 — 圆环 + 数据 */
|
||||||
|
.production-col { display: flex; flex-direction: column; }
|
||||||
|
.production-col .milestone-col-header { margin-bottom: 4px; }
|
||||||
|
.production-ring-layout {
|
||||||
|
flex: 1; display: flex; align-items: center; gap: 12px;
|
||||||
|
}
|
||||||
|
.production-info {
|
||||||
|
flex: 1; display: flex; flex-direction: column; gap: 12px;
|
||||||
|
}
|
||||||
|
.prod-info-row {
|
||||||
|
display: flex; justify-content: space-between; align-items: baseline;
|
||||||
|
}
|
||||||
|
.prod-info-label { font-size: 12px; color: var(--text-secondary); white-space: nowrap; }
|
||||||
|
.prod-info-value { font-size: 14px; font-weight: 600; color: var(--text-primary); white-space: nowrap; }
|
||||||
|
.production-info { max-width: 140px; }
|
||||||
|
|
||||||
/* 进度时间轴 */
|
/* 进度时间轴 */
|
||||||
.progress-section { margin-bottom: 20px; }
|
|
||||||
.progress-label-row {
|
.progress-label-row {
|
||||||
display: flex; justify-content: space-between; align-items: center;
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
font-size: 13px; color: var(--text-secondary); margin-bottom: 8px;
|
font-size: 13px; color: var(--text-secondary); margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
.timeline-section { margin-top: 4px; }
|
.timeline-section { margin-top: 4px; }
|
||||||
.timeline-bar-wrapper { position: relative; }
|
.timeline-bar-wrapper { position: relative; margin-top: 24px; }
|
||||||
.timeline-bar {
|
.timeline-bar {
|
||||||
height: 8px; background: #E5E6EB; border-radius: 4px; position: relative; overflow: visible;
|
height: 8px; background: #E5E6EB; border-radius: 4px; position: relative; overflow: visible;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,6 +12,7 @@
|
|||||||
<el-select v-model="filter.status" placeholder="状态" clearable style="width:120px" @change="load">
|
<el-select v-model="filter.status" placeholder="状态" clearable style="width:120px" @change="load">
|
||||||
<el-option label="制作中" value="制作中" />
|
<el-option label="制作中" value="制作中" />
|
||||||
<el-option label="已完成" value="已完成" />
|
<el-option label="已完成" value="已完成" />
|
||||||
|
<el-option label="废弃" value="废弃" />
|
||||||
</el-select>
|
</el-select>
|
||||||
<el-select v-model="filter.project_type" placeholder="项目类型" clearable style="width:150px" @change="load">
|
<el-select v-model="filter.project_type" placeholder="项目类型" clearable style="width:150px" @change="load">
|
||||||
<el-option v-for="t in projectTypes" :key="t" :label="t" :value="t" />
|
<el-option v-for="t in projectTypes" :key="t" :label="t" :value="t" />
|
||||||
@ -34,7 +35,7 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="状态" width="90">
|
<el-table-column label="状态" width="90">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag size="small" :type="row.status === '已完成' ? 'success' : 'info'">{{ row.status }}</el-tag>
|
<el-tag size="small" :type="row.status === '已完成' ? 'success' : row.status === '废弃' ? 'danger' : 'info'">{{ row.status }}</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="leader_name" label="负责人" width="90" />
|
<el-table-column prop="leader_name" label="负责人" width="90" />
|
||||||
@ -43,10 +44,8 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="进度" width="180">
|
<el-table-column label="进度" width="180">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div class="cell-progress">
|
<div class="cell-stage">
|
||||||
<el-progress :percentage="Math.min(row.progress_percent, 100)" :stroke-width="6" :show-text="false"
|
<span class="stage-tag" :class="'stage-' + (row.current_stage || '')">{{ stageLabel(row) }}</span>
|
||||||
:color="row.progress_percent > 100 ? '#FF9500' : '#3370FF'" />
|
|
||||||
<span class="progress-text">{{ row.progress_percent }}%</span>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@ -94,6 +93,36 @@
|
|||||||
<el-form-item v-if="form.project_type === '客户正式项目'" label="合同金额">
|
<el-form-item v-if="form.project_type === '客户正式项目'" label="合同金额">
|
||||||
<el-input-number v-model="form.contract_amount" :min="0" :step="10000" :controls="false" placeholder="甲方合同总金额(元)" style="width:100%" />
|
<el-input-number v-model="form.contract_amount" :min="0" :step="10000" :controls="false" placeholder="甲方合同总金额(元)" style="width:100%" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
|
<!-- 里程碑模板 -->
|
||||||
|
<el-form-item label="里程碑">
|
||||||
|
<div class="ms-template">
|
||||||
|
<div class="ms-phase-group">
|
||||||
|
<div class="ms-phase-label">前期</div>
|
||||||
|
<div v-for="(ms, i) in msTemplate.filter(m => m.phase === '前期')" :key="'pre'+i" class="ms-check-item">
|
||||||
|
<el-checkbox v-model="ms.checked" :label="ms.name" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ms-phase-group">
|
||||||
|
<div class="ms-phase-label">后期</div>
|
||||||
|
<div v-for="(ms, i) in msTemplate.filter(m => m.phase === '后期')" :key="'post'+i" class="ms-check-item">
|
||||||
|
<el-checkbox v-model="ms.checked" :label="ms.name" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ms-custom-add">
|
||||||
|
<el-input v-model="customMs.name" size="small" placeholder="自定义里程碑" style="flex:1">
|
||||||
|
<template #prepend>
|
||||||
|
<el-select v-model="customMs.phase" style="width:80px" size="small">
|
||||||
|
<el-option label="前期" value="前期" /><el-option label="后期" value="后期" />
|
||||||
|
</el-select>
|
||||||
|
</template>
|
||||||
|
<template #append>
|
||||||
|
<el-button @click="addCustomMs"><el-icon><Plus /></el-icon></el-button>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="showCreate = false">取消</el-button>
|
<el-button @click="showCreate = false">取消</el-button>
|
||||||
@ -124,6 +153,42 @@ const form = reactive({
|
|||||||
estimated_completion_date: null, contract_amount: null,
|
estimated_completion_date: null, contract_amount: null,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 里程碑模板
|
||||||
|
const defaultMsTemplate = [
|
||||||
|
{ name: '策划案', phase: '前期', sort_order: 1, checked: true },
|
||||||
|
{ name: '剧本', phase: '前期', sort_order: 2, checked: true },
|
||||||
|
{ name: '分镜', phase: '前期', sort_order: 3, checked: true },
|
||||||
|
{ name: '人设图', phase: '前期', sort_order: 4, checked: true },
|
||||||
|
{ name: '场景图', phase: '前期', sort_order: 5, checked: true },
|
||||||
|
{ name: '配音', phase: '后期', sort_order: 1, checked: true },
|
||||||
|
{ name: '音效', phase: '后期', sort_order: 2, checked: true },
|
||||||
|
{ name: '修补镜头', phase: '后期', sort_order: 3, checked: true },
|
||||||
|
{ name: '杂项', phase: '后期', sort_order: 4, checked: true },
|
||||||
|
]
|
||||||
|
const msTemplate = reactive([...defaultMsTemplate.map(m => ({...m}))])
|
||||||
|
const customMs = reactive({ name: '', phase: '前期' })
|
||||||
|
|
||||||
|
function addCustomMs() {
|
||||||
|
const name = customMs.name.trim()
|
||||||
|
if (!name) return
|
||||||
|
const phase = customMs.phase
|
||||||
|
const samePhase = msTemplate.filter(m => m.phase === phase)
|
||||||
|
const maxOrder = samePhase.length > 0 ? Math.max(...samePhase.map(m => m.sort_order)) : 0
|
||||||
|
msTemplate.push({ name, phase, sort_order: maxOrder + 1, checked: true })
|
||||||
|
customMs.name = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function stageLabel(row) {
|
||||||
|
const s = row.phase_summary
|
||||||
|
if (!s) return row.progress_percent + '%'
|
||||||
|
const stage = row.current_stage
|
||||||
|
if (stage === '前期') return `前期 ${s.pre.completed}/${s.pre.total}`
|
||||||
|
if (stage === '制作') return `制作 ${row.progress_percent}%`
|
||||||
|
if (stage === '后期') return `后期 ${s.post.completed}/${s.post.total}`
|
||||||
|
if (stage === '已完成') return '已完成'
|
||||||
|
return row.progress_percent + '%'
|
||||||
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try { projects.value = await projectApi.list(filter) } finally { loading.value = false }
|
try { projects.value = await projectApi.list(filter) } finally { loading.value = false }
|
||||||
@ -133,9 +198,14 @@ async function handleCreate() {
|
|||||||
if (!form.name) { ElMessage.warning('请输入项目名称'); return }
|
if (!form.name) { ElMessage.warning('请输入项目名称'); return }
|
||||||
creating.value = true
|
creating.value = true
|
||||||
try {
|
try {
|
||||||
await projectApi.create(form)
|
const checkedMs = msTemplate.filter(m => m.checked).map(m => ({
|
||||||
|
name: m.name, phase: m.phase, sort_order: m.sort_order,
|
||||||
|
}))
|
||||||
|
await projectApi.create({ ...form, milestones: checkedMs })
|
||||||
ElMessage.success('项目已创建')
|
ElMessage.success('项目已创建')
|
||||||
showCreate.value = false
|
showCreate.value = false
|
||||||
|
// reset milestone template
|
||||||
|
msTemplate.splice(0, msTemplate.length, ...defaultMsTemplate.map(m => ({...m})))
|
||||||
load()
|
load()
|
||||||
} finally { creating.value = false }
|
} finally { creating.value = false }
|
||||||
}
|
}
|
||||||
@ -157,9 +227,15 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
.card-body { padding: 4px 0; }
|
.card-body { padding: 4px 0; }
|
||||||
.cell-bold { font-weight: 500; color: var(--text-primary); }
|
.cell-bold { font-weight: 500; color: var(--text-primary); }
|
||||||
.cell-progress { display: flex; align-items: center; gap: 8px; }
|
.cell-stage { display: flex; align-items: center; }
|
||||||
.cell-progress .el-progress { flex: 1; }
|
.stage-tag {
|
||||||
.progress-text { font-size: 12px; font-weight: 600; color: var(--text-secondary); min-width: 36px; text-align: right; }
|
font-size: 12px; font-weight: 600; padding: 2px 10px; border-radius: 4px;
|
||||||
|
background: var(--bg-hover); color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.stage-tag.stage-前期 { background: #F0F1F5; color: #8F959E; }
|
||||||
|
.stage-tag.stage-制作 { background: #EBF1FF; color: #3370FF; }
|
||||||
|
.stage-tag.stage-后期 { background: #FFF3E0; color: #FF9500; }
|
||||||
|
.stage-tag.stage-已完成 { background: #E8F8EE; color: #34C759; }
|
||||||
.rate-badge {
|
.rate-badge {
|
||||||
font-size: 12px; font-weight: 600; color: var(--text-secondary);
|
font-size: 12px; font-weight: 600; color: var(--text-secondary);
|
||||||
background: var(--bg-hover); padding: 2px 8px; border-radius: 4px; display: inline-block;
|
background: var(--bg-hover); padding: 2px 8px; border-radius: 4px; display: inline-block;
|
||||||
@ -168,4 +244,11 @@ onMounted(async () => {
|
|||||||
.inline-field { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
.inline-field { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||||
.field-unit { font-size: 13px; color: var(--text-secondary); white-space: nowrap; }
|
.field-unit { font-size: 13px; color: var(--text-secondary); white-space: nowrap; }
|
||||||
.field-hint { font-size: 12px; color: var(--text-placeholder, #C0C4CC); margin-left: 4px; }
|
.field-hint { font-size: 12px; color: var(--text-placeholder, #C0C4CC); margin-left: 4px; }
|
||||||
|
|
||||||
|
/* 里程碑模板 */
|
||||||
|
.ms-template { width: 100%; }
|
||||||
|
.ms-phase-group { margin-bottom: 12px; }
|
||||||
|
.ms-phase-label { font-size: 12px; font-weight: 600; color: var(--text-secondary); margin-bottom: 4px; }
|
||||||
|
.ms-check-item { padding: 2px 0; }
|
||||||
|
.ms-custom-add { margin-top: 8px; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
<el-button type="primary" @click="showCreate = true"><el-icon><Plus /></el-icon> 新增用户</el-button>
|
<el-button type="primary" @click="showCreate = true"><el-icon><Plus /></el-icon> 新增用户</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-table :data="users" v-loading="loading" stripe>
|
<el-table :data="sortedUsers" v-loading="loading" stripe>
|
||||||
<el-table-column label="姓名" width="100">
|
<el-table-column label="姓名" width="100">
|
||||||
<template #default="{row}">
|
<template #default="{row}">
|
||||||
<router-link :to="`/users/${row.id}/detail`" class="user-link">{{ row.name }}</router-link>
|
<router-link :to="`/users/${row.id}/detail`" class="user-link">{{ row.name }}</router-link>
|
||||||
@ -90,7 +90,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, onMounted } from 'vue'
|
import { ref, reactive, computed, onMounted } from 'vue'
|
||||||
import { userApi, roleApi } from '../api'
|
import { userApi, roleApi } from '../api'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
@ -99,6 +99,17 @@ const showCreate = ref(false)
|
|||||||
const editingId = ref(null)
|
const editingId = ref(null)
|
||||||
const users = ref([])
|
const users = ref([])
|
||||||
const roles = ref([])
|
const roles = ref([])
|
||||||
|
|
||||||
|
const ROLE_ORDER = ['超级管理员', '主管', '组长', '组员']
|
||||||
|
const sortedUsers = computed(() => {
|
||||||
|
return [...users.value].sort((a, b) => {
|
||||||
|
const ia = ROLE_ORDER.indexOf(a.role_name)
|
||||||
|
const ib = ROLE_ORDER.indexOf(b.role_name)
|
||||||
|
const ai = ia >= 0 ? ia : 999
|
||||||
|
const bi = ib >= 0 ? ib : 999
|
||||||
|
return ai - bi
|
||||||
|
})
|
||||||
|
})
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
username: '', password: '', name: '', phase_group: '制作', role_id: null,
|
username: '', password: '', name: '', phase_group: '制作', role_id: null,
|
||||||
monthly_salary: 0, bonus: 0, social_insurance: 0, is_active: 1,
|
monthly_salary: 0, bonus: 0, social_insurance: 0, is_active: 1,
|
||||||
|
|||||||
181
之前的上下文.md
Normal file
181
之前的上下文.md
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
这个系统是否可以植入AI模型进去?
|
||||||
|
如果植入AI模型,AI模型可以为这个系统提供哪些功能?植入AI模型的话,也是以连接API的方式。
|
||||||
|
完全可以。你们的系统数据结构清晰,接入 AI API 非常适合。结合你们的业务场景(动画团队管理),以下是最有实用价值的 AI 功能方向:
|
||||||
|
|
||||||
|
一、数据分析与预测类
|
||||||
|
1. 项目风险预警
|
||||||
|
|
||||||
|
根据历史提交数据(velocity)、当前进度、剩余时间,预测项目是否会延期
|
||||||
|
自动识别"产出骤降"等异常模式
|
||||||
|
在仪表盘展示风险等级
|
||||||
|
2. 成本预估与优化
|
||||||
|
|
||||||
|
基于历史项目数据,新建项目时自动预估总成本
|
||||||
|
分析 AI 工具 / 外包 / 人力成本结构,给出优化建议
|
||||||
|
月度成本趋势预测
|
||||||
|
3. 团队效率分析
|
||||||
|
|
||||||
|
分析每个成员的产出趋势、擅长阶段
|
||||||
|
项目人员分配建议(哪些人适合前期、哪些人制作效率高)
|
||||||
|
二、自然语言交互类
|
||||||
|
4. 智能问答助手
|
||||||
|
|
||||||
|
用自然语言查询数据:"本月哪个项目损耗率最高?"、"张三上周产出了多少?"
|
||||||
|
实现方式:AI 读取系统数据后回答,不需要用户记菜单
|
||||||
|
5. 自动生成报告
|
||||||
|
|
||||||
|
周报/月报一键生成:"本月4个项目进展摘要、团队产出排名、成本概览"
|
||||||
|
项目结算时自动生成分析总结
|
||||||
|
三、流程辅助类
|
||||||
|
6. 里程碑智能建议
|
||||||
|
|
||||||
|
创建项目时,根据项目类型和规模,AI 建议里程碑模板
|
||||||
|
根据前期完成速度,预估中期/后期开始时间
|
||||||
|
7. 提交记录审核
|
||||||
|
|
||||||
|
检测异常提交(如单日产出异常高、重复提交)
|
||||||
|
自动分类和标注提交内容
|
||||||
|
技术实现架构
|
||||||
|
|
||||||
|
前端 ←→ 后端 API ←→ AI Service Layer ←→ Claude/GPT API
|
||||||
|
↓
|
||||||
|
系统数据库(读取上下文)
|
||||||
|
核心是在后端加一个 AI 服务层:
|
||||||
|
|
||||||
|
|
||||||
|
# backend/routers/ai.py(示意)
|
||||||
|
@router.post("/api/ai/chat")
|
||||||
|
def ai_chat(question: str, db, current_user):
|
||||||
|
# 1. 根据问题意图,从数据库查出相关数据
|
||||||
|
# 2. 组装成 prompt + context 发给 AI API
|
||||||
|
# 3. 返回 AI 回答
|
||||||
|
|
||||||
|
@router.get("/api/ai/project/{id}/risk")
|
||||||
|
def project_risk(project_id, db):
|
||||||
|
# 1. 查出项目进度、提交历史、时间线
|
||||||
|
# 2. 让 AI 分析风险并返回评估
|
||||||
|
建议优先级
|
||||||
|
优先级 功能 价值 难度
|
||||||
|
P0 智能问答助手 高 中
|
||||||
|
P0 项目风险预警 高 中
|
||||||
|
P1 自动生成报告 高 低
|
||||||
|
P1 成本预估 中 中
|
||||||
|
P2 人员分配建议 中 高
|
||||||
|
P2 提交异常检测 低 中
|
||||||
|
最推荐先做智能问答 + 项目风险预警,这两个对你们团队日常管理帮助最大,且实现难度适中。要不要选一个方向开始实现?
|
||||||
|
|
||||||
|
1. 项目风险预警
|
||||||
|
根据历史提交数据(velocity)、当前进度、剩余时间,预测项目是否会延期
|
||||||
|
在仪表盘展示风险等级
|
||||||
|
|
||||||
|
3. 团队效率分析
|
||||||
|
分析每个成员的产出趋势、擅长阶段
|
||||||
|
项目人员分配建议(哪些人适合前期、哪些人制作效率高)
|
||||||
|
|
||||||
|
4. 智能问答助手
|
||||||
|
|
||||||
|
5. 自动生成报告
|
||||||
|
我希望它每隔一段时间生成一次总结报告,并自动推送到项目管理人员的手机上。你看看这个有什么比较好的实现方式吗?比如说接入飞书bot
|
||||||
|
|
||||||
|
|
||||||
|
我整理了一下,这几个点是我认为比较实际且需要的功能。关于第五点,你来帮我分析一下。
|
||||||
|
自动报告 + 飞书推送 分析
|
||||||
|
你们团队用飞书的话,飞书机器人是最佳方案。有两种接入方式:
|
||||||
|
|
||||||
|
方案对比
|
||||||
|
群机器人 Webhook 自建飞书应用
|
||||||
|
接入难度 极低(5分钟) 中(需要飞书开放平台注册)
|
||||||
|
推送方式 只能发到群 可以私聊推送给个人
|
||||||
|
消息格式 富文本 / 卡片消息 富文本 / 卡片 / 交互式
|
||||||
|
适合场景 项目群定期播报 按角色分发不同报告
|
||||||
|
需要审核 不需要 需要企业管理员审批
|
||||||
|
推荐:先用 Webhook 群机器人 快速上线,后续再升级为自建应用。
|
||||||
|
|
||||||
|
整体架构
|
||||||
|
|
||||||
|
┌─────────────────────────────────────────────┐
|
||||||
|
│ FastAPI 后端 │
|
||||||
|
│ │
|
||||||
|
│ APScheduler (定时调度) │
|
||||||
|
│ ├── 每日 18:00 → 日报 │
|
||||||
|
│ ├── 每周五 18:00 → 周报 │
|
||||||
|
│ └── 每月1日 10:00 → 月报 │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ report_service.py │
|
||||||
|
│ ├── 从数据库汇总项目/提交/成本数据 │
|
||||||
|
│ ├── 调用 AI API 生成自然语言总结 │
|
||||||
|
│ └── 格式化为飞书卡片消息 │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ feishu_bot.py │
|
||||||
|
│ └── POST → 飞书 Webhook URL │
|
||||||
|
└─────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────┐
|
||||||
|
│ 飞书项目群 │ ← 管理人员在这个群里收到推送
|
||||||
|
│ 📊 日报/周报 │
|
||||||
|
└──────────────┘
|
||||||
|
报告内容设计
|
||||||
|
日报(简短,卡片形式):
|
||||||
|
|
||||||
|
|
||||||
|
📊 AirLabs 日报 — 2026-02-13
|
||||||
|
|
||||||
|
进行中项目:4 个
|
||||||
|
今日提交:8 人次,总产出 3分42秒
|
||||||
|
风险项目:「品牌方 TVC」前期 2/5 未完成,距截止仅剩 19 天
|
||||||
|
|
||||||
|
👤 今日未提交:张三、李四
|
||||||
|
周报(AI 生成摘要):
|
||||||
|
|
||||||
|
|
||||||
|
📋 AirLabs 周报 — 第7周
|
||||||
|
|
||||||
|
【项目进展】
|
||||||
|
• 品牌方 TVC:制作阶段,产出 213.9%,已超产,建议关注损耗
|
||||||
|
• AI 短剧原创 S1:前期 3/5,剧本和分镜已完成
|
||||||
|
• ...
|
||||||
|
|
||||||
|
【团队效率】
|
||||||
|
本周人均日产出 45秒,较上周 +12%
|
||||||
|
效率最高:王五(日均 68秒)
|
||||||
|
|
||||||
|
【成本概览】
|
||||||
|
本周 AI 工具支出 ¥2,400,外包 ¥0
|
||||||
|
|
||||||
|
【AI 建议】
|
||||||
|
品牌方 TVC 损耗率达 113.9%,建议复盘测试流程...
|
||||||
|
关键技术选型
|
||||||
|
定时调度:APScheduler — 直接集成进 FastAPI,不需要额外部署 Celery/Redis
|
||||||
|
|
||||||
|
AI 生成报告:调用 Claude API — 把数据库汇总数据作为 context,让 AI 生成自然语言总结 + 风险建议
|
||||||
|
|
||||||
|
飞书 Webhook:一个 HTTP POST 就能发消息,核心代码非常简单:
|
||||||
|
|
||||||
|
|
||||||
|
# 示意(不是完整代码)
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
FEISHU_WEBHOOK = "https://open.feishu.cn/open-apis/bot/v2/hook/xxx"
|
||||||
|
|
||||||
|
async def send_to_feishu(title, content):
|
||||||
|
card = {
|
||||||
|
"msg_type": "interactive",
|
||||||
|
"card": {
|
||||||
|
"header": {"title": {"tag": "plain_text", "content": title}},
|
||||||
|
"elements": [{"tag": "markdown", "content": content}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
await client.post(FEISHU_WEBHOOK, json=card)
|
||||||
|
实现步骤
|
||||||
|
在飞书群创建 Webhook 机器人(飞书群设置 → 群机器人 → 自定义机器人),拿到 webhook URL
|
||||||
|
后端新增 report_service.py:汇总数据 + 调 AI API 生成报告文本
|
||||||
|
后端新增 feishu_bot.py:发送飞书卡片消息
|
||||||
|
main.py 集成 APScheduler:配置定时任务
|
||||||
|
后端新增手动触发接口:POST /api/reports/generate — 方便调试和手动生成
|
||||||
|
你觉得这个方案可以的话,我可以开始实现。你需要先在飞书群里创建一个 Webhook 机器人拿到 URL,另外需要一个 AI API key(Claude 或其他)。
|
||||||
|
|
||||||
|
你现在手头有这些吗?还是先让我把代码框架搭起来,webhook 和 API key 后面再配?
|
||||||
307
项目总结文档.md
Normal file
307
项目总结文档.md
Normal file
@ -0,0 +1,307 @@
|
|||||||
|
# AirLabs Project 项目总结文档
|
||||||
|
|
||||||
|
> 内容组 · 项目制周期 / 成本 / 产出管理系统
|
||||||
|
> 更新日期:2025-02-11
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 项目概述
|
||||||
|
|
||||||
|
### 1.1 定位
|
||||||
|
|
||||||
|
服务于**以 AI 动画/视觉内容为核心产出的内容团队**(约 30 人以内)的内部管理系统。
|
||||||
|
|
||||||
|
**核心价值**:以项目为核心,以内容提交为最小记录单位,自动汇总产出、进度、成本与损耗,支持项目完成后统一结算。
|
||||||
|
|
||||||
|
### 1.2 解决的核心问题
|
||||||
|
|
||||||
|
- 项目**实际投入成本**不可见
|
||||||
|
- 实际产出是否匹配项目目标
|
||||||
|
- 测试与损耗占比无法量化
|
||||||
|
- 项目超期难以追踪
|
||||||
|
- 项目结束后是否盈利
|
||||||
|
|
||||||
|
### 1.3 覆盖范围(V1)
|
||||||
|
|
||||||
|
- 内容组、内容类项目、动画/视觉内容生产
|
||||||
|
- PC 端 Web 应用(预留响应式,V2 适配移动端)
|
||||||
|
- **不包含**:产品研发周期、精细工时、人事绩效、审批验收、移动端专属适配
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 技术架构
|
||||||
|
|
||||||
|
| 层级 | 技术选型 | 说明 |
|
||||||
|
|------|----------|------|
|
||||||
|
| 前端 | Vue 3 + Element Plus + Vite | 后台管理 UI |
|
||||||
|
| 后端 | Python FastAPI | 高性能 API |
|
||||||
|
| 数据库 | SQLite | 零配置,MVP 适用 |
|
||||||
|
| 图表 | ECharts | 仪表盘可视化 |
|
||||||
|
| 认证 | JWT Token | 登录鉴权与权限控制 |
|
||||||
|
|
||||||
|
### 2.1 部署
|
||||||
|
|
||||||
|
- **CI/CD**:Gitea Actions 自动构建
|
||||||
|
- **镜像**:Docker 构建,推送到华为云 SWR
|
||||||
|
- **运行**:K3s 集群,通过 Ingress 暴露服务
|
||||||
|
- **前端**:静态构建后由后端统一托管(`/` 路径返回 index.html)
|
||||||
|
|
||||||
|
### 2.2 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
AirLabs Project/
|
||||||
|
├── backend/ # FastAPI 后端
|
||||||
|
│ ├── main.py # 入口、静态文件托管、角色初始化
|
||||||
|
│ ├── models.py # ORM 模型、枚举、权限定义
|
||||||
|
│ ├── calculations.py # 成本分摊、损耗、效率、结算计算引擎
|
||||||
|
│ ├── config.py # 配置
|
||||||
|
│ ├── database.py # SQLAlchemy 引擎与会话
|
||||||
|
│ ├── auth.py # 密码哈希、JWT
|
||||||
|
│ ├── schemas.py # Pydantic 模型
|
||||||
|
│ └── routers/ # API 路由
|
||||||
|
│ ├── auth.py # 登录、me
|
||||||
|
│ ├── users.py # 用户 CRUD
|
||||||
|
│ ├── projects.py # 项目 CRUD、完成、结算、效率
|
||||||
|
│ ├── submissions.py# 内容提交 CRUD、历史
|
||||||
|
│ ├── costs.py # AI 工具、外包、人力调整、固定开支
|
||||||
|
│ ├── dashboard.py # 仪表盘
|
||||||
|
│ └── roles.py # 角色、权限
|
||||||
|
├── frontend/ # Vue 3 前端
|
||||||
|
│ └── src/
|
||||||
|
│ ├── views/ # 页面:Dashboard, Projects, ProjectDetail, Submissions,
|
||||||
|
│ │ # Costs, Users, MemberDetail, Roles, Settlement, Login
|
||||||
|
│ ├── api/index.js # 接口封装
|
||||||
|
│ ├── stores/auth.js# 用户状态
|
||||||
|
│ └── router/ # 路由与权限守卫
|
||||||
|
├── k8s/ # K8s manifests
|
||||||
|
├── .gitea/workflows/ # 部署流程
|
||||||
|
└── 人员清单.md # 团队成员与薪资
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 核心数据模型
|
||||||
|
|
||||||
|
### 3.1 项目(Project)
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| name | 项目名称 |
|
||||||
|
| project_type | 客户正式/客户测试/内部原创/内部测试 |
|
||||||
|
| status | **制作中 / 已完成 / 废弃** |
|
||||||
|
| leader_id | 负责人 |
|
||||||
|
| current_phase | 前期/制作/后期 |
|
||||||
|
| episode_duration_minutes | 单集正片时长(分钟) |
|
||||||
|
| episode_count | 集数 |
|
||||||
|
| target_total_seconds | 目标总秒数(计算属性 = 单集×集数×60) |
|
||||||
|
| estimated_completion_date | 预估完成日期 |
|
||||||
|
| actual_completion_date | 实际完成日期 |
|
||||||
|
| contract_amount | 回款金额(客户正式项目) |
|
||||||
|
|
||||||
|
### 3.2 用户(User)
|
||||||
|
|
||||||
|
- 姓名、用户名、所属阶段组(前期/制作/后期)
|
||||||
|
- 角色、月薪、社保、奖金
|
||||||
|
- **日成本** = (月薪+奖金+社保) ÷ 22 个工作日
|
||||||
|
|
||||||
|
### 3.3 内容提交(Submission)
|
||||||
|
|
||||||
|
- 提交人、所属项目、项目阶段、工作类型(制作/测试/方案)
|
||||||
|
- 内容制作类型(内容制作/设定策划/剪辑后期/其他)
|
||||||
|
- 产出时长(分钟/秒,换算为 total_seconds)
|
||||||
|
- 投入时长(小时,可选)
|
||||||
|
- 提交对象(组长/制片/内部/外部)
|
||||||
|
- 提交日期
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 业务逻辑
|
||||||
|
|
||||||
|
### 4.1 成本结构
|
||||||
|
|
||||||
|
| 成本类型 | 计算方式 |
|
||||||
|
|----------|----------|
|
||||||
|
| **人力成本** | 按提交自动分摊:有秒数按秒数比例,无秒数按条数比例;支持 CostOverride 手动调整 |
|
||||||
|
| **AI 工具成本** | 指定项目 / 内容组整体(按产出秒数分摊)/ 手动分摊 |
|
||||||
|
| **外包成本** | 按项目直接归属 |
|
||||||
|
| **固定开支** | 办公室租金、水电费,按项目产出秒数比例分摊 |
|
||||||
|
|
||||||
|
### 4.2 损耗(Waste)与亏损(Loss)
|
||||||
|
|
||||||
|
**概念区分:**
|
||||||
|
|
||||||
|
- **损耗**:生产效率指标(测试试错 + 超产废片),盈利项目也有
|
||||||
|
- **亏损**:财务结果(总成本 > 回款),仅客户正式项目
|
||||||
|
|
||||||
|
**损耗计算:**
|
||||||
|
|
||||||
|
- **测试损耗**:工作类型为「测试」的提交秒数
|
||||||
|
- **超产损耗**:累计提交秒数 − 目标秒数(>0 部分)
|
||||||
|
- **废弃项目**:全部产出直接记为损耗,损耗率 100%
|
||||||
|
|
||||||
|
**损耗率** = 总损耗秒数 ÷ 目标秒数 × 100%
|
||||||
|
|
||||||
|
### 4.3 团队效率(人均基准对比法)
|
||||||
|
|
||||||
|
- 人均基准 = 目标秒数 ÷ 参与制作人数
|
||||||
|
- 超出比例 = (个人提交 − 人均基准) ÷ 人均基准 × 100%
|
||||||
|
|
||||||
|
### 4.4 进度计算
|
||||||
|
|
||||||
|
- 完成百分比 = 当前有秒数提交的累计秒数 ÷ 目标总秒数 × 100%
|
||||||
|
- 仅统计有产出秒数的提交(制作组 + 后期补拍)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 权限体系
|
||||||
|
|
||||||
|
| 角色 | 可见范围 | 典型权限 |
|
||||||
|
|------|----------|----------|
|
||||||
|
| 超级管理员 | 全量 | 全部 |
|
||||||
|
| 主管 | 所有项目 | dashboard, project CRUD+complete, submission, cost, settlement, efficiency |
|
||||||
|
| 组长 | 所负责项目 | project view+create, submission, cost, efficiency |
|
||||||
|
| 成员 | 本人提交、项目公开信息 | project view, submission |
|
||||||
|
|
||||||
|
**权限标识**:`dashboard:view`, `project:view/create/edit/delete/complete`, `submission:view/create`, `cost:view/create/delete`, `user:view/manage`, `role:manage`, `settlement:view`, `efficiency:view`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 前端页面与路由
|
||||||
|
|
||||||
|
| 路径 | 页面 | 权限 |
|
||||||
|
|------|------|------|
|
||||||
|
| /login | 登录 | 公开 |
|
||||||
|
| /dashboard | 仪表盘 | dashboard:view |
|
||||||
|
| /projects | 项目列表 | project:view |
|
||||||
|
| /projects/:id | 项目详情、提交、损耗、进度 | project:view |
|
||||||
|
| /submissions | 提交记录 | submission:view |
|
||||||
|
| /costs | 成本管理(AI/外包/固定开支/人力调整) | cost:view |
|
||||||
|
| /users | 用户管理 | user:manage |
|
||||||
|
| /users/:id/detail | 成员详情 | user:view |
|
||||||
|
| /roles | 角色管理 | role:manage |
|
||||||
|
| /settlement/:id | 项目结算 | settlement:view |
|
||||||
|
|
||||||
|
**项目详情页操作**:编辑、确认完成(制作中→已完成)、标记废弃(制作中→废弃)、查看结算(已完成)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 废弃项目特性(近期新增)
|
||||||
|
|
||||||
|
- **状态**:`ProjectStatus.ABANDONED = "废弃"`
|
||||||
|
- **含义**:项目中途停止,不再继续制作
|
||||||
|
- **损耗**:全部产出记为损耗,损耗率 100%
|
||||||
|
- **操作**:
|
||||||
|
- 项目详情页「标记废弃」按钮(仅制作中可见)
|
||||||
|
- 编辑表单中状态下拉增加「废弃」选项
|
||||||
|
- **仪表盘**:废弃项目纳入总损耗统计、成本构成统计、损耗排行
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 人员与团队(来自 人员清单.md)
|
||||||
|
|
||||||
|
- 22 人:主管 3、组长 4、组员 15、Owner 2
|
||||||
|
- 职位涵盖:总导演、AI 导演、制片、动画制作、剪辑、编剧等
|
||||||
|
- 薪资与社保已录入,用于人力成本计算
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. PRD 与实现对照
|
||||||
|
|
||||||
|
| PRD 要求 | 实现情况 |
|
||||||
|
|----------|----------|
|
||||||
|
| 项目管理(创建/编辑/状态/目标) | ✅ |
|
||||||
|
| 内容提交表单 | ✅ |
|
||||||
|
| 项目详情(进度、提交、损耗) | ✅ |
|
||||||
|
| 人力/AI/外包/固定开支成本 | ✅ |
|
||||||
|
| 仪表盘 | ✅ |
|
||||||
|
| 项目结算、盈亏 | ✅ |
|
||||||
|
| 废弃项目 | ✅(PRD 未写,实际补充) |
|
||||||
|
| 登录与 4 级权限 | ✅ |
|
||||||
|
| 历史修改记录(版本追溯、修改原因) | ⚠️ 部分(有 SubmissionHistory 表与 history 接口,前端展示待完善) |
|
||||||
|
| 用户管理(新增/编辑/薪资) | ✅ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. AI 能力模块(V2 新增)
|
||||||
|
|
||||||
|
### 10.1 技术架构
|
||||||
|
|
||||||
|
```
|
||||||
|
FastAPI 后端
|
||||||
|
├── services/ai_service.py # 豆包模型调用(OpenAI 兼容协议)
|
||||||
|
├── services/report_service.py # 报告数据汇总 + AI 摘要
|
||||||
|
├── services/feishu_service.py # 飞书自建应用消息推送
|
||||||
|
├── services/scheduler_service.py # APScheduler 定时任务
|
||||||
|
└── routers/reports.py # 手动触发报告 API
|
||||||
|
↓ ↓
|
||||||
|
豆包 AI (ARK) 飞书开放平台
|
||||||
|
生成报告摘要 私聊推送给管理员
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.2 功能清单
|
||||||
|
|
||||||
|
| 功能 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 自动日报 | 每天 20:00,今日提交、项目进展、风险提醒 |
|
||||||
|
| 自动周报 | 每周五 20:00,项目进展、团队产出、成本概览 |
|
||||||
|
| 自动月报 | 每月1日 10:00,上月完整数据、成本明细、盈亏 |
|
||||||
|
| 项目风险预警 | 仪表盘展示,规则引擎检测超期/进度/损耗/停滞 |
|
||||||
|
| 手动触发 | POST /api/reports/{daily,weekly,monthly},调试用 |
|
||||||
|
| 报告预览 | POST /api/reports/preview/{type},不推送仅预览 |
|
||||||
|
|
||||||
|
### 10.3 新增依赖
|
||||||
|
|
||||||
|
| 包 | 用途 |
|
||||||
|
|----|------|
|
||||||
|
| openai | 豆包 AI 调用(兼容协议) |
|
||||||
|
| httpx | 飞书 API 异步请求 |
|
||||||
|
| apscheduler | 定时任务调度 |
|
||||||
|
| python-dotenv | .env 配置文件 |
|
||||||
|
|
||||||
|
### 10.4 配置项
|
||||||
|
|
||||||
|
通过 `backend/.env` 配置:
|
||||||
|
- `ARK_API_KEY` — 豆包 API Key
|
||||||
|
- `ARK_MODEL` — 模型名称
|
||||||
|
- `FEISHU_APP_ID` / `FEISHU_APP_SECRET` — 飞书应用凭证
|
||||||
|
- `REPORT_RECEIVERS` — 接收人手机号(逗号分隔)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. PRD 与实现对照(更新)
|
||||||
|
|
||||||
|
| PRD 要求 | 实现情况 |
|
||||||
|
|----------|----------|
|
||||||
|
| 项目管理(创建/编辑/状态/目标) | ✅ |
|
||||||
|
| 内容提交表单 | ✅ |
|
||||||
|
| 项目详情(进度、提交、损耗) | ✅ |
|
||||||
|
| 人力/AI/外包/固定开支成本 | ✅ |
|
||||||
|
| 仪表盘 | ✅ |
|
||||||
|
| 项目结算、盈亏 | ✅ |
|
||||||
|
| 废弃项目 | ✅(PRD 未写,实际补充) |
|
||||||
|
| 登录与 4 级权限 | ✅ |
|
||||||
|
| 历史修改记录 | ⚠️ 部分 |
|
||||||
|
| 用户管理 | ✅ |
|
||||||
|
| AI 自动报告 + 飞书推送 | ✅ V2 新增 |
|
||||||
|
| 项目风险预警 | ✅ V2 新增 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 已知待办 / 待完善
|
||||||
|
|
||||||
|
- **历史修改记录**:后端已有 `SubmissionHistory`,前端完整展示与 diff 对比可完善
|
||||||
|
- **数据导出**:PRD 提到 V2 支持 Excel/PDF,当前未实现
|
||||||
|
- **移动端适配**:V2 计划
|
||||||
|
- **数据库升级**:若用户量增长,可迁移至 PostgreSQL
|
||||||
|
- **AI 智能问答**:V3 计划,前端聊天页面 + 自然语言查询
|
||||||
|
- **按权限分级推送**:V3 计划,不同角色收到不同报告内容
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 默认账号
|
||||||
|
|
||||||
|
- 首次启动自动创建:`admin` / `admin123`(超级管理员)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*本文档用于项目交接、复盘与后续迭代参考。*
|
||||||
Loading…
x
Reference in New Issue
Block a user