Compare commits
6 Commits
f7b9db6f42
...
90707005ed
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90707005ed | ||
|
|
0dbe00a8b4 | ||
|
|
e751418540 | ||
|
|
56ea51fb8b | ||
|
|
8aa8b805a4 | ||
|
|
6ac44d47fb |
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.
@ -7,7 +7,6 @@ from jose import JWTError, jwt
|
|||||||
from passlib.context import CryptContext
|
from passlib.context import CryptContext
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from models import User, UserRole
|
|
||||||
from config import SECRET_KEY, ALGORITHM, ACCESS_TOKEN_EXPIRE_MINUTES
|
from config import SECRET_KEY, ALGORITHM, ACCESS_TOKEN_EXPIRE_MINUTES
|
||||||
|
|
||||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
@ -29,8 +28,9 @@ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -
|
|||||||
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||||
|
|
||||||
|
|
||||||
def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)) -> User:
|
def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)):
|
||||||
"""从 JWT token 解析当前用户"""
|
"""从 JWT token 解析当前用户"""
|
||||||
|
from models import User
|
||||||
credentials_exception = HTTPException(
|
credentials_exception = HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="登录已过期,请重新登录",
|
detail="登录已过期,请重新登录",
|
||||||
@ -51,13 +51,13 @@ def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
def require_role(*roles: UserRole):
|
def require_permission(*perms: str):
|
||||||
"""权限装饰器:要求当前用户具有指定角色之一"""
|
"""权限校验依赖:要求当前用户拥有指定权限中的至少一项"""
|
||||||
def role_checker(current_user: User = Depends(get_current_user)):
|
def perm_checker(current_user=Depends(get_current_user)):
|
||||||
if current_user.role not in [r.value for r in roles] and current_user.role not in roles:
|
if not current_user.role_ref:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=403, detail="未分配角色")
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
user_perms = current_user.permissions or []
|
||||||
detail="权限不足"
|
if not any(p in user_perms for p in perms):
|
||||||
)
|
raise HTTPException(status_code=403, detail="权限不足")
|
||||||
return current_user
|
return current_user
|
||||||
return role_checker
|
return perm_checker
|
||||||
|
|||||||
@ -8,7 +8,7 @@ from collections import defaultdict
|
|||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
from models import (
|
from models import (
|
||||||
User, Project, Submission, AIToolCost, AIToolCostAllocation,
|
User, Project, Submission, AIToolCost, AIToolCostAllocation,
|
||||||
OutsourceCost, CostOverride, WorkType, CostAllocationType
|
OutsourceCost, CostOverride, OverheadCost, WorkType, CostAllocationType
|
||||||
)
|
)
|
||||||
from config import WORKING_DAYS_PER_MONTH
|
from config import WORKING_DAYS_PER_MONTH
|
||||||
|
|
||||||
@ -142,34 +142,67 @@ def calc_outsource_cost_for_project(project_id: int, db: Session) -> float:
|
|||||||
return round(total, 2)
|
return round(total, 2)
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────── 固定开支分摊 ────────────────────────────
|
||||||
|
|
||||||
|
def calc_overhead_cost_for_project(project_id: int, db: Session) -> float:
|
||||||
|
"""
|
||||||
|
计算某项目分摊的固定开支(办公室租金+水电费)
|
||||||
|
规则:按所有项目的产出秒数比例均摊
|
||||||
|
"""
|
||||||
|
total_overhead = db.query(sa_func.sum(OverheadCost.amount)).scalar() or 0
|
||||||
|
if total_overhead == 0:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
all_secs = db.query(sa_func.sum(Submission.total_seconds)).filter(
|
||||||
|
Submission.total_seconds > 0
|
||||||
|
).scalar() or 0
|
||||||
|
proj_secs = db.query(sa_func.sum(Submission.total_seconds)).filter(
|
||||||
|
Submission.project_id == project_id,
|
||||||
|
Submission.total_seconds > 0,
|
||||||
|
).scalar() or 0
|
||||||
|
|
||||||
|
if all_secs > 0:
|
||||||
|
ratio = proj_secs / all_secs
|
||||||
|
return round(total_overhead * ratio, 2)
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────── 损耗计算 ────────────────────────────
|
# ──────────────────────────── 损耗计算 ────────────────────────────
|
||||||
|
|
||||||
def calc_waste_for_project(project_id: int, db: Session) -> dict:
|
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
|
||||||
|
test_waste = 0.0
|
||||||
|
overproduction_waste = total_submitted
|
||||||
|
waste_rate = 100.0 if total_submitted > 0 else 0.0
|
||||||
|
else:
|
||||||
|
# 测试损耗:工作类型为"测试"的全部秒数
|
||||||
|
test_waste = db.query(sa_func.sum(Submission.total_seconds)).filter(
|
||||||
|
Submission.project_id == project_id,
|
||||||
|
Submission.work_type == WorkType.TEST,
|
||||||
|
).scalar() or 0
|
||||||
|
# 超产损耗(仅计算生产性提交超出目标的部分,排除测试秒数避免双重计数)
|
||||||
|
production_submitted = total_submitted - test_waste
|
||||||
|
overproduction_waste = max(0, production_submitted - target)
|
||||||
total_waste = test_waste + overproduction_waste
|
total_waste = test_waste + overproduction_waste
|
||||||
waste_rate = round(total_waste / target * 100, 1) if target > 0 else 0
|
waste_rate = round(total_waste / target * 100, 1) if target > 0 else 0
|
||||||
|
|
||||||
@ -243,7 +276,8 @@ def calc_project_settlement(project_id: int, db: Session) -> dict:
|
|||||||
labor = calc_labor_cost_for_project(project_id, db)
|
labor = calc_labor_cost_for_project(project_id, db)
|
||||||
ai_tool = calc_ai_tool_cost_for_project(project_id, db)
|
ai_tool = calc_ai_tool_cost_for_project(project_id, db)
|
||||||
outsource = calc_outsource_cost_for_project(project_id, db)
|
outsource = calc_outsource_cost_for_project(project_id, db)
|
||||||
total_cost = labor + ai_tool + outsource
|
overhead = calc_overhead_cost_for_project(project_id, db)
|
||||||
|
total_cost = labor + ai_tool + outsource + overhead
|
||||||
waste = calc_waste_for_project(project_id, db)
|
waste = calc_waste_for_project(project_id, db)
|
||||||
efficiency = calc_team_efficiency(project_id, db)
|
efficiency = calc_team_efficiency(project_id, db)
|
||||||
|
|
||||||
@ -254,6 +288,7 @@ def calc_project_settlement(project_id: int, db: Session) -> dict:
|
|||||||
"labor_cost": labor,
|
"labor_cost": labor,
|
||||||
"ai_tool_cost": ai_tool,
|
"ai_tool_cost": ai_tool,
|
||||||
"outsource_cost": outsource,
|
"outsource_cost": outsource,
|
||||||
|
"overhead_cost": overhead,
|
||||||
"total_cost": round(total_cost, 2),
|
"total_cost": round(total_cost, 2),
|
||||||
**waste,
|
**waste,
|
||||||
"team_efficiency": efficiency,
|
"team_efficiency": efficiency,
|
||||||
|
|||||||
@ -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, UserRole, PhaseGroup
|
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)
|
||||||
@ -30,6 +40,8 @@ from routers.projects import router as projects_router
|
|||||||
from routers.submissions import router as submissions_router
|
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.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)
|
||||||
@ -37,6 +49,8 @@ app.include_router(projects_router)
|
|||||||
app.include_router(submissions_router)
|
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(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")
|
||||||
@ -52,18 +66,82 @@ if os.path.exists(frontend_dir):
|
|||||||
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
def init_default_owner():
|
async def start_scheduler():
|
||||||
"""首次启动时创建默认 Owner 账号"""
|
"""启动定时任务调度器"""
|
||||||
|
from services.scheduler_service import setup_scheduler
|
||||||
|
setup_scheduler()
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("shutdown")
|
||||||
|
async def stop_scheduler():
|
||||||
|
"""关闭定时任务调度器"""
|
||||||
|
from services.scheduler_service import scheduler
|
||||||
|
scheduler.shutdown(wait=False)
|
||||||
|
logger.info("[定时任务] 已关闭")
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
def init_roles_and_admin():
|
||||||
|
"""首次启动时创建内置角色和默认管理员"""
|
||||||
from database import SessionLocal
|
from database import SessionLocal
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
if not db.query(User).filter(User.role == UserRole.OWNER).first():
|
# 初始化内置角色
|
||||||
|
for role_name, role_def in BUILTIN_ROLES.items():
|
||||||
|
existing = db.query(Role).filter(Role.name == role_name).first()
|
||||||
|
if not existing:
|
||||||
|
role = Role(
|
||||||
|
name=role_name,
|
||||||
|
description=role_def["description"],
|
||||||
|
permissions=role_def["permissions"],
|
||||||
|
is_system=1,
|
||||||
|
)
|
||||||
|
db.add(role)
|
||||||
|
print(f"[OK] created role: {role_name}")
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# 迁移旧成本权限 → 细分权限
|
||||||
|
old_cost_perms = set(COST_PERM_MIGRATION.keys())
|
||||||
|
for role in db.query(Role).all():
|
||||||
|
perms = list(role.permissions or [])
|
||||||
|
changed = False
|
||||||
|
for old_perm, new_perms in COST_PERM_MIGRATION.items():
|
||||||
|
if old_perm in perms:
|
||||||
|
perms.remove(old_perm)
|
||||||
|
for np in new_perms:
|
||||||
|
if np not in perms:
|
||||||
|
perms.append(np)
|
||||||
|
changed = True
|
||||||
|
if changed:
|
||||||
|
role.permissions = perms
|
||||||
|
print(f"[MIGRATE] upgraded cost permissions for role: {role.name}")
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# 为已有项目补充默认里程碑
|
||||||
|
for proj in db.query(Project).all():
|
||||||
|
has_ms = db.query(ProjectMilestone).filter(
|
||||||
|
ProjectMilestone.project_id == proj.id
|
||||||
|
).first()
|
||||||
|
if not has_ms:
|
||||||
|
for ms in DEFAULT_MILESTONES:
|
||||||
|
db.add(ProjectMilestone(
|
||||||
|
project_id=proj.id,
|
||||||
|
name=ms["name"],
|
||||||
|
phase=PhaseGroup(ms["phase"]),
|
||||||
|
sort_order=ms.get("sort_order", 0),
|
||||||
|
))
|
||||||
|
print(f"[MIGRATE] added default milestones for project: {proj.name}")
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# 创建默认管理员(关联超级管理员角色)
|
||||||
|
admin_role = db.query(Role).filter(Role.name == "超级管理员").first()
|
||||||
|
if admin_role and not db.query(User).filter(User.username == "admin").first():
|
||||||
owner = User(
|
owner = User(
|
||||||
username="admin",
|
username="admin",
|
||||||
password_hash=hash_password("admin123"),
|
password_hash=hash_password("admin123"),
|
||||||
name="管理员",
|
name="管理员",
|
||||||
phase_group=PhaseGroup.PRODUCTION,
|
phase_group=PhaseGroup.PRODUCTION,
|
||||||
role=UserRole.OWNER,
|
role_id=admin_role.id,
|
||||||
monthly_salary=0,
|
monthly_salary=0,
|
||||||
)
|
)
|
||||||
db.add(owner)
|
db.add(owner)
|
||||||
|
|||||||
@ -9,6 +9,92 @@ from database import Base
|
|||||||
import enum
|
import enum
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────── 权限标识符定义 ────────────────────────────
|
||||||
|
|
||||||
|
ALL_PERMISSIONS = [
|
||||||
|
# 仪表盘
|
||||||
|
("dashboard:view", "查看仪表盘", "仪表盘"),
|
||||||
|
# 项目管理
|
||||||
|
("project:view", "查看项目", "项目管理"),
|
||||||
|
("project:create", "创建项目", "项目管理"),
|
||||||
|
("project:edit", "编辑项目", "项目管理"),
|
||||||
|
("project:delete", "删除项目", "项目管理"),
|
||||||
|
("project:complete", "确认完成项目", "项目管理"),
|
||||||
|
# 内容提交
|
||||||
|
("submission:view", "查看提交记录", "内容提交"),
|
||||||
|
("submission:create", "新增提交", "内容提交"),
|
||||||
|
# 成本管理 —— 按类型细分
|
||||||
|
("cost_ai:view", "查看AI工具成本", "成本管理"),
|
||||||
|
("cost_ai:create", "录入AI工具成本", "成本管理"),
|
||||||
|
("cost_ai:delete", "删除AI工具成本", "成本管理"),
|
||||||
|
("cost_outsource:view", "查看外包成本", "成本管理"),
|
||||||
|
("cost_outsource:create", "录入外包成本", "成本管理"),
|
||||||
|
("cost_outsource:delete", "删除外包成本", "成本管理"),
|
||||||
|
("cost_overhead:view", "查看固定开支", "成本管理"),
|
||||||
|
("cost_overhead:create", "录入固定开支", "成本管理"),
|
||||||
|
("cost_overhead:delete", "删除固定开支", "成本管理"),
|
||||||
|
("cost_labor:view", "查看人力调整", "成本管理"),
|
||||||
|
("cost_labor:create", "录入人力调整", "成本管理"),
|
||||||
|
# 用户与角色
|
||||||
|
("user:view", "查看用户列表", "用户与角色"),
|
||||||
|
("user:manage", "管理用户", "用户与角色"),
|
||||||
|
("role:manage", "管理角色", "用户与角色"),
|
||||||
|
# 结算与效率
|
||||||
|
("settlement:view", "查看结算报告", "结算与效率"),
|
||||||
|
("efficiency:view", "查看团队效率", "结算与效率"),
|
||||||
|
]
|
||||||
|
|
||||||
|
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 = {
|
||||||
|
"超级管理员": {
|
||||||
|
"description": "系统最高权限,拥有全部功能",
|
||||||
|
"permissions": PERMISSION_KEYS[:], # 全部
|
||||||
|
},
|
||||||
|
"主管": {
|
||||||
|
"description": "管理项目和成本,不可管理用户和角色",
|
||||||
|
"permissions": [
|
||||||
|
"dashboard:view",
|
||||||
|
"project:view", "project:create", "project:edit", "project:complete",
|
||||||
|
"submission:view", "submission:create",
|
||||||
|
"cost_ai:view", "cost_ai:create", "cost_ai:delete",
|
||||||
|
"cost_outsource:view", "cost_outsource:create", "cost_outsource:delete",
|
||||||
|
"cost_overhead:view", "cost_overhead:create", "cost_overhead:delete",
|
||||||
|
"cost_labor:view", "cost_labor:create",
|
||||||
|
"user:view",
|
||||||
|
"settlement:view", "efficiency:view",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"组长": {
|
||||||
|
"description": "管理本组提交和查看成本",
|
||||||
|
"permissions": [
|
||||||
|
"project:view", "project:create",
|
||||||
|
"submission:view", "submission:create",
|
||||||
|
"cost_ai:view", "cost_ai:create",
|
||||||
|
"efficiency:view",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"成员": {
|
||||||
|
"description": "提交内容和查看项目",
|
||||||
|
"permissions": [
|
||||||
|
"project:view",
|
||||||
|
"submission:view", "submission:create",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────── 枚举定义 ────────────────────────────
|
# ──────────────────────────── 枚举定义 ────────────────────────────
|
||||||
|
|
||||||
class ProjectType(str, enum.Enum):
|
class ProjectType(str, enum.Enum):
|
||||||
@ -21,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):
|
||||||
@ -29,13 +116,6 @@ class PhaseGroup(str, enum.Enum):
|
|||||||
POST = "后期"
|
POST = "后期"
|
||||||
|
|
||||||
|
|
||||||
class UserRole(str, enum.Enum):
|
|
||||||
MEMBER = "成员"
|
|
||||||
LEADER = "组长"
|
|
||||||
SUPERVISOR = "主管"
|
|
||||||
OWNER = "Owner"
|
|
||||||
|
|
||||||
|
|
||||||
class WorkType(str, enum.Enum):
|
class WorkType(str, enum.Enum):
|
||||||
PRODUCTION = "制作"
|
PRODUCTION = "制作"
|
||||||
TEST = "测试"
|
TEST = "测试"
|
||||||
@ -73,6 +153,29 @@ class OutsourceType(str, enum.Enum):
|
|||||||
FULL_EPISODE = "整集"
|
FULL_EPISODE = "整集"
|
||||||
|
|
||||||
|
|
||||||
|
class OverheadCostType(str, enum.Enum):
|
||||||
|
OFFICE_RENT = "办公室租金"
|
||||||
|
UTILITIES = "水电费"
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────── 角色 ────────────────────────────
|
||||||
|
|
||||||
|
class Role(Base):
|
||||||
|
__tablename__ = "roles"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
name = Column(String(50), unique=True, nullable=False)
|
||||||
|
description = Column(String(200), nullable=True)
|
||||||
|
permissions = Column(JSON, nullable=False, default=[]) # 权限标识符列表
|
||||||
|
is_system = Column(Integer, nullable=False, default=0) # 1=内置角色不可删
|
||||||
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
|
||||||
|
users = relationship("User", back_populates="role_ref")
|
||||||
|
|
||||||
|
def has_permission(self, perm: str) -> bool:
|
||||||
|
return perm in (self.permissions or [])
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────── 用户 ────────────────────────────
|
# ──────────────────────────── 用户 ────────────────────────────
|
||||||
|
|
||||||
class User(Base):
|
class User(Base):
|
||||||
@ -83,20 +186,37 @@ class User(Base):
|
|||||||
password_hash = Column(String(255), nullable=False)
|
password_hash = Column(String(255), nullable=False)
|
||||||
name = Column(String(50), nullable=False)
|
name = Column(String(50), nullable=False)
|
||||||
phase_group = Column(SAEnum(PhaseGroup), nullable=False)
|
phase_group = Column(SAEnum(PhaseGroup), nullable=False)
|
||||||
role = Column(SAEnum(UserRole), nullable=False, default=UserRole.MEMBER)
|
role_id = Column(Integer, ForeignKey("roles.id"), nullable=False)
|
||||||
monthly_salary = Column(Float, nullable=False, default=0)
|
monthly_salary = Column(Float, nullable=False, default=0)
|
||||||
|
bonus = Column(Float, nullable=False, default=0)
|
||||||
|
social_insurance = Column(Float, nullable=False, default=0)
|
||||||
is_active = Column(Integer, nullable=False, default=1)
|
is_active = Column(Integer, nullable=False, default=1)
|
||||||
created_at = Column(DateTime, server_default=func.now())
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
|
||||||
# 关系
|
# 关系
|
||||||
|
role_ref = relationship("Role", back_populates="users")
|
||||||
submissions = relationship("Submission", back_populates="user")
|
submissions = relationship("Submission", back_populates="user")
|
||||||
led_projects = relationship("Project", back_populates="leader")
|
led_projects = relationship("Project", back_populates="leader")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def role_name(self):
|
||||||
|
return self.role_ref.name if self.role_ref else ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def permissions(self):
|
||||||
|
return self.role_ref.permissions if self.role_ref else []
|
||||||
|
|
||||||
|
def has_permission(self, perm: str) -> bool:
|
||||||
|
return self.role_ref.has_permission(perm) if self.role_ref else False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def monthly_total_cost(self):
|
||||||
|
return (self.monthly_salary or 0) + (self.bonus or 0) + (self.social_insurance or 0)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def daily_cost(self):
|
def daily_cost(self):
|
||||||
"""日成本 = 月薪 ÷ 22"""
|
|
||||||
from config import WORKING_DAYS_PER_MONTH
|
from config import WORKING_DAYS_PER_MONTH
|
||||||
return round(self.monthly_salary / WORKING_DAYS_PER_MONTH, 2) if self.monthly_salary else 0
|
return round(self.monthly_total_cost / WORKING_DAYS_PER_MONTH, 2) if self.monthly_total_cost else 0
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────── 项目 ────────────────────────────
|
# ──────────────────────────── 项目 ────────────────────────────
|
||||||
@ -114,18 +234,17 @@ class Project(Base):
|
|||||||
episode_count = Column(Integer, nullable=False)
|
episode_count = Column(Integer, nullable=False)
|
||||||
estimated_completion_date = Column(Date, nullable=True)
|
estimated_completion_date = Column(Date, nullable=True)
|
||||||
actual_completion_date = Column(Date, nullable=True)
|
actual_completion_date = Column(Date, nullable=True)
|
||||||
contract_amount = Column(Float, nullable=True) # 仅客户正式项目
|
contract_amount = Column(Float, nullable=True)
|
||||||
created_at = Column(DateTime, server_default=func.now())
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
|
||||||
# 关系
|
|
||||||
leader = relationship("User", back_populates="led_projects")
|
leader = relationship("User", back_populates="led_projects")
|
||||||
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):
|
||||||
"""目标总秒数 = 单集时长(分) × 60 × 集数"""
|
|
||||||
return int(self.episode_duration_minutes * 60 * self.episode_count)
|
return int(self.episode_duration_minutes * 60 * self.episode_count)
|
||||||
|
|
||||||
|
|
||||||
@ -142,14 +261,13 @@ class Submission(Base):
|
|||||||
content_type = Column(SAEnum(ContentType), nullable=False)
|
content_type = Column(SAEnum(ContentType), nullable=False)
|
||||||
duration_minutes = Column(Float, nullable=True, default=0)
|
duration_minutes = Column(Float, nullable=True, default=0)
|
||||||
duration_seconds = Column(Float, nullable=True, default=0)
|
duration_seconds = Column(Float, nullable=True, default=0)
|
||||||
total_seconds = Column(Float, nullable=False, default=0) # 系统自动计算
|
total_seconds = Column(Float, nullable=False, default=0)
|
||||||
hours_spent = Column(Float, nullable=True) # 可选:投入时长(小时)
|
hours_spent = Column(Float, nullable=True)
|
||||||
submit_to = Column(SAEnum(SubmitTo), nullable=False)
|
submit_to = Column(SAEnum(SubmitTo), nullable=False)
|
||||||
description = Column(Text, nullable=True)
|
description = Column(Text, nullable=True)
|
||||||
submit_date = Column(Date, nullable=False)
|
submit_date = Column(Date, nullable=False)
|
||||||
created_at = Column(DateTime, server_default=func.now())
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
|
||||||
# 关系
|
|
||||||
user = relationship("User", back_populates="submissions")
|
user = relationship("User", back_populates="submissions")
|
||||||
project = relationship("Project", back_populates="submissions")
|
project = relationship("Project", back_populates="submissions")
|
||||||
history = relationship("SubmissionHistory", back_populates="submission")
|
history = relationship("SubmissionHistory", back_populates="submission")
|
||||||
@ -165,23 +283,21 @@ class AIToolCost(Base):
|
|||||||
subscription_period = Column(SAEnum(SubscriptionPeriod), nullable=False)
|
subscription_period = Column(SAEnum(SubscriptionPeriod), nullable=False)
|
||||||
amount = Column(Float, nullable=False)
|
amount = Column(Float, nullable=False)
|
||||||
allocation_type = Column(SAEnum(CostAllocationType), nullable=False)
|
allocation_type = Column(SAEnum(CostAllocationType), nullable=False)
|
||||||
project_id = Column(Integer, ForeignKey("projects.id"), nullable=True) # 指定项目时
|
project_id = Column(Integer, ForeignKey("projects.id"), nullable=True)
|
||||||
recorded_by = Column(Integer, ForeignKey("users.id"), nullable=False)
|
recorded_by = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
record_date = Column(Date, nullable=False)
|
record_date = Column(Date, nullable=False)
|
||||||
created_at = Column(DateTime, server_default=func.now())
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
|
||||||
# 关系
|
|
||||||
allocations = relationship("AIToolCostAllocation", back_populates="ai_tool_cost")
|
allocations = relationship("AIToolCostAllocation", back_populates="ai_tool_cost")
|
||||||
|
|
||||||
|
|
||||||
class AIToolCostAllocation(Base):
|
class AIToolCostAllocation(Base):
|
||||||
"""AI 工具成本手动分摊明细"""
|
|
||||||
__tablename__ = "ai_tool_cost_allocations"
|
__tablename__ = "ai_tool_cost_allocations"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
ai_tool_cost_id = Column(Integer, ForeignKey("ai_tool_costs.id"), nullable=False)
|
ai_tool_cost_id = Column(Integer, ForeignKey("ai_tool_costs.id"), nullable=False)
|
||||||
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False)
|
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False)
|
||||||
percentage = Column(Float, nullable=False) # 0-100
|
percentage = Column(Float, nullable=False)
|
||||||
|
|
||||||
ai_tool_cost = relationship("AIToolCost", back_populates="allocations")
|
ai_tool_cost = relationship("AIToolCost", back_populates="allocations")
|
||||||
project = relationship("Project", back_populates="ai_tool_allocations")
|
project = relationship("Project", back_populates="ai_tool_allocations")
|
||||||
@ -208,7 +324,6 @@ class OutsourceCost(Base):
|
|||||||
# ──────────────────────────── 人力成本手动调整 ────────────────────────────
|
# ──────────────────────────── 人力成本手动调整 ────────────────────────────
|
||||||
|
|
||||||
class CostOverride(Base):
|
class CostOverride(Base):
|
||||||
"""管理员手动修改某人某天的成本分摊"""
|
|
||||||
__tablename__ = "cost_overrides"
|
__tablename__ = "cost_overrides"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
@ -224,7 +339,6 @@ class CostOverride(Base):
|
|||||||
# ──────────────────────────── 提交历史版本 ────────────────────────────
|
# ──────────────────────────── 提交历史版本 ────────────────────────────
|
||||||
|
|
||||||
class SubmissionHistory(Base):
|
class SubmissionHistory(Base):
|
||||||
"""内容提交的修改历史"""
|
|
||||||
__tablename__ = "submission_history"
|
__tablename__ = "submission_history"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
@ -236,3 +350,49 @@ class SubmissionHistory(Base):
|
|||||||
created_at = Column(DateTime, server_default=func.now())
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
|
||||||
submission = relationship("Submission", back_populates="history")
|
submission = relationship("Submission", back_populates="history")
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────── 固定开支 ────────────────────────────
|
||||||
|
|
||||||
|
class OverheadCost(Base):
|
||||||
|
__tablename__ = "overhead_costs"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
cost_type = Column(SAEnum(OverheadCostType), nullable=False)
|
||||||
|
amount = Column(Float, nullable=False)
|
||||||
|
record_month = Column(String(7), nullable=False)
|
||||||
|
note = Column(Text, nullable=True)
|
||||||
|
recorded_by = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────── 项目里程碑 ────────────────────────────
|
||||||
|
|
||||||
|
class ProjectMilestone(Base):
|
||||||
|
__tablename__ = "project_milestones"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False)
|
||||||
|
name = Column(String(100), nullable=False)
|
||||||
|
phase = Column(SAEnum(PhaseGroup), nullable=False)
|
||||||
|
is_completed = Column(Integer, nullable=False, default=0) # 0/1
|
||||||
|
completed_at = Column(DateTime, nullable=True)
|
||||||
|
sort_order = Column(Integer, nullable=False, default=0)
|
||||||
|
|
||||||
|
project = relationship("Project", back_populates="milestones")
|
||||||
|
|
||||||
|
|
||||||
|
# 默认里程碑模板
|
||||||
|
DEFAULT_MILESTONES = [
|
||||||
|
# 前期
|
||||||
|
{"name": "策划案", "phase": "前期", "sort_order": 1},
|
||||||
|
{"name": "剧本", "phase": "前期", "sort_order": 2},
|
||||||
|
{"name": "分镜", "phase": "前期", "sort_order": 3},
|
||||||
|
{"name": "人设图", "phase": "前期", "sort_order": 4},
|
||||||
|
{"name": "场景图", "phase": "前期", "sort_order": 5},
|
||||||
|
# 后期
|
||||||
|
{"name": "配音", "phase": "后期", "sort_order": 1},
|
||||||
|
{"name": "音效", "phase": "后期", "sort_order": 2},
|
||||||
|
{"name": "修补镜头", "phase": "后期", "sort_order": 3},
|
||||||
|
{"name": "杂项", "phase": "后期", "sort_order": 4},
|
||||||
|
]
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -27,8 +27,13 @@ def get_me(current_user: User = Depends(get_current_user)):
|
|||||||
username=current_user.username,
|
username=current_user.username,
|
||||||
name=current_user.name,
|
name=current_user.name,
|
||||||
phase_group=current_user.phase_group.value if hasattr(current_user.phase_group, 'value') else current_user.phase_group,
|
phase_group=current_user.phase_group.value if hasattr(current_user.phase_group, 'value') else current_user.phase_group,
|
||||||
role=current_user.role.value if hasattr(current_user.role, 'value') else current_user.role,
|
role_id=current_user.role_id,
|
||||||
|
role_name=current_user.role_name,
|
||||||
|
permissions=current_user.permissions,
|
||||||
monthly_salary=current_user.monthly_salary,
|
monthly_salary=current_user.monthly_salary,
|
||||||
|
bonus=current_user.bonus or 0,
|
||||||
|
social_insurance=current_user.social_insurance or 0,
|
||||||
|
monthly_total_cost=current_user.monthly_total_cost,
|
||||||
daily_cost=current_user.daily_cost,
|
daily_cost=current_user.daily_cost,
|
||||||
is_active=current_user.is_active,
|
is_active=current_user.is_active,
|
||||||
created_at=current_user.created_at,
|
created_at=current_user.created_at,
|
||||||
|
|||||||
@ -5,14 +5,15 @@ from typing import List, Optional
|
|||||||
from datetime import date
|
from datetime import date
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from models import (
|
from models import (
|
||||||
User, UserRole, AIToolCost, AIToolCostAllocation, OutsourceCost,
|
User, AIToolCost, AIToolCostAllocation, OutsourceCost,
|
||||||
CostOverride, SubscriptionPeriod, CostAllocationType, OutsourceType
|
CostOverride, OverheadCost, SubscriptionPeriod, CostAllocationType,
|
||||||
|
OutsourceType, OverheadCostType
|
||||||
)
|
)
|
||||||
from schemas import (
|
from schemas import (
|
||||||
AIToolCostCreate, AIToolCostOut, OutsourceCostCreate, OutsourceCostOut,
|
AIToolCostCreate, AIToolCostOut, OutsourceCostCreate, OutsourceCostOut,
|
||||||
CostOverrideCreate
|
CostOverrideCreate, OverheadCostCreate, OverheadCostOut
|
||||||
)
|
)
|
||||||
from auth import get_current_user, require_role
|
from auth import get_current_user, require_permission
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/costs", tags=["成本管理"])
|
router = APIRouter(prefix="/api/costs", tags=["成本管理"])
|
||||||
|
|
||||||
@ -22,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_role(UserRole.OWNER, UserRole.SUPERVISOR, UserRole.LEADER))
|
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 [
|
||||||
@ -44,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_role(UserRole.OWNER, UserRole.SUPERVISOR, UserRole.LEADER))
|
current_user: User = Depends(require_permission("cost_ai:create"))
|
||||||
):
|
):
|
||||||
cost = AIToolCost(
|
cost = AIToolCost(
|
||||||
tool_name=req.tool_name,
|
tool_name=req.tool_name,
|
||||||
@ -84,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_role(UserRole.OWNER))
|
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:
|
||||||
@ -101,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_role(UserRole.OWNER, UserRole.SUPERVISOR, UserRole.LEADER))
|
current_user: User = Depends(require_permission("cost_outsource:view"))
|
||||||
):
|
):
|
||||||
q = db.query(OutsourceCost)
|
q = db.query(OutsourceCost)
|
||||||
if project_id:
|
if project_id:
|
||||||
@ -123,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_role(UserRole.OWNER, UserRole.SUPERVISOR, UserRole.LEADER))
|
current_user: User = Depends(require_permission("cost_outsource:create"))
|
||||||
):
|
):
|
||||||
cost = OutsourceCost(
|
cost = OutsourceCost(
|
||||||
project_id=req.project_id,
|
project_id=req.project_id,
|
||||||
@ -150,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_role(UserRole.OWNER))
|
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:
|
||||||
@ -166,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_role(UserRole.OWNER, UserRole.SUPERVISOR))
|
current_user: User = Depends(require_permission("cost_labor:create"))
|
||||||
):
|
):
|
||||||
override = CostOverride(
|
override = CostOverride(
|
||||||
user_id=req.user_id,
|
user_id=req.user_id,
|
||||||
@ -186,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_role(UserRole.OWNER, UserRole.SUPERVISOR))
|
current_user: User = Depends(require_permission("cost_labor:view"))
|
||||||
):
|
):
|
||||||
q = db.query(CostOverride)
|
q = db.query(CostOverride)
|
||||||
if user_id:
|
if user_id:
|
||||||
@ -203,3 +204,66 @@ def list_cost_overrides(
|
|||||||
}
|
}
|
||||||
for r in records
|
for r in records
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────── 固定开支(办公室租金、水电费) ────────────────────
|
||||||
|
|
||||||
|
@router.get("/overhead", response_model=List[OverheadCostOut])
|
||||||
|
def list_overhead_costs(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_permission("cost_overhead:view"))
|
||||||
|
):
|
||||||
|
costs = db.query(OverheadCost).order_by(OverheadCost.record_month.desc()).all()
|
||||||
|
return [
|
||||||
|
OverheadCostOut(
|
||||||
|
id=c.id,
|
||||||
|
cost_type=c.cost_type.value if hasattr(c.cost_type, 'value') else c.cost_type,
|
||||||
|
amount=c.amount,
|
||||||
|
record_month=c.record_month,
|
||||||
|
note=c.note,
|
||||||
|
recorded_by=c.recorded_by,
|
||||||
|
created_at=c.created_at,
|
||||||
|
)
|
||||||
|
for c in costs
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/overhead", response_model=OverheadCostOut)
|
||||||
|
def create_overhead_cost(
|
||||||
|
req: OverheadCostCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_permission("cost_overhead:create"))
|
||||||
|
):
|
||||||
|
cost = OverheadCost(
|
||||||
|
cost_type=OverheadCostType(req.cost_type),
|
||||||
|
amount=req.amount,
|
||||||
|
record_month=req.record_month,
|
||||||
|
note=req.note,
|
||||||
|
recorded_by=current_user.id,
|
||||||
|
)
|
||||||
|
db.add(cost)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(cost)
|
||||||
|
return OverheadCostOut(
|
||||||
|
id=cost.id,
|
||||||
|
cost_type=cost.cost_type.value,
|
||||||
|
amount=cost.amount,
|
||||||
|
record_month=cost.record_month,
|
||||||
|
note=cost.note,
|
||||||
|
recorded_by=cost.recorded_by,
|
||||||
|
created_at=cost.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/overhead/{cost_id}")
|
||||||
|
def delete_overhead_cost(
|
||||||
|
cost_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_permission("cost_overhead:delete"))
|
||||||
|
):
|
||||||
|
cost = db.query(OverheadCost).filter(OverheadCost.id == cost_id).first()
|
||||||
|
if not cost:
|
||||||
|
raise HTTPException(status_code=404, detail="记录不存在")
|
||||||
|
db.delete(cost)
|
||||||
|
db.commit()
|
||||||
|
return {"message": "已删除"}
|
||||||
|
|||||||
@ -5,14 +5,15 @@ from sqlalchemy import func as sa_func
|
|||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from models import (
|
from models import (
|
||||||
User, UserRole, Project, Submission, AIToolCost,
|
User, Project, Submission, AIToolCost,
|
||||||
ProjectStatus, ProjectType, WorkType
|
ProjectStatus, ProjectType, WorkType
|
||||||
)
|
)
|
||||||
from auth import get_current_user, require_role
|
from auth import get_current_user, require_permission
|
||||||
from calculations import (
|
from calculations import (
|
||||||
calc_project_settlement, calc_waste_for_project,
|
calc_project_settlement, calc_waste_for_project,
|
||||||
calc_labor_cost_for_project, calc_ai_tool_cost_for_project,
|
calc_labor_cost_for_project, calc_ai_tool_cost_for_project,
|
||||||
calc_outsource_cost_for_project, calc_team_efficiency
|
calc_outsource_cost_for_project, calc_overhead_cost_for_project,
|
||||||
|
calc_team_efficiency
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["仪表盘与结算"])
|
router = APIRouter(prefix="/api", tags=["仪表盘与结算"])
|
||||||
@ -21,12 +22,13 @@ router = APIRouter(prefix="/api", tags=["仪表盘与结算"])
|
|||||||
@router.get("/dashboard")
|
@router.get("/dashboard")
|
||||||
def get_dashboard(
|
def get_dashboard(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(require_role(UserRole.OWNER))
|
current_user: User = Depends(require_permission("dashboard:view"))
|
||||||
):
|
):
|
||||||
"""全局仪表盘数据"""
|
"""全局仪表盘数据"""
|
||||||
# 项目概览
|
# 项目概览
|
||||||
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()
|
||||||
@ -90,10 +92,14 @@ 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 = []
|
||||||
for p in active + completed:
|
total_waste_seconds_all = 0.0
|
||||||
|
total_target_seconds_all = 0.0
|
||||||
|
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_target_seconds_all += p.target_total_seconds or 0
|
||||||
if w.get("total_waste_seconds", 0) > 0:
|
if w.get("total_waste_seconds", 0) > 0:
|
||||||
waste_ranking.append({
|
waste_ranking.append({
|
||||||
"project_id": p.id,
|
"project_id": p.id,
|
||||||
@ -102,6 +108,7 @@ def get_dashboard(
|
|||||||
"waste_rate": w["waste_rate"],
|
"waste_rate": w["waste_rate"],
|
||||||
})
|
})
|
||||||
waste_ranking.sort(key=lambda x: x["waste_rate"], reverse=True)
|
waste_ranking.sort(key=lambda x: x["waste_rate"], reverse=True)
|
||||||
|
total_waste_rate = round(total_waste_seconds_all / total_target_seconds_all * 100, 1) if total_target_seconds_all > 0 else 0
|
||||||
|
|
||||||
# 已结算项目
|
# 已结算项目
|
||||||
settled = []
|
settled = []
|
||||||
@ -133,15 +140,18 @@ def get_dashboard(
|
|||||||
total_labor_all = 0.0
|
total_labor_all = 0.0
|
||||||
total_ai_all = 0.0
|
total_ai_all = 0.0
|
||||||
total_outsource_all = 0.0
|
total_outsource_all = 0.0
|
||||||
for p in active + completed:
|
total_overhead_all = 0.0
|
||||||
|
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)
|
||||||
|
total_overhead_all += calc_overhead_cost_for_project(p.id, db)
|
||||||
|
|
||||||
cost_breakdown = [
|
cost_breakdown = [
|
||||||
{"name": "人力成本", "value": round(total_labor_all, 0)},
|
{"name": "人力成本", "value": round(total_labor_all, 0)},
|
||||||
{"name": "AI工具", "value": round(total_ai_all, 0)},
|
{"name": "AI工具", "value": round(total_ai_all, 0)},
|
||||||
{"name": "外包", "value": round(total_outsource_all, 0)},
|
{"name": "外包", "value": round(total_outsource_all, 0)},
|
||||||
|
{"name": "固定开支", "value": round(total_overhead_all, 0)},
|
||||||
]
|
]
|
||||||
|
|
||||||
# ── 图表数据:各项目产出对比(进行中项目) ──
|
# ── 图表数据:各项目产出对比(进行中项目) ──
|
||||||
@ -157,6 +167,47 @@ def get_dashboard(
|
|||||||
"target": p.target_total_seconds,
|
"target": p.target_total_seconds,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# ── 盈利概览 ──
|
||||||
|
total_contract = 0.0
|
||||||
|
total_cost_completed = 0.0
|
||||||
|
for s in settled:
|
||||||
|
if s.get("contract_amount"):
|
||||||
|
total_contract += s["contract_amount"]
|
||||||
|
total_cost_completed += s.get("total_cost", 0)
|
||||||
|
total_profit = total_contract - total_cost_completed
|
||||||
|
profit_rate = round(total_profit / total_contract * 100, 1) if total_contract > 0 else 0
|
||||||
|
|
||||||
|
# 进行中项目的合同额和当前成本
|
||||||
|
in_progress_contract = 0.0
|
||||||
|
in_progress_cost = 0.0
|
||||||
|
for p in active:
|
||||||
|
if p.contract_amount:
|
||||||
|
in_progress_contract += p.contract_amount
|
||||||
|
in_progress_cost += calc_labor_cost_for_project(p.id, db) + calc_ai_tool_cost_for_project(p.id, db) + calc_outsource_cost_for_project(p.id, db) + calc_overhead_cost_for_project(p.id, db)
|
||||||
|
|
||||||
|
# 每个项目的盈亏(用于柱状图)
|
||||||
|
profit_by_project = []
|
||||||
|
for s in settled:
|
||||||
|
if s.get("contract_amount") and s["contract_amount"] > 0:
|
||||||
|
profit_by_project.append({
|
||||||
|
"name": s["project_name"],
|
||||||
|
"profit": round((s.get("contract_amount", 0) or 0) - s.get("total_cost", 0), 0),
|
||||||
|
})
|
||||||
|
|
||||||
|
profitability = {
|
||||||
|
"total_contract": round(total_contract, 0),
|
||||||
|
"total_cost": round(total_cost_completed, 0),
|
||||||
|
"total_profit": round(total_profit, 0),
|
||||||
|
"profit_rate": profit_rate,
|
||||||
|
"in_progress_contract": round(in_progress_contract, 0),
|
||||||
|
"in_progress_cost": round(in_progress_cost, 0),
|
||||||
|
"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),
|
||||||
@ -165,8 +216,12 @@ def get_dashboard(
|
|||||||
"monthly_total_seconds": round(monthly_secs, 1),
|
"monthly_total_seconds": round(monthly_secs, 1),
|
||||||
"avg_daily_seconds_per_person": avg_daily,
|
"avg_daily_seconds_per_person": avg_daily,
|
||||||
"projects": project_summaries,
|
"projects": project_summaries,
|
||||||
|
"total_waste_seconds": round(total_waste_seconds_all, 0),
|
||||||
|
"total_waste_rate": total_waste_rate,
|
||||||
"waste_ranking": waste_ranking,
|
"waste_ranking": waste_ranking,
|
||||||
"settled_projects": settled,
|
"settled_projects": settled,
|
||||||
|
"profitability": profitability,
|
||||||
|
"risk_alerts": risk_alerts,
|
||||||
# 图表数据
|
# 图表数据
|
||||||
"daily_trend": daily_trend,
|
"daily_trend": daily_trend,
|
||||||
"cost_breakdown": cost_breakdown,
|
"cost_breakdown": cost_breakdown,
|
||||||
@ -178,7 +233,7 @@ def get_dashboard(
|
|||||||
def get_settlement(
|
def get_settlement(
|
||||||
project_id: int,
|
project_id: int,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(require_role(UserRole.OWNER))
|
current_user: User = Depends(require_permission("settlement:view"))
|
||||||
):
|
):
|
||||||
"""项目结算报告"""
|
"""项目结算报告"""
|
||||||
project = db.query(Project).filter(Project.id == project_id).first()
|
project = db.query(Project).filter(Project.id == project_id).first()
|
||||||
@ -191,7 +246,7 @@ def get_settlement(
|
|||||||
def get_efficiency(
|
def get_efficiency(
|
||||||
project_id: int,
|
project_id: int,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(require_role(UserRole.OWNER, UserRole.SUPERVISOR, UserRole.LEADER))
|
current_user: User = Depends(require_permission("efficiency:view"))
|
||||||
):
|
):
|
||||||
"""项目团队效率数据"""
|
"""项目团队效率数据"""
|
||||||
return calc_team_efficiency(project_id, db)
|
return calc_team_efficiency(project_id, db)
|
||||||
|
|||||||
@ -3,13 +3,18 @@ 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, UserRole, ProjectType,
|
User, Project, Submission, ProjectType,
|
||||||
ProjectStatus, PhaseGroup, WorkType
|
ProjectStatus, PhaseGroup, WorkType,
|
||||||
|
ProjectMilestone, DEFAULT_MILESTONES
|
||||||
)
|
)
|
||||||
from schemas import ProjectCreate, ProjectUpdate, ProjectOut
|
from schemas import (
|
||||||
from auth import get_current_user, require_role
|
ProjectCreate, ProjectUpdate, ProjectOut,
|
||||||
|
MilestoneOut, MilestoneCreate
|
||||||
|
)
|
||||||
|
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -76,7 +127,7 @@ def list_projects(
|
|||||||
def create_project(
|
def create_project(
|
||||||
req: ProjectCreate,
|
req: ProjectCreate,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(require_role(UserRole.OWNER, UserRole.SUPERVISOR))
|
current_user: User = Depends(require_permission("project:create"))
|
||||||
):
|
):
|
||||||
project = Project(
|
project = Project(
|
||||||
name=req.name,
|
name=req.name,
|
||||||
@ -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)
|
||||||
@ -111,7 +173,7 @@ def update_project(
|
|||||||
project_id: int,
|
project_id: int,
|
||||||
req: ProjectUpdate,
|
req: ProjectUpdate,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(require_role(UserRole.OWNER, UserRole.SUPERVISOR))
|
current_user: User = Depends(require_permission("project:edit"))
|
||||||
):
|
):
|
||||||
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:
|
||||||
@ -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:
|
||||||
@ -141,18 +206,127 @@ def update_project(
|
|||||||
return enrich_project(p, db)
|
return enrich_project(p, db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{project_id}")
|
||||||
|
def delete_project(
|
||||||
|
project_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_permission("project:delete"))
|
||||||
|
):
|
||||||
|
"""删除项目及其关联数据"""
|
||||||
|
from models import OutsourceCost, AIToolCostAllocation, CostOverride, SubmissionHistory
|
||||||
|
p = db.query(Project).filter(Project.id == project_id).first()
|
||||||
|
if not p:
|
||||||
|
raise HTTPException(status_code=404, detail="项目不存在")
|
||||||
|
|
||||||
|
# 删除关联数据
|
||||||
|
subs = db.query(Submission).filter(Submission.project_id == project_id).all()
|
||||||
|
for s in subs:
|
||||||
|
db.query(SubmissionHistory).filter(SubmissionHistory.submission_id == s.id).delete()
|
||||||
|
db.query(Submission).filter(Submission.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(CostOverride).filter(CostOverride.project_id == project_id).delete()
|
||||||
|
db.query(ProjectMilestone).filter(ProjectMilestone.project_id == project_id).delete()
|
||||||
|
db.delete(p)
|
||||||
|
db.commit()
|
||||||
|
return {"message": "项目已删除"}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{project_id}/complete")
|
@router.post("/{project_id}/complete")
|
||||||
def complete_project(
|
def complete_project(
|
||||||
project_id: int,
|
project_id: int,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(require_role(UserRole.OWNER))
|
current_user: User = Depends(require_permission("project:complete"))
|
||||||
):
|
):
|
||||||
"""Owner 手动确认项目完成"""
|
"""Owner 手动确认项目完成"""
|
||||||
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)
|
||||||
113
backend/routers/roles.py
Normal file
113
backend/routers/roles.py
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
"""角色管理路由"""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import List
|
||||||
|
from database import get_db
|
||||||
|
from models import Role, User, ALL_PERMISSIONS, PERMISSION_KEYS
|
||||||
|
from auth import get_current_user, require_permission
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/roles", tags=["角色管理"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/permissions")
|
||||||
|
def get_all_permissions(current_user: User = Depends(get_current_user)):
|
||||||
|
"""获取系统全部权限定义(供前端勾选面板使用)"""
|
||||||
|
groups = {}
|
||||||
|
for key, label, group in ALL_PERMISSIONS:
|
||||||
|
if group not in groups:
|
||||||
|
groups[group] = []
|
||||||
|
groups[group].append({"key": key, "label": label})
|
||||||
|
return [{"group": g, "permissions": perms} for g, perms in groups.items()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/")
|
||||||
|
def list_roles(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
roles = db.query(Role).order_by(Role.is_system.desc(), Role.id).all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": r.id,
|
||||||
|
"name": r.name,
|
||||||
|
"description": r.description,
|
||||||
|
"permissions": r.permissions or [],
|
||||||
|
"is_system": bool(r.is_system),
|
||||||
|
"user_count": db.query(User).filter(User.role_id == r.id).count(),
|
||||||
|
"created_at": r.created_at,
|
||||||
|
}
|
||||||
|
for r in roles
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/")
|
||||||
|
def create_role(
|
||||||
|
req: dict,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_permission("role:manage"))
|
||||||
|
):
|
||||||
|
name = req.get("name", "").strip()
|
||||||
|
if not name:
|
||||||
|
raise HTTPException(status_code=400, detail="角色名称不能为空")
|
||||||
|
if db.query(Role).filter(Role.name == name).first():
|
||||||
|
raise HTTPException(status_code=400, detail="角色名称已存在")
|
||||||
|
|
||||||
|
perms = [p for p in req.get("permissions", []) if p in PERMISSION_KEYS]
|
||||||
|
role = Role(
|
||||||
|
name=name,
|
||||||
|
description=req.get("description", ""),
|
||||||
|
permissions=perms,
|
||||||
|
is_system=0,
|
||||||
|
)
|
||||||
|
db.add(role)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(role)
|
||||||
|
return {"id": role.id, "name": role.name, "message": "角色已创建"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{role_id}")
|
||||||
|
def update_role(
|
||||||
|
role_id: int,
|
||||||
|
req: dict,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_permission("role:manage"))
|
||||||
|
):
|
||||||
|
role = db.query(Role).filter(Role.id == role_id).first()
|
||||||
|
if not role:
|
||||||
|
raise HTTPException(status_code=404, detail="角色不存在")
|
||||||
|
|
||||||
|
name = req.get("name")
|
||||||
|
if name is not None:
|
||||||
|
name = name.strip()
|
||||||
|
existing = db.query(Role).filter(Role.name == name, Role.id != role_id).first()
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=400, detail="角色名称已存在")
|
||||||
|
role.name = name
|
||||||
|
|
||||||
|
if "description" in req:
|
||||||
|
role.description = req["description"]
|
||||||
|
|
||||||
|
if "permissions" in req:
|
||||||
|
role.permissions = [p for p in req["permissions"] if p in PERMISSION_KEYS]
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {"message": "角色已更新"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{role_id}")
|
||||||
|
def delete_role(
|
||||||
|
role_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_permission("role:manage"))
|
||||||
|
):
|
||||||
|
role = db.query(Role).filter(Role.id == role_id).first()
|
||||||
|
if not role:
|
||||||
|
raise HTTPException(status_code=404, detail="角色不存在")
|
||||||
|
if role.is_system:
|
||||||
|
raise HTTPException(status_code=400, detail="系统内置角色不可删除")
|
||||||
|
user_count = db.query(User).filter(User.role_id == role_id).count()
|
||||||
|
if user_count > 0:
|
||||||
|
raise HTTPException(status_code=400, detail=f"该角色下还有 {user_count} 个用户,请先转移用户再删除")
|
||||||
|
db.delete(role)
|
||||||
|
db.commit()
|
||||||
|
return {"message": "角色已删除"}
|
||||||
@ -5,11 +5,11 @@ from typing import List, Optional
|
|||||||
from datetime import date
|
from datetime import date
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from models import (
|
from models import (
|
||||||
User, Submission, SubmissionHistory, Project, UserRole,
|
User, Submission, SubmissionHistory, Project,
|
||||||
PhaseGroup, WorkType, ContentType, SubmitTo
|
PhaseGroup, WorkType, ContentType, SubmitTo
|
||||||
)
|
)
|
||||||
from schemas import SubmissionCreate, SubmissionUpdate, SubmissionOut
|
from schemas import SubmissionCreate, SubmissionUpdate, SubmissionOut
|
||||||
from auth import get_current_user, require_role
|
from auth import get_current_user, require_permission
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/submissions", tags=["内容提交"])
|
router = APIRouter(prefix="/api/submissions", tags=["内容提交"])
|
||||||
|
|
||||||
@ -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)
|
||||||
# 成员只能看自己的
|
# 查看项目内提交时,所有人都可见(方便横向对比)
|
||||||
if current_user.role == UserRole.MEMBER:
|
# 全局提交列表时,没有 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)
|
||||||
@ -98,7 +101,7 @@ def update_submission(
|
|||||||
submission_id: int,
|
submission_id: int,
|
||||||
req: SubmissionUpdate,
|
req: SubmissionUpdate,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(require_role(UserRole.OWNER, UserRole.SUPERVISOR, UserRole.LEADER))
|
current_user: User = Depends(require_permission("submission:create"))
|
||||||
):
|
):
|
||||||
"""高权限修改提交记录(需填写原因)"""
|
"""高权限修改提交记录(需填写原因)"""
|
||||||
sub = db.query(Submission).filter(Submission.id == submission_id).first()
|
sub = db.query(Submission).filter(Submission.id == submission_id).first()
|
||||||
@ -174,7 +177,7 @@ def update_submission(
|
|||||||
def get_submission_history(
|
def get_submission_history(
|
||||||
submission_id: int,
|
submission_id: int,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(require_role(UserRole.OWNER, UserRole.SUPERVISOR, UserRole.LEADER))
|
current_user: User = Depends(require_permission("submission:view"))
|
||||||
):
|
):
|
||||||
"""查看提交的修改历史"""
|
"""查看提交的修改历史"""
|
||||||
records = db.query(SubmissionHistory).filter(
|
records = db.query(SubmissionHistory).filter(
|
||||||
|
|||||||
@ -3,9 +3,9 @@ from fastapi import APIRouter, Depends, HTTPException
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from typing import List
|
from typing import List
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from models import User, UserRole, PhaseGroup
|
from models import User, Role, PhaseGroup
|
||||||
from schemas import UserCreate, UserUpdate, UserOut
|
from schemas import UserCreate, UserUpdate, UserOut
|
||||||
from auth import get_current_user, hash_password, require_role
|
from auth import get_current_user, hash_password, require_permission
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/users", tags=["用户管理"])
|
router = APIRouter(prefix="/api/users", tags=["用户管理"])
|
||||||
|
|
||||||
@ -14,8 +14,12 @@ def user_to_out(u: User) -> UserOut:
|
|||||||
return UserOut(
|
return UserOut(
|
||||||
id=u.id, username=u.username, name=u.name,
|
id=u.id, username=u.username, name=u.name,
|
||||||
phase_group=u.phase_group.value if hasattr(u.phase_group, 'value') else u.phase_group,
|
phase_group=u.phase_group.value if hasattr(u.phase_group, 'value') else u.phase_group,
|
||||||
role=u.role.value if hasattr(u.role, 'value') else u.role,
|
role_id=u.role_id, role_name=u.role_name, permissions=u.permissions,
|
||||||
monthly_salary=u.monthly_salary, daily_cost=u.daily_cost,
|
monthly_salary=u.monthly_salary,
|
||||||
|
bonus=u.bonus or 0,
|
||||||
|
social_insurance=u.social_insurance or 0,
|
||||||
|
monthly_total_cost=u.monthly_total_cost,
|
||||||
|
daily_cost=u.daily_cost,
|
||||||
is_active=u.is_active, created_at=u.created_at,
|
is_active=u.is_active, created_at=u.created_at,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -23,7 +27,7 @@ def user_to_out(u: User) -> UserOut:
|
|||||||
@router.get("/", response_model=List[UserOut])
|
@router.get("/", response_model=List[UserOut])
|
||||||
def list_users(
|
def list_users(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(require_role(UserRole.OWNER, UserRole.SUPERVISOR, UserRole.LEADER))
|
current_user: User = Depends(require_permission("user:view"))
|
||||||
):
|
):
|
||||||
users = db.query(User).order_by(User.created_at.desc()).all()
|
users = db.query(User).order_by(User.created_at.desc()).all()
|
||||||
return [user_to_out(u) for u in users]
|
return [user_to_out(u) for u in users]
|
||||||
@ -33,7 +37,7 @@ def list_users(
|
|||||||
def create_user(
|
def create_user(
|
||||||
req: UserCreate,
|
req: UserCreate,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(require_role(UserRole.OWNER))
|
current_user: User = Depends(require_permission("user:manage"))
|
||||||
):
|
):
|
||||||
if db.query(User).filter(User.username == req.username).first():
|
if db.query(User).filter(User.username == req.username).first():
|
||||||
raise HTTPException(status_code=400, detail="用户名已存在")
|
raise HTTPException(status_code=400, detail="用户名已存在")
|
||||||
@ -42,8 +46,10 @@ def create_user(
|
|||||||
password_hash=hash_password(req.password),
|
password_hash=hash_password(req.password),
|
||||||
name=req.name,
|
name=req.name,
|
||||||
phase_group=PhaseGroup(req.phase_group),
|
phase_group=PhaseGroup(req.phase_group),
|
||||||
role=UserRole(req.role),
|
role_id=req.role_id,
|
||||||
monthly_salary=req.monthly_salary,
|
monthly_salary=req.monthly_salary,
|
||||||
|
bonus=req.bonus,
|
||||||
|
social_insurance=req.social_insurance,
|
||||||
)
|
)
|
||||||
db.add(user)
|
db.add(user)
|
||||||
db.commit()
|
db.commit()
|
||||||
@ -56,7 +62,7 @@ def update_user(
|
|||||||
user_id: int,
|
user_id: int,
|
||||||
req: UserUpdate,
|
req: UserUpdate,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(require_role(UserRole.OWNER))
|
current_user: User = Depends(require_permission("user:manage"))
|
||||||
):
|
):
|
||||||
user = db.query(User).filter(User.id == user_id).first()
|
user = db.query(User).filter(User.id == user_id).first()
|
||||||
if not user:
|
if not user:
|
||||||
@ -65,10 +71,14 @@ def update_user(
|
|||||||
user.name = req.name
|
user.name = req.name
|
||||||
if req.phase_group is not None:
|
if req.phase_group is not None:
|
||||||
user.phase_group = PhaseGroup(req.phase_group)
|
user.phase_group = PhaseGroup(req.phase_group)
|
||||||
if req.role is not None:
|
if req.role_id is not None:
|
||||||
user.role = UserRole(req.role)
|
user.role_id = req.role_id
|
||||||
if req.monthly_salary is not None:
|
if req.monthly_salary is not None:
|
||||||
user.monthly_salary = req.monthly_salary
|
user.monthly_salary = req.monthly_salary
|
||||||
|
if req.bonus is not None:
|
||||||
|
user.bonus = req.bonus
|
||||||
|
if req.social_insurance is not None:
|
||||||
|
user.social_insurance = req.social_insurance
|
||||||
if req.is_active is not None:
|
if req.is_active is not None:
|
||||||
user.is_active = req.is_active
|
user.is_active = req.is_active
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@ -23,15 +23,19 @@ class UserCreate(BaseModel):
|
|||||||
password: str
|
password: str
|
||||||
name: str
|
name: str
|
||||||
phase_group: str # 前期/制作/后期
|
phase_group: str # 前期/制作/后期
|
||||||
role: str = "成员"
|
role_id: int
|
||||||
monthly_salary: float = 0
|
monthly_salary: float = 0
|
||||||
|
bonus: float = 0
|
||||||
|
social_insurance: float = 0
|
||||||
|
|
||||||
|
|
||||||
class UserUpdate(BaseModel):
|
class UserUpdate(BaseModel):
|
||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
phase_group: Optional[str] = None
|
phase_group: Optional[str] = None
|
||||||
role: Optional[str] = None
|
role_id: Optional[int] = None
|
||||||
monthly_salary: Optional[float] = None
|
monthly_salary: Optional[float] = None
|
||||||
|
bonus: Optional[float] = None
|
||||||
|
social_insurance: Optional[float] = None
|
||||||
is_active: Optional[int] = None
|
is_active: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
@ -40,8 +44,13 @@ class UserOut(BaseModel):
|
|||||||
username: str
|
username: str
|
||||||
name: str
|
name: str
|
||||||
phase_group: str
|
phase_group: str
|
||||||
role: str
|
role_id: int
|
||||||
|
role_name: str
|
||||||
|
permissions: List[str] = []
|
||||||
monthly_salary: float
|
monthly_salary: float
|
||||||
|
bonus: float
|
||||||
|
social_insurance: float
|
||||||
|
monthly_total_cost: float
|
||||||
daily_cost: float
|
daily_cost: float
|
||||||
is_active: int
|
is_active: int
|
||||||
created_at: Optional[datetime] = None
|
created_at: Optional[datetime] = None
|
||||||
@ -52,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
|
||||||
@ -61,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):
|
||||||
@ -96,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
|
||||||
@ -214,6 +245,28 @@ class CostOverrideCreate(BaseModel):
|
|||||||
reason: Optional[str] = None
|
reason: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────── 固定开支 ────────────────────────────
|
||||||
|
|
||||||
|
class OverheadCostCreate(BaseModel):
|
||||||
|
cost_type: str # 办公室租金/水电费
|
||||||
|
amount: float
|
||||||
|
record_month: str # YYYY-MM
|
||||||
|
note: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class OverheadCostOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
cost_type: str
|
||||||
|
amount: float
|
||||||
|
record_month: str
|
||||||
|
note: Optional[str] = None
|
||||||
|
recorded_by: int
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────── 仪表盘 ────────────────────────────
|
# ──────────────────────────── 仪表盘 ────────────────────────────
|
||||||
|
|
||||||
class DashboardSummary(BaseModel):
|
class DashboardSummary(BaseModel):
|
||||||
|
|||||||
@ -14,22 +14,35 @@ def seed():
|
|||||||
db.execute(table.delete())
|
db.execute(table.delete())
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
# ── 初始化内置角色 ──
|
||||||
|
roles = {}
|
||||||
|
for role_name, role_def in BUILTIN_ROLES.items():
|
||||||
|
role = Role(
|
||||||
|
name=role_name,
|
||||||
|
description=role_def["description"],
|
||||||
|
permissions=role_def["permissions"],
|
||||||
|
is_system=1,
|
||||||
|
)
|
||||||
|
db.add(role)
|
||||||
|
roles[role_name] = role
|
||||||
|
db.flush()
|
||||||
|
|
||||||
# ── 用户 ──
|
# ── 用户 ──
|
||||||
users = [
|
users = [
|
||||||
User(username="admin", password_hash=hash_password("admin123"),
|
User(username="admin", password_hash=hash_password("admin123"),
|
||||||
name="老板", phase_group=PhaseGroup.PRODUCTION, role=UserRole.OWNER, monthly_salary=0),
|
name="老板", phase_group=PhaseGroup.PRODUCTION, role_id=roles["超级管理员"].id, monthly_salary=0),
|
||||||
User(username="zhangsan", password_hash=hash_password("123456"),
|
User(username="zhangsan", password_hash=hash_password("123456"),
|
||||||
name="张三", phase_group=PhaseGroup.PRE, role=UserRole.LEADER, monthly_salary=15000),
|
name="张三", phase_group=PhaseGroup.PRE, role_id=roles["组长"].id, monthly_salary=15000),
|
||||||
User(username="lisi", password_hash=hash_password("123456"),
|
User(username="lisi", password_hash=hash_password("123456"),
|
||||||
name="李四", phase_group=PhaseGroup.PRODUCTION, role=UserRole.LEADER, monthly_salary=18000),
|
name="李四", phase_group=PhaseGroup.PRODUCTION, role_id=roles["组长"].id, monthly_salary=18000),
|
||||||
User(username="wangwu", password_hash=hash_password("123456"),
|
User(username="wangwu", password_hash=hash_password("123456"),
|
||||||
name="王五", phase_group=PhaseGroup.PRODUCTION, role=UserRole.MEMBER, monthly_salary=12000),
|
name="王五", phase_group=PhaseGroup.PRODUCTION, role_id=roles["成员"].id, monthly_salary=12000),
|
||||||
User(username="zhaoliu", password_hash=hash_password("123456"),
|
User(username="zhaoliu", password_hash=hash_password("123456"),
|
||||||
name="赵六", phase_group=PhaseGroup.PRODUCTION, role=UserRole.MEMBER, monthly_salary=12000),
|
name="赵六", phase_group=PhaseGroup.PRODUCTION, role_id=roles["成员"].id, monthly_salary=12000),
|
||||||
User(username="sunqi", password_hash=hash_password("123456"),
|
User(username="sunqi", password_hash=hash_password("123456"),
|
||||||
name="孙七", phase_group=PhaseGroup.POST, role=UserRole.MEMBER, monthly_salary=13000),
|
name="孙七", phase_group=PhaseGroup.POST, role_id=roles["成员"].id, monthly_salary=13000),
|
||||||
User(username="producer", password_hash=hash_password("123456"),
|
User(username="producer", password_hash=hash_password("123456"),
|
||||||
name="陈制片", phase_group=PhaseGroup.PRODUCTION, role=UserRole.SUPERVISOR, monthly_salary=20000),
|
name="陈制片", phase_group=PhaseGroup.PRODUCTION, role_id=roles["主管"].id, monthly_salary=20000),
|
||||||
]
|
]
|
||||||
db.add_all(users)
|
db.add_all(users)
|
||||||
db.flush()
|
db.flush()
|
||||||
@ -69,7 +82,6 @@ def seed():
|
|||||||
base_date = date.today() - timedelta(days=14)
|
base_date = date.today() - timedelta(days=14)
|
||||||
submissions = []
|
submissions = []
|
||||||
|
|
||||||
# 张三(前期组)给项目 A 和 D 做前期
|
|
||||||
for i in range(5):
|
for i in range(5):
|
||||||
d = base_date + timedelta(days=i)
|
d = base_date + timedelta(days=i)
|
||||||
submissions.append(Submission(
|
submissions.append(Submission(
|
||||||
@ -89,10 +101,9 @@ def seed():
|
|||||||
submit_date=d,
|
submit_date=d,
|
||||||
))
|
))
|
||||||
|
|
||||||
# 李四(制作组组长)主要做项目 A
|
|
||||||
for i in range(10):
|
for i in range(10):
|
||||||
d = base_date + timedelta(days=i)
|
d = base_date + timedelta(days=i)
|
||||||
secs = 45 + (i % 3) * 15 # 45-75秒
|
secs = 45 + (i % 3) * 15
|
||||||
wt = WorkType.TEST if i < 2 else WorkType.PRODUCTION
|
wt = WorkType.TEST if i < 2 else WorkType.PRODUCTION
|
||||||
submissions.append(Submission(
|
submissions.append(Submission(
|
||||||
user_id=lisi.id, project_id=proj_a.id,
|
user_id=lisi.id, project_id=proj_a.id,
|
||||||
@ -103,10 +114,9 @@ def seed():
|
|||||||
submit_date=d,
|
submit_date=d,
|
||||||
))
|
))
|
||||||
|
|
||||||
# 王五(制作组)做项目 A 和 B
|
|
||||||
for i in range(8):
|
for i in range(8):
|
||||||
d = base_date + timedelta(days=i)
|
d = base_date + timedelta(days=i)
|
||||||
secs = 30 + (i % 4) * 20 # 30-90秒
|
secs = 30 + (i % 4) * 20
|
||||||
submissions.append(Submission(
|
submissions.append(Submission(
|
||||||
user_id=wangwu.id, project_id=proj_a.id,
|
user_id=wangwu.id, project_id=proj_a.id,
|
||||||
project_phase=PhaseGroup.PRODUCTION, work_type=WorkType.PRODUCTION,
|
project_phase=PhaseGroup.PRODUCTION, work_type=WorkType.PRODUCTION,
|
||||||
@ -127,10 +137,9 @@ def seed():
|
|||||||
submit_date=d,
|
submit_date=d,
|
||||||
))
|
))
|
||||||
|
|
||||||
# 赵六(制作组)做项目 A
|
|
||||||
for i in range(10):
|
for i in range(10):
|
||||||
d = base_date + timedelta(days=i)
|
d = base_date + timedelta(days=i)
|
||||||
secs = 50 + (i % 2) * 30 # 50-80秒
|
secs = 50 + (i % 2) * 30
|
||||||
wt = WorkType.TEST if i < 1 else WorkType.PRODUCTION
|
wt = WorkType.TEST if i < 1 else WorkType.PRODUCTION
|
||||||
submissions.append(Submission(
|
submissions.append(Submission(
|
||||||
user_id=zhaoliu.id, project_id=proj_a.id,
|
user_id=zhaoliu.id, project_id=proj_a.id,
|
||||||
@ -141,7 +150,6 @@ def seed():
|
|||||||
submit_date=d,
|
submit_date=d,
|
||||||
))
|
))
|
||||||
|
|
||||||
# 孙七(后期组)剪辑
|
|
||||||
for i in range(3):
|
for i in range(3):
|
||||||
d = base_date + timedelta(days=i + 10)
|
d = base_date + timedelta(days=i + 10)
|
||||||
submissions.append(Submission(
|
submissions.append(Submission(
|
||||||
@ -151,7 +159,6 @@ def seed():
|
|||||||
submit_to=SubmitTo.PRODUCER, description=f"第{i+1}集粗剪完成",
|
submit_to=SubmitTo.PRODUCER, description=f"第{i+1}集粗剪完成",
|
||||||
submit_date=d,
|
submit_date=d,
|
||||||
))
|
))
|
||||||
# 后期补拍
|
|
||||||
submissions.append(Submission(
|
submissions.append(Submission(
|
||||||
user_id=sunqi.id, project_id=proj_a.id,
|
user_id=sunqi.id, project_id=proj_a.id,
|
||||||
project_phase=PhaseGroup.POST, work_type=WorkType.PRODUCTION,
|
project_phase=PhaseGroup.POST, work_type=WorkType.PRODUCTION,
|
||||||
@ -163,7 +170,6 @@ def seed():
|
|||||||
|
|
||||||
db.add_all(submissions)
|
db.add_all(submissions)
|
||||||
|
|
||||||
# ── AI 工具成本 ──
|
|
||||||
db.add(AIToolCost(
|
db.add(AIToolCost(
|
||||||
tool_name="Midjourney", subscription_period=SubscriptionPeriod.MONTHLY,
|
tool_name="Midjourney", subscription_period=SubscriptionPeriod.MONTHLY,
|
||||||
amount=200, allocation_type=CostAllocationType.TEAM,
|
amount=200, allocation_type=CostAllocationType.TEAM,
|
||||||
@ -176,7 +182,6 @@ def seed():
|
|||||||
recorded_by=producer.id, record_date=date.today().replace(day=1),
|
recorded_by=producer.id, record_date=date.today().replace(day=1),
|
||||||
))
|
))
|
||||||
|
|
||||||
# ── 外包成本 ──
|
|
||||||
db.add(OutsourceCost(
|
db.add(OutsourceCost(
|
||||||
project_id=proj_a.id, outsource_type=OutsourceType.ANIMATION,
|
project_id=proj_a.id, outsource_type=OutsourceType.ANIMATION,
|
||||||
episode_start=10, episode_end=13, amount=20000,
|
episode_start=10, episode_end=13, amount=20000,
|
||||||
@ -185,6 +190,7 @@ def seed():
|
|||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
print("[OK] seed data generated")
|
print("[OK] seed data generated")
|
||||||
|
print(f" - roles: {len(roles)}")
|
||||||
print(f" - users: {len(users)}")
|
print(f" - users: {len(users)}")
|
||||||
print(f" - projects: 4")
|
print(f" - projects: 4")
|
||||||
print(f" - submissions: {len(submissions)}")
|
print(f" - submissions: {len(submissions)}")
|
||||||
|
|||||||
362
backend/seed_demo.py
Normal file
362
backend/seed_demo.py
Normal file
@ -0,0 +1,362 @@
|
|||||||
|
"""补充演示数据 - 只添加项目/提交/成本,不动用户和角色"""
|
||||||
|
from datetime import date, timedelta
|
||||||
|
from database import SessionLocal, engine
|
||||||
|
from models import *
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
|
||||||
|
|
||||||
|
def get_user(username):
|
||||||
|
u = db.query(User).filter(User.username == username).first()
|
||||||
|
if not u:
|
||||||
|
print(f" [WARN] user '{username}' not found")
|
||||||
|
return u
|
||||||
|
|
||||||
|
|
||||||
|
def seed_demo():
|
||||||
|
# 清除旧的项目相关数据(不动 users 和 roles)
|
||||||
|
db.query(SubmissionHistory).delete()
|
||||||
|
db.query(Submission).delete()
|
||||||
|
db.query(OutsourceCost).delete()
|
||||||
|
db.query(CostOverride).delete()
|
||||||
|
db.query(AIToolCost).delete()
|
||||||
|
db.query(OverheadCost).delete()
|
||||||
|
db.query(Project).delete()
|
||||||
|
db.commit()
|
||||||
|
print("[1] Cleared old project data")
|
||||||
|
|
||||||
|
# 获取真实用户
|
||||||
|
huhaonan = get_user("huhaonan") # 主管/总导演
|
||||||
|
dengqingrui = get_user("dengqingrui") # 主管/AI导演
|
||||||
|
qiushaohui = get_user("qiushaohui") # 主管/制片
|
||||||
|
chenbaodan = get_user("chenbaodan") # 组长/动画制作
|
||||||
|
maruoqing = get_user("maruoqing") # 组长/AI导演
|
||||||
|
weichunli = get_user("weichunli") # 组长/AI导演
|
||||||
|
panziyan = get_user("panziyan") # 组长/剪辑
|
||||||
|
daixiaoqian = get_user("daixiaoqian") # 组员/动画制作
|
||||||
|
tanruping = get_user("tanruping") # 组员/动画制作
|
||||||
|
zhengyiqing = get_user("zhengyiqing") # 组员/动画制作
|
||||||
|
huangxuewen = get_user("huangxuewen") # 组员/动画制作
|
||||||
|
liushiqi = get_user("liushiqi") # 组员/动画制作
|
||||||
|
daiwei = get_user("daiwei") # 组员/动画制作
|
||||||
|
huangrongying = get_user("huangrongying") # 组员/编剧
|
||||||
|
jiahaozheng = get_user("jiahaozheng") # 组员/剪辑
|
||||||
|
wangyansen = get_user("wangyansen") # 组员/剪辑
|
||||||
|
huangqiuxia = get_user("huangqiuxia") # 组员/动画制作
|
||||||
|
lijing = get_user("lijing") # 组员/动画制作
|
||||||
|
yemeilian = get_user("yemeilian") # 组员/动画制作
|
||||||
|
chenxuanying = get_user("chenxuanying") # 组员/动画制作
|
||||||
|
|
||||||
|
# ── 项目 ──
|
||||||
|
proj_a = Project(
|
||||||
|
name="星际漫游 第一季", project_type=ProjectType.CLIENT_FORMAL,
|
||||||
|
leader_id=huhaonan.id, current_phase=PhaseGroup.PRODUCTION,
|
||||||
|
episode_duration_minutes=5, episode_count=13,
|
||||||
|
estimated_completion_date=date.today() + timedelta(days=60),
|
||||||
|
contract_amount=100000,
|
||||||
|
)
|
||||||
|
proj_b = Project(
|
||||||
|
name="品牌方 TVC 宣传片", project_type=ProjectType.CLIENT_FORMAL,
|
||||||
|
leader_id=dengqingrui.id, current_phase=PhaseGroup.PRODUCTION,
|
||||||
|
episode_duration_minutes=1, episode_count=3,
|
||||||
|
estimated_completion_date=date.today() + timedelta(days=20),
|
||||||
|
contract_amount=50000,
|
||||||
|
)
|
||||||
|
proj_c = Project(
|
||||||
|
name="甲方风格测试", project_type=ProjectType.CLIENT_TEST,
|
||||||
|
leader_id=maruoqing.id, current_phase=PhaseGroup.PRE,
|
||||||
|
episode_duration_minutes=1, episode_count=1,
|
||||||
|
)
|
||||||
|
proj_d = Project(
|
||||||
|
name="AI 短剧原创 S1", project_type=ProjectType.INTERNAL_ORIGINAL,
|
||||||
|
leader_id=weichunli.id, current_phase=PhaseGroup.PRE,
|
||||||
|
episode_duration_minutes=8, episode_count=6,
|
||||||
|
estimated_completion_date=date.today() + timedelta(days=90),
|
||||||
|
)
|
||||||
|
db.add_all([proj_a, proj_b, proj_c, proj_d])
|
||||||
|
db.flush()
|
||||||
|
print("[2] Created 4 projects")
|
||||||
|
|
||||||
|
# ── 内容提交(模拟近 20 天的数据) ──
|
||||||
|
base = date.today() - timedelta(days=20)
|
||||||
|
subs = []
|
||||||
|
|
||||||
|
# --- 项目A:星际漫游 ---
|
||||||
|
# 黄溶莹 - 编剧 - 前期方案
|
||||||
|
for i in range(6):
|
||||||
|
d = base + timedelta(days=i)
|
||||||
|
subs.append(Submission(
|
||||||
|
user_id=huangrongying.id, project_id=proj_a.id,
|
||||||
|
project_phase=PhaseGroup.PRE, work_type=WorkType.PLAN,
|
||||||
|
content_type=ContentType.DESIGN, total_seconds=0,
|
||||||
|
submit_to=SubmitTo.INTERNAL, description=f"第{i+1}集剧本初稿",
|
||||||
|
submit_date=d,
|
||||||
|
))
|
||||||
|
|
||||||
|
# 陈保丹 - 组长 - 动画制作
|
||||||
|
for i in range(12):
|
||||||
|
d = base + timedelta(days=i + 3)
|
||||||
|
secs = 55 + (i % 3) * 20
|
||||||
|
wt = WorkType.TEST if i < 2 else WorkType.PRODUCTION
|
||||||
|
subs.append(Submission(
|
||||||
|
user_id=chenbaodan.id, project_id=proj_a.id,
|
||||||
|
project_phase=PhaseGroup.PRODUCTION, work_type=wt,
|
||||||
|
content_type=ContentType.ANIMATION, total_seconds=secs,
|
||||||
|
duration_minutes=secs // 60, duration_seconds=secs % 60,
|
||||||
|
submit_to=SubmitTo.INTERNAL, description=f"第1集场景{i+1}动画",
|
||||||
|
submit_date=d,
|
||||||
|
))
|
||||||
|
|
||||||
|
# 代晓倩 - 动画制作
|
||||||
|
for i in range(10):
|
||||||
|
d = base + timedelta(days=i + 2)
|
||||||
|
secs = 40 + (i % 4) * 15
|
||||||
|
subs.append(Submission(
|
||||||
|
user_id=daixiaoqian.id, project_id=proj_a.id,
|
||||||
|
project_phase=PhaseGroup.PRODUCTION, work_type=WorkType.PRODUCTION,
|
||||||
|
content_type=ContentType.ANIMATION, total_seconds=secs,
|
||||||
|
duration_minutes=secs // 60, duration_seconds=secs % 60,
|
||||||
|
submit_to=SubmitTo.LEADER, description=f"第2集片段{i+1}",
|
||||||
|
submit_date=d,
|
||||||
|
))
|
||||||
|
|
||||||
|
# 谭如平 - 动画制作
|
||||||
|
for i in range(8):
|
||||||
|
d = base + timedelta(days=i + 4)
|
||||||
|
secs = 35 + (i % 3) * 25
|
||||||
|
wt = WorkType.TEST if i == 0 else WorkType.PRODUCTION
|
||||||
|
subs.append(Submission(
|
||||||
|
user_id=tanruping.id, project_id=proj_a.id,
|
||||||
|
project_phase=PhaseGroup.PRODUCTION, work_type=wt,
|
||||||
|
content_type=ContentType.ANIMATION, total_seconds=secs,
|
||||||
|
duration_minutes=secs // 60, duration_seconds=secs % 60,
|
||||||
|
submit_to=SubmitTo.LEADER, description=f"第3集镜头{i+1}",
|
||||||
|
submit_date=d,
|
||||||
|
))
|
||||||
|
|
||||||
|
# 郑奕晴 - 动画制作
|
||||||
|
for i in range(9):
|
||||||
|
d = base + timedelta(days=i + 3)
|
||||||
|
secs = 45 + (i % 2) * 30
|
||||||
|
subs.append(Submission(
|
||||||
|
user_id=zhengyiqing.id, project_id=proj_a.id,
|
||||||
|
project_phase=PhaseGroup.PRODUCTION, work_type=WorkType.PRODUCTION,
|
||||||
|
content_type=ContentType.ANIMATION, total_seconds=secs,
|
||||||
|
duration_minutes=secs // 60, duration_seconds=secs % 60,
|
||||||
|
submit_to=SubmitTo.LEADER, description=f"第4集场景动画{i+1}",
|
||||||
|
submit_date=d,
|
||||||
|
))
|
||||||
|
|
||||||
|
# 黄雪雯 - 动画制作
|
||||||
|
for i in range(7):
|
||||||
|
d = base + timedelta(days=i + 5)
|
||||||
|
secs = 30 + (i % 3) * 20
|
||||||
|
subs.append(Submission(
|
||||||
|
user_id=huangxuewen.id, project_id=proj_a.id,
|
||||||
|
project_phase=PhaseGroup.PRODUCTION, work_type=WorkType.PRODUCTION,
|
||||||
|
content_type=ContentType.ANIMATION, total_seconds=secs,
|
||||||
|
duration_minutes=secs // 60, duration_seconds=secs % 60,
|
||||||
|
submit_to=SubmitTo.LEADER, description=f"第5集片段{i+1}",
|
||||||
|
submit_date=d,
|
||||||
|
))
|
||||||
|
|
||||||
|
# 潘梓彦 - 剪辑 - 后期
|
||||||
|
for i in range(4):
|
||||||
|
d = base + timedelta(days=i + 14)
|
||||||
|
subs.append(Submission(
|
||||||
|
user_id=panziyan.id, project_id=proj_a.id,
|
||||||
|
project_phase=PhaseGroup.POST, work_type=WorkType.PRODUCTION,
|
||||||
|
content_type=ContentType.EDITING, total_seconds=0,
|
||||||
|
submit_to=SubmitTo.PRODUCER, description=f"第{i+1}集粗剪",
|
||||||
|
submit_date=d,
|
||||||
|
))
|
||||||
|
|
||||||
|
# 贾浩正 - 剪辑
|
||||||
|
for i in range(3):
|
||||||
|
d = base + timedelta(days=i + 15)
|
||||||
|
subs.append(Submission(
|
||||||
|
user_id=jiahaozheng.id, project_id=proj_a.id,
|
||||||
|
project_phase=PhaseGroup.POST, work_type=WorkType.PRODUCTION,
|
||||||
|
content_type=ContentType.EDITING, total_seconds=0,
|
||||||
|
submit_to=SubmitTo.PRODUCER, description=f"第{i+5}集粗剪",
|
||||||
|
submit_date=d,
|
||||||
|
))
|
||||||
|
|
||||||
|
# --- 项目B:品牌方 TVC ---
|
||||||
|
# 马若情 - AI导演
|
||||||
|
for i in range(6):
|
||||||
|
d = base + timedelta(days=i + 5)
|
||||||
|
secs = 20 + i * 10
|
||||||
|
subs.append(Submission(
|
||||||
|
user_id=maruoqing.id, project_id=proj_b.id,
|
||||||
|
project_phase=PhaseGroup.PRODUCTION, work_type=WorkType.PRODUCTION,
|
||||||
|
content_type=ContentType.ANIMATION, total_seconds=secs,
|
||||||
|
duration_minutes=secs // 60, duration_seconds=secs % 60,
|
||||||
|
submit_to=SubmitTo.INTERNAL, description=f"TVC 片段{i+1}",
|
||||||
|
submit_date=d,
|
||||||
|
))
|
||||||
|
|
||||||
|
# 刘诗琪 - 动画制作
|
||||||
|
for i in range(5):
|
||||||
|
d = base + timedelta(days=i + 7)
|
||||||
|
secs = 15 + (i % 3) * 10
|
||||||
|
subs.append(Submission(
|
||||||
|
user_id=liushiqi.id, project_id=proj_b.id,
|
||||||
|
project_phase=PhaseGroup.PRODUCTION, work_type=WorkType.PRODUCTION,
|
||||||
|
content_type=ContentType.ANIMATION, total_seconds=secs,
|
||||||
|
duration_minutes=secs // 60, duration_seconds=secs % 60,
|
||||||
|
submit_to=SubmitTo.LEADER, description=f"TVC 补充镜头{i+1}",
|
||||||
|
submit_date=d,
|
||||||
|
))
|
||||||
|
|
||||||
|
# 王炎森 - 剪辑
|
||||||
|
for i in range(3):
|
||||||
|
d = base + timedelta(days=i + 13)
|
||||||
|
subs.append(Submission(
|
||||||
|
user_id=wangyansen.id, project_id=proj_b.id,
|
||||||
|
project_phase=PhaseGroup.POST, work_type=WorkType.PRODUCTION,
|
||||||
|
content_type=ContentType.EDITING, total_seconds=0,
|
||||||
|
submit_to=SubmitTo.PRODUCER, description=f"TVC 第{i+1}版剪辑",
|
||||||
|
submit_date=d,
|
||||||
|
))
|
||||||
|
|
||||||
|
# --- 项目C:甲方风格测试 ---
|
||||||
|
for i in range(3):
|
||||||
|
d = base + timedelta(days=i + 1)
|
||||||
|
subs.append(Submission(
|
||||||
|
user_id=huangrongying.id, project_id=proj_c.id,
|
||||||
|
project_phase=PhaseGroup.PRE, work_type=WorkType.PLAN,
|
||||||
|
content_type=ContentType.DESIGN, total_seconds=0,
|
||||||
|
submit_to=SubmitTo.INTERNAL, description=f"风格方案{i+1}",
|
||||||
|
submit_date=d,
|
||||||
|
))
|
||||||
|
for i in range(4):
|
||||||
|
d = base + timedelta(days=i + 4)
|
||||||
|
secs = 10 + i * 5
|
||||||
|
subs.append(Submission(
|
||||||
|
user_id=daiwei.id, project_id=proj_c.id,
|
||||||
|
project_phase=PhaseGroup.PRODUCTION, work_type=WorkType.TEST,
|
||||||
|
content_type=ContentType.ANIMATION, total_seconds=secs,
|
||||||
|
duration_minutes=secs // 60, duration_seconds=secs % 60,
|
||||||
|
submit_to=SubmitTo.INTERNAL, description=f"风格测试片段{i+1}",
|
||||||
|
submit_date=d,
|
||||||
|
))
|
||||||
|
|
||||||
|
# --- 项目D:AI 短剧原创 ---
|
||||||
|
for i in range(5):
|
||||||
|
d = base + timedelta(days=i)
|
||||||
|
subs.append(Submission(
|
||||||
|
user_id=huangrongying.id, project_id=proj_d.id,
|
||||||
|
project_phase=PhaseGroup.PRE, work_type=WorkType.PLAN,
|
||||||
|
content_type=ContentType.DESIGN, total_seconds=0,
|
||||||
|
submit_to=SubmitTo.INTERNAL, description=f"原创剧本第{i+1}集大纲",
|
||||||
|
submit_date=d,
|
||||||
|
))
|
||||||
|
for i in range(6):
|
||||||
|
d = base + timedelta(days=i + 8)
|
||||||
|
secs = 60 + (i % 3) * 25
|
||||||
|
subs.append(Submission(
|
||||||
|
user_id=weichunli.id, project_id=proj_d.id,
|
||||||
|
project_phase=PhaseGroup.PRODUCTION, work_type=WorkType.PRODUCTION,
|
||||||
|
content_type=ContentType.ANIMATION, total_seconds=secs,
|
||||||
|
duration_minutes=secs // 60, duration_seconds=secs % 60,
|
||||||
|
submit_to=SubmitTo.INTERNAL, description=f"原创第1集片段{i+1}",
|
||||||
|
submit_date=d,
|
||||||
|
))
|
||||||
|
for i in range(5):
|
||||||
|
d = base + timedelta(days=i + 10)
|
||||||
|
secs = 50 + (i % 2) * 35
|
||||||
|
subs.append(Submission(
|
||||||
|
user_id=huangqiuxia.id, project_id=proj_d.id,
|
||||||
|
project_phase=PhaseGroup.PRODUCTION, work_type=WorkType.PRODUCTION,
|
||||||
|
content_type=ContentType.ANIMATION, total_seconds=secs,
|
||||||
|
duration_minutes=secs // 60, duration_seconds=secs % 60,
|
||||||
|
submit_to=SubmitTo.LEADER, description=f"原创第2集动画{i+1}",
|
||||||
|
submit_date=d,
|
||||||
|
))
|
||||||
|
for i in range(4):
|
||||||
|
d = base + timedelta(days=i + 12)
|
||||||
|
secs = 45 + i * 15
|
||||||
|
subs.append(Submission(
|
||||||
|
user_id=lijing.id, project_id=proj_d.id,
|
||||||
|
project_phase=PhaseGroup.PRODUCTION, work_type=WorkType.PRODUCTION,
|
||||||
|
content_type=ContentType.ANIMATION, total_seconds=secs,
|
||||||
|
duration_minutes=secs // 60, duration_seconds=secs % 60,
|
||||||
|
submit_to=SubmitTo.LEADER, description=f"原创第3集片段{i+1}",
|
||||||
|
submit_date=d,
|
||||||
|
))
|
||||||
|
for i in range(3):
|
||||||
|
d = base + timedelta(days=i + 14)
|
||||||
|
secs = 40 + i * 20
|
||||||
|
subs.append(Submission(
|
||||||
|
user_id=yemeilian.id, project_id=proj_d.id,
|
||||||
|
project_phase=PhaseGroup.PRODUCTION, work_type=WorkType.PRODUCTION,
|
||||||
|
content_type=ContentType.ANIMATION, total_seconds=secs,
|
||||||
|
duration_minutes=secs // 60, duration_seconds=secs % 60,
|
||||||
|
submit_to=SubmitTo.LEADER, description=f"原创第4集动画{i+1}",
|
||||||
|
submit_date=d,
|
||||||
|
))
|
||||||
|
for i in range(3):
|
||||||
|
d = base + timedelta(days=i + 15)
|
||||||
|
secs = 55 + (i % 2) * 20
|
||||||
|
subs.append(Submission(
|
||||||
|
user_id=chenxuanying.id, project_id=proj_d.id,
|
||||||
|
project_phase=PhaseGroup.PRODUCTION, work_type=WorkType.PRODUCTION,
|
||||||
|
content_type=ContentType.ANIMATION, total_seconds=secs,
|
||||||
|
duration_minutes=secs // 60, duration_seconds=secs % 60,
|
||||||
|
submit_to=SubmitTo.LEADER, description=f"原创第5集场景{i+1}",
|
||||||
|
submit_date=d,
|
||||||
|
))
|
||||||
|
|
||||||
|
db.add_all(subs)
|
||||||
|
print(f"[3] Created {len(subs)} submissions")
|
||||||
|
|
||||||
|
# ── AI 工具成本 ──
|
||||||
|
db.add(AIToolCost(
|
||||||
|
tool_name="Midjourney", subscription_period=SubscriptionPeriod.MONTHLY,
|
||||||
|
amount=200, allocation_type=CostAllocationType.TEAM,
|
||||||
|
recorded_by=qiushaohui.id, record_date=date.today().replace(day=1),
|
||||||
|
))
|
||||||
|
db.add(AIToolCost(
|
||||||
|
tool_name="Runway", subscription_period=SubscriptionPeriod.MONTHLY,
|
||||||
|
amount=600, allocation_type=CostAllocationType.PROJECT,
|
||||||
|
project_id=proj_a.id,
|
||||||
|
recorded_by=qiushaohui.id, record_date=date.today().replace(day=1),
|
||||||
|
))
|
||||||
|
db.add(AIToolCost(
|
||||||
|
tool_name="ChatGPT Plus", subscription_period=SubscriptionPeriod.MONTHLY,
|
||||||
|
amount=150, allocation_type=CostAllocationType.TEAM,
|
||||||
|
recorded_by=qiushaohui.id, record_date=date.today().replace(day=1),
|
||||||
|
))
|
||||||
|
print("[4] Created 3 AI tool costs")
|
||||||
|
|
||||||
|
# ── 外包成本 ──
|
||||||
|
db.add(OutsourceCost(
|
||||||
|
project_id=proj_a.id, outsource_type=OutsourceType.ANIMATION,
|
||||||
|
episode_start=10, episode_end=13, amount=20000,
|
||||||
|
recorded_by=qiushaohui.id, record_date=date.today() - timedelta(days=5),
|
||||||
|
))
|
||||||
|
print("[5] Created 1 outsource cost")
|
||||||
|
|
||||||
|
# ── 固定开支 ──
|
||||||
|
db.add(OverheadCost(
|
||||||
|
cost_type=OverheadCostType.OFFICE_RENT,
|
||||||
|
amount=8000, record_month=date.today().strftime("%Y-%m"),
|
||||||
|
recorded_by=qiushaohui.id, note="办公室月租",
|
||||||
|
))
|
||||||
|
db.add(OverheadCost(
|
||||||
|
cost_type=OverheadCostType.UTILITIES,
|
||||||
|
amount=500, record_month=date.today().strftime("%Y-%m"),
|
||||||
|
recorded_by=qiushaohui.id, note="水电费",
|
||||||
|
))
|
||||||
|
print("[6] Created 2 overhead costs")
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
print("\n[DONE] Demo data seeded successfully!")
|
||||||
|
print(f" Projects: 4")
|
||||||
|
print(f" Submissions: {len(subs)}")
|
||||||
|
print(f" AI tools: 3, Outsource: 1, Overhead: 2")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
seed_demo()
|
||||||
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"
|
||||||
|
)
|
||||||
@ -24,7 +24,9 @@ api.interceptors.response.use(
|
|||||||
if (err.response?.status === 401) {
|
if (err.response?.status === 401) {
|
||||||
const isOnLogin = window.location.pathname === '/login'
|
const isOnLogin = window.location.pathname === '/login'
|
||||||
localStorage.removeItem('token')
|
localStorage.removeItem('token')
|
||||||
if (!isOnLogin) {
|
if (isOnLogin) {
|
||||||
|
ElMessage.error(msg || '用户名或密码错误')
|
||||||
|
} else {
|
||||||
router.push('/login')
|
router.push('/login')
|
||||||
ElMessage.error('登录已过期,请重新登录')
|
ElMessage.error('登录已过期,请重新登录')
|
||||||
}
|
}
|
||||||
@ -57,9 +59,14 @@ export const projectApi = {
|
|||||||
create: (data) => api.post('/projects/', data),
|
create: (data) => api.post('/projects/', data),
|
||||||
update: (id, data) => api.put(`/projects/${id}`, data),
|
update: (id, data) => api.put(`/projects/${id}`, data),
|
||||||
get: (id) => api.get(`/projects/${id}`),
|
get: (id) => api.get(`/projects/${id}`),
|
||||||
|
delete: (id) => api.delete(`/projects/${id}`),
|
||||||
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}`),
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 内容提交 ──
|
// ── 内容提交 ──
|
||||||
@ -80,6 +87,18 @@ export const costApi = {
|
|||||||
deleteOutsource: (id) => api.delete(`/costs/outsource/${id}`),
|
deleteOutsource: (id) => api.delete(`/costs/outsource/${id}`),
|
||||||
createOverride: (data) => api.post('/costs/overrides', data),
|
createOverride: (data) => api.post('/costs/overrides', data),
|
||||||
listOverrides: (params) => api.get('/costs/overrides', { params }),
|
listOverrides: (params) => api.get('/costs/overrides', { params }),
|
||||||
|
listOverhead: () => api.get('/costs/overhead'),
|
||||||
|
createOverhead: (data) => api.post('/costs/overhead', data),
|
||||||
|
deleteOverhead: (id) => api.delete(`/costs/overhead/${id}`),
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 角色 ──
|
||||||
|
export const roleApi = {
|
||||||
|
list: () => api.get('/roles/'),
|
||||||
|
create: (data) => api.post('/roles/', data),
|
||||||
|
update: (id, data) => api.put(`/roles/${id}`, data),
|
||||||
|
delete: (id) => api.delete(`/roles/${id}`),
|
||||||
|
permissions: () => api.get('/roles/permissions'),
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 仪表盘 ──
|
// ── 仪表盘 ──
|
||||||
|
|||||||
@ -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.role || hasRole(item.role)"
|
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>
|
||||||
@ -32,7 +32,7 @@
|
|||||||
<div class="user-avatar">{{ authStore.user?.name?.[0] || '?' }}</div>
|
<div class="user-avatar">{{ authStore.user?.name?.[0] || '?' }}</div>
|
||||||
<div class="user-meta">
|
<div class="user-meta">
|
||||||
<div class="user-name">{{ authStore.user?.name }}</div>
|
<div class="user-name">{{ authStore.user?.name }}</div>
|
||||||
<div class="user-role">{{ authStore.user?.role }}</div>
|
<div class="user-role">{{ authStore.user?.role_name }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -69,11 +69,12 @@ const authStore = useAuthStore()
|
|||||||
const isCollapsed = ref(false)
|
const isCollapsed = ref(false)
|
||||||
|
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
{ path: '/dashboard', label: '仪表盘', icon: 'Odometer', role: 'Owner' },
|
{ path: '/dashboard', label: '仪表盘', icon: 'Odometer', perm: 'dashboard:view' },
|
||||||
{ path: '/projects', label: '项目管理', icon: 'FolderOpened' },
|
{ path: '/projects', label: '项目管理', icon: 'FolderOpened', perm: 'project:view' },
|
||||||
{ path: '/submissions', label: '内容提交', icon: 'EditPen' },
|
{ path: '/submissions', label: '内容提交', icon: 'EditPen', perm: 'submission:view' },
|
||||||
{ path: '/costs', label: '成本管理', icon: 'Money', role: 'leader+' },
|
{ path: '/costs', label: '成本管理', icon: 'Money', perm: ['cost_ai:view', 'cost_outsource:view', 'cost_overhead:view', 'cost_labor:view'] },
|
||||||
{ path: '/users', label: '用户管理', icon: 'User', role: 'Owner' },
|
{ path: '/users', label: '用户管理', icon: 'User', perm: 'user:manage' },
|
||||||
|
{ path: '/roles', label: '角色管理', icon: 'Lock', perm: 'role:manage' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const titleMap = {
|
const titleMap = {
|
||||||
@ -82,6 +83,7 @@ const titleMap = {
|
|||||||
'/submissions': '内容提交',
|
'/submissions': '内容提交',
|
||||||
'/costs': '成本管理',
|
'/costs': '成本管理',
|
||||||
'/users': '用户管理',
|
'/users': '用户管理',
|
||||||
|
'/roles': '角色管理',
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentTitle = computed(() => {
|
const currentTitle = computed(() => {
|
||||||
@ -94,12 +96,6 @@ function isActive(path) {
|
|||||||
return route.path === path || route.path.startsWith(path + '/')
|
return route.path === path || route.path.startsWith(path + '/')
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasRole(role) {
|
|
||||||
if (role === 'Owner') return authStore.isOwner()
|
|
||||||
if (role === 'leader+') return authStore.isLeaderOrAbove()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (authStore.token && !authStore.user) {
|
if (authStore.token && !authStore.user) {
|
||||||
await authStore.fetchUser()
|
await authStore.fetchUser()
|
||||||
|
|||||||
@ -5,15 +5,17 @@ const routes = [
|
|||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
component: () => import('../components/Layout.vue'),
|
component: () => import('../components/Layout.vue'),
|
||||||
redirect: '/dashboard',
|
redirect: '/projects',
|
||||||
children: [
|
children: [
|
||||||
{ path: 'dashboard', name: 'Dashboard', component: () => import('../views/Dashboard.vue'), meta: { roles: ['Owner'] } },
|
{ path: 'dashboard', name: 'Dashboard', component: () => import('../views/Dashboard.vue'), meta: { perm: 'dashboard:view' } },
|
||||||
{ path: 'projects', name: 'Projects', component: () => import('../views/Projects.vue') },
|
{ path: 'projects', name: 'Projects', component: () => import('../views/Projects.vue'), meta: { perm: 'project:view' } },
|
||||||
{ path: 'projects/:id', name: 'ProjectDetail', component: () => import('../views/ProjectDetail.vue') },
|
{ path: 'projects/:id', name: 'ProjectDetail', component: () => import('../views/ProjectDetail.vue'), meta: { perm: 'project:view' } },
|
||||||
{ path: 'submissions', name: 'Submissions', component: () => import('../views/Submissions.vue') },
|
{ path: 'submissions', name: 'Submissions', component: () => import('../views/Submissions.vue'), meta: { perm: 'submission:view' } },
|
||||||
{ path: 'costs', name: 'Costs', component: () => import('../views/Costs.vue'), meta: { roles: ['Owner', '主管', '组长'] } },
|
{ 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: { roles: ['Owner'] } },
|
{ path: 'users', name: 'Users', component: () => import('../views/Users.vue'), meta: { perm: 'user:manage' } },
|
||||||
{ path: 'settlement/:id', name: 'Settlement', component: () => import('../views/Settlement.vue'), meta: { roles: ['Owner'] } },
|
{ 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: 'settlement/:id', name: 'Settlement', component: () => import('../views/Settlement.vue'), meta: { perm: 'settlement:view' } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@ -26,18 +28,47 @@ const router = createRouter({
|
|||||||
// 路由守卫
|
// 路由守卫
|
||||||
router.beforeEach(async (to, from, next) => {
|
router.beforeEach(async (to, from, next) => {
|
||||||
const token = localStorage.getItem('token')
|
const token = localStorage.getItem('token')
|
||||||
|
|
||||||
if (to.meta.public) {
|
if (to.meta.public) {
|
||||||
// 已登录时访问登录页,直接跳首页
|
|
||||||
if (to.path === '/login' && token) {
|
if (to.path === '/login' && token) {
|
||||||
next('/')
|
next('/')
|
||||||
} else {
|
} else {
|
||||||
next()
|
next()
|
||||||
}
|
}
|
||||||
} else if (!token) {
|
return
|
||||||
next('/login')
|
|
||||||
} else {
|
|
||||||
next()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
next('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 有 token 但还没加载用户信息(刷新页面时),先加载
|
||||||
|
const { useAuthStore } = await import('../stores/auth')
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
if (!authStore.user) {
|
||||||
|
await authStore.fetchUser()
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchUser 失败会 logout 清 token
|
||||||
|
if (!localStorage.getItem('token')) {
|
||||||
|
next('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 权限校验:如果路由要求特定权限,且用户没有,跳到第一个有权限的页面
|
||||||
|
const checkPerm = (perm) => {
|
||||||
|
if (!perm) return true
|
||||||
|
if (Array.isArray(perm)) return perm.some(p => authStore.hasPermission(p))
|
||||||
|
return authStore.hasPermission(perm)
|
||||||
|
}
|
||||||
|
if (to.meta.perm && !checkPerm(to.meta.perm)) {
|
||||||
|
const fallback = routes[1].children.find(r => checkPerm(r.meta?.perm))
|
||||||
|
next(fallback ? '/' + fallback.path : '/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next()
|
||||||
})
|
})
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
@ -10,7 +10,6 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
const res = await authApi.login({ username, password })
|
const res = await authApi.login({ username, password })
|
||||||
token.value = res.access_token
|
token.value = res.access_token
|
||||||
localStorage.setItem('token', res.access_token)
|
localStorage.setItem('token', res.access_token)
|
||||||
// 登录后立即获取用户信息,失败不影响登录流程
|
|
||||||
try {
|
try {
|
||||||
user.value = await authApi.me()
|
user.value = await authApi.me()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -33,9 +32,13 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
localStorage.removeItem('token')
|
localStorage.removeItem('token')
|
||||||
}
|
}
|
||||||
|
|
||||||
const isOwner = () => user.value?.role === 'Owner'
|
/**
|
||||||
const isSupervisor = () => ['Owner', '主管'].includes(user.value?.role)
|
* 核心权限判断方法
|
||||||
const isLeaderOrAbove = () => ['Owner', '主管', '组长'].includes(user.value?.role)
|
* @param {string} perm - 权限标识符,如 'dashboard:view'
|
||||||
|
*/
|
||||||
|
function hasPermission(perm) {
|
||||||
|
return (user.value?.permissions || []).includes(perm)
|
||||||
|
}
|
||||||
|
|
||||||
return { user, token, login, fetchUser, logout, isOwner, isSupervisor, isLeaderOrAbove }
|
return { user, token, login, fetchUser, logout, hasPermission }
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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.isOwner()">
|
<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,11 +39,29 @@
|
|||||||
<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.isOwner()">
|
<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 v-if="authStore.hasPermission('cost_overhead:view')" label="固定开支" name="overhead">
|
||||||
|
<div class="tab-header">
|
||||||
|
<el-button v-if="authStore.hasPermission('cost_overhead:create')" type="primary" size="small" @click="showOHForm = true"><el-icon><Plus /></el-icon> 新增</el-button>
|
||||||
|
</div>
|
||||||
|
<el-table :data="overheadCosts" v-loading="loadingOH" stripe size="small">
|
||||||
|
<el-table-column prop="cost_type" label="费用类型" width="140" />
|
||||||
|
<el-table-column label="金额" width="120" align="right">
|
||||||
|
<template #default="{row}">¥{{ row.amount.toLocaleString() }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="record_month" label="所属月份" width="110" />
|
||||||
|
<el-table-column prop="note" label="备注" show-overflow-tooltip />
|
||||||
|
<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>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-tab-pane>
|
||||||
</el-tabs>
|
</el-tabs>
|
||||||
|
|
||||||
<!-- AI 工具新增弹窗 -->
|
<!-- AI 工具新增弹窗 -->
|
||||||
@ -112,6 +130,31 @@
|
|||||||
<el-button type="primary" @click="createOut">保存</el-button>
|
<el-button type="primary" @click="createOut">保存</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 固定开支新增弹窗 -->
|
||||||
|
<el-dialog v-model="showOHForm" title="新增固定开支" width="480px" destroy-on-close>
|
||||||
|
<el-form :model="ohForm" label-width="100px">
|
||||||
|
<el-form-item label="费用类型">
|
||||||
|
<el-select v-model="ohForm.cost_type" placeholder="选择开支类型" style="width:100%">
|
||||||
|
<el-option label="办公室租金 — 每月办公室租赁费用" value="办公室租金" />
|
||||||
|
<el-option label="水电费 — 每月水电物业等费用" value="水电费" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="金额">
|
||||||
|
<el-input-number v-model="ohForm.amount" :min="0" :controls="false" placeholder="当月费用(元)" style="width:100%" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="所属月份">
|
||||||
|
<el-date-picker v-model="ohForm.record_month" type="month" value-format="YYYY-MM" placeholder="选择月份" style="width:100%" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="备注">
|
||||||
|
<el-input v-model="ohForm.note" placeholder="(选填)补充说明" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="showOHForm = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="createOH">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -122,25 +165,37 @@ 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 aiCosts = ref([])
|
const aiCosts = ref([])
|
||||||
const outCosts = ref([])
|
const outCosts = ref([])
|
||||||
|
const overheadCosts = ref([])
|
||||||
const projects = ref([])
|
const projects = ref([])
|
||||||
const showAIForm = ref(false)
|
const showAIForm = ref(false)
|
||||||
const showOutForm = ref(false)
|
const showOutForm = ref(false)
|
||||||
|
const showOHForm = ref(false)
|
||||||
|
|
||||||
const projectMap = computed(() => {
|
const projectMap = computed(() => {
|
||||||
const m = {}; projects.value.forEach(p => m[p.id] = p.name); return m
|
const m = {}; projects.value.forEach(p => m[p.id] = p.name); return m
|
||||||
})
|
})
|
||||||
|
|
||||||
const today = new Date().toISOString().split('T')[0]
|
const today = new Date().toISOString().split('T')[0]
|
||||||
|
const currentMonth = today.slice(0, 7)
|
||||||
const aiForm = reactive({ tool_name: '', subscription_period: '月', amount: 0, allocation_type: '内容组整体', project_id: null, record_date: today })
|
const aiForm = reactive({ tool_name: '', subscription_period: '月', amount: 0, allocation_type: '内容组整体', project_id: null, record_date: today })
|
||||||
const outForm = reactive({ project_id: null, outsource_type: '动画', episode_start: 1, episode_end: 1, amount: 0, record_date: today })
|
const outForm = reactive({ project_id: null, outsource_type: '动画', episode_start: 1, episode_end: 1, amount: 0, record_date: today })
|
||||||
|
const ohForm = reactive({ cost_type: '办公室租金', amount: 0, record_month: currentMonth, note: '' })
|
||||||
|
|
||||||
async function loadAI() { loadingAI.value = true; try { aiCosts.value = await costApi.listAITools() } finally { loadingAI.value = false } }
|
async function loadAI() { loadingAI.value = true; try { aiCosts.value = await costApi.listAITools() } finally { loadingAI.value = false } }
|
||||||
async function loadOut() { loadingOut.value = true; try { outCosts.value = await costApi.listOutsource({}) } finally { loadingOut.value = false } }
|
async function loadOut() { loadingOut.value = true; try { outCosts.value = await costApi.listOutsource({}) } finally { loadingOut.value = false } }
|
||||||
|
async function loadOH() { loadingOH.value = true; try { overheadCosts.value = await costApi.listOverhead() } finally { loadingOH.value = false } }
|
||||||
|
|
||||||
async function createAI() {
|
async function createAI() {
|
||||||
await costApi.createAITool(aiForm); ElMessage.success('已添加'); showAIForm.value = false; loadAI()
|
await costApi.createAITool(aiForm); ElMessage.success('已添加'); showAIForm.value = false; loadAI()
|
||||||
@ -148,15 +203,24 @@ async function createAI() {
|
|||||||
async function createOut() {
|
async function createOut() {
|
||||||
await costApi.createOutsource(outForm); ElMessage.success('已添加'); showOutForm.value = false; loadOut()
|
await costApi.createOutsource(outForm); ElMessage.success('已添加'); showOutForm.value = false; loadOut()
|
||||||
}
|
}
|
||||||
|
async function createOH() {
|
||||||
|
await costApi.createOverhead(ohForm); ElMessage.success('已添加'); showOHForm.value = false; loadOH()
|
||||||
|
}
|
||||||
async function deleteAI(id) {
|
async function deleteAI(id) {
|
||||||
await ElMessageBox.confirm('确认删除?'); await costApi.deleteAITool(id); ElMessage.success('已删除'); loadAI()
|
await ElMessageBox.confirm('确认删除?'); await costApi.deleteAITool(id); ElMessage.success('已删除'); loadAI()
|
||||||
}
|
}
|
||||||
async function deleteOut(id) {
|
async function deleteOut(id) {
|
||||||
await ElMessageBox.confirm('确认删除?'); await costApi.deleteOutsource(id); ElMessage.success('已删除'); loadOut()
|
await ElMessageBox.confirm('确认删除?'); await costApi.deleteOutsource(id); ElMessage.success('已删除'); loadOut()
|
||||||
}
|
}
|
||||||
|
async function deleteOH(id) {
|
||||||
|
await ElMessageBox.confirm('确认删除?'); await costApi.deleteOverhead(id); ElMessage.success('已删除'); loadOH()
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
loadAI(); loadOut()
|
// 仅加载有权限的成本数据
|
||||||
|
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>
|
||||||
|
|||||||
@ -30,6 +30,66 @@
|
|||||||
<div class="stat-label">人均日产出</div>
|
<div class="stat-label">人均日产出</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="stat-card" v-if="data.profitability">
|
||||||
|
<div class="stat-icon" :class="data.profitability.total_profit >= 0 ? 'green' : 'red'">
|
||||||
|
<el-icon :size="20"><Coin /></el-icon>
|
||||||
|
</div>
|
||||||
|
<div class="stat-body">
|
||||||
|
<div class="stat-value" :class="{ profit: data.profitability.total_profit >= 0, loss: data.profitability.total_profit < 0 }">
|
||||||
|
{{ data.profitability.total_profit >= 0 ? '+' : '' }}¥{{ formatNum(data.profitability.total_profit) }}
|
||||||
|
</div>
|
||||||
|
<div class="stat-label">已结算利润</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card" v-if="data.profitability">
|
||||||
|
<div class="stat-icon" :class="data.profitability.profit_rate >= 0 ? 'green' : 'red'">
|
||||||
|
<el-icon :size="20"><DataAnalysis /></el-icon>
|
||||||
|
</div>
|
||||||
|
<div class="stat-body">
|
||||||
|
<div class="stat-value" :class="{ profit: data.profitability.profit_rate >= 0, loss: data.profitability.profit_rate < 0 }">
|
||||||
|
{{ data.profitability.profit_rate }}%
|
||||||
|
</div>
|
||||||
|
<div class="stat-label">利润率</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon" :class="(data.total_waste_rate || 0) > 20 ? 'red' : 'orange'">
|
||||||
|
<el-icon :size="20"><WarnTriangleFilled /></el-icon>
|
||||||
|
</div>
|
||||||
|
<div class="stat-body">
|
||||||
|
<div class="stat-value" :style="{color: (data.total_waste_rate || 0) > 20 ? '#FF3B30' : '#FF9500'}">
|
||||||
|
{{ data.total_waste_rate || 0 }}%
|
||||||
|
</div>
|
||||||
|
<div class="stat-label">总损耗率({{ formatSecs(data.total_waste_seconds) }})</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>
|
||||||
|
|
||||||
<!-- 图表行:产出趋势 + 成本构成 -->
|
<!-- 图表行:产出趋势 + 成本构成 -->
|
||||||
@ -82,18 +142,37 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 盈利分析图表 -->
|
||||||
|
<div class="chart-row" v-if="data.profitability?.profit_by_project?.length">
|
||||||
|
<div class="card chart-card full-width">
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="card-title">项目盈亏分析</span>
|
||||||
|
<span class="card-count" v-if="data.profitability">
|
||||||
|
合同总额 ¥{{ formatNum(data.profitability.total_contract) }} · 总成本 ¥{{ formatNum(data.profitability.total_cost) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body"><div ref="profitChartRef" class="chart-container"></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 已结算项目 -->
|
<!-- 已结算项目 -->
|
||||||
<div class="card" v-if="data.settled_projects?.length">
|
<div class="card" v-if="data.settled_projects?.length">
|
||||||
<div class="card-header"><span class="card-title">已结算项目</span></div>
|
<div class="card-header"><span class="card-title">已结算项目</span></div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<el-table :data="data.settled_projects" size="small">
|
<el-table :data="data.settled_projects" size="small">
|
||||||
<el-table-column prop="project_name" label="项目" />
|
<el-table-column prop="project_name" label="项目" />
|
||||||
|
<el-table-column label="合同金额" align="right" width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span v-if="row.contract_amount">¥{{ formatNum(row.contract_amount) }}</span>
|
||||||
|
<span v-else class="text-muted">—</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="总成本" align="right" width="120">
|
<el-table-column label="总成本" align="right" width="120">
|
||||||
<template #default="{ row }">¥{{ formatNum(row.total_cost) }}</template>
|
<template #default="{ row }">¥{{ formatNum(row.total_cost) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="盈亏" align="right" width="120">
|
<el-table-column label="盈亏" align="right" width="120">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span v-if="row.profit_loss != null" class="profit" :class="{ loss: row.profit_loss < 0 }">
|
<span v-if="row.profit_loss != null" class="profit-text" :class="{ loss: row.profit_loss < 0 }">
|
||||||
{{ row.profit_loss >= 0 ? '+' : '' }}¥{{ formatNum(row.profit_loss) }}
|
{{ row.profit_loss >= 0 ? '+' : '' }}¥{{ formatNum(row.profit_loss) }}
|
||||||
</span>
|
</span>
|
||||||
<span v-else class="text-muted">—</span>
|
<span v-else class="text-muted">—</span>
|
||||||
@ -118,8 +197,9 @@ const trendChartRef = ref(null)
|
|||||||
const costChartRef = ref(null)
|
const costChartRef = ref(null)
|
||||||
const comparisonChartRef = ref(null)
|
const comparisonChartRef = ref(null)
|
||||||
const wasteChartRef = ref(null)
|
const wasteChartRef = ref(null)
|
||||||
|
const profitChartRef = ref(null)
|
||||||
|
|
||||||
let trendChart, costChart, comparisonChart, wasteChart
|
let trendChart, costChart, comparisonChart, wasteChart, profitChart
|
||||||
|
|
||||||
function formatNum(n) { return (n || 0).toLocaleString('zh-CN', { maximumFractionDigits: 0 }) }
|
function formatNum(n) { return (n || 0).toLocaleString('zh-CN', { maximumFractionDigits: 0 }) }
|
||||||
function formatSecs(s) {
|
function formatSecs(s) {
|
||||||
@ -226,11 +306,34 @@ function initWasteChart(ranking) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function initProfitChart(profitData) {
|
||||||
|
if (!profitChartRef.value || !profitData?.length) return
|
||||||
|
profitChart = echarts.init(profitChartRef.value)
|
||||||
|
const names = profitData.map(p => p.name.length > 10 ? p.name.slice(0, 10) + '…' : p.name)
|
||||||
|
profitChart.setOption({
|
||||||
|
tooltip: { trigger: 'axis', formatter: p => `${p[0].name}<br/>盈亏 <b>${p[0].value >= 0 ? '+' : ''}¥${p[0].value.toLocaleString()}</b>` },
|
||||||
|
grid: { left: 12, right: 24, top: 16, bottom: 16, containLabel: true },
|
||||||
|
xAxis: { type: 'value', axisLabel: { fontSize: 11, color: '#8F959E', formatter: v => v >= 1000 ? (v/1000) + 'k' : v }, splitLine: { lineStyle: { color: '#F0F1F2' } } },
|
||||||
|
yAxis: { type: 'category', data: names, axisLabel: { fontSize: 12, color: '#3B3F46' }, axisLine: { show: false }, axisTick: { show: false } },
|
||||||
|
series: [{
|
||||||
|
type: 'bar', barWidth: 16,
|
||||||
|
data: profitData.map(p => ({
|
||||||
|
value: p.profit,
|
||||||
|
itemStyle: {
|
||||||
|
color: p.profit >= 0 ? '#34C759' : '#FF3B30',
|
||||||
|
borderRadius: p.profit >= 0 ? [0, 4, 4, 0] : [4, 0, 0, 4],
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function handleResize() {
|
function handleResize() {
|
||||||
trendChart?.resize()
|
trendChart?.resize()
|
||||||
costChart?.resize()
|
costChart?.resize()
|
||||||
comparisonChart?.resize()
|
comparisonChart?.resize()
|
||||||
wasteChart?.resize()
|
wasteChart?.resize()
|
||||||
|
profitChart?.resize()
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
@ -242,6 +345,9 @@ onMounted(async () => {
|
|||||||
initCostChart(data.value.cost_breakdown)
|
initCostChart(data.value.cost_breakdown)
|
||||||
initComparisonChart(data.value.project_comparison)
|
initComparisonChart(data.value.project_comparison)
|
||||||
initWasteChart(data.value.waste_ranking)
|
initWasteChart(data.value.waste_ranking)
|
||||||
|
if (data.value.profitability?.profit_by_project?.length) {
|
||||||
|
initProfitChart(data.value.profitability.profit_by_project)
|
||||||
|
}
|
||||||
window.addEventListener('resize', handleResize)
|
window.addEventListener('resize', handleResize)
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
@ -254,12 +360,14 @@ onUnmounted(() => {
|
|||||||
costChart?.dispose()
|
costChart?.dispose()
|
||||||
comparisonChart?.dispose()
|
comparisonChart?.dispose()
|
||||||
wasteChart?.dispose()
|
wasteChart?.dispose()
|
||||||
|
profitChart?.dispose()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* 统计网格 */
|
/* 统计网格 */
|
||||||
.stat-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 20px; }
|
.stat-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 20px; }
|
||||||
|
@media (max-width: 1200px) { .stat-grid { grid-template-columns: repeat(2, 1fr); } }
|
||||||
.stat-card {
|
.stat-card {
|
||||||
background: var(--bg-card); border: 1px solid var(--border-color);
|
background: var(--bg-card); border: 1px solid var(--border-color);
|
||||||
border-radius: var(--radius-md); padding: 20px; display: flex; align-items: center; gap: 16px;
|
border-radius: var(--radius-md); padding: 20px; display: flex; align-items: center; gap: 16px;
|
||||||
@ -269,6 +377,7 @@ onUnmounted(() => {
|
|||||||
.stat-icon.orange { background: #FFF3E0; color: #FF9500; }
|
.stat-icon.orange { background: #FFF3E0; color: #FF9500; }
|
||||||
.stat-icon.green { background: #E8F8EE; color: #34C759; }
|
.stat-icon.green { background: #E8F8EE; color: #34C759; }
|
||||||
.stat-icon.purple { background: #F0E8FE; color: #9B59B6; }
|
.stat-icon.purple { background: #F0E8FE; color: #9B59B6; }
|
||||||
|
.stat-icon.red { background: #FFE8E7; color: #FF3B30; }
|
||||||
.stat-body { flex: 1; }
|
.stat-body { flex: 1; }
|
||||||
.stat-value { font-size: 22px; font-weight: 700; color: var(--text-primary); line-height: 1.2; }
|
.stat-value { font-size: 22px; font-weight: 700; color: var(--text-primary); line-height: 1.2; }
|
||||||
.stat-label { font-size: 12px; color: var(--text-secondary); margin-top: 4px; }
|
.stat-label { font-size: 12px; color: var(--text-secondary); margin-top: 4px; }
|
||||||
@ -279,6 +388,7 @@ onUnmounted(() => {
|
|||||||
.chart-card.wide { flex: 2; }
|
.chart-card.wide { flex: 2; }
|
||||||
.chart-card.narrow { flex: 1; }
|
.chart-card.narrow { flex: 1; }
|
||||||
.chart-card.half { flex: 1; }
|
.chart-card.half { flex: 1; }
|
||||||
|
.chart-card.full-width { flex: 1; }
|
||||||
.chart-container { width: 100%; height: 260px; }
|
.chart-container { width: 100%; height: 260px; }
|
||||||
|
|
||||||
/* 卡片 */
|
/* 卡片 */
|
||||||
@ -307,7 +417,29 @@ onUnmounted(() => {
|
|||||||
.progress-pct { font-size: 14px; font-weight: 600; color: var(--primary); }
|
.progress-pct { font-size: 14px; font-weight: 600; color: var(--primary); }
|
||||||
.progress-meta { display: flex; justify-content: space-between; font-size: 12px; color: var(--text-secondary); margin-top: 6px; }
|
.progress-meta { display: flex; justify-content: space-between; font-size: 12px; color: var(--text-secondary); margin-top: 6px; }
|
||||||
|
|
||||||
.profit { font-weight: 600; color: #34C759; }
|
.stat-value.profit { color: #34C759; }
|
||||||
.profit.loss { color: #FF3B30; }
|
.stat-value.loss { color: #FF3B30; }
|
||||||
|
.profit-text { font-weight: 600; color: #34C759; }
|
||||||
|
.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>
|
||||||
|
|||||||
@ -23,7 +23,7 @@
|
|||||||
<el-form-item>
|
<el-form-item>
|
||||||
<el-input v-model="form.password" placeholder="密码" type="password" size="large" show-password />
|
<el-input v-model="form.password" placeholder="密码" type="password" size="large" show-password />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-button type="primary" size="large" :loading="loading" @click="handleLogin" class="login-btn">
|
<el-button type="primary" size="large" :loading="loading" native-type="submit" class="login-btn">
|
||||||
登 录
|
登 录
|
||||||
</el-button>
|
</el-button>
|
||||||
</el-form>
|
</el-form>
|
||||||
|
|||||||
335
frontend/src/views/MemberDetail.vue
Normal file
335
frontend/src/views/MemberDetail.vue
Normal file
@ -0,0 +1,335 @@
|
|||||||
|
<template>
|
||||||
|
<div v-loading="loading">
|
||||||
|
<div class="page-header">
|
||||||
|
<div class="page-header-left">
|
||||||
|
<el-button text @click="$router.back()" class="back-btn"><el-icon><ArrowLeft /></el-icon></el-button>
|
||||||
|
<h2>{{ member.name || '成员详情' }}</h2>
|
||||||
|
<el-tag size="small">{{ member.role_name }}</el-tag>
|
||||||
|
<el-tag size="small" type="info">{{ member.phase_group }}</el-tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 基本信息 -->
|
||||||
|
<div class="card info-card">
|
||||||
|
<div class="info-grid">
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">日成本</span>
|
||||||
|
<span class="info-value">¥{{ member.daily_cost || 0 }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">月总成本</span>
|
||||||
|
<span class="info-value">¥{{ (member.monthly_total_cost || 0).toLocaleString() }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">总提交</span>
|
||||||
|
<span class="info-value">{{ allSubmissions.length }} 次</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">总产出</span>
|
||||||
|
<span class="info-value">{{ formatSecs(totalOutputSecs) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">活跃天数</span>
|
||||||
|
<span class="info-value">{{ activeDays }} 天</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">日均产出</span>
|
||||||
|
<span class="info-value">{{ formatSecs(avgDailySecs) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 90天日历热力图 -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><span class="card-title">近 90 天提交热力图</span></div>
|
||||||
|
<div class="card-body heatmap-body">
|
||||||
|
<div class="calendar-heatmap" v-if="calendarData.length">
|
||||||
|
<div class="heatmap-summary">近 90 天内有 <strong>{{ activeDays }}</strong> 天有提交</div>
|
||||||
|
<div class="calendar-wrapper">
|
||||||
|
<div class="weekday-labels">
|
||||||
|
<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 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">
|
||||||
|
<span class="legend-text">少</span>
|
||||||
|
<span class="calendar-day level-0"></span>
|
||||||
|
<span class="calendar-day level-1"></span>
|
||||||
|
<span class="calendar-day level-2"></span>
|
||||||
|
<span class="calendar-day level-3"></span>
|
||||||
|
<span class="calendar-day level-4"></span>
|
||||||
|
<span class="legend-text">多</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-empty v-else description="暂无提交数据" :image-size="60" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 按项目分组的提交 -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><span class="card-title">按项目统计</span></div>
|
||||||
|
<div class="card-body" v-if="projectGroups.length">
|
||||||
|
<div v-for="pg in projectGroups" :key="pg.projectId" class="project-group">
|
||||||
|
<div class="project-group-header" @click="toggleProjectExpand(pg.projectId)">
|
||||||
|
<div class="project-group-info">
|
||||||
|
<span class="project-group-name">{{ pg.projectName }}</span>
|
||||||
|
<el-tag size="small" type="info">{{ pg.submissions.length }} 次提交</el-tag>
|
||||||
|
<span class="project-group-secs">总产出 {{ formatSecs(pg.totalSecs) }}</span>
|
||||||
|
</div>
|
||||||
|
<el-icon :class="{ rotated: expandedProjectIds.has(pg.projectId) }"><ArrowRight /></el-icon>
|
||||||
|
</div>
|
||||||
|
<transition name="expand">
|
||||||
|
<div v-if="expandedProjectIds.has(pg.projectId)" class="project-group-body">
|
||||||
|
<el-table :data="pg.submissions" size="small">
|
||||||
|
<el-table-column prop="submit_date" label="日期" width="110" />
|
||||||
|
<el-table-column label="工作类型" width="80">
|
||||||
|
<template #default="{row}">
|
||||||
|
<el-tag :type="row.work_type === '测试' ? 'warning' : row.work_type === '方案' ? 'info' : ''" size="small">{{ row.work_type }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="content_type" label="内容类型" width="100" />
|
||||||
|
<el-table-column label="产出" width="90" align="right">
|
||||||
|
<template #default="{row}">{{ row.total_seconds > 0 ? formatSecs(row.total_seconds) : '—' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="description" label="描述" show-overflow-tooltip />
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body" v-else>
|
||||||
|
<el-empty description="暂无提交记录" :image-size="60" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { userApi, submissionApi } from '../api'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const loading = ref(false)
|
||||||
|
const member = ref({})
|
||||||
|
const allSubmissions = ref([])
|
||||||
|
const expandedProjectIds = ref(new Set())
|
||||||
|
|
||||||
|
const totalOutputSecs = computed(() => allSubmissions.value.reduce((s, r) => s + (r.total_seconds || 0), 0))
|
||||||
|
|
||||||
|
const activeDays = computed(() => {
|
||||||
|
const dates = new Set(allSubmissions.value.map(s => s.submit_date))
|
||||||
|
return dates.size
|
||||||
|
})
|
||||||
|
|
||||||
|
const avgDailySecs = computed(() => {
|
||||||
|
return activeDays.value > 0 ? Math.round(totalOutputSecs.value / activeDays.value) : 0
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 90天日历热力图 ──
|
||||||
|
const calendarData = computed(() => {
|
||||||
|
const result = []
|
||||||
|
const today = new Date()
|
||||||
|
for (let i = 89; i >= 0; i--) {
|
||||||
|
const d = new Date(today)
|
||||||
|
d.setDate(d.getDate() - i)
|
||||||
|
result.push({ date: d.toISOString().slice(0, 10), secs: 0, count: 0 })
|
||||||
|
}
|
||||||
|
// 填入提交数据
|
||||||
|
const dateMap = {}
|
||||||
|
result.forEach((d, idx) => { dateMap[d.date] = idx })
|
||||||
|
allSubmissions.value.forEach(s => {
|
||||||
|
const idx = dateMap[s.submit_date]
|
||||||
|
if (idx !== undefined) {
|
||||||
|
result[idx].secs += s.total_seconds || 0
|
||||||
|
result[idx].count += 1
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
const calendarWeeks = computed(() => {
|
||||||
|
const weeks = []
|
||||||
|
const data = calendarData.value
|
||||||
|
let week = []
|
||||||
|
const firstDay = new Date(data[0]?.date).getDay()
|
||||||
|
for (let i = 0; i < firstDay; i++) week.push({ date: '', secs: 0, count: 0, empty: true })
|
||||||
|
data.forEach(d => {
|
||||||
|
week.push(d)
|
||||||
|
if (week.length === 7) { weeks.push(week); week = [] }
|
||||||
|
})
|
||||||
|
if (week.length) weeks.push(week)
|
||||||
|
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) {
|
||||||
|
if (day.empty) return 'empty'
|
||||||
|
if (day.count === 0) return 'level-0'
|
||||||
|
if (day.secs < 30) return 'level-1'
|
||||||
|
if (day.secs < 120) return 'level-2'
|
||||||
|
if (day.secs < 300) return 'level-3'
|
||||||
|
return 'level-4'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDayTitle(day) {
|
||||||
|
if (day.empty) return ''
|
||||||
|
if (day.count === 0) return `${day.date}: 无提交`
|
||||||
|
return `${day.date}: ${day.count}次提交, 产出${formatSecs(day.secs)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 按项目分组 ──
|
||||||
|
const projectGroups = computed(() => {
|
||||||
|
const groups = {}
|
||||||
|
allSubmissions.value.forEach(s => {
|
||||||
|
if (!groups[s.project_id]) {
|
||||||
|
groups[s.project_id] = {
|
||||||
|
projectId: s.project_id,
|
||||||
|
projectName: s.project_name,
|
||||||
|
submissions: [],
|
||||||
|
totalSecs: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
groups[s.project_id].submissions.push(s)
|
||||||
|
groups[s.project_id].totalSecs += s.total_seconds || 0
|
||||||
|
})
|
||||||
|
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) {
|
||||||
|
if (!s) return '0秒'
|
||||||
|
const abs = Math.abs(s)
|
||||||
|
const m = Math.floor(abs / 60)
|
||||||
|
const sec = Math.round(abs % 60)
|
||||||
|
const sign = s < 0 ? '-' : ''
|
||||||
|
return m > 0 ? `${sign}${m}分${sec > 0 ? sec + '秒' : ''}` : `${sign}${sec}秒`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const id = route.params.id
|
||||||
|
member.value = await userApi.get(id)
|
||||||
|
allSubmissions.value = await submissionApi.list({ user_id: id })
|
||||||
|
} finally { loading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
|
||||||
|
.page-header-left { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.page-header-left h2 { font-size: 18px; font-weight: 600; }
|
||||||
|
.back-btn { font-size: 16px !important; padding: 4px !important; }
|
||||||
|
|
||||||
|
.info-card { margin-bottom: 16px; }
|
||||||
|
.info-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0; padding: 16px 20px; }
|
||||||
|
.info-item { display: flex; flex-direction: column; padding: 8px 0; border-bottom: 1px solid #f0f1f2; }
|
||||||
|
.info-item:nth-last-child(-n+3) { border-bottom: none; }
|
||||||
|
.info-label { font-size: 12px; color: var(--text-secondary); margin-bottom: 4px; }
|
||||||
|
.info-value { font-size: 14px; color: var(--text-primary); font-weight: 500; }
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--bg-card); border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md); margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.card-header {
|
||||||
|
padding: 16px 20px; border-bottom: 1px solid var(--border-light);
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
}
|
||||||
|
.card-title { font-size: 14px; font-weight: 600; }
|
||||||
|
.card-body { padding: 20px; }
|
||||||
|
|
||||||
|
/* 日历热力图 */
|
||||||
|
.heatmap-body { min-height: 160px; }
|
||||||
|
.heatmap-summary {
|
||||||
|
font-size: 13px; color: var(--text-secondary); margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.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-1 { background: #9be9a8; }
|
||||||
|
.calendar-day.level-2 { background: #40c463; }
|
||||||
|
.calendar-day.level-3 { background: #30a14e; }
|
||||||
|
.calendar-day.level-4 { background: #216e39; }
|
||||||
|
.calendar-day:hover:not(.empty) { outline: 2px solid #30a14e; outline-offset: 1px; }
|
||||||
|
.calendar-legend {
|
||||||
|
display: flex; align-items: center; gap: 4px; margin-top: 12px;
|
||||||
|
justify-content: flex-end; font-size: 12px;
|
||||||
|
}
|
||||||
|
.legend-text { color: var(--text-secondary); margin: 0 6px; }
|
||||||
|
|
||||||
|
/* 项目分组 */
|
||||||
|
.project-group { border-bottom: 1px solid #f0f1f2; }
|
||||||
|
.project-group:last-child { border-bottom: none; }
|
||||||
|
.project-group-header {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
padding: 12px 0; cursor: pointer;
|
||||||
|
}
|
||||||
|
.project-group-header:hover { background: #FAFBFC; margin: 0 -20px; padding-left: 20px; padding-right: 20px; }
|
||||||
|
.project-group-info { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.project-group-name { font-size: 14px; font-weight: 500; color: var(--text-primary); }
|
||||||
|
.project-group-secs { font-size: 13px; color: var(--text-secondary); }
|
||||||
|
.project-group-header .el-icon { transition: transform 0.2s; color: var(--text-secondary); }
|
||||||
|
.project-group-header .el-icon.rotated { transform: rotate(90deg); }
|
||||||
|
.project-group-body { padding-bottom: 12px; }
|
||||||
|
|
||||||
|
.expand-enter-active, .expand-leave-active { transition: all 0.2s; overflow: hidden; }
|
||||||
|
.expand-enter-from, .expand-leave-to { opacity: 0; max-height: 0; }
|
||||||
|
</style>
|
||||||
@ -5,14 +5,47 @@
|
|||||||
<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.isOwner() && project.status === '制作中'" type="danger" plain @click="handleComplete">确认完成</el-button>
|
<el-button v-if="authStore.hasPermission('project:edit')" @click="openEdit">编辑项目</el-button>
|
||||||
<el-button v-if="authStore.isOwner() && project.status === '已完成'" type="primary" @click="$router.push(`/settlement/${project.id}`)">查看结算</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('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>
|
||||||
|
|
||||||
|
<!-- 项目信息 -->
|
||||||
|
<div class="card info-card">
|
||||||
|
<div class="info-grid">
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">合同金额</span>
|
||||||
|
<span class="info-value price">{{ project.contract_amount ? '¥' + project.contract_amount.toLocaleString() : '未设置' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">负责人</span>
|
||||||
|
<span class="info-value">{{ project.leader_name || '未指定' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">当前阶段</span>
|
||||||
|
<span class="info-value">{{ project.current_phase || '—' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">创建时间</span>
|
||||||
|
<span class="info-value">{{ project.created_at ? project.created_at.slice(0,10) : '—' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">预计完成</span>
|
||||||
|
<span class="info-value">{{ project.estimated_completion_date || '未设置' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">目标时长</span>
|
||||||
|
<span class="info-value">{{ project.episode_count }}集 × {{ project.episode_duration_minutes }}分 = {{ formatSecs(project.target_total_seconds) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 概览卡片 -->
|
<!-- 概览卡片 -->
|
||||||
<div class="stat-grid">
|
<div class="stat-grid">
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
@ -24,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>
|
||||||
@ -33,21 +66,145 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 进度条 -->
|
<!-- 里程碑进度 -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header"><span class="card-title">项目进度</span></div>
|
<div class="card-header">
|
||||||
|
<span class="card-title">项目进度</span>
|
||||||
|
<span class="card-count" v-if="remainingDays !== null">
|
||||||
|
{{ remainingDays > 0 ? `剩余 ${remainingDays} 天` : remainingDays === 0 ? '今天截止' : `已超期 ${Math.abs(remainingDays)} 天` }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<el-progress :percentage="Math.min(project.progress_percent, 100)" :stroke-width="8" :show-text="false"
|
<!-- 三阶段分段进度条 -->
|
||||||
:color="progressColor" style="margin-bottom:12px" />
|
<div class="milestone-pipeline" v-if="project.phase_summary">
|
||||||
<div class="meta-row">
|
<div class="segmented-bar">
|
||||||
<span>目标:{{ project.episode_count }}集 × {{ project.episode_duration_minutes }}分 = {{ formatSecs(project.target_total_seconds) }}</span>
|
<div class="seg-group" style="flex:2">
|
||||||
<span v-if="project.estimated_completion_date">预估完成:{{ project.estimated_completion_date }}</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>
|
||||||
|
|
||||||
|
<!-- 时间轴 -->
|
||||||
|
<div class="timeline-section" v-if="project.created_at && project.estimated_completion_date">
|
||||||
|
<div class="progress-label-row">
|
||||||
|
<span>时间进度</span>
|
||||||
|
<span :style="{color: timePercent > 100 ? '#FF3B30' : '#8F959E', fontWeight: 600}">{{ timePercent }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="timeline-bar-wrapper">
|
||||||
|
<div class="timeline-bar">
|
||||||
|
<div class="timeline-elapsed" :style="{width: Math.min(timePercent, 100) + '%'}"></div>
|
||||||
|
<div class="timeline-today-marker" :style="{left: Math.min(timePercent, 100) + '%'}" v-if="timePercent <= 100">
|
||||||
|
<span class="timeline-today-label">今天</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="timeline-labels">
|
||||||
|
<span>{{ project.created_at ? project.created_at.slice(0,10) : '' }}</span>
|
||||||
|
<span>{{ project.estimated_completion_date }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 团队效率 -->
|
<!-- 团队效率 -->
|
||||||
<div v-if="authStore.isLeaderOrAbove() && efficiency.length" class="card">
|
<div v-if="authStore.hasPermission('efficiency:view') && efficiency.length" class="card">
|
||||||
<div class="card-header"><span class="card-title">团队效率</span></div>
|
<div class="card-header"><span class="card-title">团队效率</span></div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<el-table :data="efficiency" size="small">
|
<el-table :data="efficiency" size="small">
|
||||||
@ -70,6 +227,144 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 提交概览热力图 -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="card-title">提交概览</span>
|
||||||
|
<div class="heatmap-controls">
|
||||||
|
<el-radio-group v-model="heatmapRange" size="small">
|
||||||
|
<el-radio-button label="30">最近30天</el-radio-button>
|
||||||
|
<el-radio-button label="all">项目全周期</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body heatmap-body" v-if="heatmapUsers.length">
|
||||||
|
<div class="heatmap-scroll">
|
||||||
|
<table class="heatmap-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="heatmap-name-col">成员</th>
|
||||||
|
<th v-for="d in heatmapDates" :key="d" class="heatmap-date-col" :class="{ today: d === todayStr }">
|
||||||
|
{{ d.slice(5) }}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="user in heatmapUsers" :key="user.id">
|
||||||
|
<td class="heatmap-name-col">
|
||||||
|
<a class="member-link" @click="openMemberDrawer(user)">{{ user.name }}</a>
|
||||||
|
</td>
|
||||||
|
<td v-for="d in heatmapDates" :key="d" class="heatmap-cell"
|
||||||
|
:class="getCellClass(user.id, d)"
|
||||||
|
@mouseenter="showCellTooltip($event, user.id, d)"
|
||||||
|
@mouseleave="hideCellTooltip">
|
||||||
|
<span v-if="getCellData(user.id, d)">{{ getCellData(user.id, d).totalSecs }}s</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!-- 图例 -->
|
||||||
|
<div class="heatmap-legend">
|
||||||
|
<span class="legend-item"><span class="legend-dot type-制作"></span>制作</span>
|
||||||
|
<span class="legend-item"><span class="legend-dot type-测试"></span>测试</span>
|
||||||
|
<span class="legend-item"><span class="legend-dot type-方案"></span>方案</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body" v-else>
|
||||||
|
<el-empty description="暂无提交记录" :image-size="60" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 悬浮提示 -->
|
||||||
|
<div class="cell-tooltip" v-if="tooltip.visible" :style="{ top: tooltip.y + 'px', left: tooltip.x + 'px' }">
|
||||||
|
<div v-for="(s, i) in tooltip.items" :key="i" class="tooltip-row">
|
||||||
|
<el-tag :type="s.work_type === '测试' ? 'warning' : s.work_type === '方案' ? 'info' : ''" size="small">{{ s.work_type }}</el-tag>
|
||||||
|
<span class="tooltip-type">{{ s.content_type }}</span>
|
||||||
|
<span class="tooltip-secs" v-if="s.total_seconds > 0">{{ formatSecs(s.total_seconds) }}</span>
|
||||||
|
<span class="tooltip-desc" v-if="s.description">{{ s.description }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 成员提交抽屉 -->
|
||||||
|
<el-drawer v-model="drawerVisible" :title="drawerUser?.name + ' 的提交记录'" size="480px">
|
||||||
|
<div v-if="drawerUser" class="drawer-content">
|
||||||
|
<div class="drawer-summary">
|
||||||
|
<span>总提交 <strong>{{ drawerSubmissions.length }}</strong> 次</span>
|
||||||
|
<span>总产出 <strong>{{ formatSecs(drawerTotalSecs) }}</strong></span>
|
||||||
|
<router-link :to="`/users/${drawerUser.id}/detail`" class="drawer-link">查看完整详情 →</router-link>
|
||||||
|
</div>
|
||||||
|
<div v-for="(group, date) in drawerGrouped" :key="date" class="drawer-date-group">
|
||||||
|
<div class="drawer-date-header">{{ date }}</div>
|
||||||
|
<div v-for="s in group" :key="s.id" class="drawer-sub-item">
|
||||||
|
<div class="drawer-sub-top">
|
||||||
|
<el-tag :type="s.work_type === '测试' ? 'warning' : s.work_type === '方案' ? 'info' : ''" size="small">{{ s.work_type }}</el-tag>
|
||||||
|
<span>{{ s.content_type }}</span>
|
||||||
|
<span v-if="s.total_seconds > 0" class="drawer-sub-secs">{{ formatSecs(s.total_seconds) }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="s.description" class="drawer-sub-desc">{{ s.description }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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">
|
||||||
@ -98,11 +393,12 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, 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()
|
||||||
@ -111,10 +407,242 @@ 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 progressColor = computed(() => project.value.progress_percent > 100 ? '#FF9500' : '#3370FF')
|
// ── 圆环进度图 ──
|
||||||
|
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 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 remainingDays = computed(() => {
|
||||||
|
if (!project.value.estimated_completion_date) return null
|
||||||
|
const end = new Date(project.value.estimated_completion_date)
|
||||||
|
const now = new Date()
|
||||||
|
return Math.ceil((end - now) / (1000 * 60 * 60 * 24))
|
||||||
|
})
|
||||||
|
|
||||||
|
const timePercent = computed(() => {
|
||||||
|
const p = project.value
|
||||||
|
if (!p.created_at || !p.estimated_completion_date) return 0
|
||||||
|
const start = new Date(p.created_at)
|
||||||
|
const end = new Date(p.estimated_completion_date)
|
||||||
|
const now = new Date()
|
||||||
|
const total = end - start
|
||||||
|
if (total <= 0) return 100
|
||||||
|
return Math.round((now - start) / total * 100)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 热力图 ──
|
||||||
|
const heatmapRange = ref('30')
|
||||||
|
|
||||||
|
const heatmapDates = computed(() => {
|
||||||
|
const subs = submissions.value
|
||||||
|
if (!subs.length) return []
|
||||||
|
if (heatmapRange.value === 'all') {
|
||||||
|
// 项目全周期:从最早提交到今天
|
||||||
|
const dates = subs.map(s => s.submit_date).sort()
|
||||||
|
const start = new Date(dates[0])
|
||||||
|
const end = new Date(todayStr)
|
||||||
|
const result = []
|
||||||
|
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
|
||||||
|
result.push(d.toISOString().slice(0, 10))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
} else {
|
||||||
|
// 最近30天
|
||||||
|
const result = []
|
||||||
|
const today = new Date()
|
||||||
|
for (let i = 29; i >= 0; i--) {
|
||||||
|
const d = new Date(today)
|
||||||
|
d.setDate(d.getDate() - i)
|
||||||
|
result.push(d.toISOString().slice(0, 10))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const heatmapUsers = computed(() => {
|
||||||
|
const subs = submissions.value
|
||||||
|
const userMap = new Map()
|
||||||
|
subs.forEach(s => {
|
||||||
|
if (!userMap.has(s.user_id)) {
|
||||||
|
userMap.set(s.user_id, { id: s.user_id, name: s.user_name })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return Array.from(userMap.values())
|
||||||
|
})
|
||||||
|
|
||||||
|
// 按 userId + date 索引提交数据
|
||||||
|
const heatmapIndex = computed(() => {
|
||||||
|
const idx = {}
|
||||||
|
submissions.value.forEach(s => {
|
||||||
|
const key = `${s.user_id}_${s.submit_date}`
|
||||||
|
if (!idx[key]) idx[key] = { items: [], totalSecs: 0, mainType: '' }
|
||||||
|
idx[key].items.push(s)
|
||||||
|
idx[key].totalSecs += s.total_seconds || 0
|
||||||
|
})
|
||||||
|
// 确定主要工作类型(按产出秒数最多的类型)
|
||||||
|
Object.values(idx).forEach(cell => {
|
||||||
|
const typeMap = {}
|
||||||
|
cell.items.forEach(s => {
|
||||||
|
typeMap[s.work_type] = (typeMap[s.work_type] || 0) + (s.total_seconds || 1)
|
||||||
|
})
|
||||||
|
cell.mainType = Object.entries(typeMap).sort((a, b) => b[1] - a[1])[0]?.[0] || '制作'
|
||||||
|
cell.totalSecs = Math.round(cell.totalSecs)
|
||||||
|
})
|
||||||
|
return idx
|
||||||
|
})
|
||||||
|
|
||||||
|
function getCellData(userId, date) {
|
||||||
|
return heatmapIndex.value[`${userId}_${date}`] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCellClass(userId, date) {
|
||||||
|
const cell = getCellData(userId, date)
|
||||||
|
if (!cell) return ''
|
||||||
|
return `has-data type-${cell.mainType}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 悬浮提示 ──
|
||||||
|
const tooltip = reactive({ visible: false, x: 0, y: 0, items: [] })
|
||||||
|
|
||||||
|
function showCellTooltip(e, userId, date) {
|
||||||
|
const cell = getCellData(userId, date)
|
||||||
|
if (!cell) return
|
||||||
|
const rect = e.target.getBoundingClientRect()
|
||||||
|
tooltip.x = rect.left
|
||||||
|
tooltip.y = rect.bottom + 4
|
||||||
|
tooltip.items = cell.items
|
||||||
|
tooltip.visible = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideCellTooltip() {
|
||||||
|
tooltip.visible = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 成员抽屉 ──
|
||||||
|
const drawerVisible = ref(false)
|
||||||
|
const drawerUser = ref(null)
|
||||||
|
|
||||||
|
const drawerSubmissions = computed(() => {
|
||||||
|
if (!drawerUser.value) return []
|
||||||
|
return submissions.value.filter(s => s.user_id === drawerUser.value.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
const drawerTotalSecs = computed(() => {
|
||||||
|
return drawerSubmissions.value.reduce((sum, s) => sum + (s.total_seconds || 0), 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
const drawerGrouped = computed(() => {
|
||||||
|
const groups = {}
|
||||||
|
drawerSubmissions.value.forEach(s => {
|
||||||
|
if (!groups[s.submit_date]) groups[s.submit_date] = []
|
||||||
|
groups[s.submit_date].push(s)
|
||||||
|
})
|
||||||
|
// 按日期降序
|
||||||
|
const sorted = {}
|
||||||
|
Object.keys(groups).sort((a, b) => b.localeCompare(a)).forEach(k => { sorted[k] = groups[k] })
|
||||||
|
return sorted
|
||||||
|
})
|
||||||
|
|
||||||
|
function openMemberDrawer(user) {
|
||||||
|
drawerUser.value = user
|
||||||
|
drawerVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 工具函数 ──
|
||||||
function formatSecs(s) {
|
function formatSecs(s) {
|
||||||
if (!s) return '0秒'
|
if (!s) return '0秒'
|
||||||
const abs = Math.abs(s)
|
const abs = Math.abs(s)
|
||||||
@ -124,28 +652,95 @@ 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 {
|
||||||
const id = route.params.id
|
const id = route.params.id
|
||||||
project.value = await projectApi.get(id)
|
project.value = await projectApi.get(id)
|
||||||
submissions.value = await submissionApi.list({ project_id: id })
|
submissions.value = await submissionApi.list({ project_id: id })
|
||||||
if (authStore.isLeaderOrAbove()) {
|
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 }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
'删除后项目及其所有提交记录、成本数据将被永久清除,此操作不可撤销。',
|
||||||
|
'确认删除项目',
|
||||||
|
{ type: 'error', confirmButtonText: '确认删除', cancelButtonText: '取消' }
|
||||||
|
)
|
||||||
|
await projectApi.delete(route.params.id)
|
||||||
|
ElMessage.success('项目已删除')
|
||||||
|
router.push('/projects')
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
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>
|
||||||
@ -154,6 +749,21 @@ onMounted(load)
|
|||||||
.page-header-left h2 { font-size: 18px; font-weight: 600; }
|
.page-header-left h2 { font-size: 18px; font-weight: 600; }
|
||||||
.back-btn { font-size: 16px !important; padding: 4px !important; }
|
.back-btn { font-size: 16px !important; padding: 4px !important; }
|
||||||
|
|
||||||
|
/* 项目信息卡片 */
|
||||||
|
.info-card { margin-bottom: 16px; }
|
||||||
|
.info-grid {
|
||||||
|
display: grid; grid-template-columns: repeat(3, 1fr); gap: 0;
|
||||||
|
padding: 16px 20px;
|
||||||
|
}
|
||||||
|
.info-item {
|
||||||
|
display: flex; flex-direction: column; padding: 8px 0;
|
||||||
|
border-bottom: 1px solid var(--border-light, #f0f1f2);
|
||||||
|
}
|
||||||
|
.info-item:nth-last-child(-n+3) { border-bottom: none; }
|
||||||
|
.info-label { font-size: 12px; color: var(--text-secondary); margin-bottom: 4px; }
|
||||||
|
.info-value { font-size: 14px; color: var(--text-primary); font-weight: 500; }
|
||||||
|
.info-value.price { color: #FF9500; font-weight: 700; font-size: 16px; }
|
||||||
|
|
||||||
.stat-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 16px; }
|
.stat-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 16px; }
|
||||||
.stat-card {
|
.stat-card {
|
||||||
background: var(--bg-card); border: 1px solid var(--border-color);
|
background: var(--bg-card); border: 1px solid var(--border-color);
|
||||||
@ -173,10 +783,175 @@ onMounted(load)
|
|||||||
.card-title { font-size: 14px; font-weight: 600; }
|
.card-title { font-size: 14px; font-weight: 600; }
|
||||||
.card-count { font-size: 12px; color: var(--text-secondary); }
|
.card-count { font-size: 12px; color: var(--text-secondary); }
|
||||||
.card-body { padding: 20px; }
|
.card-body { padding: 20px; }
|
||||||
.meta-row { display: flex; justify-content: space-between; font-size: 13px; color: var(--text-secondary); }
|
|
||||||
.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;
|
||||||
}
|
}
|
||||||
.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-label-row {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
font-size: 13px; color: var(--text-secondary); margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.timeline-section { margin-top: 4px; }
|
||||||
|
.timeline-bar-wrapper { position: relative; margin-top: 24px; }
|
||||||
|
.timeline-bar {
|
||||||
|
height: 8px; background: #E5E6EB; border-radius: 4px; position: relative; overflow: visible;
|
||||||
|
}
|
||||||
|
.timeline-elapsed {
|
||||||
|
height: 100%; background: #3370FF; border-radius: 4px 0 0 4px;
|
||||||
|
transition: width 0.3s;
|
||||||
|
}
|
||||||
|
.timeline-today-marker {
|
||||||
|
position: absolute; top: -4px; transform: translateX(-50%);
|
||||||
|
width: 16px; height: 16px; background: #3370FF; border: 2px solid #fff;
|
||||||
|
border-radius: 50%; box-shadow: 0 1px 4px rgba(0,0,0,0.15);
|
||||||
|
}
|
||||||
|
.timeline-today-label {
|
||||||
|
position: absolute; top: -20px; left: 50%; transform: translateX(-50%);
|
||||||
|
font-size: 11px; color: #3370FF; font-weight: 600; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.timeline-labels {
|
||||||
|
display: flex; justify-content: space-between; font-size: 12px; color: var(--text-secondary); margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 热力图 */
|
||||||
|
.heatmap-controls { display: flex; gap: 8px; }
|
||||||
|
.heatmap-body { padding: 16px 20px !important; }
|
||||||
|
.heatmap-scroll { overflow-x: auto; margin-bottom: 12px; }
|
||||||
|
.heatmap-table {
|
||||||
|
border-collapse: collapse; width: max-content; min-width: 100%;
|
||||||
|
}
|
||||||
|
.heatmap-table th, .heatmap-table td {
|
||||||
|
border: 1px solid var(--border-light, #f0f1f2); padding: 0; text-align: center;
|
||||||
|
font-size: 11px; height: 32px; min-width: 44px;
|
||||||
|
}
|
||||||
|
.heatmap-table thead th {
|
||||||
|
background: #F7F8FA; color: var(--text-secondary); font-weight: 500;
|
||||||
|
position: sticky; top: 0; z-index: 1;
|
||||||
|
}
|
||||||
|
.heatmap-table thead th.today { background: #E8F0FE; color: #3370FF; font-weight: 600; }
|
||||||
|
.heatmap-name-col {
|
||||||
|
position: sticky; left: 0; z-index: 2; background: #fff;
|
||||||
|
min-width: 80px; max-width: 80px; text-align: left; padding: 0 8px !important;
|
||||||
|
font-weight: 500; font-size: 12px; color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.heatmap-table thead .heatmap-name-col { background: #F7F8FA; }
|
||||||
|
.heatmap-cell { cursor: default; transition: background 0.15s; }
|
||||||
|
.heatmap-cell:hover { filter: brightness(0.92); }
|
||||||
|
.heatmap-cell.has-data { font-weight: 600; color: #fff; }
|
||||||
|
.heatmap-cell.type-制作 { background: #3370FF; }
|
||||||
|
.heatmap-cell.type-测试 { background: #FF9500; }
|
||||||
|
.heatmap-cell.type-方案 { background: #8F959E; }
|
||||||
|
.member-link {
|
||||||
|
color: #3370FF; cursor: pointer; text-decoration: none;
|
||||||
|
}
|
||||||
|
.member-link:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
.heatmap-legend { display: flex; gap: 16px; font-size: 12px; color: var(--text-secondary); }
|
||||||
|
.legend-item { display: flex; align-items: center; gap: 4px; }
|
||||||
|
.legend-dot { width: 10px; height: 10px; border-radius: 2px; display: inline-block; }
|
||||||
|
.legend-dot.type-制作 { background: #3370FF; }
|
||||||
|
.legend-dot.type-测试 { background: #FF9500; }
|
||||||
|
.legend-dot.type-方案 { background: #8F959E; }
|
||||||
|
|
||||||
|
/* 悬浮提示 */
|
||||||
|
.cell-tooltip {
|
||||||
|
position: fixed; z-index: 9999;
|
||||||
|
background: #fff; border: 1px solid var(--border-color); border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 16px rgba(0,0,0,0.12); padding: 10px 14px;
|
||||||
|
max-width: 320px; pointer-events: none;
|
||||||
|
}
|
||||||
|
.tooltip-row { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; flex-wrap: wrap; }
|
||||||
|
.tooltip-row:last-child { margin-bottom: 0; }
|
||||||
|
.tooltip-type { font-size: 12px; color: var(--text-secondary); }
|
||||||
|
.tooltip-secs { font-size: 12px; font-weight: 600; color: var(--text-primary); }
|
||||||
|
.tooltip-desc { font-size: 11px; color: #8F959E; width: 100%; margin-top: 2px; }
|
||||||
|
|
||||||
|
/* 成员抽屉 */
|
||||||
|
.drawer-content { padding: 0 4px; }
|
||||||
|
.drawer-summary {
|
||||||
|
display: flex; gap: 16px; align-items: center; flex-wrap: wrap;
|
||||||
|
padding: 12px 16px; background: #F7F8FA; border-radius: 8px;
|
||||||
|
font-size: 13px; color: var(--text-secondary); margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.drawer-summary strong { color: var(--text-primary); }
|
||||||
|
.drawer-link { color: #3370FF; font-size: 13px; margin-left: auto; }
|
||||||
|
.drawer-date-group { margin-bottom: 16px; }
|
||||||
|
.drawer-date-header {
|
||||||
|
font-size: 13px; font-weight: 600; color: var(--text-primary);
|
||||||
|
padding-bottom: 8px; border-bottom: 1px solid var(--border-light, #f0f1f2);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.drawer-sub-item {
|
||||||
|
padding: 6px 0; border-bottom: 1px solid var(--border-light, #f0f1f2);
|
||||||
|
}
|
||||||
|
.drawer-sub-item:last-child { border-bottom: none; }
|
||||||
|
.drawer-sub-top { display: flex; align-items: center; gap: 8px; font-size: 13px; }
|
||||||
|
.drawer-sub-secs { font-weight: 600; color: var(--text-primary); margin-left: auto; }
|
||||||
|
.drawer-sub-desc { font-size: 12px; color: #8F959E; margin-top: 4px; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div></div>
|
<div></div>
|
||||||
<el-button v-if="authStore.isSupervisor()" type="primary" @click="showCreate = true">
|
<el-button v-if="authStore.hasPermission('project:create')" type="primary" @click="showCreate = true">
|
||||||
<el-icon><Plus /></el-icon> 新建项目
|
<el-icon><Plus /></el-icon> 新建项目
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
@ -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,16 +198,21 @@ 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 }
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
load()
|
load()
|
||||||
if (authStore.isLeaderOrAbove()) {
|
if (authStore.hasPermission('user:view')) {
|
||||||
try { users.value = await userApi.list() } catch {}
|
try { users.value = await userApi.list() } catch {}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -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>
|
||||||
|
|||||||
196
frontend/src/views/Roles.vue
Normal file
196
frontend/src/views/Roles.vue
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="page-header">
|
||||||
|
<h2>角色管理</h2>
|
||||||
|
<el-button type="primary" @click="openCreate"><el-icon><Plus /></el-icon> 新建角色</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table :data="roles" v-loading="loading" stripe>
|
||||||
|
<el-table-column prop="name" label="角色名称" width="140">
|
||||||
|
<template #default="{row}">
|
||||||
|
<span class="role-name">{{ row.name }}</span>
|
||||||
|
<el-tag v-if="row.is_system" size="small" type="info" style="margin-left:6px">内置</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="description" label="描述" min-width="200" show-overflow-tooltip />
|
||||||
|
<el-table-column label="权限数" width="90" align="center">
|
||||||
|
<template #default="{row}">
|
||||||
|
<span class="perm-count">{{ row.permissions.length }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="用户数" width="90" align="center">
|
||||||
|
<template #default="{row}">{{ row.user_count }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="150">
|
||||||
|
<template #default="{row}">
|
||||||
|
<el-button text size="small" @click="openEdit(row)">编辑权限</el-button>
|
||||||
|
<el-button v-if="!row.is_system" text type="danger" size="small" @click="handleDelete(row)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<!-- 新建/编辑角色弹窗 -->
|
||||||
|
<el-dialog v-model="showDialog" :title="editingId ? '编辑角色' : '新建角色'" width="640px" destroy-on-close>
|
||||||
|
<el-form :model="form" label-width="80px">
|
||||||
|
<el-form-item label="角色名称">
|
||||||
|
<el-input v-model="form.name" :disabled="editingSystem" placeholder="如:制片人、实习生等" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="描述">
|
||||||
|
<el-input v-model="form.description" placeholder="(选填)角色职责说明" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<div class="perm-panel">
|
||||||
|
<div class="perm-panel-header">
|
||||||
|
<span class="perm-panel-title">权限配置</span>
|
||||||
|
<el-button text size="small" @click="toggleAll">{{ isAllSelected ? '取消全选' : '全选' }}</el-button>
|
||||||
|
</div>
|
||||||
|
<div v-for="group in permGroups" :key="group.group" class="perm-group">
|
||||||
|
<div class="perm-group-header">
|
||||||
|
<el-checkbox
|
||||||
|
:model-value="isGroupAllSelected(group)"
|
||||||
|
:indeterminate="isGroupIndeterminate(group)"
|
||||||
|
@change="toggleGroup(group, $event)"
|
||||||
|
>{{ group.group }}</el-checkbox>
|
||||||
|
</div>
|
||||||
|
<div class="perm-items">
|
||||||
|
<el-checkbox
|
||||||
|
v-for="p in group.permissions"
|
||||||
|
:key="p.key"
|
||||||
|
:model-value="form.permissions.includes(p.key)"
|
||||||
|
@change="togglePerm(p.key, $event)"
|
||||||
|
>{{ p.label }}</el-checkbox>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="showDialog = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { roleApi } from '../api'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
const showDialog = ref(false)
|
||||||
|
const editingId = ref(null)
|
||||||
|
const editingSystem = ref(false)
|
||||||
|
const roles = ref([])
|
||||||
|
const permGroups = ref([])
|
||||||
|
|
||||||
|
const form = ref({ name: '', description: '', permissions: [] })
|
||||||
|
|
||||||
|
const allPermKeys = computed(() => permGroups.value.flatMap(g => g.permissions.map(p => p.key)))
|
||||||
|
const isAllSelected = computed(() => allPermKeys.value.length > 0 && allPermKeys.value.every(k => form.value.permissions.includes(k)))
|
||||||
|
|
||||||
|
function isGroupAllSelected(group) {
|
||||||
|
return group.permissions.every(p => form.value.permissions.includes(p.key))
|
||||||
|
}
|
||||||
|
function isGroupIndeterminate(group) {
|
||||||
|
const selected = group.permissions.filter(p => form.value.permissions.includes(p.key)).length
|
||||||
|
return selected > 0 && selected < group.permissions.length
|
||||||
|
}
|
||||||
|
function toggleGroup(group, checked) {
|
||||||
|
const keys = group.permissions.map(p => p.key)
|
||||||
|
if (checked) {
|
||||||
|
form.value.permissions = [...new Set([...form.value.permissions, ...keys])]
|
||||||
|
} else {
|
||||||
|
form.value.permissions = form.value.permissions.filter(k => !keys.includes(k))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function togglePerm(key, checked) {
|
||||||
|
if (checked) {
|
||||||
|
form.value.permissions.push(key)
|
||||||
|
} else {
|
||||||
|
form.value.permissions = form.value.permissions.filter(k => k !== key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function toggleAll() {
|
||||||
|
if (isAllSelected.value) {
|
||||||
|
form.value.permissions = []
|
||||||
|
} else {
|
||||||
|
form.value.permissions = [...allPermKeys.value]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
editingId.value = null
|
||||||
|
editingSystem.value = false
|
||||||
|
form.value = { name: '', description: '', permissions: [] }
|
||||||
|
showDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(role) {
|
||||||
|
editingId.value = role.id
|
||||||
|
editingSystem.value = role.is_system
|
||||||
|
form.value = {
|
||||||
|
name: role.name,
|
||||||
|
description: role.description || '',
|
||||||
|
permissions: [...role.permissions],
|
||||||
|
}
|
||||||
|
showDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!form.value.name.trim()) { ElMessage.warning('请输入角色名称'); return }
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
if (editingId.value) {
|
||||||
|
await roleApi.update(editingId.value, form.value)
|
||||||
|
ElMessage.success('角色已更新')
|
||||||
|
} else {
|
||||||
|
await roleApi.create(form.value)
|
||||||
|
ElMessage.success('角色已创建')
|
||||||
|
}
|
||||||
|
showDialog.value = false
|
||||||
|
load()
|
||||||
|
} finally { saving.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(role) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确认删除角色「${role.name}」?`, '删除角色', { type: 'warning' })
|
||||||
|
await roleApi.delete(role.id)
|
||||||
|
ElMessage.success('角色已删除')
|
||||||
|
load()
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try { roles.value = await roleApi.list() } finally { loading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
load()
|
||||||
|
try { permGroups.value = await roleApi.permissions() } catch {}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
|
||||||
|
.page-header h2 { font-size: 18px; font-weight: 600; }
|
||||||
|
.role-name { font-weight: 600; color: var(--text-primary); }
|
||||||
|
.perm-count { font-weight: 600; color: var(--primary); }
|
||||||
|
|
||||||
|
/* 权限勾选面板 */
|
||||||
|
.perm-panel { margin-top: 8px; }
|
||||||
|
.perm-panel-header {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
padding: 8px 0; border-bottom: 1px solid var(--border-light); margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.perm-panel-title { font-size: 14px; font-weight: 600; color: var(--text-primary); }
|
||||||
|
.perm-group { margin-bottom: 12px; }
|
||||||
|
.perm-group-header {
|
||||||
|
padding: 6px 12px; background: #F7F8FA; border-radius: 6px;
|
||||||
|
margin-bottom: 6px; font-weight: 500;
|
||||||
|
}
|
||||||
|
.perm-items { padding: 4px 12px 4px 28px; display: flex; flex-wrap: wrap; gap: 8px 20px; }
|
||||||
|
</style>
|
||||||
@ -18,16 +18,19 @@
|
|||||||
|
|
||||||
<!-- 成本汇总 -->
|
<!-- 成本汇总 -->
|
||||||
<el-row :gutter="16" class="stat-row">
|
<el-row :gutter="16" class="stat-row">
|
||||||
<el-col :span="6">
|
<el-col :span="5">
|
||||||
<el-card shadow="hover"><div class="stat-label">人力成本</div><div class="stat-value">¥{{ fmt(data.labor_cost) }}</div></el-card>
|
<el-card shadow="hover"><div class="stat-label">人力成本</div><div class="stat-value">¥{{ fmt(data.labor_cost) }}</div></el-card>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="6">
|
<el-col :span="5">
|
||||||
<el-card shadow="hover"><div class="stat-label">AI 工具成本</div><div class="stat-value">¥{{ fmt(data.ai_tool_cost) }}</div></el-card>
|
<el-card shadow="hover"><div class="stat-label">AI 工具成本</div><div class="stat-value">¥{{ fmt(data.ai_tool_cost) }}</div></el-card>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="6">
|
<el-col :span="4">
|
||||||
<el-card shadow="hover"><div class="stat-label">外包成本</div><div class="stat-value">¥{{ fmt(data.outsource_cost) }}</div></el-card>
|
<el-card shadow="hover"><div class="stat-label">外包成本</div><div class="stat-value">¥{{ fmt(data.outsource_cost) }}</div></el-card>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="6">
|
<el-col :span="5">
|
||||||
|
<el-card shadow="hover"><div class="stat-label">固定开支</div><div class="stat-value">¥{{ fmt(data.overhead_cost) }}</div></el-card>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="5">
|
||||||
<el-card shadow="hover">
|
<el-card shadow="hover">
|
||||||
<div class="stat-label">项目总成本</div>
|
<div class="stat-label">项目总成本</div>
|
||||||
<div class="stat-value" style="color:#e6a23c">¥{{ fmt(data.total_cost) }}</div>
|
<div class="stat-value" style="color:#e6a23c">¥{{ fmt(data.total_cost) }}</div>
|
||||||
|
|||||||
@ -5,25 +5,40 @@
|
|||||||
<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 prop="name" label="姓名" width="100" />
|
<el-table-column label="姓名" width="100">
|
||||||
<el-table-column prop="username" label="用户名" width="120" />
|
|
||||||
<el-table-column prop="phase_group" label="阶段组" width="80" />
|
|
||||||
<el-table-column label="角色" width="90">
|
|
||||||
<template #default="{row}">
|
<template #default="{row}">
|
||||||
<el-tag :type="roleMap[row.role]" size="small">{{ row.role }}</el-tag>
|
<router-link :to="`/users/${row.id}/detail`" class="user-link">{{ row.name }}</router-link>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="月薪" width="110" align="right">
|
<el-table-column prop="username" label="用户名" width="120" />
|
||||||
|
<el-table-column prop="phase_group" label="阶段" width="70" />
|
||||||
|
<el-table-column label="角色" width="80">
|
||||||
|
<template #default="{row}">
|
||||||
|
<el-tag size="small">{{ row.role_name }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="月薪" width="100" align="right">
|
||||||
<template #default="{row}">¥{{ row.monthly_salary.toLocaleString() }}</template>
|
<template #default="{row}">¥{{ row.monthly_salary.toLocaleString() }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="日成本" width="100" align="right">
|
<el-table-column label="奖金" width="90" align="right">
|
||||||
|
<template #default="{row}">{{ row.bonus > 0 ? '¥' + row.bonus.toLocaleString() : '—' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="五险一金" width="100" align="right">
|
||||||
|
<template #default="{row}">{{ row.social_insurance > 0 ? '¥' + row.social_insurance.toLocaleString() : '—' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="月总成本" width="110" align="right">
|
||||||
|
<template #default="{row}">
|
||||||
|
<span style="font-weight:600;color:var(--text-primary)">¥{{ row.monthly_total_cost.toLocaleString() }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="日成本" width="90" align="right">
|
||||||
<template #default="{row}">¥{{ row.daily_cost.toLocaleString() }}</template>
|
<template #default="{row}">¥{{ row.daily_cost.toLocaleString() }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="状态" width="80">
|
<el-table-column label="状态" width="70">
|
||||||
<template #default="{row}"><el-tag :type="row.is_active ? 'success' : 'danger'" size="small">{{ row.is_active ? '启用' : '停用' }}</el-tag></template>
|
<template #default="{row}"><el-tag :type="row.is_active ? 'success' : 'danger'" size="small">{{ row.is_active ? '启用' : '停用' }}</el-tag></template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="100">
|
<el-table-column label="操作" width="80">
|
||||||
<template #default="{row}">
|
<template #default="{row}">
|
||||||
<el-button text size="small" @click="editUser(row)">编辑</el-button>
|
<el-button text size="small" @click="editUser(row)">编辑</el-button>
|
||||||
</template>
|
</template>
|
||||||
@ -31,8 +46,8 @@
|
|||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
<!-- 新增/编辑对话框 -->
|
<!-- 新增/编辑对话框 -->
|
||||||
<el-dialog v-model="showCreate" :title="editingId ? '编辑用户' : '新增用户'" width="480px" destroy-on-close>
|
<el-dialog v-model="showCreate" :title="editingId ? '编辑用户' : '新增用户'" width="520px" destroy-on-close>
|
||||||
<el-form :model="form" label-width="80px">
|
<el-form :model="form" label-width="90px">
|
||||||
<el-form-item label="姓名"><el-input v-model="form.name" /></el-form-item>
|
<el-form-item label="姓名"><el-input v-model="form.name" /></el-form-item>
|
||||||
<el-form-item v-if="!editingId" label="用户名"><el-input v-model="form.username" /></el-form-item>
|
<el-form-item v-if="!editingId" label="用户名"><el-input v-model="form.username" /></el-form-item>
|
||||||
<el-form-item v-if="!editingId" label="密码"><el-input v-model="form.password" type="password" /></el-form-item>
|
<el-form-item v-if="!editingId" label="密码"><el-input v-model="form.password" type="password" /></el-form-item>
|
||||||
@ -44,17 +59,25 @@
|
|||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="角色">
|
<el-form-item label="角色">
|
||||||
<el-select v-model="form.role" placeholder="系统权限角色" style="width:100%">
|
<el-select v-model="form.role_id" placeholder="系统权限角色" style="width:100%">
|
||||||
<el-option label="成员 — 仅提交内容" value="成员" />
|
<el-option v-for="r in roles" :key="r.id" :label="r.name" :value="r.id" />
|
||||||
<el-option label="组长 — 管理本组提交" value="组长" />
|
|
||||||
<el-option label="主管 — 管理项目和人员" value="主管" />
|
|
||||||
<el-option label="Owner — 全部权限" value="Owner" />
|
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-divider content-position="left">成本信息</el-divider>
|
||||||
<el-form-item label="月薪">
|
<el-form-item label="月薪">
|
||||||
<el-input-number v-model="form.monthly_salary" :min="0" :step="1000" :controls="false" placeholder="月薪(元),日成本=月薪÷22" style="width:100%" />
|
<el-input-number v-model="form.monthly_salary" :min="0" :step="1000" :controls="false" placeholder="基本月薪(元)" style="width:100%" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item v-if="editingId" label="状态">
|
<el-form-item label="奖金">
|
||||||
|
<el-input-number v-model="form.bonus" :min="0" :step="500" :controls="false" placeholder="月度奖金(元),无则填0" style="width:100%" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="五险一金">
|
||||||
|
<el-input-number v-model="form.social_insurance" :min="0" :step="500" :controls="false" placeholder="公司承担的五险一金(元/月)" style="width:100%" />
|
||||||
|
</el-form-item>
|
||||||
|
<div class="cost-summary" v-if="form.monthly_salary || form.bonus || form.social_insurance">
|
||||||
|
月总成本 = ¥{{ (form.monthly_salary || 0).toLocaleString() }} + ¥{{ (form.bonus || 0).toLocaleString() }} + ¥{{ (form.social_insurance || 0).toLocaleString() }} = <strong>¥{{ ((form.monthly_salary || 0) + (form.bonus || 0) + (form.social_insurance || 0)).toLocaleString() }}</strong>
|
||||||
|
→ 日成本 <strong>¥{{ (((form.monthly_salary || 0) + (form.bonus || 0) + (form.social_insurance || 0)) / 22).toFixed(0) }}</strong>
|
||||||
|
</div>
|
||||||
|
<el-form-item v-if="editingId" label="状态" style="margin-top:16px">
|
||||||
<el-switch v-model="form.is_active" :active-value="1" :inactive-value="0" active-text="启用" inactive-text="停用" />
|
<el-switch v-model="form.is_active" :active-value="1" :inactive-value="0" active-text="启用" inactive-text="停用" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
@ -67,28 +90,50 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, onMounted } from 'vue'
|
import { ref, reactive, computed, onMounted } from 'vue'
|
||||||
import { userApi } from '../api'
|
import { userApi, roleApi } from '../api'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const showCreate = ref(false)
|
const showCreate = ref(false)
|
||||||
const editingId = ref(null)
|
const editingId = ref(null)
|
||||||
const users = ref([])
|
const users = ref([])
|
||||||
const roleMap = { 'Owner': 'danger', '主管': 'warning', '组长': '', '成员': 'info' }
|
const roles = ref([])
|
||||||
const form = reactive({ username: '', password: '', name: '', phase_group: '制作', role: '成员', monthly_salary: 0, is_active: 1 })
|
|
||||||
|
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({
|
||||||
|
username: '', password: '', name: '', phase_group: '制作', role_id: null,
|
||||||
|
monthly_salary: 0, bonus: 0, social_insurance: 0, is_active: 1,
|
||||||
|
})
|
||||||
|
|
||||||
async function load() { loading.value = true; try { users.value = await userApi.list() } finally { loading.value = false } }
|
async function load() { loading.value = true; try { users.value = await userApi.list() } finally { loading.value = false } }
|
||||||
|
|
||||||
function editUser(u) {
|
function editUser(u) {
|
||||||
editingId.value = u.id
|
editingId.value = u.id
|
||||||
Object.assign(form, { name: u.name, phase_group: u.phase_group, role: u.role, monthly_salary: u.monthly_salary, is_active: u.is_active })
|
Object.assign(form, {
|
||||||
|
name: u.name, phase_group: u.phase_group, role_id: u.role_id,
|
||||||
|
monthly_salary: u.monthly_salary, bonus: u.bonus || 0,
|
||||||
|
social_insurance: u.social_insurance || 0, is_active: u.is_active,
|
||||||
|
})
|
||||||
showCreate.value = true
|
showCreate.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
if (editingId.value) {
|
if (editingId.value) {
|
||||||
await userApi.update(editingId.value, { name: form.name, phase_group: form.phase_group, role: form.role, monthly_salary: form.monthly_salary, is_active: form.is_active })
|
await userApi.update(editingId.value, {
|
||||||
|
name: form.name, phase_group: form.phase_group, role_id: form.role_id,
|
||||||
|
monthly_salary: form.monthly_salary, bonus: form.bonus,
|
||||||
|
social_insurance: form.social_insurance, is_active: form.is_active,
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
await userApi.create(form)
|
await userApi.create(form)
|
||||||
}
|
}
|
||||||
@ -98,10 +143,21 @@ async function handleSave() {
|
|||||||
load()
|
load()
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(load)
|
onMounted(async () => {
|
||||||
|
load()
|
||||||
|
try { roles.value = await roleApi.list() } catch {}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
|
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
|
||||||
.page-header h2 { font-size: 18px; font-weight: 600; }
|
.page-header h2 { font-size: 18px; font-weight: 600; }
|
||||||
|
.cost-summary {
|
||||||
|
background: #F7F8FA; border-radius: 6px; padding: 10px 16px;
|
||||||
|
font-size: 13px; color: var(--text-secondary); margin: -4px 0 8px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.cost-summary strong { color: var(--text-primary); }
|
||||||
|
.user-link { color: #3370FF; text-decoration: none; font-weight: 500; }
|
||||||
|
.user-link:hover { text-decoration: underline; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
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 后面再配?
|
||||||
23
人员清单.md
Normal file
23
人员清单.md
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
序号 姓名 职级 职位 工资 社保
|
||||||
|
1 胡皓南 主管 总导演 15929.52 1904.11
|
||||||
|
2 邓庆瑞 主管 AI导演 12429.52 1904.11
|
||||||
|
3 邱绍辉 主管 制片 9429.52 1,904.11
|
||||||
|
4 陈保丹 组长 动画制作 6729.52 2,159.11
|
||||||
|
5 代晓倩 组员 动画制作 4000 1,904.11
|
||||||
|
6 谭如平 组员 动画制作 3000 1,904.11
|
||||||
|
7 马若情 组长 AI导演 4000 1,904.11
|
||||||
|
8 郑奕晴 组员 动画制作 3000 1,904.11
|
||||||
|
9 韦春利 组长 AI导演 4000 1,904.11
|
||||||
|
10 黄雪雯 组员 动画制作 3000
|
||||||
|
11 潘梓彦 组长 剪辑 5000 1,904.11
|
||||||
|
12 刘诗琪 组员 动画制作 3000 1,904.11
|
||||||
|
13 戴玮 组员 动画制作 3000 1,904.11
|
||||||
|
14 黄溶莹 组员 编剧 3000 11.02
|
||||||
|
15 贾浩正 组员 剪辑 3000 11.02
|
||||||
|
16 王炎森 组员 剪辑 3000 11.02
|
||||||
|
17 黄秋霞 组员 动画制作 3000 11.02
|
||||||
|
18 李晶 组员 动画制作 3000 11.02
|
||||||
|
19 叶美莲 组员 动画制作 3000 11.02
|
||||||
|
20 陈煊颖 组员 动画制作 3000 11.02
|
||||||
|
21 土豆 Owner CTO 10000 1,904.11
|
||||||
|
22 青椒 Owner CEO 10000 1,904.11
|
||||||
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