Compare commits
No commits in common. "90707005ed26bbd6b498fad911846b4c694a31d0" and "f7b9db6f42d5ce793b1b4384236d0c34dad5304c" have entirely different histories.
90707005ed
...
f7b9db6f42
@ -1,335 +0,0 @@
|
||||
# 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,61 +581,6 @@ 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,6 +7,7 @@ from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
from sqlalchemy.orm import Session
|
||||
from database import get_db
|
||||
from models import User, UserRole
|
||||
from config import SECRET_KEY, ALGORITHM, ACCESS_TOKEN_EXPIRE_MINUTES
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
@ -28,9 +29,8 @@ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -
|
||||
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)):
|
||||
def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)) -> User:
|
||||
"""从 JWT token 解析当前用户"""
|
||||
from models import User
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="登录已过期,请重新登录",
|
||||
@ -51,13 +51,13 @@ def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(
|
||||
return user
|
||||
|
||||
|
||||
def require_permission(*perms: str):
|
||||
"""权限校验依赖:要求当前用户拥有指定权限中的至少一项"""
|
||||
def perm_checker(current_user=Depends(get_current_user)):
|
||||
if not current_user.role_ref:
|
||||
raise HTTPException(status_code=403, detail="未分配角色")
|
||||
user_perms = current_user.permissions or []
|
||||
if not any(p in user_perms for p in perms):
|
||||
raise HTTPException(status_code=403, detail="权限不足")
|
||||
def require_role(*roles: UserRole):
|
||||
"""权限装饰器:要求当前用户具有指定角色之一"""
|
||||
def role_checker(current_user: User = Depends(get_current_user)):
|
||||
if current_user.role not in [r.value for r in roles] and current_user.role not in roles:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="权限不足"
|
||||
)
|
||||
return current_user
|
||||
return perm_checker
|
||||
return role_checker
|
||||
|
||||
@ -8,7 +8,7 @@ from collections import defaultdict
|
||||
from datetime import date, timedelta
|
||||
from models import (
|
||||
User, Project, Submission, AIToolCost, AIToolCostAllocation,
|
||||
OutsourceCost, CostOverride, OverheadCost, WorkType, CostAllocationType
|
||||
OutsourceCost, CostOverride, WorkType, CostAllocationType
|
||||
)
|
||||
from config import WORKING_DAYS_PER_MONTH
|
||||
|
||||
@ -142,69 +142,36 @@ def calc_outsource_cost_for_project(project_id: int, db: Session) -> float:
|
||||
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:
|
||||
"""
|
||||
计算项目损耗
|
||||
返回: {test_waste, overproduction_waste, total_waste, waste_rate, target_seconds}
|
||||
废弃项目:全部产出直接记为损耗
|
||||
"""
|
||||
from models import ProjectStatus
|
||||
project = db.query(Project).filter(Project.id == project_id).first()
|
||||
if not project:
|
||||
return {}
|
||||
|
||||
target = project.target_total_seconds
|
||||
|
||||
# 测试损耗:工作类型为"测试"的全部秒数
|
||||
test_waste = db.query(sa_func.sum(Submission.total_seconds)).filter(
|
||||
Submission.project_id == project_id,
|
||||
Submission.work_type == WorkType.TEST,
|
||||
).scalar() or 0
|
||||
|
||||
# 全部有秒数的提交总量
|
||||
total_submitted = db.query(sa_func.sum(Submission.total_seconds)).filter(
|
||||
Submission.project_id == project_id,
|
||||
Submission.total_seconds > 0,
|
||||
).scalar() or 0
|
||||
|
||||
# 废弃项目:全部产出记为损耗
|
||||
if project.status == ProjectStatus.ABANDONED:
|
||||
total_waste = total_submitted
|
||||
test_waste = 0.0
|
||||
overproduction_waste = total_submitted
|
||||
waste_rate = 100.0 if total_submitted > 0 else 0.0
|
||||
else:
|
||||
# 测试损耗:工作类型为"测试"的全部秒数
|
||||
test_waste = db.query(sa_func.sum(Submission.total_seconds)).filter(
|
||||
Submission.project_id == project_id,
|
||||
Submission.work_type == WorkType.TEST,
|
||||
).scalar() or 0
|
||||
# 超产损耗(仅计算生产性提交超出目标的部分,排除测试秒数避免双重计数)
|
||||
production_submitted = total_submitted - test_waste
|
||||
overproduction_waste = max(0, production_submitted - target)
|
||||
total_waste = test_waste + overproduction_waste
|
||||
waste_rate = round(total_waste / target * 100, 1) if target > 0 else 0
|
||||
# 超产损耗
|
||||
overproduction_waste = max(0, total_submitted - target)
|
||||
|
||||
total_waste = test_waste + overproduction_waste
|
||||
waste_rate = round(total_waste / target * 100, 1) if target > 0 else 0
|
||||
|
||||
return {
|
||||
"target_seconds": target,
|
||||
@ -276,8 +243,7 @@ def calc_project_settlement(project_id: int, db: Session) -> dict:
|
||||
labor = calc_labor_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)
|
||||
overhead = calc_overhead_cost_for_project(project_id, db)
|
||||
total_cost = labor + ai_tool + outsource + overhead
|
||||
total_cost = labor + ai_tool + outsource
|
||||
waste = calc_waste_for_project(project_id, db)
|
||||
efficiency = calc_team_efficiency(project_id, db)
|
||||
|
||||
@ -288,7 +254,6 @@ def calc_project_settlement(project_id: int, db: Session) -> dict:
|
||||
"labor_cost": labor,
|
||||
"ai_tool_cost": ai_tool,
|
||||
"outsource_cost": outsource,
|
||||
"overhead_cost": overhead,
|
||||
"total_cost": round(total_cost, 2),
|
||||
**waste,
|
||||
"team_efficiency": efficiency,
|
||||
|
||||
@ -1,8 +1,5 @@
|
||||
"""应用配置"""
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# 数据库
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./airlabs.db")
|
||||
@ -17,15 +14,3 @@ CORS_ORIGINS = os.getenv("CORS_ORIGINS", "*").split(",")
|
||||
|
||||
# 成本计算
|
||||
WORKING_DAYS_PER_MONTH = 22
|
||||
|
||||
# 豆包 AI(火山引擎 ARK)
|
||||
ARK_API_KEY = os.getenv("ARK_API_KEY", "")
|
||||
ARK_MODEL = os.getenv("ARK_MODEL", "doubao-seed-1-8-251228")
|
||||
ARK_BASE_URL = os.getenv("ARK_BASE_URL", "https://ark.cn-beijing.volces.com/api/v3")
|
||||
|
||||
# 飞书自建应用
|
||||
FEISHU_APP_ID = os.getenv("FEISHU_APP_ID", "")
|
||||
FEISHU_APP_SECRET = os.getenv("FEISHU_APP_SECRET", "")
|
||||
|
||||
# 报告接收人手机号
|
||||
REPORT_RECEIVERS = [p.strip() for p in os.getenv("REPORT_RECEIVERS", "").split(",") if p.strip()]
|
||||
|
||||
@ -1,22 +1,12 @@
|
||||
"""AirLabs Project —— 主入口"""
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse
|
||||
from database import engine, Base
|
||||
from models import (
|
||||
User, Role, PhaseGroup, BUILTIN_ROLES, COST_PERM_MIGRATION,
|
||||
Project, ProjectMilestone, DEFAULT_MILESTONES
|
||||
)
|
||||
from models import User, UserRole, PhaseGroup
|
||||
from auth import hash_password
|
||||
import os
|
||||
import logging
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 创建所有表
|
||||
Base.metadata.create_all(bind=engine)
|
||||
@ -40,8 +30,6 @@ from routers.projects import router as projects_router
|
||||
from routers.submissions import router as submissions_router
|
||||
from routers.costs import router as costs_router
|
||||
from routers.dashboard import router as dashboard_router
|
||||
from routers.roles import router as roles_router
|
||||
from routers.reports import router as reports_router
|
||||
|
||||
app.include_router(auth_router)
|
||||
app.include_router(users_router)
|
||||
@ -49,8 +37,6 @@ app.include_router(projects_router)
|
||||
app.include_router(submissions_router)
|
||||
app.include_router(costs_router)
|
||||
app.include_router(dashboard_router)
|
||||
app.include_router(roles_router)
|
||||
app.include_router(reports_router)
|
||||
|
||||
# 前端静态文件
|
||||
frontend_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "frontend", "dist")
|
||||
@ -66,82 +52,18 @@ if os.path.exists(frontend_dir):
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def start_scheduler():
|
||||
"""启动定时任务调度器"""
|
||||
from services.scheduler_service import setup_scheduler
|
||||
setup_scheduler()
|
||||
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def stop_scheduler():
|
||||
"""关闭定时任务调度器"""
|
||||
from services.scheduler_service import scheduler
|
||||
scheduler.shutdown(wait=False)
|
||||
logger.info("[定时任务] 已关闭")
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def init_roles_and_admin():
|
||||
"""首次启动时创建内置角色和默认管理员"""
|
||||
def init_default_owner():
|
||||
"""首次启动时创建默认 Owner 账号"""
|
||||
from database import SessionLocal
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# 初始化内置角色
|
||||
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():
|
||||
if not db.query(User).filter(User.role == UserRole.OWNER).first():
|
||||
owner = User(
|
||||
username="admin",
|
||||
password_hash=hash_password("admin123"),
|
||||
name="管理员",
|
||||
phase_group=PhaseGroup.PRODUCTION,
|
||||
role_id=admin_role.id,
|
||||
role=UserRole.OWNER,
|
||||
monthly_salary=0,
|
||||
)
|
||||
db.add(owner)
|
||||
|
||||
@ -9,92 +9,6 @@ from database import Base
|
||||
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):
|
||||
@ -107,7 +21,6 @@ class ProjectType(str, enum.Enum):
|
||||
class ProjectStatus(str, enum.Enum):
|
||||
IN_PROGRESS = "制作中"
|
||||
COMPLETED = "已完成"
|
||||
ABANDONED = "废弃"
|
||||
|
||||
|
||||
class PhaseGroup(str, enum.Enum):
|
||||
@ -116,6 +29,13 @@ class PhaseGroup(str, enum.Enum):
|
||||
POST = "后期"
|
||||
|
||||
|
||||
class UserRole(str, enum.Enum):
|
||||
MEMBER = "成员"
|
||||
LEADER = "组长"
|
||||
SUPERVISOR = "主管"
|
||||
OWNER = "Owner"
|
||||
|
||||
|
||||
class WorkType(str, enum.Enum):
|
||||
PRODUCTION = "制作"
|
||||
TEST = "测试"
|
||||
@ -153,29 +73,6 @@ class OutsourceType(str, enum.Enum):
|
||||
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):
|
||||
@ -186,37 +83,20 @@ class User(Base):
|
||||
password_hash = Column(String(255), nullable=False)
|
||||
name = Column(String(50), nullable=False)
|
||||
phase_group = Column(SAEnum(PhaseGroup), nullable=False)
|
||||
role_id = Column(Integer, ForeignKey("roles.id"), nullable=False)
|
||||
role = Column(SAEnum(UserRole), nullable=False, default=UserRole.MEMBER)
|
||||
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)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
# 关系
|
||||
role_ref = relationship("Role", back_populates="users")
|
||||
submissions = relationship("Submission", back_populates="user")
|
||||
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
|
||||
def daily_cost(self):
|
||||
"""日成本 = 月薪 ÷ 22"""
|
||||
from config import WORKING_DAYS_PER_MONTH
|
||||
return round(self.monthly_total_cost / WORKING_DAYS_PER_MONTH, 2) if self.monthly_total_cost else 0
|
||||
return round(self.monthly_salary / WORKING_DAYS_PER_MONTH, 2) if self.monthly_salary else 0
|
||||
|
||||
|
||||
# ──────────────────────────── 项目 ────────────────────────────
|
||||
@ -234,17 +114,18 @@ class Project(Base):
|
||||
episode_count = Column(Integer, nullable=False)
|
||||
estimated_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())
|
||||
|
||||
# 关系
|
||||
leader = relationship("User", back_populates="led_projects")
|
||||
submissions = relationship("Submission", back_populates="project")
|
||||
outsource_costs = relationship("OutsourceCost", back_populates="project")
|
||||
ai_tool_allocations = relationship("AIToolCostAllocation", back_populates="project")
|
||||
milestones = relationship("ProjectMilestone", back_populates="project", cascade="all, delete-orphan")
|
||||
|
||||
@property
|
||||
def target_total_seconds(self):
|
||||
"""目标总秒数 = 单集时长(分) × 60 × 集数"""
|
||||
return int(self.episode_duration_minutes * 60 * self.episode_count)
|
||||
|
||||
|
||||
@ -261,13 +142,14 @@ class Submission(Base):
|
||||
content_type = Column(SAEnum(ContentType), nullable=False)
|
||||
duration_minutes = Column(Float, nullable=True, default=0)
|
||||
duration_seconds = Column(Float, nullable=True, default=0)
|
||||
total_seconds = Column(Float, nullable=False, default=0)
|
||||
hours_spent = Column(Float, nullable=True)
|
||||
total_seconds = Column(Float, nullable=False, default=0) # 系统自动计算
|
||||
hours_spent = Column(Float, nullable=True) # 可选:投入时长(小时)
|
||||
submit_to = Column(SAEnum(SubmitTo), nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
submit_date = Column(Date, nullable=False)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
# 关系
|
||||
user = relationship("User", back_populates="submissions")
|
||||
project = relationship("Project", back_populates="submissions")
|
||||
history = relationship("SubmissionHistory", back_populates="submission")
|
||||
@ -283,21 +165,23 @@ class AIToolCost(Base):
|
||||
subscription_period = Column(SAEnum(SubscriptionPeriod), nullable=False)
|
||||
amount = Column(Float, 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)
|
||||
record_date = Column(Date, nullable=False)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
# 关系
|
||||
allocations = relationship("AIToolCostAllocation", back_populates="ai_tool_cost")
|
||||
|
||||
|
||||
class AIToolCostAllocation(Base):
|
||||
"""AI 工具成本手动分摊明细"""
|
||||
__tablename__ = "ai_tool_cost_allocations"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
ai_tool_cost_id = Column(Integer, ForeignKey("ai_tool_costs.id"), nullable=False)
|
||||
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False)
|
||||
percentage = Column(Float, nullable=False)
|
||||
percentage = Column(Float, nullable=False) # 0-100
|
||||
|
||||
ai_tool_cost = relationship("AIToolCost", back_populates="allocations")
|
||||
project = relationship("Project", back_populates="ai_tool_allocations")
|
||||
@ -324,6 +208,7 @@ class OutsourceCost(Base):
|
||||
# ──────────────────────────── 人力成本手动调整 ────────────────────────────
|
||||
|
||||
class CostOverride(Base):
|
||||
"""管理员手动修改某人某天的成本分摊"""
|
||||
__tablename__ = "cost_overrides"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
@ -339,6 +224,7 @@ class CostOverride(Base):
|
||||
# ──────────────────────────── 提交历史版本 ────────────────────────────
|
||||
|
||||
class SubmissionHistory(Base):
|
||||
"""内容提交的修改历史"""
|
||||
__tablename__ = "submission_history"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
@ -350,49 +236,3 @@ class SubmissionHistory(Base):
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
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,7 +6,3 @@ python-jose[cryptography]
|
||||
passlib[bcrypt]
|
||||
bcrypt==4.0.1
|
||||
python-multipart
|
||||
openai
|
||||
httpx
|
||||
apscheduler
|
||||
python-dotenv
|
||||
|
||||
@ -27,13 +27,8 @@ def get_me(current_user: User = Depends(get_current_user)):
|
||||
username=current_user.username,
|
||||
name=current_user.name,
|
||||
phase_group=current_user.phase_group.value if hasattr(current_user.phase_group, 'value') else current_user.phase_group,
|
||||
role_id=current_user.role_id,
|
||||
role_name=current_user.role_name,
|
||||
permissions=current_user.permissions,
|
||||
role=current_user.role.value if hasattr(current_user.role, 'value') else current_user.role,
|
||||
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,
|
||||
is_active=current_user.is_active,
|
||||
created_at=current_user.created_at,
|
||||
|
||||
@ -5,15 +5,14 @@ from typing import List, Optional
|
||||
from datetime import date
|
||||
from database import get_db
|
||||
from models import (
|
||||
User, AIToolCost, AIToolCostAllocation, OutsourceCost,
|
||||
CostOverride, OverheadCost, SubscriptionPeriod, CostAllocationType,
|
||||
OutsourceType, OverheadCostType
|
||||
User, UserRole, AIToolCost, AIToolCostAllocation, OutsourceCost,
|
||||
CostOverride, SubscriptionPeriod, CostAllocationType, OutsourceType
|
||||
)
|
||||
from schemas import (
|
||||
AIToolCostCreate, AIToolCostOut, OutsourceCostCreate, OutsourceCostOut,
|
||||
CostOverrideCreate, OverheadCostCreate, OverheadCostOut
|
||||
CostOverrideCreate
|
||||
)
|
||||
from auth import get_current_user, require_permission
|
||||
from auth import get_current_user, require_role
|
||||
|
||||
router = APIRouter(prefix="/api/costs", tags=["成本管理"])
|
||||
|
||||
@ -23,7 +22,7 @@ router = APIRouter(prefix="/api/costs", tags=["成本管理"])
|
||||
@router.get("/ai-tools", response_model=List[AIToolCostOut])
|
||||
def list_ai_tool_costs(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("cost_ai:view"))
|
||||
current_user: User = Depends(require_role(UserRole.OWNER, UserRole.SUPERVISOR, UserRole.LEADER))
|
||||
):
|
||||
costs = db.query(AIToolCost).order_by(AIToolCost.record_date.desc()).all()
|
||||
return [
|
||||
@ -45,7 +44,7 @@ def list_ai_tool_costs(
|
||||
def create_ai_tool_cost(
|
||||
req: AIToolCostCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("cost_ai:create"))
|
||||
current_user: User = Depends(require_role(UserRole.OWNER, UserRole.SUPERVISOR, UserRole.LEADER))
|
||||
):
|
||||
cost = AIToolCost(
|
||||
tool_name=req.tool_name,
|
||||
@ -85,7 +84,7 @@ def create_ai_tool_cost(
|
||||
def delete_ai_tool_cost(
|
||||
cost_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("cost_ai:delete"))
|
||||
current_user: User = Depends(require_role(UserRole.OWNER))
|
||||
):
|
||||
cost = db.query(AIToolCost).filter(AIToolCost.id == cost_id).first()
|
||||
if not cost:
|
||||
@ -102,7 +101,7 @@ def delete_ai_tool_cost(
|
||||
def list_outsource_costs(
|
||||
project_id: Optional[int] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("cost_outsource:view"))
|
||||
current_user: User = Depends(require_role(UserRole.OWNER, UserRole.SUPERVISOR, UserRole.LEADER))
|
||||
):
|
||||
q = db.query(OutsourceCost)
|
||||
if project_id:
|
||||
@ -124,7 +123,7 @@ def list_outsource_costs(
|
||||
def create_outsource_cost(
|
||||
req: OutsourceCostCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("cost_outsource:create"))
|
||||
current_user: User = Depends(require_role(UserRole.OWNER, UserRole.SUPERVISOR, UserRole.LEADER))
|
||||
):
|
||||
cost = OutsourceCost(
|
||||
project_id=req.project_id,
|
||||
@ -151,7 +150,7 @@ def create_outsource_cost(
|
||||
def delete_outsource_cost(
|
||||
cost_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("cost_outsource:delete"))
|
||||
current_user: User = Depends(require_role(UserRole.OWNER))
|
||||
):
|
||||
cost = db.query(OutsourceCost).filter(OutsourceCost.id == cost_id).first()
|
||||
if not cost:
|
||||
@ -167,7 +166,7 @@ def delete_outsource_cost(
|
||||
def create_cost_override(
|
||||
req: CostOverrideCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("cost_labor:create"))
|
||||
current_user: User = Depends(require_role(UserRole.OWNER, UserRole.SUPERVISOR))
|
||||
):
|
||||
override = CostOverride(
|
||||
user_id=req.user_id,
|
||||
@ -187,7 +186,7 @@ def list_cost_overrides(
|
||||
user_id: Optional[int] = Query(None),
|
||||
project_id: Optional[int] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("cost_labor:view"))
|
||||
current_user: User = Depends(require_role(UserRole.OWNER, UserRole.SUPERVISOR))
|
||||
):
|
||||
q = db.query(CostOverride)
|
||||
if user_id:
|
||||
@ -204,66 +203,3 @@ def list_cost_overrides(
|
||||
}
|
||||
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,15 +5,14 @@ from sqlalchemy import func as sa_func
|
||||
from datetime import date, timedelta
|
||||
from database import get_db
|
||||
from models import (
|
||||
User, Project, Submission, AIToolCost,
|
||||
User, UserRole, Project, Submission, AIToolCost,
|
||||
ProjectStatus, ProjectType, WorkType
|
||||
)
|
||||
from auth import get_current_user, require_permission
|
||||
from auth import get_current_user, require_role
|
||||
from calculations import (
|
||||
calc_project_settlement, 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_outsource_cost_for_project, calc_team_efficiency
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["仪表盘与结算"])
|
||||
@ -22,13 +21,12 @@ router = APIRouter(prefix="/api", tags=["仪表盘与结算"])
|
||||
@router.get("/dashboard")
|
||||
def get_dashboard(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("dashboard:view"))
|
||||
current_user: User = Depends(require_role(UserRole.OWNER))
|
||||
):
|
||||
"""全局仪表盘数据"""
|
||||
# 项目概览
|
||||
active = db.query(Project).filter(Project.status == ProjectStatus.IN_PROGRESS).all()
|
||||
completed = db.query(Project).filter(Project.status == ProjectStatus.COMPLETED).all()
|
||||
abandoned = db.query(Project).filter(Project.status == ProjectStatus.ABANDONED).all()
|
||||
|
||||
# 当月日期范围
|
||||
today = date.today()
|
||||
@ -92,14 +90,10 @@ def get_dashboard(
|
||||
"estimated_completion_date": str(p.estimated_completion_date) if p.estimated_completion_date else None,
|
||||
})
|
||||
|
||||
# 损耗排行(含废弃项目,废弃项目全部产出记为损耗)
|
||||
# 损耗排行
|
||||
waste_ranking = []
|
||||
total_waste_seconds_all = 0.0
|
||||
total_target_seconds_all = 0.0
|
||||
for p in active + completed + abandoned:
|
||||
for p in active + completed:
|
||||
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:
|
||||
waste_ranking.append({
|
||||
"project_id": p.id,
|
||||
@ -108,7 +102,6 @@ def get_dashboard(
|
||||
"waste_rate": w["waste_rate"],
|
||||
})
|
||||
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 = []
|
||||
@ -140,18 +133,15 @@ def get_dashboard(
|
||||
total_labor_all = 0.0
|
||||
total_ai_all = 0.0
|
||||
total_outsource_all = 0.0
|
||||
total_overhead_all = 0.0
|
||||
for p in active + completed + abandoned:
|
||||
for p in active + completed:
|
||||
total_labor_all += calc_labor_cost_for_project(p.id, db)
|
||||
total_ai_all += calc_ai_tool_cost_for_project(p.id, db)
|
||||
total_outsource_all += calc_outsource_cost_for_project(p.id, db)
|
||||
total_overhead_all += calc_overhead_cost_for_project(p.id, db)
|
||||
|
||||
cost_breakdown = [
|
||||
{"name": "人力成本", "value": round(total_labor_all, 0)},
|
||||
{"name": "AI工具", "value": round(total_ai_all, 0)},
|
||||
{"name": "外包", "value": round(total_outsource_all, 0)},
|
||||
{"name": "固定开支", "value": round(total_overhead_all, 0)},
|
||||
]
|
||||
|
||||
# ── 图表数据:各项目产出对比(进行中项目) ──
|
||||
@ -167,47 +157,6 @@ def get_dashboard(
|
||||
"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 {
|
||||
"active_projects": len(active),
|
||||
"completed_projects": len(completed),
|
||||
@ -216,12 +165,8 @@ def get_dashboard(
|
||||
"monthly_total_seconds": round(monthly_secs, 1),
|
||||
"avg_daily_seconds_per_person": avg_daily,
|
||||
"projects": project_summaries,
|
||||
"total_waste_seconds": round(total_waste_seconds_all, 0),
|
||||
"total_waste_rate": total_waste_rate,
|
||||
"waste_ranking": waste_ranking,
|
||||
"settled_projects": settled,
|
||||
"profitability": profitability,
|
||||
"risk_alerts": risk_alerts,
|
||||
# 图表数据
|
||||
"daily_trend": daily_trend,
|
||||
"cost_breakdown": cost_breakdown,
|
||||
@ -233,7 +178,7 @@ def get_dashboard(
|
||||
def get_settlement(
|
||||
project_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("settlement:view"))
|
||||
current_user: User = Depends(require_role(UserRole.OWNER))
|
||||
):
|
||||
"""项目结算报告"""
|
||||
project = db.query(Project).filter(Project.id == project_id).first()
|
||||
@ -246,7 +191,7 @@ def get_settlement(
|
||||
def get_efficiency(
|
||||
project_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("efficiency:view"))
|
||||
current_user: User = Depends(require_role(UserRole.OWNER, UserRole.SUPERVISOR, UserRole.LEADER))
|
||||
):
|
||||
"""项目团队效率数据"""
|
||||
return calc_team_efficiency(project_id, db)
|
||||
|
||||
@ -3,18 +3,13 @@ from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func as sa_func
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
from database import get_db
|
||||
from models import (
|
||||
User, Project, Submission, ProjectType,
|
||||
ProjectStatus, PhaseGroup, WorkType,
|
||||
ProjectMilestone, DEFAULT_MILESTONES
|
||||
User, Project, Submission, UserRole, ProjectType,
|
||||
ProjectStatus, PhaseGroup, WorkType
|
||||
)
|
||||
from schemas import (
|
||||
ProjectCreate, ProjectUpdate, ProjectOut,
|
||||
MilestoneOut, MilestoneCreate
|
||||
)
|
||||
from auth import get_current_user, require_permission
|
||||
from schemas import ProjectCreate, ProjectUpdate, ProjectOut
|
||||
from auth import get_current_user, require_role
|
||||
|
||||
router = APIRouter(prefix="/api/projects", tags=["项目管理"])
|
||||
|
||||
@ -30,60 +25,17 @@ def enrich_project(p: Project, db: Session) -> ProjectOut:
|
||||
target = p.target_total_seconds
|
||||
progress = round(total_secs / target * 100, 1) if target > 0 else 0
|
||||
|
||||
# 损耗 = 测试损耗 + 超产损耗(排除测试秒数避免双重计数)
|
||||
# 损耗 = 测试损耗 + 超产损耗
|
||||
test_secs = db.query(sa_func.sum(Submission.total_seconds)).filter(
|
||||
Submission.project_id == p.id,
|
||||
Submission.work_type == WorkType.TEST
|
||||
).scalar() or 0
|
||||
production_secs = total_secs - test_secs
|
||||
overproduction = max(0, production_secs - target)
|
||||
overproduction = max(0, total_secs - target)
|
||||
waste = test_secs + overproduction
|
||||
waste_rate = round(waste / target * 100, 1) if target > 0 else 0
|
||||
|
||||
leader_name = p.leader.name if p.leader else None
|
||||
|
||||
# 里程碑数据
|
||||
ms_rows = db.query(ProjectMilestone).filter(
|
||||
ProjectMilestone.project_id == p.id
|
||||
).order_by(ProjectMilestone.phase, ProjectMilestone.sort_order).all()
|
||||
|
||||
milestones_out = [
|
||||
MilestoneOut(
|
||||
id=m.id, name=m.name,
|
||||
phase=m.phase.value if hasattr(m.phase, 'value') else m.phase,
|
||||
is_completed=bool(m.is_completed),
|
||||
completed_at=m.completed_at,
|
||||
sort_order=m.sort_order,
|
||||
)
|
||||
for m in ms_rows
|
||||
]
|
||||
|
||||
# 阶段摘要
|
||||
pre_ms = [m for m in ms_rows if (m.phase.value if hasattr(m.phase, 'value') else m.phase) == "前期"]
|
||||
post_ms = [m for m in ms_rows if (m.phase.value if hasattr(m.phase, 'value') else m.phase) == "后期"]
|
||||
pre_completed = sum(1 for m in pre_ms if m.is_completed)
|
||||
post_completed = sum(1 for m in post_ms if m.is_completed)
|
||||
|
||||
phase_summary = {
|
||||
"pre": {"total": len(pre_ms), "completed": pre_completed},
|
||||
"production": {
|
||||
"progress_percent": progress,
|
||||
"submitted_seconds": round(total_secs, 1),
|
||||
"target_seconds": target,
|
||||
},
|
||||
"post": {"total": len(post_ms), "completed": post_completed},
|
||||
}
|
||||
|
||||
# 自动推断当前阶段
|
||||
if len(pre_ms) > 0 and pre_completed < len(pre_ms):
|
||||
current_stage = "前期"
|
||||
elif progress < 100:
|
||||
current_stage = "制作"
|
||||
elif len(post_ms) > 0 and post_completed < len(post_ms):
|
||||
current_stage = "后期"
|
||||
else:
|
||||
current_stage = "已完成"
|
||||
|
||||
return ProjectOut(
|
||||
id=p.id, name=p.name,
|
||||
project_type=p.project_type.value if hasattr(p.project_type, 'value') else p.project_type,
|
||||
@ -101,9 +53,6 @@ def enrich_project(p: Project, db: Session) -> ProjectOut:
|
||||
progress_percent=progress,
|
||||
waste_seconds=round(waste, 1),
|
||||
waste_rate=waste_rate,
|
||||
milestones=milestones_out,
|
||||
phase_summary=phase_summary,
|
||||
current_stage=current_stage,
|
||||
)
|
||||
|
||||
|
||||
@ -127,7 +76,7 @@ def list_projects(
|
||||
def create_project(
|
||||
req: ProjectCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("project:create"))
|
||||
current_user: User = Depends(require_role(UserRole.OWNER, UserRole.SUPERVISOR))
|
||||
):
|
||||
project = Project(
|
||||
name=req.name,
|
||||
@ -140,17 +89,6 @@ def create_project(
|
||||
contract_amount=req.contract_amount,
|
||||
)
|
||||
db.add(project)
|
||||
db.flush()
|
||||
|
||||
# 创建里程碑
|
||||
ms_list = req.milestones if req.milestones else DEFAULT_MILESTONES
|
||||
for ms in ms_list:
|
||||
db.add(ProjectMilestone(
|
||||
project_id=project.id,
|
||||
name=ms["name"],
|
||||
phase=PhaseGroup(ms["phase"]),
|
||||
sort_order=ms.get("sort_order", 0),
|
||||
))
|
||||
db.commit()
|
||||
db.refresh(project)
|
||||
return enrich_project(project, db)
|
||||
@ -173,7 +111,7 @@ def update_project(
|
||||
project_id: int,
|
||||
req: ProjectUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("project:edit"))
|
||||
current_user: User = Depends(require_role(UserRole.OWNER, UserRole.SUPERVISOR))
|
||||
):
|
||||
p = db.query(Project).filter(Project.id == project_id).first()
|
||||
if not p:
|
||||
@ -183,10 +121,7 @@ def update_project(
|
||||
if req.project_type is not None:
|
||||
p.project_type = ProjectType(req.project_type)
|
||||
if req.status is not None:
|
||||
new_status = ProjectStatus(req.status)
|
||||
p.status = new_status
|
||||
if new_status in (ProjectStatus.IN_PROGRESS, ProjectStatus.ABANDONED):
|
||||
p.actual_completion_date = None
|
||||
p.status = ProjectStatus(req.status)
|
||||
if req.leader_id is not None:
|
||||
p.leader_id = req.leader_id
|
||||
if req.current_phase is not None:
|
||||
@ -206,127 +141,18 @@ def update_project(
|
||||
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")
|
||||
def complete_project(
|
||||
project_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("project:complete"))
|
||||
current_user: User = Depends(require_role(UserRole.OWNER))
|
||||
):
|
||||
"""Owner 手动确认项目完成"""
|
||||
p = db.query(Project).filter(Project.id == project_id).first()
|
||||
if not p:
|
||||
raise HTTPException(status_code=404, detail="项目不存在")
|
||||
from datetime import date as date_today
|
||||
from datetime import date
|
||||
p.status = ProjectStatus.COMPLETED
|
||||
p.actual_completion_date = date_today.today()
|
||||
p.actual_completion_date = date.today()
|
||||
db.commit()
|
||||
return {"message": "项目已标记为完成", "project_id": project_id}
|
||||
|
||||
|
||||
# ──────────────────── 里程碑管理 ────────────────────
|
||||
|
||||
@router.get("/{project_id}/milestones", response_model=List[MilestoneOut])
|
||||
def list_milestones(
|
||||
project_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
ms = db.query(ProjectMilestone).filter(
|
||||
ProjectMilestone.project_id == project_id
|
||||
).order_by(ProjectMilestone.phase, ProjectMilestone.sort_order).all()
|
||||
return [
|
||||
MilestoneOut(
|
||||
id=m.id, name=m.name,
|
||||
phase=m.phase.value if hasattr(m.phase, 'value') else m.phase,
|
||||
is_completed=bool(m.is_completed),
|
||||
completed_at=m.completed_at,
|
||||
sort_order=m.sort_order,
|
||||
)
|
||||
for m in ms
|
||||
]
|
||||
|
||||
|
||||
@router.post("/{project_id}/milestones", response_model=MilestoneOut)
|
||||
def add_milestone(
|
||||
project_id: int,
|
||||
req: MilestoneCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("project:edit"))
|
||||
):
|
||||
p = db.query(Project).filter(Project.id == project_id).first()
|
||||
if not p:
|
||||
raise HTTPException(status_code=404, detail="项目不存在")
|
||||
# 找到同阶段最大 sort_order
|
||||
max_order = db.query(sa_func.max(ProjectMilestone.sort_order)).filter(
|
||||
ProjectMilestone.project_id == project_id,
|
||||
ProjectMilestone.phase == PhaseGroup(req.phase),
|
||||
).scalar() or 0
|
||||
m = ProjectMilestone(
|
||||
project_id=project_id,
|
||||
name=req.name,
|
||||
phase=PhaseGroup(req.phase),
|
||||
sort_order=max_order + 1,
|
||||
)
|
||||
db.add(m)
|
||||
db.commit()
|
||||
db.refresh(m)
|
||||
return MilestoneOut(
|
||||
id=m.id, name=m.name,
|
||||
phase=m.phase.value, is_completed=False,
|
||||
completed_at=None, sort_order=m.sort_order,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/milestones/{milestone_id}/toggle")
|
||||
def toggle_milestone(
|
||||
milestone_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("project:edit"))
|
||||
):
|
||||
m = db.query(ProjectMilestone).filter(ProjectMilestone.id == milestone_id).first()
|
||||
if not m:
|
||||
raise HTTPException(status_code=404, detail="里程碑不存在")
|
||||
m.is_completed = 0 if m.is_completed else 1
|
||||
m.completed_at = datetime.now() if m.is_completed else None
|
||||
db.commit()
|
||||
return {"id": m.id, "is_completed": bool(m.is_completed)}
|
||||
|
||||
|
||||
@router.delete("/milestones/{milestone_id}")
|
||||
def delete_milestone(
|
||||
milestone_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("project:edit"))
|
||||
):
|
||||
m = db.query(ProjectMilestone).filter(ProjectMilestone.id == milestone_id).first()
|
||||
if not m:
|
||||
raise HTTPException(status_code=404, detail="里程碑不存在")
|
||||
db.delete(m)
|
||||
db.commit()
|
||||
return {"message": "已删除"}
|
||||
|
||||
@ -1,107 +0,0 @@
|
||||
"""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)
|
||||
@ -1,113 +0,0 @@
|
||||
"""角色管理路由"""
|
||||
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 database import get_db
|
||||
from models import (
|
||||
User, Submission, SubmissionHistory, Project,
|
||||
User, Submission, SubmissionHistory, Project, UserRole,
|
||||
PhaseGroup, WorkType, ContentType, SubmitTo
|
||||
)
|
||||
from schemas import SubmissionCreate, SubmissionUpdate, SubmissionOut
|
||||
from auth import get_current_user, require_permission
|
||||
from auth import get_current_user, require_role
|
||||
|
||||
router = APIRouter(prefix="/api/submissions", tags=["内容提交"])
|
||||
|
||||
@ -44,11 +44,8 @@ def list_submissions(
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
q = db.query(Submission)
|
||||
# 查看项目内提交时,所有人都可见(方便横向对比)
|
||||
# 全局提交列表时,没有 user:view 权限只能看自己的
|
||||
if project_id:
|
||||
pass # 项目内提交不做用户过滤
|
||||
elif not current_user.has_permission("user:view"):
|
||||
# 成员只能看自己的
|
||||
if current_user.role == UserRole.MEMBER:
|
||||
q = q.filter(Submission.user_id == current_user.id)
|
||||
elif user_id:
|
||||
q = q.filter(Submission.user_id == user_id)
|
||||
@ -101,7 +98,7 @@ def update_submission(
|
||||
submission_id: int,
|
||||
req: SubmissionUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("submission:create"))
|
||||
current_user: User = Depends(require_role(UserRole.OWNER, UserRole.SUPERVISOR, UserRole.LEADER))
|
||||
):
|
||||
"""高权限修改提交记录(需填写原因)"""
|
||||
sub = db.query(Submission).filter(Submission.id == submission_id).first()
|
||||
@ -177,7 +174,7 @@ def update_submission(
|
||||
def get_submission_history(
|
||||
submission_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("submission:view"))
|
||||
current_user: User = Depends(require_role(UserRole.OWNER, UserRole.SUPERVISOR, UserRole.LEADER))
|
||||
):
|
||||
"""查看提交的修改历史"""
|
||||
records = db.query(SubmissionHistory).filter(
|
||||
|
||||
@ -3,9 +3,9 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
from database import get_db
|
||||
from models import User, Role, PhaseGroup
|
||||
from models import User, UserRole, PhaseGroup
|
||||
from schemas import UserCreate, UserUpdate, UserOut
|
||||
from auth import get_current_user, hash_password, require_permission
|
||||
from auth import get_current_user, hash_password, require_role
|
||||
|
||||
router = APIRouter(prefix="/api/users", tags=["用户管理"])
|
||||
|
||||
@ -14,12 +14,8 @@ def user_to_out(u: User) -> UserOut:
|
||||
return UserOut(
|
||||
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,
|
||||
role_id=u.role_id, role_name=u.role_name, permissions=u.permissions,
|
||||
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,
|
||||
role=u.role.value if hasattr(u.role, 'value') else u.role,
|
||||
monthly_salary=u.monthly_salary, daily_cost=u.daily_cost,
|
||||
is_active=u.is_active, created_at=u.created_at,
|
||||
)
|
||||
|
||||
@ -27,7 +23,7 @@ def user_to_out(u: User) -> UserOut:
|
||||
@router.get("/", response_model=List[UserOut])
|
||||
def list_users(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("user:view"))
|
||||
current_user: User = Depends(require_role(UserRole.OWNER, UserRole.SUPERVISOR, UserRole.LEADER))
|
||||
):
|
||||
users = db.query(User).order_by(User.created_at.desc()).all()
|
||||
return [user_to_out(u) for u in users]
|
||||
@ -37,7 +33,7 @@ def list_users(
|
||||
def create_user(
|
||||
req: UserCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("user:manage"))
|
||||
current_user: User = Depends(require_role(UserRole.OWNER))
|
||||
):
|
||||
if db.query(User).filter(User.username == req.username).first():
|
||||
raise HTTPException(status_code=400, detail="用户名已存在")
|
||||
@ -46,10 +42,8 @@ def create_user(
|
||||
password_hash=hash_password(req.password),
|
||||
name=req.name,
|
||||
phase_group=PhaseGroup(req.phase_group),
|
||||
role_id=req.role_id,
|
||||
role=UserRole(req.role),
|
||||
monthly_salary=req.monthly_salary,
|
||||
bonus=req.bonus,
|
||||
social_insurance=req.social_insurance,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
@ -62,7 +56,7 @@ def update_user(
|
||||
user_id: int,
|
||||
req: UserUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("user:manage"))
|
||||
current_user: User = Depends(require_role(UserRole.OWNER))
|
||||
):
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
@ -71,14 +65,10 @@ def update_user(
|
||||
user.name = req.name
|
||||
if req.phase_group is not None:
|
||||
user.phase_group = PhaseGroup(req.phase_group)
|
||||
if req.role_id is not None:
|
||||
user.role_id = req.role_id
|
||||
if req.role is not None:
|
||||
user.role = UserRole(req.role)
|
||||
if req.monthly_salary is not None:
|
||||
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:
|
||||
user.is_active = req.is_active
|
||||
db.commit()
|
||||
|
||||
@ -23,19 +23,15 @@ class UserCreate(BaseModel):
|
||||
password: str
|
||||
name: str
|
||||
phase_group: str # 前期/制作/后期
|
||||
role_id: int
|
||||
role: str = "成员"
|
||||
monthly_salary: float = 0
|
||||
bonus: float = 0
|
||||
social_insurance: float = 0
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
phase_group: Optional[str] = None
|
||||
role_id: Optional[int] = None
|
||||
role: Optional[str] = None
|
||||
monthly_salary: Optional[float] = None
|
||||
bonus: Optional[float] = None
|
||||
social_insurance: Optional[float] = None
|
||||
is_active: Optional[int] = None
|
||||
|
||||
|
||||
@ -44,13 +40,8 @@ class UserOut(BaseModel):
|
||||
username: str
|
||||
name: str
|
||||
phase_group: str
|
||||
role_id: int
|
||||
role_name: str
|
||||
permissions: List[str] = []
|
||||
role: str
|
||||
monthly_salary: float
|
||||
bonus: float
|
||||
social_insurance: float
|
||||
monthly_total_cost: float
|
||||
daily_cost: float
|
||||
is_active: int
|
||||
created_at: Optional[datetime] = None
|
||||
@ -61,23 +52,6 @@ class UserOut(BaseModel):
|
||||
|
||||
# ──────────────────────────── 项目 ────────────────────────────
|
||||
|
||||
class MilestoneOut(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
phase: str
|
||||
is_completed: bool
|
||||
completed_at: Optional[datetime] = None
|
||||
sort_order: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class MilestoneCreate(BaseModel):
|
||||
name: str
|
||||
phase: str # 前期/后期
|
||||
|
||||
|
||||
class ProjectCreate(BaseModel):
|
||||
name: str
|
||||
project_type: str
|
||||
@ -87,7 +61,6 @@ class ProjectCreate(BaseModel):
|
||||
episode_count: int
|
||||
estimated_completion_date: Optional[date] = None
|
||||
contract_amount: Optional[float] = None
|
||||
milestones: Optional[List[dict]] = None # [{"name", "phase", "sort_order"}]
|
||||
|
||||
|
||||
class ProjectUpdate(BaseModel):
|
||||
@ -123,10 +96,6 @@ class ProjectOut(BaseModel):
|
||||
progress_percent: Optional[float] = 0
|
||||
waste_seconds: Optional[float] = 0
|
||||
waste_rate: Optional[float] = 0
|
||||
# 里程碑
|
||||
milestones: Optional[List[MilestoneOut]] = []
|
||||
phase_summary: Optional[dict] = None
|
||||
current_stage: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@ -245,28 +214,6 @@ class CostOverrideCreate(BaseModel):
|
||||
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):
|
||||
|
||||
@ -14,35 +14,22 @@ def seed():
|
||||
db.execute(table.delete())
|
||||
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 = [
|
||||
User(username="admin", password_hash=hash_password("admin123"),
|
||||
name="老板", phase_group=PhaseGroup.PRODUCTION, role_id=roles["超级管理员"].id, monthly_salary=0),
|
||||
name="老板", phase_group=PhaseGroup.PRODUCTION, role=UserRole.OWNER, monthly_salary=0),
|
||||
User(username="zhangsan", password_hash=hash_password("123456"),
|
||||
name="张三", phase_group=PhaseGroup.PRE, role_id=roles["组长"].id, monthly_salary=15000),
|
||||
name="张三", phase_group=PhaseGroup.PRE, role=UserRole.LEADER, monthly_salary=15000),
|
||||
User(username="lisi", password_hash=hash_password("123456"),
|
||||
name="李四", phase_group=PhaseGroup.PRODUCTION, role_id=roles["组长"].id, monthly_salary=18000),
|
||||
name="李四", phase_group=PhaseGroup.PRODUCTION, role=UserRole.LEADER, monthly_salary=18000),
|
||||
User(username="wangwu", password_hash=hash_password("123456"),
|
||||
name="王五", phase_group=PhaseGroup.PRODUCTION, role_id=roles["成员"].id, monthly_salary=12000),
|
||||
name="王五", phase_group=PhaseGroup.PRODUCTION, role=UserRole.MEMBER, monthly_salary=12000),
|
||||
User(username="zhaoliu", password_hash=hash_password("123456"),
|
||||
name="赵六", phase_group=PhaseGroup.PRODUCTION, role_id=roles["成员"].id, monthly_salary=12000),
|
||||
name="赵六", phase_group=PhaseGroup.PRODUCTION, role=UserRole.MEMBER, monthly_salary=12000),
|
||||
User(username="sunqi", password_hash=hash_password("123456"),
|
||||
name="孙七", phase_group=PhaseGroup.POST, role_id=roles["成员"].id, monthly_salary=13000),
|
||||
name="孙七", phase_group=PhaseGroup.POST, role=UserRole.MEMBER, monthly_salary=13000),
|
||||
User(username="producer", password_hash=hash_password("123456"),
|
||||
name="陈制片", phase_group=PhaseGroup.PRODUCTION, role_id=roles["主管"].id, monthly_salary=20000),
|
||||
name="陈制片", phase_group=PhaseGroup.PRODUCTION, role=UserRole.SUPERVISOR, monthly_salary=20000),
|
||||
]
|
||||
db.add_all(users)
|
||||
db.flush()
|
||||
@ -82,6 +69,7 @@ def seed():
|
||||
base_date = date.today() - timedelta(days=14)
|
||||
submissions = []
|
||||
|
||||
# 张三(前期组)给项目 A 和 D 做前期
|
||||
for i in range(5):
|
||||
d = base_date + timedelta(days=i)
|
||||
submissions.append(Submission(
|
||||
@ -101,9 +89,10 @@ def seed():
|
||||
submit_date=d,
|
||||
))
|
||||
|
||||
# 李四(制作组组长)主要做项目 A
|
||||
for i in range(10):
|
||||
d = base_date + timedelta(days=i)
|
||||
secs = 45 + (i % 3) * 15
|
||||
secs = 45 + (i % 3) * 15 # 45-75秒
|
||||
wt = WorkType.TEST if i < 2 else WorkType.PRODUCTION
|
||||
submissions.append(Submission(
|
||||
user_id=lisi.id, project_id=proj_a.id,
|
||||
@ -114,9 +103,10 @@ def seed():
|
||||
submit_date=d,
|
||||
))
|
||||
|
||||
# 王五(制作组)做项目 A 和 B
|
||||
for i in range(8):
|
||||
d = base_date + timedelta(days=i)
|
||||
secs = 30 + (i % 4) * 20
|
||||
secs = 30 + (i % 4) * 20 # 30-90秒
|
||||
submissions.append(Submission(
|
||||
user_id=wangwu.id, project_id=proj_a.id,
|
||||
project_phase=PhaseGroup.PRODUCTION, work_type=WorkType.PRODUCTION,
|
||||
@ -137,9 +127,10 @@ def seed():
|
||||
submit_date=d,
|
||||
))
|
||||
|
||||
# 赵六(制作组)做项目 A
|
||||
for i in range(10):
|
||||
d = base_date + timedelta(days=i)
|
||||
secs = 50 + (i % 2) * 30
|
||||
secs = 50 + (i % 2) * 30 # 50-80秒
|
||||
wt = WorkType.TEST if i < 1 else WorkType.PRODUCTION
|
||||
submissions.append(Submission(
|
||||
user_id=zhaoliu.id, project_id=proj_a.id,
|
||||
@ -150,6 +141,7 @@ def seed():
|
||||
submit_date=d,
|
||||
))
|
||||
|
||||
# 孙七(后期组)剪辑
|
||||
for i in range(3):
|
||||
d = base_date + timedelta(days=i + 10)
|
||||
submissions.append(Submission(
|
||||
@ -159,6 +151,7 @@ def seed():
|
||||
submit_to=SubmitTo.PRODUCER, description=f"第{i+1}集粗剪完成",
|
||||
submit_date=d,
|
||||
))
|
||||
# 后期补拍
|
||||
submissions.append(Submission(
|
||||
user_id=sunqi.id, project_id=proj_a.id,
|
||||
project_phase=PhaseGroup.POST, work_type=WorkType.PRODUCTION,
|
||||
@ -170,6 +163,7 @@ def seed():
|
||||
|
||||
db.add_all(submissions)
|
||||
|
||||
# ── AI 工具成本 ──
|
||||
db.add(AIToolCost(
|
||||
tool_name="Midjourney", subscription_period=SubscriptionPeriod.MONTHLY,
|
||||
amount=200, allocation_type=CostAllocationType.TEAM,
|
||||
@ -182,6 +176,7 @@ def seed():
|
||||
recorded_by=producer.id, record_date=date.today().replace(day=1),
|
||||
))
|
||||
|
||||
# ── 外包成本 ──
|
||||
db.add(OutsourceCost(
|
||||
project_id=proj_a.id, outsource_type=OutsourceType.ANIMATION,
|
||||
episode_start=10, episode_end=13, amount=20000,
|
||||
@ -190,7 +185,6 @@ def seed():
|
||||
|
||||
db.commit()
|
||||
print("[OK] seed data generated")
|
||||
print(f" - roles: {len(roles)}")
|
||||
print(f" - users: {len(users)}")
|
||||
print(f" - projects: 4")
|
||||
print(f" - submissions: {len(submissions)}")
|
||||
|
||||
@ -1,362 +0,0 @@
|
||||
"""补充演示数据 - 只添加项目/提交/成本,不动用户和角色"""
|
||||
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()
|
||||
@ -1,96 +0,0 @@
|
||||
"""豆包大模型服务 —— 通过火山引擎 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 ""
|
||||
@ -1,146 +0,0 @@
|
||||
"""飞书自建应用消息推送服务"""
|
||||
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()
|
||||
@ -1,507 +0,0 @@
|
||||
"""报告生成服务 —— 汇总数据库数据 + 调用 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
|
||||
@ -1,78 +0,0 @@
|
||||
"""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,9 +24,7 @@ api.interceptors.response.use(
|
||||
if (err.response?.status === 401) {
|
||||
const isOnLogin = window.location.pathname === '/login'
|
||||
localStorage.removeItem('token')
|
||||
if (isOnLogin) {
|
||||
ElMessage.error(msg || '用户名或密码错误')
|
||||
} else {
|
||||
if (!isOnLogin) {
|
||||
router.push('/login')
|
||||
ElMessage.error('登录已过期,请重新登录')
|
||||
}
|
||||
@ -59,14 +57,9 @@ export const projectApi = {
|
||||
create: (data) => api.post('/projects/', data),
|
||||
update: (id, data) => api.put(`/projects/${id}`, data),
|
||||
get: (id) => api.get(`/projects/${id}`),
|
||||
delete: (id) => api.delete(`/projects/${id}`),
|
||||
complete: (id) => api.post(`/projects/${id}/complete`),
|
||||
settlement: (id) => api.get(`/projects/${id}/settlement`),
|
||||
efficiency: (id) => api.get(`/projects/${id}/efficiency`),
|
||||
milestones: (id) => api.get(`/projects/${id}/milestones`),
|
||||
addMilestone: (id, data) => api.post(`/projects/${id}/milestones`, data),
|
||||
toggleMilestone: (milestoneId) => api.put(`/projects/milestones/${milestoneId}/toggle`),
|
||||
deleteMilestone: (milestoneId) => api.delete(`/projects/milestones/${milestoneId}`),
|
||||
}
|
||||
|
||||
// ── 内容提交 ──
|
||||
@ -87,18 +80,6 @@ export const costApi = {
|
||||
deleteOutsource: (id) => api.delete(`/costs/outsource/${id}`),
|
||||
createOverride: (data) => api.post('/costs/overrides', data),
|
||||
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"
|
||||
class="nav-item"
|
||||
:class="{ active: isActive(item.path) }"
|
||||
v-show="!item.perm || (Array.isArray(item.perm) ? item.perm.some(p => authStore.hasPermission(p)) : authStore.hasPermission(item.perm))"
|
||||
v-show="!item.role || hasRole(item.role)"
|
||||
>
|
||||
<el-icon :size="18"><component :is="item.icon" /></el-icon>
|
||||
<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-meta">
|
||||
<div class="user-name">{{ authStore.user?.name }}</div>
|
||||
<div class="user-role">{{ authStore.user?.role_name }}</div>
|
||||
<div class="user-role">{{ authStore.user?.role }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -69,12 +69,11 @@ const authStore = useAuthStore()
|
||||
const isCollapsed = ref(false)
|
||||
|
||||
const menuItems = [
|
||||
{ path: '/dashboard', label: '仪表盘', icon: 'Odometer', perm: 'dashboard:view' },
|
||||
{ path: '/projects', label: '项目管理', icon: 'FolderOpened', perm: 'project:view' },
|
||||
{ path: '/submissions', label: '内容提交', icon: 'EditPen', perm: 'submission:view' },
|
||||
{ path: '/costs', label: '成本管理', icon: 'Money', perm: ['cost_ai:view', 'cost_outsource:view', 'cost_overhead:view', 'cost_labor:view'] },
|
||||
{ path: '/users', label: '用户管理', icon: 'User', perm: 'user:manage' },
|
||||
{ path: '/roles', label: '角色管理', icon: 'Lock', perm: 'role:manage' },
|
||||
{ path: '/dashboard', label: '仪表盘', icon: 'Odometer', role: 'Owner' },
|
||||
{ path: '/projects', label: '项目管理', icon: 'FolderOpened' },
|
||||
{ path: '/submissions', label: '内容提交', icon: 'EditPen' },
|
||||
{ path: '/costs', label: '成本管理', icon: 'Money', role: 'leader+' },
|
||||
{ path: '/users', label: '用户管理', icon: 'User', role: 'Owner' },
|
||||
]
|
||||
|
||||
const titleMap = {
|
||||
@ -83,7 +82,6 @@ const titleMap = {
|
||||
'/submissions': '内容提交',
|
||||
'/costs': '成本管理',
|
||||
'/users': '用户管理',
|
||||
'/roles': '角色管理',
|
||||
}
|
||||
|
||||
const currentTitle = computed(() => {
|
||||
@ -96,6 +94,12 @@ function isActive(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 () => {
|
||||
if (authStore.token && !authStore.user) {
|
||||
await authStore.fetchUser()
|
||||
|
||||
@ -5,17 +5,15 @@ const routes = [
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('../components/Layout.vue'),
|
||||
redirect: '/projects',
|
||||
redirect: '/dashboard',
|
||||
children: [
|
||||
{ path: 'dashboard', name: 'Dashboard', component: () => import('../views/Dashboard.vue'), meta: { perm: 'dashboard:view' } },
|
||||
{ path: 'projects', name: 'Projects', component: () => import('../views/Projects.vue'), meta: { perm: 'project:view' } },
|
||||
{ path: 'projects/:id', name: 'ProjectDetail', component: () => import('../views/ProjectDetail.vue'), meta: { perm: 'project:view' } },
|
||||
{ path: 'submissions', name: 'Submissions', component: () => import('../views/Submissions.vue'), meta: { perm: 'submission:view' } },
|
||||
{ path: 'costs', name: 'Costs', component: () => import('../views/Costs.vue'), meta: { perm: ['cost_ai:view', 'cost_outsource:view', 'cost_overhead:view', 'cost_labor:view'] } },
|
||||
{ path: 'users', name: 'Users', component: () => import('../views/Users.vue'), meta: { perm: 'user:manage' } },
|
||||
{ path: 'users/:id/detail', name: 'MemberDetail', component: () => import('../views/MemberDetail.vue'), meta: { perm: 'user:view' } },
|
||||
{ path: 'roles', name: 'Roles', component: () => import('../views/Roles.vue'), meta: { perm: 'role:manage' } },
|
||||
{ path: 'settlement/:id', name: 'Settlement', component: () => import('../views/Settlement.vue'), meta: { perm: 'settlement:view' } },
|
||||
{ path: 'dashboard', name: 'Dashboard', component: () => import('../views/Dashboard.vue'), meta: { roles: ['Owner'] } },
|
||||
{ path: 'projects', name: 'Projects', component: () => import('../views/Projects.vue') },
|
||||
{ path: 'projects/:id', name: 'ProjectDetail', component: () => import('../views/ProjectDetail.vue') },
|
||||
{ path: 'submissions', name: 'Submissions', component: () => import('../views/Submissions.vue') },
|
||||
{ path: 'costs', name: 'Costs', component: () => import('../views/Costs.vue'), meta: { roles: ['Owner', '主管', '组长'] } },
|
||||
{ path: 'users', name: 'Users', component: () => import('../views/Users.vue'), meta: { roles: ['Owner'] } },
|
||||
{ path: 'settlement/:id', name: 'Settlement', component: () => import('../views/Settlement.vue'), meta: { roles: ['Owner'] } },
|
||||
],
|
||||
},
|
||||
]
|
||||
@ -28,47 +26,18 @@ const router = createRouter({
|
||||
// 路由守卫
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
const token = localStorage.getItem('token')
|
||||
|
||||
if (to.meta.public) {
|
||||
// 已登录时访问登录页,直接跳首页
|
||||
if (to.path === '/login' && token) {
|
||||
next('/')
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
} else if (!token) {
|
||||
next('/login')
|
||||
return
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
|
||||
// 有 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
|
||||
|
||||
@ -10,6 +10,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
const res = await authApi.login({ username, password })
|
||||
token.value = res.access_token
|
||||
localStorage.setItem('token', res.access_token)
|
||||
// 登录后立即获取用户信息,失败不影响登录流程
|
||||
try {
|
||||
user.value = await authApi.me()
|
||||
} catch (e) {
|
||||
@ -32,13 +33,9 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
localStorage.removeItem('token')
|
||||
}
|
||||
|
||||
/**
|
||||
* 核心权限判断方法
|
||||
* @param {string} perm - 权限标识符,如 'dashboard:view'
|
||||
*/
|
||||
function hasPermission(perm) {
|
||||
return (user.value?.permissions || []).includes(perm)
|
||||
}
|
||||
const isOwner = () => user.value?.role === 'Owner'
|
||||
const isSupervisor = () => ['Owner', '主管'].includes(user.value?.role)
|
||||
const isLeaderOrAbove = () => ['Owner', '主管', '组长'].includes(user.value?.role)
|
||||
|
||||
return { user, token, login, fetchUser, logout, hasPermission }
|
||||
return { user, token, login, fetchUser, logout, isOwner, isSupervisor, isLeaderOrAbove }
|
||||
})
|
||||
|
||||
@ -4,9 +4,9 @@
|
||||
|
||||
<el-tabs v-model="activeTab">
|
||||
<!-- AI 工具成本 -->
|
||||
<el-tab-pane v-if="authStore.hasPermission('cost_ai:view')" label="AI 工具成本" name="ai">
|
||||
<el-tab-pane label="AI 工具成本" name="ai">
|
||||
<div class="tab-header">
|
||||
<el-button v-if="authStore.hasPermission('cost_ai:create')" type="primary" size="small" @click="showAIForm = true"><el-icon><Plus /></el-icon> 新增</el-button>
|
||||
<el-button type="primary" size="small" @click="showAIForm = true"><el-icon><Plus /></el-icon> 新增</el-button>
|
||||
</div>
|
||||
<el-table :data="aiCosts" v-loading="loadingAI" stripe size="small">
|
||||
<el-table-column prop="tool_name" label="工具名称" width="140" />
|
||||
@ -16,16 +16,16 @@
|
||||
</el-table-column>
|
||||
<el-table-column prop="allocation_type" label="归属方式" width="120" />
|
||||
<el-table-column prop="record_date" label="录入日期" width="110" />
|
||||
<el-table-column label="操作" width="80" v-if="authStore.hasPermission('cost_ai:delete')">
|
||||
<el-table-column label="操作" width="80" v-if="authStore.isOwner()">
|
||||
<template #default="{row}"><el-button text type="danger" size="small" @click="deleteAI(row.id)">删除</el-button></template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 外包成本 -->
|
||||
<el-tab-pane v-if="authStore.hasPermission('cost_outsource:view')" label="外包成本" name="outsource">
|
||||
<el-tab-pane label="外包成本" name="outsource">
|
||||
<div class="tab-header">
|
||||
<el-button v-if="authStore.hasPermission('cost_outsource:create')" type="primary" size="small" @click="showOutForm = true"><el-icon><Plus /></el-icon> 新增</el-button>
|
||||
<el-button type="primary" size="small" @click="showOutForm = true"><el-icon><Plus /></el-icon> 新增</el-button>
|
||||
</div>
|
||||
<el-table :data="outCosts" v-loading="loadingOut" stripe size="small">
|
||||
<el-table-column label="项目" width="160">
|
||||
@ -39,29 +39,11 @@
|
||||
<template #default="{row}">¥{{ row.amount.toLocaleString() }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="record_date" label="录入日期" width="110" />
|
||||
<el-table-column label="操作" width="80" v-if="authStore.hasPermission('cost_outsource:delete')">
|
||||
<el-table-column label="操作" width="80" v-if="authStore.isOwner()">
|
||||
<template #default="{row}"><el-button text type="danger" size="small" @click="deleteOut(row.id)">删除</el-button></template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</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>
|
||||
|
||||
<!-- AI 工具新增弹窗 -->
|
||||
@ -130,31 +112,6 @@
|
||||
<el-button type="primary" @click="createOut">保存</el-button>
|
||||
</template>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@ -165,37 +122,25 @@ import { useAuthStore } from '../stores/auth'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// 默认选中第一个有权限的 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 activeTab = ref('ai')
|
||||
const loadingAI = ref(false)
|
||||
const loadingOut = ref(false)
|
||||
const loadingOH = ref(false)
|
||||
const aiCosts = ref([])
|
||||
const outCosts = ref([])
|
||||
const overheadCosts = ref([])
|
||||
const projects = ref([])
|
||||
const showAIForm = ref(false)
|
||||
const showOutForm = ref(false)
|
||||
const showOHForm = ref(false)
|
||||
|
||||
const projectMap = computed(() => {
|
||||
const m = {}; projects.value.forEach(p => m[p.id] = p.name); return m
|
||||
})
|
||||
|
||||
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 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 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() {
|
||||
await costApi.createAITool(aiForm); ElMessage.success('已添加'); showAIForm.value = false; loadAI()
|
||||
@ -203,24 +148,15 @@ async function createAI() {
|
||||
async function createOut() {
|
||||
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) {
|
||||
await ElMessageBox.confirm('确认删除?'); await costApi.deleteAITool(id); ElMessage.success('已删除'); loadAI()
|
||||
}
|
||||
async function deleteOut(id) {
|
||||
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 () => {
|
||||
// 仅加载有权限的成本数据
|
||||
if (authStore.hasPermission('cost_ai:view')) loadAI()
|
||||
if (authStore.hasPermission('cost_outsource:view')) loadOut()
|
||||
if (authStore.hasPermission('cost_overhead:view')) loadOH()
|
||||
loadAI(); loadOut()
|
||||
try { projects.value = await projectApi.list({}) } catch {}
|
||||
})
|
||||
</script>
|
||||
|
||||
@ -30,66 +30,6 @@
|
||||
<div class="stat-label">人均日产出</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>
|
||||
|
||||
<!-- 图表行:产出趋势 + 成本构成 -->
|
||||
@ -142,37 +82,18 @@
|
||||
</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-header"><span class="card-title">已结算项目</span></div>
|
||||
<div class="card-body">
|
||||
<el-table :data="data.settled_projects" size="small">
|
||||
<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">
|
||||
<template #default="{ row }">¥{{ formatNum(row.total_cost) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="盈亏" align="right" width="120">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.profit_loss != null" class="profit-text" :class="{ loss: row.profit_loss < 0 }">
|
||||
<span v-if="row.profit_loss != null" class="profit" :class="{ loss: row.profit_loss < 0 }">
|
||||
{{ row.profit_loss >= 0 ? '+' : '' }}¥{{ formatNum(row.profit_loss) }}
|
||||
</span>
|
||||
<span v-else class="text-muted">—</span>
|
||||
@ -197,9 +118,8 @@ const trendChartRef = ref(null)
|
||||
const costChartRef = ref(null)
|
||||
const comparisonChartRef = ref(null)
|
||||
const wasteChartRef = ref(null)
|
||||
const profitChartRef = ref(null)
|
||||
|
||||
let trendChart, costChart, comparisonChart, wasteChart, profitChart
|
||||
let trendChart, costChart, comparisonChart, wasteChart
|
||||
|
||||
function formatNum(n) { return (n || 0).toLocaleString('zh-CN', { maximumFractionDigits: 0 }) }
|
||||
function formatSecs(s) {
|
||||
@ -306,34 +226,11 @@ 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() {
|
||||
trendChart?.resize()
|
||||
costChart?.resize()
|
||||
comparisonChart?.resize()
|
||||
wasteChart?.resize()
|
||||
profitChart?.resize()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
@ -345,9 +242,6 @@ onMounted(async () => {
|
||||
initCostChart(data.value.cost_breakdown)
|
||||
initComparisonChart(data.value.project_comparison)
|
||||
initWasteChart(data.value.waste_ranking)
|
||||
if (data.value.profitability?.profit_by_project?.length) {
|
||||
initProfitChart(data.value.profitability.profit_by_project)
|
||||
}
|
||||
window.addEventListener('resize', handleResize)
|
||||
} finally {
|
||||
loading.value = false
|
||||
@ -360,14 +254,12 @@ onUnmounted(() => {
|
||||
costChart?.dispose()
|
||||
comparisonChart?.dispose()
|
||||
wasteChart?.dispose()
|
||||
profitChart?.dispose()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 统计网格 */
|
||||
.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 {
|
||||
background: var(--bg-card); border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md); padding: 20px; display: flex; align-items: center; gap: 16px;
|
||||
@ -377,7 +269,6 @@ onUnmounted(() => {
|
||||
.stat-icon.orange { background: #FFF3E0; color: #FF9500; }
|
||||
.stat-icon.green { background: #E8F8EE; color: #34C759; }
|
||||
.stat-icon.purple { background: #F0E8FE; color: #9B59B6; }
|
||||
.stat-icon.red { background: #FFE8E7; color: #FF3B30; }
|
||||
.stat-body { flex: 1; }
|
||||
.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; }
|
||||
@ -388,7 +279,6 @@ onUnmounted(() => {
|
||||
.chart-card.wide { flex: 2; }
|
||||
.chart-card.narrow { flex: 1; }
|
||||
.chart-card.half { flex: 1; }
|
||||
.chart-card.full-width { flex: 1; }
|
||||
.chart-container { width: 100%; height: 260px; }
|
||||
|
||||
/* 卡片 */
|
||||
@ -417,29 +307,7 @@ onUnmounted(() => {
|
||||
.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; }
|
||||
|
||||
.stat-value.profit { color: #34C759; }
|
||||
.stat-value.loss { color: #FF3B30; }
|
||||
.profit-text { font-weight: 600; color: #34C759; }
|
||||
.profit-text.loss { color: #FF3B30; }
|
||||
.profit { font-weight: 600; color: #34C759; }
|
||||
.profit.loss { color: #FF3B30; }
|
||||
.text-muted { color: var(--text-secondary); }
|
||||
|
||||
/* 风险预警 */
|
||||
.risk-card { margin-bottom: 16px; }
|
||||
.risk-item {
|
||||
padding: 14px 0; border-bottom: 1px solid var(--border-light);
|
||||
cursor: pointer; margin: 0 -20px; padding-left: 20px; padding-right: 20px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.risk-item:last-child { border-bottom: none; }
|
||||
.risk-item:hover { background: #FFFBF5; }
|
||||
.risk-item.high { border-left: 3px solid #FF3B30; }
|
||||
.risk-item.medium { border-left: 3px solid #FF9500; }
|
||||
.risk-header { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
|
||||
.risk-name { font-size: 14px; font-weight: 600; color: var(--text-primary); }
|
||||
.risk-progress { font-size: 12px; color: var(--text-secondary); margin-left: auto; }
|
||||
.risk-factors { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.risk-factor {
|
||||
font-size: 12px; color: #8B572A; background: #FFF8F0;
|
||||
padding: 2px 8px; border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -23,7 +23,7 @@
|
||||
<el-form-item>
|
||||
<el-input v-model="form.password" placeholder="密码" type="password" size="large" show-password />
|
||||
</el-form-item>
|
||||
<el-button type="primary" size="large" :loading="loading" native-type="submit" class="login-btn">
|
||||
<el-button type="primary" size="large" :loading="loading" @click="handleLogin" class="login-btn">
|
||||
登 录
|
||||
</el-button>
|
||||
</el-form>
|
||||
|
||||
@ -1,335 +0,0 @@
|
||||
<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,47 +5,14 @@
|
||||
<el-button text @click="$router.push('/projects')" class="back-btn"><el-icon><ArrowLeft /></el-icon></el-button>
|
||||
<h2>{{ project.name }}</h2>
|
||||
<el-tag :type="typeTagMap[project.project_type]" size="small">{{ project.project_type }}</el-tag>
|
||||
<el-tag :type="project.status === '已完成' ? 'success' : project.status === '废弃' ? 'danger' : 'info'" size="small">{{ project.status }}</el-tag>
|
||||
<el-tag :type="project.status === '已完成' ? 'success' : 'info'" size="small">{{ project.status }}</el-tag>
|
||||
</div>
|
||||
<el-space>
|
||||
<el-button v-if="authStore.hasPermission('project:edit')" @click="openEdit">编辑项目</el-button>
|
||||
<el-button v-if="authStore.hasPermission('project:delete')" type="danger" text @click="handleDelete">删除项目</el-button>
|
||||
<el-button v-if="authStore.hasPermission('project: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-button v-if="authStore.isOwner() && project.status === '制作中'" type="danger" plain @click="handleComplete">确认完成</el-button>
|
||||
<el-button v-if="authStore.isOwner() && project.status === '已完成'" type="primary" @click="$router.push(`/settlement/${project.id}`)">查看结算</el-button>
|
||||
</el-space>
|
||||
</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-card">
|
||||
@ -57,8 +24,8 @@
|
||||
<div class="stat-value">{{ formatSecs(project.total_submitted_seconds) }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">当前阶段</div>
|
||||
<div class="stat-value" :style="{color: stageColor}">{{ project.current_stage || '—' }}</div>
|
||||
<div class="stat-label">完成进度</div>
|
||||
<div class="stat-value" :style="{color: project.progress_percent > 100 ? '#FF9500' : '#3370FF'}">{{ project.progress_percent }}%</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">损耗率</div>
|
||||
@ -66,145 +33,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 里程碑进度 -->
|
||||
<!-- 进度条 -->
|
||||
<div class="card">
|
||||
<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-header"><span class="card-title">项目进度</span></div>
|
||||
<div class="card-body">
|
||||
<!-- 三阶段分段进度条 -->
|
||||
<div class="milestone-pipeline" v-if="project.phase_summary">
|
||||
<div class="segmented-bar">
|
||||
<div class="seg-group" style="flex:2">
|
||||
<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>
|
||||
<el-progress :percentage="Math.min(project.progress_percent, 100)" :stroke-width="8" :show-text="false"
|
||||
:color="progressColor" style="margin-bottom:12px" />
|
||||
<div class="meta-row">
|
||||
<span>目标:{{ project.episode_count }}集 × {{ project.episode_duration_minutes }}分 = {{ formatSecs(project.target_total_seconds) }}</span>
|
||||
<span v-if="project.estimated_completion_date">预估完成:{{ project.estimated_completion_date }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 团队效率 -->
|
||||
<div v-if="authStore.hasPermission('efficiency:view') && efficiency.length" class="card">
|
||||
<div v-if="authStore.isLeaderOrAbove() && efficiency.length" class="card">
|
||||
<div class="card-header"><span class="card-title">团队效率</span></div>
|
||||
<div class="card-body">
|
||||
<el-table :data="efficiency" size="small">
|
||||
@ -227,144 +70,6 @@
|
||||
</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-header">
|
||||
@ -393,12 +98,11 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, reactive, onMounted, onUnmounted, nextTick, watch } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { projectApi, submissionApi, userApi } from '../api'
|
||||
import { projectApi, submissionApi } from '../api'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import * as echarts from 'echarts'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@ -407,242 +111,10 @@ const loading = ref(false)
|
||||
const project = ref({})
|
||||
const submissions = ref([])
|
||||
const efficiency = ref([])
|
||||
const users = ref([])
|
||||
const showEdit = ref(false)
|
||||
const editing = ref(false)
|
||||
const projectTypes = ['客户正式项目', '客户测试项目', '内部原创项目', '内部测试项目']
|
||||
const typeTagMap = { '客户正式项目': 'success', '客户测试项目': 'warning', '内部原创项目': '', '内部测试项目': 'info' }
|
||||
|
||||
// ── 圆环进度图 ──
|
||||
const progressChartRef = ref(null)
|
||||
let progressChart = null
|
||||
|
||||
function initProgressChart() {
|
||||
if (!progressChartRef.value) return
|
||||
const p = project.value
|
||||
const pct = p.progress_percent || 0
|
||||
const isOver = pct > 100
|
||||
|
||||
if (progressChart) progressChart.dispose()
|
||||
progressChart = echarts.init(progressChartRef.value)
|
||||
|
||||
progressChart.setOption({
|
||||
series: [{
|
||||
type: 'pie',
|
||||
radius: ['55%', '78%'],
|
||||
center: ['50%', '50%'],
|
||||
silent: true,
|
||||
label: { show: false },
|
||||
data: [
|
||||
{ value: Math.min(pct, 100), itemStyle: { color: isOver ? '#FF9500' : '#3370FF' } },
|
||||
{ value: Math.max(0, 100 - pct), itemStyle: { color: '#E5E6EB' } },
|
||||
],
|
||||
}],
|
||||
graphic: [{
|
||||
type: 'text',
|
||||
left: 'center', top: 'center',
|
||||
style: {
|
||||
text: pct + '%',
|
||||
fontSize: 22, fontWeight: 700,
|
||||
fill: isOver ? '#FF9500' : '#3370FF',
|
||||
textAlign: 'center',
|
||||
},
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
function handleProgressResize() { progressChart?.resize() }
|
||||
|
||||
const editForm = reactive({
|
||||
name: '', project_type: '客户正式项目', status: '制作中', leader_id: null, current_phase: '制作',
|
||||
episode_duration_minutes: 5, episode_count: 1,
|
||||
estimated_completion_date: null, contract_amount: null,
|
||||
})
|
||||
|
||||
const progressColor = computed(() => project.value.progress_percent > 100 ? '#FF9500' : '#3370FF')
|
||||
const 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) {
|
||||
if (!s) return '0秒'
|
||||
const abs = Math.abs(s)
|
||||
@ -652,95 +124,28 @@ function formatSecs(s) {
|
||||
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() {
|
||||
loading.value = true
|
||||
try {
|
||||
const id = route.params.id
|
||||
project.value = await projectApi.get(id)
|
||||
submissions.value = await submissionApi.list({ project_id: id })
|
||||
if (authStore.hasPermission('efficiency:view')) {
|
||||
if (authStore.isLeaderOrAbove()) {
|
||||
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 }
|
||||
}
|
||||
|
||||
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() {
|
||||
try {
|
||||
await ElMessageBox.confirm('确认将此项目标记为完成并进行结算?', '确认完成', { type: 'warning' })
|
||||
await ElMessageBox.confirm('确认将此项目标记为完成并进行结算?此操作不可撤销。', '确认完成', { type: 'warning' })
|
||||
await projectApi.complete(route.params.id)
|
||||
ElMessage.success('项目已完成')
|
||||
load()
|
||||
} 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)
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleProgressResize)
|
||||
progressChart?.dispose()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@ -749,21 +154,6 @@ onUnmounted(() => {
|
||||
.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 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-card {
|
||||
background: var(--bg-card); border: 1px solid var(--border-color);
|
||||
@ -783,175 +173,10 @@ onUnmounted(() => {
|
||||
.card-title { font-size: 14px; font-weight: 600; }
|
||||
.card-count { font-size: 12px; color: var(--text-secondary); }
|
||||
.card-body { padding: 20px; }
|
||||
.meta-row { display: flex; justify-content: space-between; font-size: 13px; color: var(--text-secondary); }
|
||||
.rate-badge {
|
||||
font-size: 12px; font-weight: 600; color: var(--text-secondary);
|
||||
background: var(--bg-hover); padding: 2px 8px; border-radius: 4px; display: inline-block;
|
||||
}
|
||||
.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>
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<div></div>
|
||||
<el-button v-if="authStore.hasPermission('project:create')" type="primary" @click="showCreate = true">
|
||||
<el-button v-if="authStore.isSupervisor()" type="primary" @click="showCreate = true">
|
||||
<el-icon><Plus /></el-icon> 新建项目
|
||||
</el-button>
|
||||
</div>
|
||||
@ -12,7 +12,6 @@
|
||||
<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-select>
|
||||
<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" />
|
||||
@ -35,7 +34,7 @@
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="row.status === '已完成' ? 'success' : row.status === '废弃' ? 'danger' : 'info'">{{ row.status }}</el-tag>
|
||||
<el-tag size="small" :type="row.status === '已完成' ? 'success' : 'info'">{{ row.status }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="leader_name" label="负责人" width="90" />
|
||||
@ -44,8 +43,10 @@
|
||||
</el-table-column>
|
||||
<el-table-column label="进度" width="180">
|
||||
<template #default="{ row }">
|
||||
<div class="cell-stage">
|
||||
<span class="stage-tag" :class="'stage-' + (row.current_stage || '')">{{ stageLabel(row) }}</span>
|
||||
<div class="cell-progress">
|
||||
<el-progress :percentage="Math.min(row.progress_percent, 100)" :stroke-width="6" :show-text="false"
|
||||
:color="row.progress_percent > 100 ? '#FF9500' : '#3370FF'" />
|
||||
<span class="progress-text">{{ row.progress_percent }}%</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@ -93,36 +94,6 @@
|
||||
<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-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>
|
||||
<template #footer>
|
||||
<el-button @click="showCreate = false">取消</el-button>
|
||||
@ -153,42 +124,6 @@ const form = reactive({
|
||||
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() {
|
||||
loading.value = true
|
||||
try { projects.value = await projectApi.list(filter) } finally { loading.value = false }
|
||||
@ -198,21 +133,16 @@ async function handleCreate() {
|
||||
if (!form.name) { ElMessage.warning('请输入项目名称'); return }
|
||||
creating.value = true
|
||||
try {
|
||||
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 })
|
||||
await projectApi.create(form)
|
||||
ElMessage.success('项目已创建')
|
||||
showCreate.value = false
|
||||
// reset milestone template
|
||||
msTemplate.splice(0, msTemplate.length, ...defaultMsTemplate.map(m => ({...m})))
|
||||
load()
|
||||
} finally { creating.value = false }
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
load()
|
||||
if (authStore.hasPermission('user:view')) {
|
||||
if (authStore.isLeaderOrAbove()) {
|
||||
try { users.value = await userApi.list() } catch {}
|
||||
}
|
||||
})
|
||||
@ -227,15 +157,9 @@ onMounted(async () => {
|
||||
}
|
||||
.card-body { padding: 4px 0; }
|
||||
.cell-bold { font-weight: 500; color: var(--text-primary); }
|
||||
.cell-stage { display: flex; align-items: center; }
|
||||
.stage-tag {
|
||||
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; }
|
||||
.cell-progress { display: flex; align-items: center; gap: 8px; }
|
||||
.cell-progress .el-progress { flex: 1; }
|
||||
.progress-text { font-size: 12px; font-weight: 600; color: var(--text-secondary); min-width: 36px; text-align: right; }
|
||||
.rate-badge {
|
||||
font-size: 12px; font-weight: 600; color: var(--text-secondary);
|
||||
background: var(--bg-hover); padding: 2px 8px; border-radius: 4px; display: inline-block;
|
||||
@ -244,11 +168,4 @@ onMounted(async () => {
|
||||
.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; }
|
||||
|
||||
/* 里程碑模板 */
|
||||
.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>
|
||||
|
||||
@ -1,196 +0,0 @@
|
||||
<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,19 +18,16 @@
|
||||
|
||||
<!-- 成本汇总 -->
|
||||
<el-row :gutter="16" class="stat-row">
|
||||
<el-col :span="5">
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover"><div class="stat-label">人力成本</div><div class="stat-value">¥{{ fmt(data.labor_cost) }}</div></el-card>
|
||||
</el-col>
|
||||
<el-col :span="5">
|
||||
<el-col :span="6">
|
||||
<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 :span="4">
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover"><div class="stat-label">外包成本</div><div class="stat-value">¥{{ fmt(data.outsource_cost) }}</div></el-card>
|
||||
</el-col>
|
||||
<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-col :span="6">
|
||||
<el-card shadow="hover">
|
||||
<div class="stat-label">项目总成本</div>
|
||||
<div class="stat-value" style="color:#e6a23c">¥{{ fmt(data.total_cost) }}</div>
|
||||
|
||||
@ -5,40 +5,25 @@
|
||||
<el-button type="primary" @click="showCreate = true"><el-icon><Plus /></el-icon> 新增用户</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="sortedUsers" v-loading="loading" stripe>
|
||||
<el-table-column label="姓名" width="100">
|
||||
<template #default="{row}">
|
||||
<router-link :to="`/users/${row.id}/detail`" class="user-link">{{ row.name }}</router-link>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table :data="users" v-loading="loading" stripe>
|
||||
<el-table-column prop="name" label="姓名" width="100" />
|
||||
<el-table-column prop="username" label="用户名" width="120" />
|
||||
<el-table-column prop="phase_group" label="阶段" width="70" />
|
||||
<el-table-column label="角色" width="80">
|
||||
<el-table-column prop="phase_group" label="阶段组" width="80" />
|
||||
<el-table-column label="角色" width="90">
|
||||
<template #default="{row}">
|
||||
<el-tag size="small">{{ row.role_name }}</el-tag>
|
||||
<el-tag :type="roleMap[row.role]" size="small">{{ row.role }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="月薪" width="100" align="right">
|
||||
<el-table-column label="月薪" width="110" align="right">
|
||||
<template #default="{row}">¥{{ row.monthly_salary.toLocaleString() }}</template>
|
||||
</el-table-column>
|
||||
<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">
|
||||
<el-table-column label="日成本" width="100" align="right">
|
||||
<template #default="{row}">¥{{ row.daily_cost.toLocaleString() }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="70">
|
||||
<el-table-column label="状态" width="80">
|
||||
<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 label="操作" width="80">
|
||||
<el-table-column label="操作" width="100">
|
||||
<template #default="{row}">
|
||||
<el-button text size="small" @click="editUser(row)">编辑</el-button>
|
||||
</template>
|
||||
@ -46,8 +31,8 @@
|
||||
</el-table>
|
||||
|
||||
<!-- 新增/编辑对话框 -->
|
||||
<el-dialog v-model="showCreate" :title="editingId ? '编辑用户' : '新增用户'" width="520px" destroy-on-close>
|
||||
<el-form :model="form" label-width="90px">
|
||||
<el-dialog v-model="showCreate" :title="editingId ? '编辑用户' : '新增用户'" width="480px" destroy-on-close>
|
||||
<el-form :model="form" label-width="80px">
|
||||
<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.password" type="password" /></el-form-item>
|
||||
@ -59,25 +44,17 @@
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="角色">
|
||||
<el-select v-model="form.role_id" placeholder="系统权限角色" style="width:100%">
|
||||
<el-option v-for="r in roles" :key="r.id" :label="r.name" :value="r.id" />
|
||||
<el-select v-model="form.role" placeholder="系统权限角色" style="width:100%">
|
||||
<el-option label="成员 — 仅提交内容" value="成员" />
|
||||
<el-option label="组长 — 管理本组提交" value="组长" />
|
||||
<el-option label="主管 — 管理项目和人员" value="主管" />
|
||||
<el-option label="Owner — 全部权限" value="Owner" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-divider content-position="left">成本信息</el-divider>
|
||||
<el-form-item label="月薪">
|
||||
<el-input-number v-model="form.monthly_salary" :min="0" :step="1000" :controls="false" placeholder="基本月薪(元)" style="width:100%" />
|
||||
<el-input-number v-model="form.monthly_salary" :min="0" :step="1000" :controls="false" placeholder="月薪(元),日成本=月薪÷22" style="width:100%" />
|
||||
</el-form-item>
|
||||
<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-form-item v-if="editingId" label="状态">
|
||||
<el-switch v-model="form.is_active" :active-value="1" :inactive-value="0" active-text="启用" inactive-text="停用" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
@ -90,50 +67,28 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { userApi, roleApi } from '../api'
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { userApi } from '../api'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const loading = ref(false)
|
||||
const showCreate = ref(false)
|
||||
const editingId = ref(null)
|
||||
const users = ref([])
|
||||
const roles = ref([])
|
||||
|
||||
const ROLE_ORDER = ['超级管理员', '主管', '组长', '组员']
|
||||
const sortedUsers = computed(() => {
|
||||
return [...users.value].sort((a, b) => {
|
||||
const ia = ROLE_ORDER.indexOf(a.role_name)
|
||||
const ib = ROLE_ORDER.indexOf(b.role_name)
|
||||
const ai = ia >= 0 ? ia : 999
|
||||
const bi = ib >= 0 ? ib : 999
|
||||
return ai - bi
|
||||
})
|
||||
})
|
||||
const form = reactive({
|
||||
username: '', password: '', name: '', phase_group: '制作', role_id: null,
|
||||
monthly_salary: 0, bonus: 0, social_insurance: 0, is_active: 1,
|
||||
})
|
||||
const roleMap = { 'Owner': 'danger', '主管': 'warning', '组长': '', '成员': 'info' }
|
||||
const form = reactive({ username: '', password: '', name: '', phase_group: '制作', role: '成员', monthly_salary: 0, is_active: 1 })
|
||||
|
||||
async function load() { loading.value = true; try { users.value = await userApi.list() } finally { loading.value = false } }
|
||||
|
||||
function editUser(u) {
|
||||
editingId.value = u.id
|
||||
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,
|
||||
})
|
||||
Object.assign(form, { name: u.name, phase_group: u.phase_group, role: u.role, monthly_salary: u.monthly_salary, is_active: u.is_active })
|
||||
showCreate.value = true
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (editingId.value) {
|
||||
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,
|
||||
})
|
||||
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 })
|
||||
} else {
|
||||
await userApi.create(form)
|
||||
}
|
||||
@ -143,21 +98,10 @@ async function handleSave() {
|
||||
load()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
load()
|
||||
try { roles.value = await roleApi.list() } catch {}
|
||||
})
|
||||
onMounted(load)
|
||||
</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; }
|
||||
.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>
|
||||
|
||||
181
之前的上下文.md
181
之前的上下文.md
@ -1,181 +0,0 @@
|
||||
这个系统是否可以植入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
23
人员清单.md
@ -1,23 +0,0 @@
|
||||
序号 姓名 职级 职位 工资 社保
|
||||
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
307
项目总结文档.md
@ -1,307 +0,0 @@
|
||||
# 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