feat: V2.1 三阶段损耗前端增强 + 路由修复 + 演示数据重写
- 仪表盘双色进度条(超100%蓝红分段)、工时损耗展示、chart tooltip增强 - 修复 Submissions.vue 延期原因字段始终显示的Bug - 修复 SPA catch-all 路由拦截 API 请求(去尾部斜杠) - seed_demo.py 重写:5项目/4类型/32里程碑/124提交,真实时间线 - 三阶段损耗计算(前期工时/制作秒数/后期工时) - ContentType 扩展为11种,里程碑增强(预估天数/开始日期/超期检测) - 更新 PRD 和项目总结文档 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
2b990f06fb
commit
dc42306c24
@ -106,9 +106,10 @@
|
|||||||
- 客户测试项目(为甲方做测试/提案,未签约,无回款)
|
- 客户测试项目(为甲方做测试/提案,未签约,无回款)
|
||||||
- 内部原创项目(自主立项的内容作品)
|
- 内部原创项目(自主立项的内容作品)
|
||||||
- 内部测试项目(技术测试、风格探索等)
|
- 内部测试项目(技术测试、风格探索等)
|
||||||
- 项目状态
|
- 项目状态
|
||||||
- 制作中
|
- 制作中
|
||||||
- 已完成(结算)
|
- 已完成(结算)
|
||||||
|
- 废弃(中途停止,全部产出记为损耗)
|
||||||
- 项目负责人
|
- 项目负责人
|
||||||
- 项目阶段结构
|
- 项目阶段结构
|
||||||
- 前期 / 制作 / 后期
|
- 前期 / 制作 / 后期
|
||||||
@ -156,11 +157,12 @@
|
|||||||
- 前期 / 制作 / 后期
|
- 前期 / 制作 / 后期
|
||||||
- 工作类型
|
- 工作类型
|
||||||
- 制作 / 测试 / 方案
|
- 制作 / 测试 / 方案
|
||||||
- 内容制作类型
|
- 内容制作类型(按阶段分组,共 11 种)
|
||||||
- 内容制作
|
- 前期:策划案 / 剧本 / 分镜 / 人设图 / 场景图
|
||||||
- 设定 / 策划
|
- 制作:动画制作
|
||||||
- 剪辑 / 后期
|
- 后期:配音 / 音效 / 修补镜头 / 剪辑
|
||||||
- 其他
|
- 其他
|
||||||
|
- 选择内容类型时系统自动设置对应的项目阶段
|
||||||
- 产出时长输入
|
- 产出时长输入
|
||||||
- 分钟(可选)
|
- 分钟(可选)
|
||||||
- 秒(可选)
|
- 秒(可选)
|
||||||
@ -174,6 +176,44 @@
|
|||||||
- 组长 / 制片 / 内部 / 外部
|
- 组长 / 制片 / 内部 / 外部
|
||||||
- 制作描述(简要说明)
|
- 制作描述(简要说明)
|
||||||
- 提交日期(默认当天,可修改为过去日期补填)
|
- 提交日期(默认当天,可修改为过去日期补填)
|
||||||
|
- 里程碑关联(系统自动)
|
||||||
|
- 根据内容类型自动匹配同名里程碑
|
||||||
|
- 如"策划案"提交自动关联"策划案"里程碑
|
||||||
|
- 延期原因
|
||||||
|
- 当关联的里程碑已超期时,必须填写延期原因
|
||||||
|
- 用于后续 AI 分析和管理复盘
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6.4 项目里程碑(ProjectMilestone)
|
||||||
|
|
||||||
|
每个项目创建时自动生成一套默认里程碑,对应各阶段的工作环节。
|
||||||
|
|
||||||
|
**里程碑字段:**
|
||||||
|
|
||||||
|
- 所属项目
|
||||||
|
- 名称(如:策划案、剧本、分镜、配音等)
|
||||||
|
- 所属阶段(前期 / 后期)
|
||||||
|
- 是否完成(勾选)
|
||||||
|
- 完成日期(系统自动记录)
|
||||||
|
- 预估工作日(管理员填写)
|
||||||
|
- 开始日期(管理员填写)
|
||||||
|
- 实际工作日(系统自动计算 = 开始日期到完成日期的工作日数,排除周末)
|
||||||
|
- 是否超期(计算值 = 实际工作日 > 预估工作日)
|
||||||
|
|
||||||
|
**默认里程碑:**
|
||||||
|
|
||||||
|
| 阶段 | 里程碑 |
|
||||||
|
|------|--------|
|
||||||
|
| 前期 | 策划案、剧本、分镜、人设图、场景图 |
|
||||||
|
| 后期 | 配音、音效、修补镜头、剪辑、杂项 |
|
||||||
|
|
||||||
|
> 制作阶段不使用里程碑,以秒数进度为度量。
|
||||||
|
|
||||||
|
**操作:**
|
||||||
|
- 管理员可添加/删除里程碑
|
||||||
|
- 管理员可设置预估工作日和开始日期
|
||||||
|
- 勾选完成后系统记录完成日期
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -298,35 +338,56 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 8.1 损耗的两个来源
|
### 8.1 三阶段损耗计算体系
|
||||||
|
|
||||||
#### 来源一:测试损耗
|
不同阶段的工作性质不同,采用不同的损耗衡量方式:
|
||||||
|
|
||||||
- 工作类型为"测试"的提交 → 全部属于测试损耗
|
| 阶段 | 计算方式 | 损耗指标 | 度量单位 |
|
||||||
- 测试产出的秒数即为测试损耗秒数
|
|------|----------|----------|----------|
|
||||||
- 前期测试、风格测试、技术测试均属此类
|
| **前期** | 工时制 | 里程碑超期工时 | 小时(h) |
|
||||||
|
| **制作** | 秒数制 | 测试损耗 + 超产损耗 | 秒(s) |
|
||||||
|
| **后期** | 工时制 | 里程碑超期工时 | 小时(h) |
|
||||||
|
|
||||||
#### 来源二:超产损耗
|
#### 前期损耗(工时制)
|
||||||
|
|
||||||
- 项目累计提交秒数超过目标秒数的部分
|
前期工作(策划案、剧本、分镜、人设图、场景图)无法用秒数衡量,改为按里程碑工期计算:
|
||||||
- **超产损耗 = 累计提交总秒数 − 项目目标总秒数**
|
|
||||||
- 仅当累计提交 > 目标时长时产生
|
- 遍历前期里程碑,对有 `预估工作日` 且已完成的里程碑
|
||||||
- 仅统计有产出秒数的提交(制作组动画 + 后期组补拍)
|
- **实际工作日** = `完成日期 − 开始日期` 的工作日数(排除周末)
|
||||||
|
- **超出天数** = max(0, 实际工作日 − 预估工作日)
|
||||||
|
- **损耗工时** = 超出天数 × 8 小时
|
||||||
|
- 未完成但已超期的里程碑也计入(用当前日期代替完成日期)
|
||||||
|
|
||||||
|
#### 制作损耗(秒数制,保持现有逻辑)
|
||||||
|
|
||||||
|
- **测试损耗** = 工作类型为"测试"且项目阶段为"制作"的提交总秒数
|
||||||
|
- **制作产出** = 项目阶段为"制作"的非测试总秒数 + 内容类型为"修补镜头"的总秒数
|
||||||
|
- **超产损耗** = max(0, 制作产出 − 目标秒数)
|
||||||
|
- **制作总损耗** = 测试损耗 + 超产损耗
|
||||||
|
- **秒数损耗率** = 制作总损耗 ÷ 目标秒数 × 100%
|
||||||
|
|
||||||
|
#### 后期损耗(工时制)
|
||||||
|
|
||||||
|
后期里程碑中:
|
||||||
|
- **剪辑**等有预估工期的里程碑:与前期同理,按工时计算超期损耗
|
||||||
|
- **修补镜头**:秒数已计入制作损耗(与目标对比)
|
||||||
|
- **配音 / 音效**:外包或 AI 生成,不计损耗
|
||||||
|
|
||||||
#### 项目总损耗
|
#### 项目总损耗
|
||||||
|
|
||||||
> **项目总损耗(秒) = 测试损耗秒数 + 超产损耗秒数**
|
> **秒数类总损耗** = 测试损耗 + 超产损耗
|
||||||
> **项目损耗率 = 项目总损耗秒数 ÷ 项目目标总秒数 × 100%**
|
> **工时类总损耗** = 前期超期工时 + 后期超期工时
|
||||||
|
> **秒数损耗率** = 秒数类总损耗 ÷ 目标秒数 × 100%
|
||||||
|
|
||||||
**注意**:损耗仅统计生产层面的秒数浪费,**不含 AI 工具成本**(工具成本计入项目总成本,但不单独算作损耗)。
|
**注意**:损耗仅统计生产层面的浪费,**不含 AI 工具成本**。
|
||||||
|
|
||||||
**示例:**
|
**示例:**
|
||||||
项目目标:13 集 × 5 分钟 = 3,900 秒
|
项目目标:13 集 × 5 分钟 = 3,900 秒
|
||||||
测试阶段提交:500 秒(测试损耗)
|
前期:策划案预估 5 天 / 实际 8 天 → 超 3 天 = 24h 损耗
|
||||||
制作 + 后期补拍提交:4,800 秒
|
制作:测试 500 秒 + 制作产出 4,800 秒 → 超产 900 秒 → 秒数损耗 1,400 秒
|
||||||
超产损耗:4,800 − 3,900 = 900 秒
|
后期:剪辑预估 3 天 / 实际 3 天 → 无超期
|
||||||
项目总损耗:500 + 900 = 1,400 秒
|
秒数损耗率:1,400 ÷ 3,900 = 35.9%
|
||||||
损耗率:1,400 ÷ 3,900 = 35.9%
|
工时损耗:24h
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -381,12 +442,13 @@ V1 不做逐条通过率标记(太复杂),采用**人均基准对比法**
|
|||||||
|
|
||||||
### 8.5 各阶段组损耗说明
|
### 8.5 各阶段组损耗说明
|
||||||
|
|
||||||
| 阶段组 | 是否计算秒数损耗 | 说明 |
|
| 阶段组 | 损耗方式 | 说明 |
|
||||||
|--------|:---:|------|
|
|--------|----------|------|
|
||||||
| 前期组 | ❌ | 方案推翻重做属于正常创作流程,不按秒数计损耗 |
|
| 前期组 | **工时制** | 按里程碑预估 vs 实际天数,超期部分 × 8h 计为损耗 |
|
||||||
| 制作组 | ✅ | 核心损耗来源,超出目标秒数的部分即为损耗 |
|
| 制作组 | **秒数制** | 核心损耗来源:测试损耗 + 超出目标秒数的超产损耗 |
|
||||||
| 后期组 — 剪辑 | ❌ | 剪辑为组装工作,无新增秒数 |
|
| 后期组 — 剪辑 | **工时制** | 按里程碑预估 vs 实际天数 |
|
||||||
| 后期组 — 补拍 | ✅ | 补拍秒数计入损耗(属于返工) |
|
| 后期组 — 补拍 | **秒数制** | 补拍秒数计入制作损耗(与目标对比) |
|
||||||
|
| 后期组 — 配音/音效 | ❌ | 外包或 AI 生成,不计损耗 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -408,13 +470,14 @@ V1 不做逐条通过率标记(太复杂),采用**人均基准对比法**
|
|||||||
|
|
||||||
### 9.2 进度条可视化
|
### 9.2 进度条可视化
|
||||||
|
|
||||||
- 显示项目进度条
|
- 显示项目进度条
|
||||||
- 预估完成时间作为基准
|
- 预估完成时间作为基准
|
||||||
- 当前时间位置实时标注
|
- 当前时间位置实时标注
|
||||||
|
|
||||||
#### 颜色规则
|
#### 颜色规则
|
||||||
- 预估周期内:绿色
|
- 进度 ≤100%:蓝色进度条
|
||||||
- 超出预估周期:红色延展
|
- 进度 >100%:**双色进度条** — 蓝色表示目标内占比,红色表示超出占比(如 258% 时蓝色占 38.8%,红色占 61.2%)
|
||||||
|
- 百分比数字超过 100% 时变为红色
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -491,8 +554,9 @@ V1 不做逐条通过率标记(太复杂),采用**人均基准对比法**
|
|||||||
- 团队人均日产出秒数
|
- 团队人均日产出秒数
|
||||||
|
|
||||||
#### 损耗视角
|
#### 损耗视角
|
||||||
- 各项目损耗率排行(测试损耗 + 超产损耗)
|
- 总损耗率 + 总工时损耗卡片
|
||||||
- 损耗趋势图(按周/月)
|
- 各项目进度条下方显示损耗率与工时损耗
|
||||||
|
- 各项目损耗率排行(柱状图,tooltip 显示秒数损耗 + 工时损耗)
|
||||||
- 损耗不含工具成本,仅反映生产效率
|
- 损耗不含工具成本,仅反映生产效率
|
||||||
|
|
||||||
#### 盈亏视角(仅客户正式项目)
|
#### 盈亏视角(仅客户正式项目)
|
||||||
@ -533,22 +597,27 @@ V1 不做逐条通过率标记(太复杂),采用**人均基准对比法**
|
|||||||
|
|
||||||
| 层级 | 技术选型 | 说明 |
|
| 层级 | 技术选型 | 说明 |
|
||||||
|------|----------|------|
|
|------|----------|------|
|
||||||
| 前端 | Vue 3 + Element Plus | 成熟的后台管理 UI 组件库 |
|
| 前端 | Vue 3 + Element Plus + Vite | 成熟的后台管理 UI 组件库 |
|
||||||
| 后端 | Python FastAPI | 高性能 API 框架 |
|
| 后端 | Python FastAPI | 高性能 API 框架 |
|
||||||
| 数据库 | SQLite(MVP) | 30 人以内完全够用,零配置 |
|
| 数据库 | MySQL 8.0(阿里云 RDS) | 生产环境;本地开发可回退 SQLite |
|
||||||
| 图表 | ECharts | 仪表盘可视化 |
|
| 图表 | ECharts | 仪表盘可视化 |
|
||||||
| 认证 | JWT Token | 登录鉴权与权限控制 |
|
| 认证 | JWT Token | 登录鉴权与权限控制 |
|
||||||
|
| AI 模型 | 豆包(火山引擎 ARK) | 报告摘要生成 |
|
||||||
|
| 消息推送 | 飞书自建应用 | 报告卡片消息 |
|
||||||
|
| 定时任务 | APScheduler | 日报/周报/月报定时触发 |
|
||||||
|
|
||||||
### 部署方式
|
### 部署方式
|
||||||
|
|
||||||
- 阿里云轻量服务器
|
- **CI/CD**:Gitea Actions 自动构建,推代码即部署
|
||||||
- 前端静态文件 + 后端 API 服务
|
- **镜像**:Docker 构建,推送到华为云 SWR
|
||||||
- SQLite 数据库文件定期备份
|
- **运行**:K3s 集群,通过 Ingress 暴露服务
|
||||||
|
- **前端**:静态构建后由后端统一托管(`/` 路径返回 index.html)
|
||||||
|
- **数据库**:阿里云 RDS MySQL,通过环境变量配置连接
|
||||||
|
|
||||||
### 后续扩展路径
|
### 后续扩展路径
|
||||||
- V2:移动端响应式适配
|
- V3:移动端响应式适配
|
||||||
- V2:数据库升级至 PostgreSQL(如用户量增长)
|
- V3:数据导出(Excel / PDF)
|
||||||
- V2:数据导出(Excel / PDF)
|
- V3:AI 智能问答助手(自然语言查询系统数据)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -167,15 +167,75 @@ def calc_overhead_cost_for_project(project_id: int, db: Session) -> float:
|
|||||||
return 0.0
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────── 损耗计算 ────────────────────────────
|
# ──────────────────────────── 工作日计算工具 ────────────────────────────
|
||||||
|
|
||||||
|
def _working_days_between(start_date, end_date) -> int:
|
||||||
|
"""计算两个日期之间的工作日数(不含周末)"""
|
||||||
|
if not start_date or not end_date:
|
||||||
|
return 0
|
||||||
|
from datetime import timedelta, date as date_type
|
||||||
|
# 统一为 date 类型
|
||||||
|
if hasattr(start_date, 'date'):
|
||||||
|
start_date = start_date.date()
|
||||||
|
if hasattr(end_date, 'date'):
|
||||||
|
end_date = end_date.date()
|
||||||
|
if end_date <= start_date:
|
||||||
|
return 0
|
||||||
|
days = 0
|
||||||
|
current = start_date
|
||||||
|
while current < end_date:
|
||||||
|
current += timedelta(days=1)
|
||||||
|
if current.weekday() < 5: # 周一~周五
|
||||||
|
days += 1
|
||||||
|
return days
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────── 里程碑损耗计算 ────────────────────────────
|
||||||
|
|
||||||
|
def _calc_milestone_waste(milestones, today=None) -> tuple:
|
||||||
|
"""
|
||||||
|
计算里程碑的工时损耗(预估天数 vs 实际天数)
|
||||||
|
返回: (waste_hours, details_list)
|
||||||
|
"""
|
||||||
|
from datetime import date as date_type, timedelta
|
||||||
|
if today is None:
|
||||||
|
today = date_type.today()
|
||||||
|
|
||||||
|
waste_hours = 0.0
|
||||||
|
details = []
|
||||||
|
for ms in milestones:
|
||||||
|
if not ms.estimated_days or not ms.start_date:
|
||||||
|
continue
|
||||||
|
# 计算实际天数
|
||||||
|
end = ms.completed_at if ms.is_completed and ms.completed_at else today
|
||||||
|
actual_days = _working_days_between(ms.start_date, end)
|
||||||
|
if actual_days > ms.estimated_days:
|
||||||
|
overrun = actual_days - ms.estimated_days
|
||||||
|
waste_h = overrun * 8
|
||||||
|
waste_hours += waste_h
|
||||||
|
details.append({
|
||||||
|
"milestone": ms.name,
|
||||||
|
"estimated_days": ms.estimated_days,
|
||||||
|
"actual_days": actual_days,
|
||||||
|
"overrun_days": overrun,
|
||||||
|
"waste_hours": waste_h,
|
||||||
|
})
|
||||||
|
return waste_hours, details
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────── 损耗计算(三阶段) ────────────────────────────
|
||||||
|
|
||||||
def calc_waste_for_project(project_id: int, db: Session) -> dict:
|
def calc_waste_for_project(project_id: int, db: Session) -> dict:
|
||||||
"""
|
"""
|
||||||
计算项目损耗
|
三阶段损耗计算:
|
||||||
返回: {test_waste, overproduction_waste, total_waste, waste_rate, target_seconds}
|
- 前期:里程碑工时制(预估天数 vs 实际天数)
|
||||||
废弃项目:全部产出直接记为损耗
|
- 制作:秒数制(产出 vs 目标,含修补镜头)
|
||||||
|
- 后期:剪辑=工时制,修补镜头=秒数(归入制作),配音/音效=不计
|
||||||
"""
|
"""
|
||||||
from models import ProjectStatus
|
from models import (
|
||||||
|
ProjectStatus, ProjectMilestone, ContentType, PhaseGroup
|
||||||
|
)
|
||||||
|
|
||||||
project = db.query(Project).filter(Project.id == project_id).first()
|
project = db.query(Project).filter(Project.id == project_id).first()
|
||||||
if not project:
|
if not project:
|
||||||
return {}
|
return {}
|
||||||
@ -188,31 +248,97 @@ def calc_waste_for_project(project_id: int, db: Session) -> dict:
|
|||||||
Submission.total_seconds > 0,
|
Submission.total_seconds > 0,
|
||||||
).scalar() or 0
|
).scalar() or 0
|
||||||
|
|
||||||
# 废弃项目:全部产出记为损耗
|
# ── 废弃项目:全部产出记为损耗 ──
|
||||||
if project.status == ProjectStatus.ABANDONED:
|
if project.status == ProjectStatus.ABANDONED:
|
||||||
total_waste = total_submitted
|
return {
|
||||||
test_waste = 0.0
|
"target_seconds": target,
|
||||||
overproduction_waste = total_submitted
|
"total_submitted_seconds": round(total_submitted, 1),
|
||||||
waste_rate = 100.0 if total_submitted > 0 else 0.0
|
"pre_waste": {"waste_hours": 0, "details": []},
|
||||||
else:
|
"production_waste": {
|
||||||
# 测试损耗:工作类型为"测试"的全部秒数
|
"test_waste_seconds": 0,
|
||||||
test_waste = db.query(sa_func.sum(Submission.total_seconds)).filter(
|
"overproduction_waste_seconds": round(total_submitted, 1),
|
||||||
Submission.project_id == project_id,
|
"total_waste_seconds": round(total_submitted, 1),
|
||||||
Submission.work_type == WorkType.TEST,
|
},
|
||||||
).scalar() or 0
|
"post_waste": {"days_waste_hours": 0, "details": []},
|
||||||
# 超产损耗(仅计算生产性提交超出目标的部分,排除测试秒数避免双重计数)
|
"total_waste_seconds": round(total_submitted, 1),
|
||||||
production_submitted = total_submitted - test_waste
|
"total_waste_hours": 0,
|
||||||
overproduction_waste = max(0, production_submitted - target)
|
"waste_rate": 100.0 if total_submitted > 0 else 0.0,
|
||||||
total_waste = test_waste + overproduction_waste
|
"test_waste_seconds": 0,
|
||||||
waste_rate = round(total_waste / target * 100, 1) if target > 0 else 0
|
"overproduction_waste_seconds": round(total_submitted, 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── 前期损耗(工时制) ──
|
||||||
|
pre_milestones = db.query(ProjectMilestone).filter(
|
||||||
|
ProjectMilestone.project_id == project_id,
|
||||||
|
ProjectMilestone.phase == PhaseGroup.PRE,
|
||||||
|
).all()
|
||||||
|
pre_waste_hours, pre_details = _calc_milestone_waste(pre_milestones)
|
||||||
|
|
||||||
|
# ── 制作损耗(秒数制) ──
|
||||||
|
# 制作阶段的提交
|
||||||
|
production_total_secs = db.query(sa_func.sum(Submission.total_seconds)).filter(
|
||||||
|
Submission.project_id == project_id,
|
||||||
|
Submission.total_seconds > 0,
|
||||||
|
Submission.project_phase == PhaseGroup.PRODUCTION,
|
||||||
|
).scalar() or 0
|
||||||
|
|
||||||
|
# 制作阶段的测试损耗
|
||||||
|
test_waste = db.query(sa_func.sum(Submission.total_seconds)).filter(
|
||||||
|
Submission.project_id == project_id,
|
||||||
|
Submission.work_type == WorkType.TEST,
|
||||||
|
Submission.project_phase == PhaseGroup.PRODUCTION,
|
||||||
|
).scalar() or 0
|
||||||
|
|
||||||
|
# 修补镜头(后期秒数,归入制作目标对比)
|
||||||
|
shot_repair_secs = db.query(sa_func.sum(Submission.total_seconds)).filter(
|
||||||
|
Submission.project_id == project_id,
|
||||||
|
Submission.content_type == ContentType.SHOT_REPAIR,
|
||||||
|
Submission.total_seconds > 0,
|
||||||
|
).scalar() or 0
|
||||||
|
|
||||||
|
# 制作产出 = 制作阶段非测试 + 修补镜头
|
||||||
|
production_output = (production_total_secs - test_waste) + shot_repair_secs
|
||||||
|
overproduction_waste = max(0, production_output - target)
|
||||||
|
production_total_waste = test_waste + overproduction_waste
|
||||||
|
|
||||||
|
# ── 后期损耗 ──
|
||||||
|
post_milestones = db.query(ProjectMilestone).filter(
|
||||||
|
ProjectMilestone.project_id == project_id,
|
||||||
|
ProjectMilestone.phase == PhaseGroup.POST,
|
||||||
|
).all()
|
||||||
|
# 只计算剪辑里程碑的工时损耗(配音/音效不计,修补镜头已在秒数中)
|
||||||
|
editing_milestones = [m for m in post_milestones if m.name == "剪辑"]
|
||||||
|
post_days_waste_hours, post_details = _calc_milestone_waste(editing_milestones)
|
||||||
|
|
||||||
|
# ── 汇总 ──
|
||||||
|
total_waste_seconds = production_total_waste
|
||||||
|
total_waste_hours = pre_waste_hours + post_days_waste_hours
|
||||||
|
waste_rate = round(total_waste_seconds / target * 100, 1) if target > 0 else 0
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"target_seconds": target,
|
"target_seconds": target,
|
||||||
"total_submitted_seconds": round(total_submitted, 1),
|
"total_submitted_seconds": round(total_submitted, 1),
|
||||||
|
# 分阶段明细
|
||||||
|
"pre_waste": {
|
||||||
|
"waste_hours": pre_waste_hours,
|
||||||
|
"details": pre_details,
|
||||||
|
},
|
||||||
|
"production_waste": {
|
||||||
|
"test_waste_seconds": round(test_waste, 1),
|
||||||
|
"overproduction_waste_seconds": round(overproduction_waste, 1),
|
||||||
|
"total_waste_seconds": round(production_total_waste, 1),
|
||||||
|
},
|
||||||
|
"post_waste": {
|
||||||
|
"days_waste_hours": post_days_waste_hours,
|
||||||
|
"details": post_details,
|
||||||
|
},
|
||||||
|
# 汇总
|
||||||
|
"total_waste_seconds": round(total_waste_seconds, 1),
|
||||||
|
"total_waste_hours": total_waste_hours,
|
||||||
|
"waste_rate": waste_rate,
|
||||||
|
# 兼容旧字段
|
||||||
"test_waste_seconds": round(test_waste, 1),
|
"test_waste_seconds": round(test_waste, 1),
|
||||||
"overproduction_waste_seconds": round(overproduction_waste, 1),
|
"overproduction_waste_seconds": round(overproduction_waste, 1),
|
||||||
"total_waste_seconds": round(total_waste, 1),
|
|
||||||
"waste_rate": waste_rate,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI, HTTPException
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
@ -13,6 +13,7 @@ from models import (
|
|||||||
)
|
)
|
||||||
from auth import hash_password
|
from auth import hash_password
|
||||||
from sqlalchemy.orm.attributes import flag_modified
|
from sqlalchemy.orm.attributes import flag_modified
|
||||||
|
from sqlalchemy import text, inspect
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
@ -76,6 +77,9 @@ if os.path.exists(frontend_dir):
|
|||||||
|
|
||||||
@app.get("/{full_path:path}")
|
@app.get("/{full_path:path}")
|
||||||
async def serve_frontend(full_path: str):
|
async def serve_frontend(full_path: str):
|
||||||
|
# 不拦截 API 路径,让 FastAPI 路由正常处理(含 redirect_slashes)
|
||||||
|
if full_path.startswith("api/") or full_path == "api":
|
||||||
|
raise HTTPException(status_code=404, detail="Not Found")
|
||||||
file_path = os.path.join(frontend_dir, full_path)
|
file_path = os.path.join(frontend_dir, full_path)
|
||||||
if os.path.isfile(file_path):
|
if os.path.isfile(file_path):
|
||||||
return FileResponse(file_path)
|
return FileResponse(file_path)
|
||||||
@ -109,6 +113,50 @@ def init_roles_and_admin():
|
|||||||
from database import SessionLocal
|
from database import SessionLocal
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
|
# ── 数据库结构迁移(幂等,必须在 ORM 查询之前) ──
|
||||||
|
try:
|
||||||
|
inspector = inspect(engine)
|
||||||
|
ms_cols = [c['name'] for c in inspector.get_columns('project_milestones')]
|
||||||
|
sub_cols = [c['name'] for c in inspector.get_columns('submissions')]
|
||||||
|
|
||||||
|
with engine.connect() as conn:
|
||||||
|
# ProjectMilestone 新字段
|
||||||
|
if 'estimated_days' not in ms_cols:
|
||||||
|
conn.execute(text("ALTER TABLE project_milestones ADD COLUMN estimated_days INT NULL"))
|
||||||
|
conn.execute(text("ALTER TABLE project_milestones ADD COLUMN start_date DATE NULL"))
|
||||||
|
conn.commit()
|
||||||
|
print("[MIGRATE] added estimated_days, start_date to project_milestones")
|
||||||
|
|
||||||
|
# Submission 新字段
|
||||||
|
if 'milestone_id' not in sub_cols:
|
||||||
|
conn.execute(text("ALTER TABLE submissions ADD COLUMN milestone_id INT NULL"))
|
||||||
|
conn.execute(text("ALTER TABLE submissions ADD COLUMN delay_reason TEXT NULL"))
|
||||||
|
conn.commit()
|
||||||
|
print("[MIGRATE] added milestone_id, delay_reason to submissions")
|
||||||
|
|
||||||
|
# MySQL: 扩展 content_type 枚举(使用 Python enum 名称)+ 旧值迁移
|
||||||
|
from config import DATABASE_URL
|
||||||
|
if not DATABASE_URL.startswith("sqlite"):
|
||||||
|
try:
|
||||||
|
conn.execute(text("""
|
||||||
|
ALTER TABLE submissions MODIFY COLUMN content_type
|
||||||
|
ENUM('PLANNING','SCRIPT','STORYBOARD','CHARACTER_DESIGN','SCENE_DESIGN',
|
||||||
|
'ANIMATION','DUBBING','SOUND_EFFECTS','SHOT_REPAIR','EDITING','OTHER',
|
||||||
|
'DESIGN') NOT NULL
|
||||||
|
"""))
|
||||||
|
conn.commit()
|
||||||
|
print("[MIGRATE] expanded content_type enum")
|
||||||
|
except Exception:
|
||||||
|
pass # 已经扩展过
|
||||||
|
|
||||||
|
# 旧值迁移:DESIGN → PLANNING
|
||||||
|
r1 = conn.execute(text("UPDATE submissions SET content_type='ANIMATION' WHERE content_type='DESIGN'"))
|
||||||
|
conn.commit()
|
||||||
|
if (r1.rowcount or 0) > 0:
|
||||||
|
print(f"[MIGRATE] remapped {r1.rowcount} old DESIGN → ANIMATION")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[MIGRATE] schema migration error (non-fatal): {e}")
|
||||||
|
|
||||||
# 初始化 / 同步内置角色权限
|
# 初始化 / 同步内置角色权限
|
||||||
for role_name, role_def in BUILTIN_ROLES.items():
|
for role_name, role_def in BUILTIN_ROLES.items():
|
||||||
existing = db.query(Role).filter(Role.name == role_name).first()
|
existing = db.query(Role).filter(Role.name == role_name).first()
|
||||||
@ -165,6 +213,19 @@ def init_roles_and_admin():
|
|||||||
print(f"[MIGRATE] added default milestones for project: {proj.name}")
|
print(f"[MIGRATE] added default milestones for project: {proj.name}")
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
# 为已有项目补充"剪辑"里程碑(如缺失)
|
||||||
|
for proj in db.query(Project).all():
|
||||||
|
has_edit = db.query(ProjectMilestone).filter(
|
||||||
|
ProjectMilestone.project_id == proj.id,
|
||||||
|
ProjectMilestone.name == "剪辑",
|
||||||
|
).first()
|
||||||
|
if not has_edit:
|
||||||
|
db.add(ProjectMilestone(
|
||||||
|
project_id=proj.id, name="剪辑",
|
||||||
|
phase=PhaseGroup("后期"), sort_order=4,
|
||||||
|
))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
# 迁移:为旧用户补充默认 role_id(成员角色)
|
# 迁移:为旧用户补充默认 role_id(成员角色)
|
||||||
member_role = db.query(Role).filter(Role.name == "成员").first()
|
member_role = db.query(Role).filter(Role.name == "成员").first()
|
||||||
if member_role:
|
if member_role:
|
||||||
|
|||||||
@ -124,9 +124,20 @@ class WorkType(str, enum.Enum):
|
|||||||
|
|
||||||
|
|
||||||
class ContentType(str, enum.Enum):
|
class ContentType(str, enum.Enum):
|
||||||
ANIMATION = "内容制作"
|
# 前期
|
||||||
DESIGN = "设定策划"
|
PLANNING = "策划案"
|
||||||
EDITING = "剪辑后期"
|
SCRIPT = "剧本"
|
||||||
|
STORYBOARD = "分镜"
|
||||||
|
CHARACTER_DESIGN = "人设图"
|
||||||
|
SCENE_DESIGN = "场景图"
|
||||||
|
# 制作
|
||||||
|
ANIMATION = "动画制作"
|
||||||
|
# 后期
|
||||||
|
DUBBING = "配音"
|
||||||
|
SOUND_EFFECTS = "音效"
|
||||||
|
SHOT_REPAIR = "修补镜头"
|
||||||
|
EDITING = "剪辑"
|
||||||
|
# 通用
|
||||||
OTHER = "其他"
|
OTHER = "其他"
|
||||||
|
|
||||||
|
|
||||||
@ -267,10 +278,13 @@ class Submission(Base):
|
|||||||
submit_to = Column(SAEnum(SubmitTo), nullable=False)
|
submit_to = Column(SAEnum(SubmitTo), nullable=False)
|
||||||
description = Column(Text, nullable=True)
|
description = Column(Text, nullable=True)
|
||||||
submit_date = Column(Date, nullable=False)
|
submit_date = Column(Date, nullable=False)
|
||||||
|
milestone_id = Column(Integer, ForeignKey("project_milestones.id"), nullable=True)
|
||||||
|
delay_reason = Column(Text, nullable=True)
|
||||||
created_at = Column(DateTime, server_default=func.now())
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
|
||||||
user = relationship("User", back_populates="submissions")
|
user = relationship("User", back_populates="submissions")
|
||||||
project = relationship("Project", back_populates="submissions")
|
project = relationship("Project", back_populates="submissions")
|
||||||
|
milestone = relationship("ProjectMilestone")
|
||||||
history = relationship("SubmissionHistory", back_populates="submission")
|
history = relationship("SubmissionHistory", back_populates="submission")
|
||||||
|
|
||||||
|
|
||||||
@ -379,6 +393,8 @@ class ProjectMilestone(Base):
|
|||||||
is_completed = Column(Integer, nullable=False, default=0) # 0/1
|
is_completed = Column(Integer, nullable=False, default=0) # 0/1
|
||||||
completed_at = Column(DateTime, nullable=True)
|
completed_at = Column(DateTime, nullable=True)
|
||||||
sort_order = Column(Integer, nullable=False, default=0)
|
sort_order = Column(Integer, nullable=False, default=0)
|
||||||
|
estimated_days = Column(Integer, nullable=True) # 预估工作日
|
||||||
|
start_date = Column(Date, nullable=True) # 开始日期
|
||||||
|
|
||||||
project = relationship("Project", back_populates="milestones")
|
project = relationship("Project", back_populates="milestones")
|
||||||
|
|
||||||
@ -395,5 +411,14 @@ DEFAULT_MILESTONES = [
|
|||||||
{"name": "配音", "phase": "后期", "sort_order": 1},
|
{"name": "配音", "phase": "后期", "sort_order": 1},
|
||||||
{"name": "音效", "phase": "后期", "sort_order": 2},
|
{"name": "音效", "phase": "后期", "sort_order": 2},
|
||||||
{"name": "修补镜头", "phase": "后期", "sort_order": 3},
|
{"name": "修补镜头", "phase": "后期", "sort_order": 3},
|
||||||
{"name": "杂项", "phase": "后期", "sort_order": 4},
|
{"name": "剪辑", "phase": "后期", "sort_order": 4},
|
||||||
|
{"name": "杂项", "phase": "后期", "sort_order": 5},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# 内容类型 → 阶段映射(用于自动设置阶段和关联里程碑)
|
||||||
|
CONTENT_PHASE_MAP = {
|
||||||
|
"策划案": "前期", "剧本": "前期", "分镜": "前期",
|
||||||
|
"人设图": "前期", "场景图": "前期",
|
||||||
|
"动画制作": "制作",
|
||||||
|
"配音": "后期", "音效": "后期", "修补镜头": "后期", "剪辑": "后期",
|
||||||
|
}
|
||||||
|
|||||||
@ -88,6 +88,7 @@ def get_dashboard(
|
|||||||
"target_seconds": target,
|
"target_seconds": target,
|
||||||
"submitted_seconds": total_secs,
|
"submitted_seconds": total_secs,
|
||||||
"waste_rate": waste.get("waste_rate", 0),
|
"waste_rate": waste.get("waste_rate", 0),
|
||||||
|
"waste_hours": waste.get("total_waste_hours", 0),
|
||||||
"is_overdue": bool(is_overdue),
|
"is_overdue": bool(is_overdue),
|
||||||
"estimated_completion_date": str(p.estimated_completion_date) if p.estimated_completion_date else None,
|
"estimated_completion_date": str(p.estimated_completion_date) if p.estimated_completion_date else None,
|
||||||
})
|
})
|
||||||
@ -95,16 +96,19 @@ def get_dashboard(
|
|||||||
# 损耗排行(含废弃项目,废弃项目全部产出记为损耗)
|
# 损耗排行(含废弃项目,废弃项目全部产出记为损耗)
|
||||||
waste_ranking = []
|
waste_ranking = []
|
||||||
total_waste_seconds_all = 0.0
|
total_waste_seconds_all = 0.0
|
||||||
|
total_waste_hours_all = 0.0
|
||||||
total_target_seconds_all = 0.0
|
total_target_seconds_all = 0.0
|
||||||
for p in active + completed + abandoned:
|
for p in active + completed + abandoned:
|
||||||
w = calc_waste_for_project(p.id, db)
|
w = calc_waste_for_project(p.id, db)
|
||||||
total_waste_seconds_all += w.get("total_waste_seconds", 0)
|
total_waste_seconds_all += w.get("total_waste_seconds", 0)
|
||||||
|
total_waste_hours_all += w.get("total_waste_hours", 0)
|
||||||
total_target_seconds_all += p.target_total_seconds or 0
|
total_target_seconds_all += p.target_total_seconds or 0
|
||||||
if w.get("total_waste_seconds", 0) > 0:
|
if w.get("total_waste_seconds", 0) > 0 or w.get("total_waste_hours", 0) > 0:
|
||||||
waste_ranking.append({
|
waste_ranking.append({
|
||||||
"project_id": p.id,
|
"project_id": p.id,
|
||||||
"project_name": p.name,
|
"project_name": p.name,
|
||||||
"waste_seconds": w["total_waste_seconds"],
|
"waste_seconds": w["total_waste_seconds"],
|
||||||
|
"waste_hours": w.get("total_waste_hours", 0),
|
||||||
"waste_rate": w["waste_rate"],
|
"waste_rate": w["waste_rate"],
|
||||||
})
|
})
|
||||||
waste_ranking.sort(key=lambda x: x["waste_rate"], reverse=True)
|
waste_ranking.sort(key=lambda x: x["waste_rate"], reverse=True)
|
||||||
@ -217,6 +221,7 @@ def get_dashboard(
|
|||||||
"avg_daily_seconds_per_person": avg_daily,
|
"avg_daily_seconds_per_person": avg_daily,
|
||||||
"projects": project_summaries,
|
"projects": project_summaries,
|
||||||
"total_waste_seconds": round(total_waste_seconds_all, 0),
|
"total_waste_seconds": round(total_waste_seconds_all, 0),
|
||||||
|
"total_waste_hours": round(total_waste_hours_all, 0),
|
||||||
"total_waste_rate": total_waste_rate,
|
"total_waste_rate": total_waste_rate,
|
||||||
"waste_ranking": waste_ranking,
|
"waste_ranking": waste_ranking,
|
||||||
"settled_projects": settled,
|
"settled_projects": settled,
|
||||||
|
|||||||
@ -12,16 +12,41 @@ from models import (
|
|||||||
)
|
)
|
||||||
from schemas import (
|
from schemas import (
|
||||||
ProjectCreate, ProjectUpdate, ProjectOut,
|
ProjectCreate, ProjectUpdate, ProjectOut,
|
||||||
MilestoneOut, MilestoneCreate
|
MilestoneOut, MilestoneCreate, MilestoneUpdate
|
||||||
)
|
)
|
||||||
|
from calculations import calc_waste_for_project, _working_days_between
|
||||||
from auth import get_current_user, require_permission
|
from auth import get_current_user, require_permission
|
||||||
|
from datetime import date as date_type
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/projects", tags=["项目管理"])
|
router = APIRouter(prefix="/api/projects", tags=["项目管理"])
|
||||||
|
|
||||||
|
|
||||||
|
def _build_milestone_out(m) -> MilestoneOut:
|
||||||
|
"""构造里程碑输出(含计算字段)"""
|
||||||
|
today = date_type.today()
|
||||||
|
actual_days = None
|
||||||
|
is_overdue = False
|
||||||
|
if m.start_date:
|
||||||
|
end = m.completed_at if m.is_completed and m.completed_at else today
|
||||||
|
actual_days = _working_days_between(m.start_date, end)
|
||||||
|
if m.estimated_days and actual_days > m.estimated_days:
|
||||||
|
is_overdue = True
|
||||||
|
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,
|
||||||
|
estimated_days=m.estimated_days,
|
||||||
|
start_date=m.start_date,
|
||||||
|
actual_days=actual_days,
|
||||||
|
is_overdue=is_overdue,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def enrich_project(p: Project, db: Session) -> ProjectOut:
|
def enrich_project(p: Project, db: Session) -> ProjectOut:
|
||||||
"""将项目对象转为带计算字段的输出"""
|
"""将项目对象转为带计算字段的输出"""
|
||||||
# 累计提交秒数(仅有秒数的提交)
|
# 累计提交秒数
|
||||||
total_secs = db.query(sa_func.sum(Submission.total_seconds)).filter(
|
total_secs = db.query(sa_func.sum(Submission.total_seconds)).filter(
|
||||||
Submission.project_id == p.id,
|
Submission.project_id == p.id,
|
||||||
Submission.total_seconds > 0
|
Submission.total_seconds > 0
|
||||||
@ -30,15 +55,11 @@ def enrich_project(p: Project, db: Session) -> ProjectOut:
|
|||||||
target = p.target_total_seconds
|
target = p.target_total_seconds
|
||||||
progress = round(total_secs / target * 100, 1) if target > 0 else 0
|
progress = round(total_secs / target * 100, 1) if target > 0 else 0
|
||||||
|
|
||||||
# 损耗 = 测试损耗 + 超产损耗(排除测试秒数避免双重计数)
|
# 集中损耗计算
|
||||||
test_secs = db.query(sa_func.sum(Submission.total_seconds)).filter(
|
waste_data = calc_waste_for_project(p.id, db)
|
||||||
Submission.project_id == p.id,
|
waste_seconds = waste_data.get("total_waste_seconds", 0)
|
||||||
Submission.work_type == WorkType.TEST
|
waste_hours = waste_data.get("total_waste_hours", 0)
|
||||||
).scalar() or 0
|
waste_rate = waste_data.get("waste_rate", 0)
|
||||||
production_secs = total_secs - test_secs
|
|
||||||
overproduction = max(0, production_secs - target)
|
|
||||||
waste = test_secs + overproduction
|
|
||||||
waste_rate = round(waste / target * 100, 1) if target > 0 else 0
|
|
||||||
|
|
||||||
leader_name = p.leader.name if p.leader else None
|
leader_name = p.leader.name if p.leader else None
|
||||||
|
|
||||||
@ -47,16 +68,7 @@ def enrich_project(p: Project, db: Session) -> ProjectOut:
|
|||||||
ProjectMilestone.project_id == p.id
|
ProjectMilestone.project_id == p.id
|
||||||
).order_by(ProjectMilestone.phase, ProjectMilestone.sort_order).all()
|
).order_by(ProjectMilestone.phase, ProjectMilestone.sort_order).all()
|
||||||
|
|
||||||
milestones_out = [
|
milestones_out = [_build_milestone_out(m) for m in ms_rows]
|
||||||
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) == "前期"]
|
pre_ms = [m for m in ms_rows if (m.phase.value if hasattr(m.phase, 'value') else m.phase) == "前期"]
|
||||||
@ -65,13 +77,20 @@ def enrich_project(p: Project, db: Session) -> ProjectOut:
|
|||||||
post_completed = sum(1 for m in post_ms if m.is_completed)
|
post_completed = sum(1 for m in post_ms if m.is_completed)
|
||||||
|
|
||||||
phase_summary = {
|
phase_summary = {
|
||||||
"pre": {"total": len(pre_ms), "completed": pre_completed},
|
"pre": {
|
||||||
|
"total": len(pre_ms), "completed": pre_completed,
|
||||||
|
"waste_hours": waste_data.get("pre_waste", {}).get("waste_hours", 0),
|
||||||
|
},
|
||||||
"production": {
|
"production": {
|
||||||
"progress_percent": progress,
|
"progress_percent": progress,
|
||||||
"submitted_seconds": round(total_secs, 1),
|
"submitted_seconds": round(total_secs, 1),
|
||||||
"target_seconds": target,
|
"target_seconds": target,
|
||||||
|
"waste": waste_data.get("production_waste", {}),
|
||||||
|
},
|
||||||
|
"post": {
|
||||||
|
"total": len(post_ms), "completed": post_completed,
|
||||||
|
"waste": waste_data.get("post_waste", {}),
|
||||||
},
|
},
|
||||||
"post": {"total": len(post_ms), "completed": post_completed},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# 自动推断当前阶段
|
# 自动推断当前阶段
|
||||||
@ -99,7 +118,8 @@ def enrich_project(p: Project, db: Session) -> ProjectOut:
|
|||||||
created_at=p.created_at,
|
created_at=p.created_at,
|
||||||
total_submitted_seconds=round(total_secs, 1),
|
total_submitted_seconds=round(total_secs, 1),
|
||||||
progress_percent=progress,
|
progress_percent=progress,
|
||||||
waste_seconds=round(waste, 1),
|
waste_seconds=round(waste_seconds, 1),
|
||||||
|
waste_hours=waste_hours,
|
||||||
waste_rate=waste_rate,
|
waste_rate=waste_rate,
|
||||||
milestones=milestones_out,
|
milestones=milestones_out,
|
||||||
phase_summary=phase_summary,
|
phase_summary=phase_summary,
|
||||||
@ -107,7 +127,7 @@ def enrich_project(p: Project, db: Session) -> ProjectOut:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_model=List[ProjectOut])
|
@router.get("", response_model=List[ProjectOut])
|
||||||
def list_projects(
|
def list_projects(
|
||||||
status: Optional[str] = Query(None),
|
status: Optional[str] = Query(None),
|
||||||
project_type: Optional[str] = Query(None),
|
project_type: Optional[str] = Query(None),
|
||||||
@ -123,7 +143,7 @@ def list_projects(
|
|||||||
return [enrich_project(p, db) for p in projects]
|
return [enrich_project(p, db) for p in projects]
|
||||||
|
|
||||||
|
|
||||||
@router.post("/", response_model=ProjectOut)
|
@router.post("", response_model=ProjectOut)
|
||||||
def create_project(
|
def create_project(
|
||||||
req: ProjectCreate,
|
req: ProjectCreate,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
@ -260,16 +280,7 @@ def list_milestones(
|
|||||||
ms = db.query(ProjectMilestone).filter(
|
ms = db.query(ProjectMilestone).filter(
|
||||||
ProjectMilestone.project_id == project_id
|
ProjectMilestone.project_id == project_id
|
||||||
).order_by(ProjectMilestone.phase, ProjectMilestone.sort_order).all()
|
).order_by(ProjectMilestone.phase, ProjectMilestone.sort_order).all()
|
||||||
return [
|
return [_build_milestone_out(m) for m in ms]
|
||||||
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)
|
@router.post("/{project_id}/milestones", response_model=MilestoneOut)
|
||||||
@ -292,15 +303,12 @@ def add_milestone(
|
|||||||
name=req.name,
|
name=req.name,
|
||||||
phase=PhaseGroup(req.phase),
|
phase=PhaseGroup(req.phase),
|
||||||
sort_order=max_order + 1,
|
sort_order=max_order + 1,
|
||||||
|
estimated_days=req.estimated_days,
|
||||||
)
|
)
|
||||||
db.add(m)
|
db.add(m)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(m)
|
db.refresh(m)
|
||||||
return MilestoneOut(
|
return _build_milestone_out(m)
|
||||||
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")
|
@router.put("/milestones/{milestone_id}/toggle")
|
||||||
@ -318,6 +326,25 @@ def toggle_milestone(
|
|||||||
return {"id": m.id, "is_completed": bool(m.is_completed)}
|
return {"id": m.id, "is_completed": bool(m.is_completed)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/milestones/{milestone_id}")
|
||||||
|
def update_milestone(
|
||||||
|
milestone_id: int,
|
||||||
|
req: MilestoneUpdate,
|
||||||
|
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="里程碑不存在")
|
||||||
|
if req.estimated_days is not None:
|
||||||
|
m.estimated_days = req.estimated_days
|
||||||
|
if req.start_date is not None:
|
||||||
|
m.start_date = req.start_date
|
||||||
|
db.commit()
|
||||||
|
db.refresh(m)
|
||||||
|
return _build_milestone_out(m)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/milestones/{milestone_id}")
|
@router.delete("/milestones/{milestone_id}")
|
||||||
def delete_milestone(
|
def delete_milestone(
|
||||||
milestone_id: int,
|
milestone_id: int,
|
||||||
|
|||||||
@ -20,7 +20,7 @@ def get_all_permissions(current_user: User = Depends(get_current_user)):
|
|||||||
return [{"group": g, "permissions": perms} for g, perms in groups.items()]
|
return [{"group": g, "permissions": perms} for g, perms in groups.items()]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/")
|
@router.get("")
|
||||||
def list_roles(
|
def list_roles(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user)
|
current_user: User = Depends(get_current_user)
|
||||||
@ -40,7 +40,7 @@ def list_roles(
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@router.post("/")
|
@router.post("")
|
||||||
def create_role(
|
def create_role(
|
||||||
req: dict,
|
req: dict,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
|||||||
@ -5,9 +5,10 @@ from typing import List, Optional
|
|||||||
from datetime import date
|
from datetime import date
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from models import (
|
from models import (
|
||||||
User, Submission, SubmissionHistory, Project,
|
User, Submission, SubmissionHistory, Project, ProjectMilestone,
|
||||||
PhaseGroup, WorkType, ContentType, SubmitTo
|
PhaseGroup, WorkType, ContentType, SubmitTo, CONTENT_PHASE_MAP
|
||||||
)
|
)
|
||||||
|
from datetime import timedelta
|
||||||
from schemas import SubmissionCreate, SubmissionUpdate, SubmissionOut
|
from schemas import SubmissionCreate, SubmissionUpdate, SubmissionOut
|
||||||
from auth import get_current_user, require_permission
|
from auth import get_current_user, require_permission
|
||||||
|
|
||||||
@ -30,11 +31,13 @@ def submission_to_out(s: Submission) -> SubmissionOut:
|
|||||||
submit_to=s.submit_to.value if hasattr(s.submit_to, 'value') else s.submit_to,
|
submit_to=s.submit_to.value if hasattr(s.submit_to, 'value') else s.submit_to,
|
||||||
description=s.description,
|
description=s.description,
|
||||||
submit_date=s.submit_date,
|
submit_date=s.submit_date,
|
||||||
|
milestone_name=s.milestone.name if s.milestone else None,
|
||||||
|
delay_reason=s.delay_reason,
|
||||||
created_at=s.created_at,
|
created_at=s.created_at,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_model=List[SubmissionOut])
|
@router.get("", response_model=List[SubmissionOut])
|
||||||
def list_submissions(
|
def list_submissions(
|
||||||
project_id: Optional[int] = Query(None),
|
project_id: Optional[int] = Query(None),
|
||||||
user_id: Optional[int] = Query(None),
|
user_id: Optional[int] = Query(None),
|
||||||
@ -62,7 +65,7 @@ def list_submissions(
|
|||||||
return [submission_to_out(s) for s in subs]
|
return [submission_to_out(s) for s in subs]
|
||||||
|
|
||||||
|
|
||||||
@router.post("/", response_model=SubmissionOut)
|
@router.post("", response_model=SubmissionOut)
|
||||||
def create_submission(
|
def create_submission(
|
||||||
req: SubmissionCreate,
|
req: SubmissionCreate,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
@ -76,6 +79,25 @@ def create_submission(
|
|||||||
# 自动计算总秒数
|
# 自动计算总秒数
|
||||||
total_seconds = (req.duration_minutes or 0) * 60 + (req.duration_seconds or 0)
|
total_seconds = (req.duration_minutes or 0) * 60 + (req.duration_seconds or 0)
|
||||||
|
|
||||||
|
# 自动关联里程碑:根据 content_type 匹配同名里程碑
|
||||||
|
milestone_id = None
|
||||||
|
content_val = req.content_type
|
||||||
|
if content_val in CONTENT_PHASE_MAP:
|
||||||
|
ms = db.query(ProjectMilestone).filter(
|
||||||
|
ProjectMilestone.project_id == req.project_id,
|
||||||
|
ProjectMilestone.name == content_val,
|
||||||
|
).first()
|
||||||
|
if ms:
|
||||||
|
milestone_id = ms.id
|
||||||
|
# 超期校验:如果里程碑已超期,必须填写延期原因
|
||||||
|
if ms.estimated_days and ms.start_date:
|
||||||
|
expected_end = ms.start_date + timedelta(days=ms.estimated_days)
|
||||||
|
if req.submit_date > expected_end and not req.delay_reason:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail=f"里程碑「{ms.name}」已超期(预估{ms.estimated_days}天),请填写延期原因"
|
||||||
|
)
|
||||||
|
|
||||||
sub = Submission(
|
sub = Submission(
|
||||||
user_id=current_user.id,
|
user_id=current_user.id,
|
||||||
project_id=req.project_id,
|
project_id=req.project_id,
|
||||||
@ -89,6 +111,8 @@ def create_submission(
|
|||||||
submit_to=SubmitTo(req.submit_to),
|
submit_to=SubmitTo(req.submit_to),
|
||||||
description=req.description,
|
description=req.description,
|
||||||
submit_date=req.submit_date,
|
submit_date=req.submit_date,
|
||||||
|
milestone_id=milestone_id,
|
||||||
|
delay_reason=req.delay_reason,
|
||||||
)
|
)
|
||||||
db.add(sub)
|
db.add(sub)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@ -24,7 +24,7 @@ def user_to_out(u: User) -> UserOut:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_model=List[UserOut])
|
@router.get("", response_model=List[UserOut])
|
||||||
def list_users(
|
def list_users(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(require_permission("user:view"))
|
current_user: User = Depends(require_permission("user:view"))
|
||||||
@ -33,7 +33,7 @@ def list_users(
|
|||||||
return [user_to_out(u) for u in users]
|
return [user_to_out(u) for u in users]
|
||||||
|
|
||||||
|
|
||||||
@router.post("/", response_model=UserOut)
|
@router.post("", response_model=UserOut)
|
||||||
def create_user(
|
def create_user(
|
||||||
req: UserCreate,
|
req: UserCreate,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
|||||||
@ -68,6 +68,10 @@ class MilestoneOut(BaseModel):
|
|||||||
is_completed: bool
|
is_completed: bool
|
||||||
completed_at: Optional[datetime] = None
|
completed_at: Optional[datetime] = None
|
||||||
sort_order: int
|
sort_order: int
|
||||||
|
estimated_days: Optional[int] = None
|
||||||
|
start_date: Optional[date] = None
|
||||||
|
actual_days: Optional[int] = None # 计算值
|
||||||
|
is_overdue: Optional[bool] = False # 计算值
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
@ -76,6 +80,12 @@ class MilestoneOut(BaseModel):
|
|||||||
class MilestoneCreate(BaseModel):
|
class MilestoneCreate(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
phase: str # 前期/后期
|
phase: str # 前期/后期
|
||||||
|
estimated_days: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class MilestoneUpdate(BaseModel):
|
||||||
|
estimated_days: Optional[int] = None
|
||||||
|
start_date: Optional[date] = None
|
||||||
|
|
||||||
|
|
||||||
class ProjectCreate(BaseModel):
|
class ProjectCreate(BaseModel):
|
||||||
@ -122,6 +132,7 @@ class ProjectOut(BaseModel):
|
|||||||
total_submitted_seconds: Optional[float] = 0
|
total_submitted_seconds: Optional[float] = 0
|
||||||
progress_percent: Optional[float] = 0
|
progress_percent: Optional[float] = 0
|
||||||
waste_seconds: Optional[float] = 0
|
waste_seconds: Optional[float] = 0
|
||||||
|
waste_hours: Optional[float] = 0
|
||||||
waste_rate: Optional[float] = 0
|
waste_rate: Optional[float] = 0
|
||||||
# 里程碑
|
# 里程碑
|
||||||
milestones: Optional[List[MilestoneOut]] = []
|
milestones: Optional[List[MilestoneOut]] = []
|
||||||
@ -145,6 +156,7 @@ class SubmissionCreate(BaseModel):
|
|||||||
submit_to: str
|
submit_to: str
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
submit_date: date
|
submit_date: date
|
||||||
|
delay_reason: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class SubmissionUpdate(BaseModel):
|
class SubmissionUpdate(BaseModel):
|
||||||
@ -176,6 +188,8 @@ class SubmissionOut(BaseModel):
|
|||||||
submit_to: str
|
submit_to: str
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
submit_date: date
|
submit_date: date
|
||||||
|
milestone_name: Optional[str] = None
|
||||||
|
delay_reason: Optional[str] = None
|
||||||
created_at: Optional[datetime] = None
|
created_at: Optional[datetime] = None
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
|
|||||||
@ -1,6 +1,15 @@
|
|||||||
"""补充演示数据 - 只添加项目/提交/成本,不动用户和角色"""
|
"""补充演示数据 - 只添加项目/提交/成本,不动用户和角色
|
||||||
from datetime import date, timedelta
|
展示场景:
|
||||||
from database import SessionLocal, engine
|
1. 双色进度条(品牌方 TVC 进度>200%)
|
||||||
|
2. 工时损耗(分镜/剪辑里程碑超期)
|
||||||
|
3. 秒数损耗(测试提交 + 超产)
|
||||||
|
4. 四种项目类型 + 五个项目
|
||||||
|
5. 前期必须结束才有制作提交
|
||||||
|
6. 内部原创配有风格测试项目
|
||||||
|
7. owners / 制片 不提交
|
||||||
|
"""
|
||||||
|
from datetime import date, datetime, timedelta
|
||||||
|
from database import SessionLocal
|
||||||
from models import *
|
from models import *
|
||||||
|
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
@ -13,6 +22,11 @@ def get_user(username):
|
|||||||
return u
|
return u
|
||||||
|
|
||||||
|
|
||||||
|
def dt(d):
|
||||||
|
"""date → datetime(用于 completed_at)"""
|
||||||
|
return datetime.combine(d, datetime.min.time())
|
||||||
|
|
||||||
|
|
||||||
def seed_demo():
|
def seed_demo():
|
||||||
# 清除旧的项目相关数据(不动 users 和 roles)
|
# 清除旧的项目相关数据(不动 users 和 roles)
|
||||||
db.query(SubmissionHistory).delete()
|
db.query(SubmissionHistory).delete()
|
||||||
@ -21,341 +35,555 @@ def seed_demo():
|
|||||||
db.query(CostOverride).delete()
|
db.query(CostOverride).delete()
|
||||||
db.query(AIToolCost).delete()
|
db.query(AIToolCost).delete()
|
||||||
db.query(OverheadCost).delete()
|
db.query(OverheadCost).delete()
|
||||||
|
db.query(ProjectMilestone).delete()
|
||||||
db.query(Project).delete()
|
db.query(Project).delete()
|
||||||
db.commit()
|
db.commit()
|
||||||
print("[1] Cleared old project data")
|
print("[1] Cleared old project data")
|
||||||
|
|
||||||
# 获取真实用户
|
# ── 获取真实用户 ──
|
||||||
huhaonan = get_user("huhaonan") # 主管/总导演
|
# owners / 制片:不提交任何东西
|
||||||
dengqingrui = get_user("dengqingrui") # 主管/AI导演
|
huhaonan = get_user("huhaonan") # owner/总导演
|
||||||
qiushaohui = get_user("qiushaohui") # 主管/制片
|
dengqingrui = get_user("dengqingrui") # owner/AI导演
|
||||||
|
qiushaohui = get_user("qiushaohui") # 制片
|
||||||
|
|
||||||
|
# 组长:可以提交
|
||||||
chenbaodan = get_user("chenbaodan") # 组长/动画制作
|
chenbaodan = get_user("chenbaodan") # 组长/动画制作
|
||||||
maruoqing = get_user("maruoqing") # 组长/AI导演
|
maruoqing = get_user("maruoqing") # 组长/AI导演
|
||||||
weichunli = get_user("weichunli") # 组长/AI导演
|
weichunli = get_user("weichunli") # 组长/AI导演
|
||||||
panziyan = get_user("panziyan") # 组长/剪辑
|
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(
|
daixiaoqian = get_user("daixiaoqian") # 动画制作
|
||||||
name="星际漫游 第一季", project_type=ProjectType.CLIENT_FORMAL,
|
tanruping = get_user("tanruping") # 动画制作
|
||||||
leader_id=huhaonan.id, current_phase=PhaseGroup.PRODUCTION,
|
zhengyiqing = get_user("zhengyiqing") # 动画制作
|
||||||
episode_duration_minutes=5, episode_count=13,
|
huangxuewen = get_user("huangxuewen") # 动画制作
|
||||||
estimated_completion_date=date.today() + timedelta(days=60),
|
liushiqi = get_user("liushiqi") # 动画制作
|
||||||
contract_amount=100000,
|
daiwei = get_user("daiwei") # 动画制作
|
||||||
)
|
huangrongying = get_user("huangrongying") # 编剧
|
||||||
proj_b = Project(
|
jiahaozheng = get_user("jiahaozheng") # 剪辑
|
||||||
|
wangyansen = get_user("wangyansen") # 剪辑
|
||||||
|
huangqiuxia = get_user("huangqiuxia") # 动画制作
|
||||||
|
lijing = get_user("lijing") # 动画制作
|
||||||
|
yemeilian = get_user("yemeilian") # 动画制作
|
||||||
|
chenxuanying = get_user("chenxuanying") # 动画制作
|
||||||
|
|
||||||
|
today = date.today()
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
# 项目 1: 品牌方 TVC 宣传片
|
||||||
|
# 客户正式 | 后期 | target=180s | 创建于 12/8
|
||||||
|
# 进度>200% → 双色进度条
|
||||||
|
# 分镜+剪辑超期 → waste_hours
|
||||||
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
proj_tvc = Project(
|
||||||
name="品牌方 TVC 宣传片", project_type=ProjectType.CLIENT_FORMAL,
|
name="品牌方 TVC 宣传片", project_type=ProjectType.CLIENT_FORMAL,
|
||||||
leader_id=dengqingrui.id, current_phase=PhaseGroup.PRODUCTION,
|
leader_id=maruoqing.id, current_phase=PhaseGroup.POST,
|
||||||
episode_duration_minutes=1, episode_count=3,
|
episode_duration_minutes=1, episode_count=3,
|
||||||
estimated_completion_date=date.today() + timedelta(days=20),
|
estimated_completion_date=date(2026, 2, 20),
|
||||||
contract_amount=50000,
|
contract_amount=50000,
|
||||||
|
created_at=dt(date(2025, 12, 8)),
|
||||||
)
|
)
|
||||||
proj_c = Project(
|
|
||||||
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
# 项目 2: 星际漫游 第一季
|
||||||
|
# 客户正式 | 制作中 | target=3900s | 创建于 12/22
|
||||||
|
# 进度~80% | 分镜微超期 → 少量 waste_hours
|
||||||
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
proj_star = Project(
|
||||||
|
name="星际漫游 第一季", project_type=ProjectType.CLIENT_FORMAL,
|
||||||
|
leader_id=chenbaodan.id, current_phase=PhaseGroup.PRODUCTION,
|
||||||
|
episode_duration_minutes=5, episode_count=13,
|
||||||
|
estimated_completion_date=date(2026, 3, 30),
|
||||||
|
contract_amount=100000,
|
||||||
|
created_at=dt(date(2025, 12, 22)),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
# 项目 3: AI 短剧原创 S1
|
||||||
|
# 内部原创 | 制作中 | target=2880s | 创建于 1/6
|
||||||
|
# 进度~40% | 无超期 → waste_hours=0
|
||||||
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
proj_orig = Project(
|
||||||
|
name="AI 短剧原创 S1", project_type=ProjectType.INTERNAL_ORIGINAL,
|
||||||
|
leader_id=weichunli.id, current_phase=PhaseGroup.PRODUCTION,
|
||||||
|
episode_duration_minutes=8, episode_count=6,
|
||||||
|
estimated_completion_date=date(2026, 4, 30),
|
||||||
|
created_at=dt(date(2026, 1, 6)),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
# 项目 4: 原创 S1 风格测试
|
||||||
|
# 内部测试 | 制作 | target=60s | 创建于 1/2
|
||||||
|
# 内部原创的配套测试项目 | 全 TEST → 高损耗率
|
||||||
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
proj_orig_test = Project(
|
||||||
|
name="原创 S1 风格测试", project_type=ProjectType.INTERNAL_TEST,
|
||||||
|
leader_id=weichunli.id, current_phase=PhaseGroup.PRODUCTION,
|
||||||
|
episode_duration_minutes=1, episode_count=1,
|
||||||
|
created_at=dt(date(2026, 1, 2)),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
# 项目 5: 甲方风格测试
|
||||||
|
# 客户测试 | 前期 | target=60s | 创建于 1/27
|
||||||
|
# 早期阶段 | 策划案超期 → waste_hours
|
||||||
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
proj_client_test = Project(
|
||||||
name="甲方风格测试", project_type=ProjectType.CLIENT_TEST,
|
name="甲方风格测试", project_type=ProjectType.CLIENT_TEST,
|
||||||
leader_id=maruoqing.id, current_phase=PhaseGroup.PRE,
|
leader_id=maruoqing.id, current_phase=PhaseGroup.PRE,
|
||||||
episode_duration_minutes=1, episode_count=1,
|
episode_duration_minutes=1, episode_count=1,
|
||||||
|
created_at=dt(date(2026, 1, 27)),
|
||||||
)
|
)
|
||||||
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 天的数据) ──
|
db.add_all([proj_tvc, proj_star, proj_orig, proj_orig_test, proj_client_test])
|
||||||
base = date.today() - timedelta(days=20)
|
db.flush()
|
||||||
|
print("[2] Created 5 projects")
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
# 里程碑
|
||||||
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
milestones = []
|
||||||
|
|
||||||
|
# ── 品牌方 TVC 里程碑 ──
|
||||||
|
# 前期
|
||||||
|
milestones.append(ProjectMilestone(
|
||||||
|
project_id=proj_tvc.id, name="策划案", phase=PhaseGroup.PRE, sort_order=1,
|
||||||
|
estimated_days=2, start_date=date(2025, 12, 8),
|
||||||
|
is_completed=1, completed_at=dt(date(2025, 12, 9)),
|
||||||
|
))
|
||||||
|
milestones.append(ProjectMilestone(
|
||||||
|
project_id=proj_tvc.id, name="分镜", phase=PhaseGroup.PRE, sort_order=3,
|
||||||
|
estimated_days=2, start_date=date(2025, 12, 10),
|
||||||
|
is_completed=1, completed_at=dt(date(2025, 12, 15)), # 实际3天,超1天→8h
|
||||||
|
))
|
||||||
|
milestones.append(ProjectMilestone(
|
||||||
|
project_id=proj_tvc.id, name="人设图", phase=PhaseGroup.PRE, sort_order=4,
|
||||||
|
estimated_days=2, start_date=date(2025, 12, 15),
|
||||||
|
is_completed=1, completed_at=dt(date(2025, 12, 16)),
|
||||||
|
))
|
||||||
|
milestones.append(ProjectMilestone(
|
||||||
|
project_id=proj_tvc.id, name="场景图", phase=PhaseGroup.PRE, sort_order=5,
|
||||||
|
estimated_days=1, start_date=date(2025, 12, 17),
|
||||||
|
is_completed=1, completed_at=dt(date(2025, 12, 17)),
|
||||||
|
))
|
||||||
|
# 后期
|
||||||
|
milestones.append(ProjectMilestone(
|
||||||
|
project_id=proj_tvc.id, name="配音", phase=PhaseGroup.POST, sort_order=1,
|
||||||
|
estimated_days=3, start_date=date(2026, 1, 27),
|
||||||
|
is_completed=1, completed_at=dt(date(2026, 1, 29)),
|
||||||
|
))
|
||||||
|
milestones.append(ProjectMilestone(
|
||||||
|
project_id=proj_tvc.id, name="音效", phase=PhaseGroup.POST, sort_order=2,
|
||||||
|
estimated_days=2, start_date=date(2026, 1, 29),
|
||||||
|
is_completed=1, completed_at=dt(date(2026, 1, 30)),
|
||||||
|
))
|
||||||
|
milestones.append(ProjectMilestone(
|
||||||
|
project_id=proj_tvc.id, name="修补镜头", phase=PhaseGroup.POST, sort_order=3,
|
||||||
|
estimated_days=2, start_date=date(2026, 1, 27),
|
||||||
|
is_completed=1, completed_at=dt(date(2026, 1, 28)),
|
||||||
|
))
|
||||||
|
milestones.append(ProjectMilestone(
|
||||||
|
project_id=proj_tvc.id, name="剪辑", phase=PhaseGroup.POST, sort_order=4,
|
||||||
|
estimated_days=3, start_date=date(2026, 2, 3),
|
||||||
|
is_completed=0, # 进行中,多轮反馈导致超期 → waste_hours
|
||||||
|
))
|
||||||
|
|
||||||
|
# ── 星际漫游 里程碑 ──
|
||||||
|
# 前期
|
||||||
|
milestones.append(ProjectMilestone(
|
||||||
|
project_id=proj_star.id, name="策划案", phase=PhaseGroup.PRE, sort_order=1,
|
||||||
|
estimated_days=3, start_date=date(2025, 12, 22),
|
||||||
|
is_completed=1, completed_at=dt(date(2025, 12, 23)),
|
||||||
|
))
|
||||||
|
milestones.append(ProjectMilestone(
|
||||||
|
project_id=proj_star.id, name="剧本", phase=PhaseGroup.PRE, sort_order=2,
|
||||||
|
estimated_days=5, start_date=date(2025, 12, 24),
|
||||||
|
is_completed=1, completed_at=dt(date(2025, 12, 30)),
|
||||||
|
))
|
||||||
|
milestones.append(ProjectMilestone(
|
||||||
|
project_id=proj_star.id, name="分镜", phase=PhaseGroup.PRE, sort_order=3,
|
||||||
|
estimated_days=3, start_date=date(2025, 12, 31),
|
||||||
|
is_completed=1, completed_at=dt(date(2026, 1, 7)), # 实际5天,超2天→16h
|
||||||
|
))
|
||||||
|
milestones.append(ProjectMilestone(
|
||||||
|
project_id=proj_star.id, name="人设图", phase=PhaseGroup.PRE, sort_order=4,
|
||||||
|
estimated_days=2, start_date=date(2026, 1, 8),
|
||||||
|
is_completed=1, completed_at=dt(date(2026, 1, 9)),
|
||||||
|
))
|
||||||
|
milestones.append(ProjectMilestone(
|
||||||
|
project_id=proj_star.id, name="场景图", phase=PhaseGroup.PRE, sort_order=5,
|
||||||
|
estimated_days=2, start_date=date(2026, 1, 10),
|
||||||
|
is_completed=1, completed_at=dt(date(2026, 1, 13)),
|
||||||
|
))
|
||||||
|
# 后期(未开始)
|
||||||
|
for name, order in [("配音", 1), ("音效", 2), ("修补镜头", 3), ("剪辑", 4)]:
|
||||||
|
milestones.append(ProjectMilestone(
|
||||||
|
project_id=proj_star.id, name=name, phase=PhaseGroup.POST, sort_order=order,
|
||||||
|
))
|
||||||
|
|
||||||
|
# ── AI 短剧原创 里程碑 ──(全部按时)
|
||||||
|
milestones.append(ProjectMilestone(
|
||||||
|
project_id=proj_orig.id, name="策划案", phase=PhaseGroup.PRE, sort_order=1,
|
||||||
|
estimated_days=2, start_date=date(2026, 1, 6),
|
||||||
|
is_completed=1, completed_at=dt(date(2026, 1, 7)),
|
||||||
|
))
|
||||||
|
milestones.append(ProjectMilestone(
|
||||||
|
project_id=proj_orig.id, name="剧本", phase=PhaseGroup.PRE, sort_order=2,
|
||||||
|
estimated_days=5, start_date=date(2026, 1, 8),
|
||||||
|
is_completed=1, completed_at=dt(date(2026, 1, 14)),
|
||||||
|
))
|
||||||
|
milestones.append(ProjectMilestone(
|
||||||
|
project_id=proj_orig.id, name="分镜", phase=PhaseGroup.PRE, sort_order=3,
|
||||||
|
estimated_days=3, start_date=date(2026, 1, 15),
|
||||||
|
is_completed=1, completed_at=dt(date(2026, 1, 17)),
|
||||||
|
))
|
||||||
|
milestones.append(ProjectMilestone(
|
||||||
|
project_id=proj_orig.id, name="人设图", phase=PhaseGroup.PRE, sort_order=4,
|
||||||
|
estimated_days=2, start_date=date(2026, 1, 20),
|
||||||
|
is_completed=1, completed_at=dt(date(2026, 1, 21)),
|
||||||
|
))
|
||||||
|
milestones.append(ProjectMilestone(
|
||||||
|
project_id=proj_orig.id, name="场景图", phase=PhaseGroup.PRE, sort_order=5,
|
||||||
|
estimated_days=1, start_date=date(2026, 1, 22),
|
||||||
|
is_completed=1, completed_at=dt(date(2026, 1, 22)),
|
||||||
|
))
|
||||||
|
for name, order in [("配音", 1), ("音效", 2), ("修补镜头", 3), ("剪辑", 4)]:
|
||||||
|
milestones.append(ProjectMilestone(
|
||||||
|
project_id=proj_orig.id, name=name, phase=PhaseGroup.POST, sort_order=order,
|
||||||
|
))
|
||||||
|
|
||||||
|
# ── 原创风格测试 里程碑 ──
|
||||||
|
milestones.append(ProjectMilestone(
|
||||||
|
project_id=proj_orig_test.id, name="策划案", phase=PhaseGroup.PRE, sort_order=1,
|
||||||
|
estimated_days=2, start_date=date(2026, 1, 2),
|
||||||
|
is_completed=1, completed_at=dt(date(2026, 1, 3)),
|
||||||
|
))
|
||||||
|
milestones.append(ProjectMilestone(
|
||||||
|
project_id=proj_orig_test.id, name="人设图", phase=PhaseGroup.PRE, sort_order=4,
|
||||||
|
estimated_days=2, start_date=date(2026, 1, 6),
|
||||||
|
is_completed=1, completed_at=dt(date(2026, 1, 7)),
|
||||||
|
))
|
||||||
|
milestones.append(ProjectMilestone(
|
||||||
|
project_id=proj_orig_test.id, name="剪辑", phase=PhaseGroup.POST, sort_order=4,
|
||||||
|
))
|
||||||
|
|
||||||
|
# ── 甲方风格测试 里程碑 ──
|
||||||
|
milestones.append(ProjectMilestone(
|
||||||
|
project_id=proj_client_test.id, name="策划案", phase=PhaseGroup.PRE, sort_order=1,
|
||||||
|
estimated_days=3, start_date=date(2026, 1, 27),
|
||||||
|
is_completed=0, # 甲方反复修改,至今未定稿 → 超期 → waste_hours
|
||||||
|
))
|
||||||
|
milestones.append(ProjectMilestone(
|
||||||
|
project_id=proj_client_test.id, name="人设图", phase=PhaseGroup.PRE, sort_order=4,
|
||||||
|
estimated_days=2, start_date=date(2026, 2, 3),
|
||||||
|
is_completed=0,
|
||||||
|
))
|
||||||
|
milestones.append(ProjectMilestone(
|
||||||
|
project_id=proj_client_test.id, name="剪辑", phase=PhaseGroup.POST, sort_order=4,
|
||||||
|
))
|
||||||
|
|
||||||
|
db.add_all(milestones)
|
||||||
|
db.flush()
|
||||||
|
print(f"[2.5] Created {len(milestones)} milestones")
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
# 内容提交
|
||||||
|
# ══════════════════════════════════════════════════════════════
|
||||||
subs = []
|
subs = []
|
||||||
|
|
||||||
# --- 项目A:星际漫游 ---
|
def add(user, proj, phase, wt, ct, secs, d, desc, hours=None):
|
||||||
# 黄溶莹 - 编剧 - 前期方案
|
"""快捷添加提交"""
|
||||||
for i in range(6):
|
|
||||||
d = base + timedelta(days=i)
|
|
||||||
subs.append(Submission(
|
subs.append(Submission(
|
||||||
user_id=huangrongying.id, project_id=proj_a.id,
|
user_id=user.id, project_id=proj.id,
|
||||||
project_phase=PhaseGroup.PRE, work_type=WorkType.PLAN,
|
project_phase=phase, work_type=wt,
|
||||||
content_type=ContentType.DESIGN, total_seconds=0,
|
content_type=ct, total_seconds=secs,
|
||||||
submit_to=SubmitTo.INTERNAL, description=f"第{i+1}集剧本初稿",
|
duration_minutes=secs // 60 if secs else 0,
|
||||||
submit_date=d,
|
duration_seconds=secs % 60 if secs else 0,
|
||||||
|
hours_spent=hours, submit_to=SubmitTo.INTERNAL,
|
||||||
|
description=desc, submit_date=d,
|
||||||
))
|
))
|
||||||
|
|
||||||
# 陈保丹 - 组长 - 动画制作
|
PRE = PhaseGroup.PRE
|
||||||
for i in range(12):
|
PROD = PhaseGroup.PRODUCTION
|
||||||
d = base + timedelta(days=i + 3)
|
POST = PhaseGroup.POST
|
||||||
secs = 55 + (i % 3) * 20
|
PLAN = WorkType.PLAN
|
||||||
wt = WorkType.TEST if i < 2 else WorkType.PRODUCTION
|
MFG = WorkType.PRODUCTION
|
||||||
subs.append(Submission(
|
TEST = WorkType.TEST
|
||||||
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):
|
# 品牌方 TVC 宣传片 (target=180s, 创建 12/8)
|
||||||
d = base + timedelta(days=i + 2)
|
# 前期: 12/8 - 12/18
|
||||||
secs = 40 + (i % 4) * 15
|
# 制作: 12/22 - 1/10
|
||||||
subs.append(Submission(
|
# 后期: 1/27 - now
|
||||||
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,
|
|
||||||
))
|
|
||||||
|
|
||||||
# 谭如平 - 动画制作
|
# 前期 - 策划案 (12/8-9)
|
||||||
for i in range(8):
|
add(huangrongying, proj_tvc, PRE, PLAN, ContentType.PLANNING, 0, date(2025, 12, 8), "TVC 策划案初稿", 4)
|
||||||
d = base + timedelta(days=i + 4)
|
add(huangrongying, proj_tvc, PRE, PLAN, ContentType.PLANNING, 0, date(2025, 12, 9), "TVC 策划案定稿", 3)
|
||||||
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,
|
|
||||||
))
|
|
||||||
|
|
||||||
# 郑奕晴 - 动画制作
|
# 前期 - 分镜 (12/10-15, 比预期多1天)
|
||||||
for i in range(9):
|
add(daixiaoqian, proj_tvc, PRE, PLAN, ContentType.STORYBOARD, 0, date(2025, 12, 10), "TVC 分镜初稿", 5)
|
||||||
d = base + timedelta(days=i + 3)
|
add(daixiaoqian, proj_tvc, PRE, PLAN, ContentType.STORYBOARD, 0, date(2025, 12, 11), "TVC 分镜修改", 4)
|
||||||
secs = 45 + (i % 2) * 30
|
add(daixiaoqian, proj_tvc, PRE, PLAN, ContentType.STORYBOARD, 0, date(2025, 12, 15), "TVC 分镜终稿", 3)
|
||||||
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,
|
|
||||||
))
|
|
||||||
|
|
||||||
# 黄雪雯 - 动画制作
|
# 前期 - 人设图 (12/15-16)
|
||||||
for i in range(7):
|
add(huangxuewen, proj_tvc, PRE, PLAN, ContentType.CHARACTER_DESIGN, 0, date(2025, 12, 15), "TVC 人设图初稿", 5)
|
||||||
d = base + timedelta(days=i + 5)
|
add(huangxuewen, proj_tvc, PRE, PLAN, ContentType.CHARACTER_DESIGN, 0, date(2025, 12, 16), "TVC 人设图定稿", 3)
|
||||||
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,
|
|
||||||
))
|
|
||||||
|
|
||||||
# 潘梓彦 - 剪辑 - 后期
|
# 前期 - 场景图 (12/17)
|
||||||
for i in range(4):
|
add(huangxuewen, proj_tvc, PRE, PLAN, ContentType.SCENE_DESIGN, 0, date(2025, 12, 17), "TVC 场景图", 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,
|
|
||||||
))
|
|
||||||
|
|
||||||
# 贾浩正 - 剪辑
|
# 制作 - 马若情 (12/22 - 12/26 第一轮, 1/8-9 修改轮)
|
||||||
for i in range(3):
|
add(maruoqing, proj_tvc, PROD, TEST, ContentType.ANIMATION, 35, date(2025, 12, 22), "TVC 片段测试", 3)
|
||||||
d = base + timedelta(days=i + 15)
|
add(maruoqing, proj_tvc, PROD, MFG, ContentType.ANIMATION, 70, date(2025, 12, 23), "TVC 第1集场景1", 4)
|
||||||
subs.append(Submission(
|
add(maruoqing, proj_tvc, PROD, MFG, ContentType.ANIMATION, 65, date(2025, 12, 24), "TVC 第1集场景2", 4)
|
||||||
user_id=jiahaozheng.id, project_id=proj_a.id,
|
add(maruoqing, proj_tvc, PROD, MFG, ContentType.ANIMATION, 75, date(2025, 12, 25), "TVC 第2集场景1", 4)
|
||||||
project_phase=PhaseGroup.POST, work_type=WorkType.PRODUCTION,
|
add(maruoqing, proj_tvc, PROD, MFG, ContentType.ANIMATION, 60, date(2025, 12, 26), "TVC 第2-3集", 4)
|
||||||
content_type=ContentType.EDITING, total_seconds=0,
|
# 甲方反馈后修改
|
||||||
submit_to=SubmitTo.PRODUCER, description=f"第{i+5}集粗剪",
|
add(maruoqing, proj_tvc, PROD, MFG, ContentType.ANIMATION, 55, date(2026, 1, 8), "TVC 修改-第1集重做", 3)
|
||||||
submit_date=d,
|
add(maruoqing, proj_tvc, PROD, MFG, ContentType.ANIMATION, 50, date(2026, 1, 9), "TVC 修改-第3集调整", 3)
|
||||||
))
|
|
||||||
|
|
||||||
# --- 项目B:品牌方 TVC ---
|
# 后期 - 修补镜头 (秒数,归入制作计算) 1/27-28
|
||||||
# 马若情 - AI导演
|
add(liushiqi, proj_tvc, POST, MFG, ContentType.SHOT_REPAIR, 25, date(2026, 1, 27), "TVC 修补镜头1", 1.5)
|
||||||
for i in range(6):
|
add(liushiqi, proj_tvc, POST, MFG, ContentType.SHOT_REPAIR, 30, date(2026, 1, 28), "TVC 修补镜头2", 1.5)
|
||||||
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,
|
|
||||||
))
|
|
||||||
|
|
||||||
# 刘诗琪 - 动画制作
|
# 后期 - 配音 (1/27-29, 每天1集)
|
||||||
for i in range(5):
|
add(panziyan, proj_tvc, POST, MFG, ContentType.DUBBING, 0, date(2026, 1, 27), "TVC 第1集配音", 4)
|
||||||
d = base + timedelta(days=i + 7)
|
add(panziyan, proj_tvc, POST, MFG, ContentType.DUBBING, 0, date(2026, 1, 28), "TVC 第2集配音", 4)
|
||||||
secs = 15 + (i % 3) * 10
|
add(panziyan, proj_tvc, POST, MFG, ContentType.DUBBING, 0, date(2026, 1, 29), "TVC 第3集配音", 3)
|
||||||
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,
|
|
||||||
))
|
|
||||||
|
|
||||||
# 王炎森 - 剪辑
|
# 后期 - 音效 (1/29-30)
|
||||||
for i in range(3):
|
add(panziyan, proj_tvc, POST, MFG, ContentType.SOUND_EFFECTS, 0, date(2026, 1, 29), "TVC 音效设计", 5)
|
||||||
d = base + timedelta(days=i + 13)
|
add(panziyan, proj_tvc, POST, MFG, ContentType.SOUND_EFFECTS, 0, date(2026, 1, 30), "TVC 音效终混", 3)
|
||||||
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:甲方风格测试 ---
|
# 后期 - 剪辑 (2/3-now, 多轮甲方反馈导致超期)
|
||||||
for i in range(3):
|
add(wangyansen, proj_tvc, POST, MFG, ContentType.EDITING, 0, date(2026, 2, 3), "TVC 第1-2集粗剪", 6)
|
||||||
d = base + timedelta(days=i + 1)
|
add(wangyansen, proj_tvc, POST, MFG, ContentType.EDITING, 0, date(2026, 2, 4), "TVC 第3集粗剪+精剪", 5)
|
||||||
subs.append(Submission(
|
add(wangyansen, proj_tvc, POST, MFG, ContentType.EDITING, 0, date(2026, 2, 10), "TVC 甲方反馈后重剪", 5)
|
||||||
user_id=huangrongying.id, project_id=proj_c.id,
|
add(wangyansen, proj_tvc, POST, MFG, ContentType.EDITING, 0, date(2026, 2, 12), "TVC 第二轮反馈修改", 4)
|
||||||
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):
|
# 星际漫游 第一季 (target=3900s, 创建 12/22)
|
||||||
d = base + timedelta(days=i)
|
# 前期: 12/22 - 1/13
|
||||||
subs.append(Submission(
|
# 制作: 1/14 - now
|
||||||
user_id=huangrongying.id, project_id=proj_d.id,
|
# ────────────────────────────────────────────
|
||||||
project_phase=PhaseGroup.PRE, work_type=WorkType.PLAN,
|
|
||||||
content_type=ContentType.DESIGN, total_seconds=0,
|
# 前期 - 策划案 (12/22-23)
|
||||||
submit_to=SubmitTo.INTERNAL, description=f"原创剧本第{i+1}集大纲",
|
add(huangrongying, proj_star, PRE, PLAN, ContentType.PLANNING, 0, date(2025, 12, 22), "星际漫游 世界观策划案", 4)
|
||||||
submit_date=d,
|
add(huangrongying, proj_star, PRE, PLAN, ContentType.PLANNING, 0, date(2025, 12, 23), "星际漫游 策划案终稿", 3)
|
||||||
))
|
|
||||||
for i in range(6):
|
# 前期 - 剧本 (12/24-30, 5个工作日)
|
||||||
d = base + timedelta(days=i + 8)
|
for i, d in enumerate([date(2025, 12, 24), date(2025, 12, 25), date(2025, 12, 26),
|
||||||
secs = 60 + (i % 3) * 25
|
date(2025, 12, 29), date(2025, 12, 30)]):
|
||||||
subs.append(Submission(
|
add(huangrongying, proj_star, PRE, PLAN, ContentType.SCRIPT, 0, d,
|
||||||
user_id=weichunli.id, project_id=proj_d.id,
|
f"第{i*3+1}-{i*3+3}集剧本", 6)
|
||||||
project_phase=PhaseGroup.PRODUCTION, work_type=WorkType.PRODUCTION,
|
|
||||||
content_type=ContentType.ANIMATION, total_seconds=secs,
|
# 前期 - 分镜 (12/31-1/7, 比预期多, 导致超期 → 16h waste)
|
||||||
duration_minutes=secs // 60, duration_seconds=secs % 60,
|
for i, d in enumerate([date(2025, 12, 31), date(2026, 1, 2), date(2026, 1, 5),
|
||||||
submit_to=SubmitTo.INTERNAL, description=f"原创第1集片段{i+1}",
|
date(2026, 1, 6), date(2026, 1, 7)]):
|
||||||
submit_date=d,
|
add(daixiaoqian, proj_star, PRE, PLAN, ContentType.STORYBOARD, 0, d,
|
||||||
))
|
f"第{i+1}批分镜({i*3+1}-{i*3+3}集)", 5)
|
||||||
for i in range(5):
|
|
||||||
d = base + timedelta(days=i + 10)
|
# 前期 - 人设图 (1/8-9)
|
||||||
secs = 50 + (i % 2) * 35
|
add(huangxuewen, proj_star, PRE, PLAN, ContentType.CHARACTER_DESIGN, 0, date(2026, 1, 8), "主要角色人设图", 5)
|
||||||
subs.append(Submission(
|
add(huangxuewen, proj_star, PRE, PLAN, ContentType.CHARACTER_DESIGN, 0, date(2026, 1, 9), "配角人设图", 4)
|
||||||
user_id=huangqiuxia.id, project_id=proj_d.id,
|
|
||||||
project_phase=PhaseGroup.PRODUCTION, work_type=WorkType.PRODUCTION,
|
# 前期 - 场景图 (1/10-13)
|
||||||
content_type=ContentType.ANIMATION, total_seconds=secs,
|
add(huangxuewen, proj_star, PRE, PLAN, ContentType.SCENE_DESIGN, 0, date(2026, 1, 12), "太空站场景图", 5)
|
||||||
duration_minutes=secs // 60, duration_seconds=secs % 60,
|
add(huangxuewen, proj_star, PRE, PLAN, ContentType.SCENE_DESIGN, 0, date(2026, 1, 13), "星球表面场景图", 4)
|
||||||
submit_to=SubmitTo.LEADER, description=f"原创第2集动画{i+1}",
|
|
||||||
submit_date=d,
|
# 制作 - 1/14 起 (~22个工作日到今天)
|
||||||
))
|
# 3个动画师,不是每天都在这个项目上
|
||||||
for i in range(4):
|
# 陈保丹: 14次提交 (2测试 + 12制作)
|
||||||
d = base + timedelta(days=i + 12)
|
star_anim_dates_chen = [
|
||||||
secs = 45 + i * 15
|
date(2026, 1, 14), date(2026, 1, 15), date(2026, 1, 16), date(2026, 1, 19),
|
||||||
subs.append(Submission(
|
date(2026, 1, 20), date(2026, 1, 22), date(2026, 1, 23), date(2026, 1, 26),
|
||||||
user_id=lijing.id, project_id=proj_d.id,
|
date(2026, 1, 28), date(2026, 1, 30), date(2026, 2, 3), date(2026, 2, 5),
|
||||||
project_phase=PhaseGroup.PRODUCTION, work_type=WorkType.PRODUCTION,
|
date(2026, 2, 9), date(2026, 2, 11),
|
||||||
content_type=ContentType.ANIMATION, total_seconds=secs,
|
]
|
||||||
duration_minutes=secs // 60, duration_seconds=secs % 60,
|
star_secs_chen = [70, 80, 85, 75, 90, 80, 95, 70, 85, 90, 80, 75, 85, 80]
|
||||||
submit_to=SubmitTo.LEADER, description=f"原创第3集片段{i+1}",
|
for i, (d, s) in enumerate(zip(star_anim_dates_chen, star_secs_chen)):
|
||||||
submit_date=d,
|
wt = TEST if i < 2 else MFG
|
||||||
))
|
add(chenbaodan, proj_star, PROD, wt, ContentType.ANIMATION, s, d,
|
||||||
for i in range(3):
|
f"第{(i//2)+1}集 场景{(i%4)+1}{'(测试)' if wt == TEST else ''}", 3.5)
|
||||||
d = base + timedelta(days=i + 14)
|
|
||||||
secs = 40 + i * 20
|
# 谭如平: 12次提交 (1测试 + 11制作)
|
||||||
subs.append(Submission(
|
star_anim_dates_tan = [
|
||||||
user_id=yemeilian.id, project_id=proj_d.id,
|
date(2026, 1, 15), date(2026, 1, 16), date(2026, 1, 19), date(2026, 1, 21),
|
||||||
project_phase=PhaseGroup.PRODUCTION, work_type=WorkType.PRODUCTION,
|
date(2026, 1, 23), date(2026, 1, 27), date(2026, 1, 29), date(2026, 2, 2),
|
||||||
content_type=ContentType.ANIMATION, total_seconds=secs,
|
date(2026, 2, 4), date(2026, 2, 6), date(2026, 2, 10), date(2026, 2, 12),
|
||||||
duration_minutes=secs // 60, duration_seconds=secs % 60,
|
]
|
||||||
submit_to=SubmitTo.LEADER, description=f"原创第4集动画{i+1}",
|
star_secs_tan = [75, 70, 80, 65, 85, 70, 75, 80, 90, 70, 75, 85]
|
||||||
submit_date=d,
|
for i, (d, s) in enumerate(zip(star_anim_dates_tan, star_secs_tan)):
|
||||||
))
|
wt = TEST if i < 1 else MFG
|
||||||
for i in range(3):
|
add(tanruping, proj_star, PROD, wt, ContentType.ANIMATION, s, d,
|
||||||
d = base + timedelta(days=i + 15)
|
f"第{(i//2)+3}集 镜头{(i%3)+1}{'(测试)' if wt == TEST else ''}", 3)
|
||||||
secs = 55 + (i % 2) * 20
|
|
||||||
subs.append(Submission(
|
# 郑奕晴: 11次提交 (1测试 + 10制作)
|
||||||
user_id=chenxuanying.id, project_id=proj_d.id,
|
star_anim_dates_zheng = [
|
||||||
project_phase=PhaseGroup.PRODUCTION, work_type=WorkType.PRODUCTION,
|
date(2026, 1, 16), date(2026, 1, 20), date(2026, 1, 22), date(2026, 1, 26),
|
||||||
content_type=ContentType.ANIMATION, total_seconds=secs,
|
date(2026, 1, 28), date(2026, 2, 2), date(2026, 2, 4), date(2026, 2, 6),
|
||||||
duration_minutes=secs // 60, duration_seconds=secs % 60,
|
date(2026, 2, 10), date(2026, 2, 12), date(2026, 2, 13),
|
||||||
submit_to=SubmitTo.LEADER, description=f"原创第5集场景{i+1}",
|
]
|
||||||
submit_date=d,
|
star_secs_zheng = [85, 75, 90, 80, 85, 95, 70, 80, 75, 90, 85]
|
||||||
))
|
for i, (d, s) in enumerate(zip(star_anim_dates_zheng, star_secs_zheng)):
|
||||||
|
wt = TEST if i < 1 else MFG
|
||||||
|
add(zhengyiqing, proj_star, PROD, wt, ContentType.ANIMATION, s, d,
|
||||||
|
f"第{(i//2)+5}集 动画{(i%3)+1}{'(测试)' if wt == TEST else ''}", 3.5)
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────
|
||||||
|
# AI 短剧原创 S1 (target=2880s, 创建 1/6)
|
||||||
|
# 前期: 1/6 - 1/22
|
||||||
|
# 制作: 1/23 - now
|
||||||
|
# ────────────────────────────────────────────
|
||||||
|
|
||||||
|
# 前期 - 策划案 (1/6-7)
|
||||||
|
add(huangrongying, proj_orig, PRE, PLAN, ContentType.PLANNING, 0, date(2026, 1, 6), "原创S1 世界观策划案", 4)
|
||||||
|
add(huangrongying, proj_orig, PRE, PLAN, ContentType.PLANNING, 0, date(2026, 1, 7), "原创S1 策划案终稿", 3)
|
||||||
|
|
||||||
|
# 前期 - 剧本 (1/8-14)
|
||||||
|
for i, d in enumerate([date(2026, 1, 8), date(2026, 1, 9), date(2026, 1, 12),
|
||||||
|
date(2026, 1, 13), date(2026, 1, 14)]):
|
||||||
|
add(huangrongying, proj_orig, PRE, PLAN, ContentType.SCRIPT, 0, d,
|
||||||
|
f"原创S1 第{i+1}集剧本", 6)
|
||||||
|
|
||||||
|
# 前期 - 分镜 (1/15-17)
|
||||||
|
for i, d in enumerate([date(2026, 1, 15), date(2026, 1, 16), date(2026, 1, 17)]):
|
||||||
|
add(daixiaoqian, proj_orig, PRE, PLAN, ContentType.STORYBOARD, 0, d,
|
||||||
|
f"原创S1 第{i*2+1}-{i*2+2}集分镜", 5)
|
||||||
|
|
||||||
|
# 前期 - 人设图 (1/20-21)
|
||||||
|
add(huangxuewen, proj_orig, PRE, PLAN, ContentType.CHARACTER_DESIGN, 0, date(2026, 1, 20), "原创S1 主角人设图", 5)
|
||||||
|
add(huangxuewen, proj_orig, PRE, PLAN, ContentType.CHARACTER_DESIGN, 0, date(2026, 1, 21), "原创S1 配角人设图", 4)
|
||||||
|
|
||||||
|
# 前期 - 场景图 (1/22)
|
||||||
|
add(huangxuewen, proj_orig, PRE, PLAN, ContentType.SCENE_DESIGN, 0, date(2026, 1, 22), "原创S1 场景图", 5)
|
||||||
|
|
||||||
|
# 制作 - 1/23 起 (~16个工作日到今天)
|
||||||
|
# 魏春丽: 6次, 黄秋霞: 5次, 李静: 4次
|
||||||
|
orig_dates_wei = [date(2026, 1, 23), date(2026, 1, 26), date(2026, 1, 29),
|
||||||
|
date(2026, 2, 3), date(2026, 2, 6), date(2026, 2, 10)]
|
||||||
|
orig_secs_wei = [80, 90, 75, 85, 95, 80]
|
||||||
|
for i, (d, s) in enumerate(zip(orig_dates_wei, orig_secs_wei)):
|
||||||
|
add(weichunli, proj_orig, PROD, MFG, ContentType.ANIMATION, s, d,
|
||||||
|
f"原创S1 第1集 片段{i+1}", 4)
|
||||||
|
|
||||||
|
orig_dates_huang = [date(2026, 1, 27), date(2026, 1, 30), date(2026, 2, 4),
|
||||||
|
date(2026, 2, 9), date(2026, 2, 12)]
|
||||||
|
orig_secs_huang = [70, 80, 65, 75, 85]
|
||||||
|
for i, (d, s) in enumerate(zip(orig_dates_huang, orig_secs_huang)):
|
||||||
|
add(huangqiuxia, proj_orig, PROD, MFG, ContentType.ANIMATION, s, d,
|
||||||
|
f"原创S1 第2集 动画{i+1}", 3.5)
|
||||||
|
|
||||||
|
orig_dates_li = [date(2026, 2, 3), date(2026, 2, 5), date(2026, 2, 10), date(2026, 2, 13)]
|
||||||
|
orig_secs_li = [75, 70, 80, 65]
|
||||||
|
for i, (d, s) in enumerate(zip(orig_dates_li, orig_secs_li)):
|
||||||
|
add(lijing, proj_orig, PROD, MFG, ContentType.ANIMATION, s, d,
|
||||||
|
f"原创S1 第3集 片段{i+1}", 3.5)
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────
|
||||||
|
# 原创 S1 风格测试 (target=60s, 创建 1/2)
|
||||||
|
# 前期: 1/2-7, 制作(全测试): 1/8-17
|
||||||
|
# ────────────────────────────────────────────
|
||||||
|
|
||||||
|
# 前期 - 策划案 (1/2-3)
|
||||||
|
add(huangrongying, proj_orig_test, PRE, PLAN, ContentType.PLANNING, 0, date(2026, 1, 2), "原创风格测试 策划案", 3)
|
||||||
|
add(huangrongying, proj_orig_test, PRE, PLAN, ContentType.PLANNING, 0, date(2026, 1, 3), "原创风格测试 策划终稿", 2)
|
||||||
|
|
||||||
|
# 前期 - 人设图 (1/6-7)
|
||||||
|
add(huangxuewen, proj_orig_test, PRE, PLAN, ContentType.CHARACTER_DESIGN, 0, date(2026, 1, 6), "风格测试 人设图初稿", 4)
|
||||||
|
add(huangxuewen, proj_orig_test, PRE, PLAN, ContentType.CHARACTER_DESIGN, 0, date(2026, 1, 7), "风格测试 人设图定稿", 3)
|
||||||
|
|
||||||
|
# 制作(测试) - 戴伟全部 TEST
|
||||||
|
test_dates = [date(2026, 1, 8), date(2026, 1, 9), date(2026, 1, 12),
|
||||||
|
date(2026, 1, 14), date(2026, 1, 16)]
|
||||||
|
test_secs = [12, 14, 10, 16, 18]
|
||||||
|
test_descs = ["角色动作测试", "场景氛围测试", "打光方案测试", "运镜方案A", "运镜方案B"]
|
||||||
|
for d, s, desc in zip(test_dates, test_secs, test_descs):
|
||||||
|
add(daiwei, proj_orig_test, PROD, TEST, ContentType.ANIMATION, s, d,
|
||||||
|
f"原创风格测试-{desc}", 2)
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────
|
||||||
|
# 甲方风格测试 (target=60s, 创建 1/27)
|
||||||
|
# 前期: 1/27 - now (策划案仍未定稿,甲方反复修改)
|
||||||
|
# ────────────────────────────────────────────
|
||||||
|
|
||||||
|
# 前期 - 策划案 (1/27起,至今未完成)
|
||||||
|
add(huangrongying, proj_client_test, PRE, PLAN, ContentType.PLANNING, 0, date(2026, 1, 27), "甲方风格策划案 v1", 4)
|
||||||
|
add(huangrongying, proj_client_test, PRE, PLAN, ContentType.PLANNING, 0, date(2026, 1, 30), "甲方风格策划案 v2(反馈修改)", 3)
|
||||||
|
add(huangrongying, proj_client_test, PRE, PLAN, ContentType.PLANNING, 0, date(2026, 2, 5), "甲方风格策划案 v3(二次反馈)", 3)
|
||||||
|
add(huangrongying, proj_client_test, PRE, PLAN, ContentType.PLANNING, 0, date(2026, 2, 11), "甲方风格策划案 v4(三次反馈)", 3)
|
||||||
|
|
||||||
|
# 前期 - 人设图 (2/3-5, 边做边等策划案定稿)
|
||||||
|
add(huangxuewen, proj_client_test, PRE, PLAN, ContentType.CHARACTER_DESIGN, 0, date(2026, 2, 3), "甲方风格 人设图初稿", 5)
|
||||||
|
add(huangxuewen, proj_client_test, PRE, PLAN, ContentType.CHARACTER_DESIGN, 0, date(2026, 2, 5), "甲方风格 人设图修改", 4)
|
||||||
|
|
||||||
|
# 少量测试动画 (2/10-12, 用于给甲方展示方向)
|
||||||
|
add(daiwei, proj_client_test, PROD, TEST, ContentType.ANIMATION, 10, date(2026, 2, 10), "甲方风格测试片段1", 2)
|
||||||
|
add(daiwei, proj_client_test, PROD, TEST, ContentType.ANIMATION, 14, date(2026, 2, 12), "甲方风格测试片段2", 2)
|
||||||
|
|
||||||
db.add_all(subs)
|
db.add_all(subs)
|
||||||
print(f"[3] Created {len(subs)} submissions")
|
print(f"[3] Created {len(subs)} submissions")
|
||||||
|
|
||||||
# ── AI 工具成本 ──
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
# 成本数据
|
||||||
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
# AI 工具成本
|
||||||
db.add(AIToolCost(
|
db.add(AIToolCost(
|
||||||
tool_name="Midjourney", subscription_period=SubscriptionPeriod.MONTHLY,
|
tool_name="Midjourney", subscription_period=SubscriptionPeriod.MONTHLY,
|
||||||
amount=200, allocation_type=CostAllocationType.TEAM,
|
amount=200, allocation_type=CostAllocationType.TEAM,
|
||||||
recorded_by=qiushaohui.id, record_date=date.today().replace(day=1),
|
recorded_by=qiushaohui.id, record_date=today.replace(day=1),
|
||||||
))
|
))
|
||||||
db.add(AIToolCost(
|
db.add(AIToolCost(
|
||||||
tool_name="Runway", subscription_period=SubscriptionPeriod.MONTHLY,
|
tool_name="Runway", subscription_period=SubscriptionPeriod.MONTHLY,
|
||||||
amount=600, allocation_type=CostAllocationType.PROJECT,
|
amount=600, allocation_type=CostAllocationType.PROJECT,
|
||||||
project_id=proj_a.id,
|
project_id=proj_star.id,
|
||||||
recorded_by=qiushaohui.id, record_date=date.today().replace(day=1),
|
recorded_by=qiushaohui.id, record_date=today.replace(day=1),
|
||||||
))
|
))
|
||||||
db.add(AIToolCost(
|
db.add(AIToolCost(
|
||||||
tool_name="ChatGPT Plus", subscription_period=SubscriptionPeriod.MONTHLY,
|
tool_name="ChatGPT Plus", subscription_period=SubscriptionPeriod.MONTHLY,
|
||||||
amount=150, allocation_type=CostAllocationType.TEAM,
|
amount=150, allocation_type=CostAllocationType.TEAM,
|
||||||
recorded_by=qiushaohui.id, record_date=date.today().replace(day=1),
|
recorded_by=qiushaohui.id, record_date=today.replace(day=1),
|
||||||
))
|
))
|
||||||
print("[4] Created 3 AI tool costs")
|
print("[4] Created 3 AI tool costs")
|
||||||
|
|
||||||
# ── 外包成本 ──
|
# 外包成本
|
||||||
db.add(OutsourceCost(
|
db.add(OutsourceCost(
|
||||||
project_id=proj_a.id, outsource_type=OutsourceType.ANIMATION,
|
project_id=proj_star.id, outsource_type=OutsourceType.ANIMATION,
|
||||||
episode_start=10, episode_end=13, amount=20000,
|
episode_start=10, episode_end=13, amount=20000,
|
||||||
recorded_by=qiushaohui.id, record_date=date.today() - timedelta(days=5),
|
recorded_by=qiushaohui.id, record_date=today - timedelta(days=5),
|
||||||
))
|
))
|
||||||
print("[5] Created 1 outsource cost")
|
print("[5] Created 1 outsource cost")
|
||||||
|
|
||||||
# ── 固定开支 ──
|
# 固定开支
|
||||||
db.add(OverheadCost(
|
db.add(OverheadCost(
|
||||||
cost_type=OverheadCostType.OFFICE_RENT,
|
cost_type=OverheadCostType.OFFICE_RENT,
|
||||||
amount=8000, record_month=date.today().strftime("%Y-%m"),
|
amount=8000, record_month=today.strftime("%Y-%m"),
|
||||||
recorded_by=qiushaohui.id, note="办公室月租",
|
recorded_by=qiushaohui.id, note="办公室月租",
|
||||||
))
|
))
|
||||||
db.add(OverheadCost(
|
db.add(OverheadCost(
|
||||||
cost_type=OverheadCostType.UTILITIES,
|
cost_type=OverheadCostType.UTILITIES,
|
||||||
amount=500, record_month=date.today().strftime("%Y-%m"),
|
amount=500, record_month=today.strftime("%Y-%m"),
|
||||||
recorded_by=qiushaohui.id, note="水电费",
|
recorded_by=qiushaohui.id, note="水电费",
|
||||||
))
|
))
|
||||||
print("[6] Created 2 overhead costs")
|
print("[6] Created 2 overhead costs")
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
print("\n[DONE] Demo data seeded successfully!")
|
|
||||||
print(f" Projects: 4")
|
# ── 打印统计摘要 ──
|
||||||
|
print(f"\n{'='*55}")
|
||||||
|
print("[DONE] Demo data seeded successfully!")
|
||||||
|
print(f" Projects: 5 | Milestones: {len(milestones)}")
|
||||||
print(f" Submissions: {len(subs)}")
|
print(f" Submissions: {len(subs)}")
|
||||||
print(f" AI tools: 3, Outsource: 1, Overhead: 2")
|
print(f" AI tools: 3 | Outsource: 1 | Overhead: 2")
|
||||||
|
print(f"\n 预期展示效果:")
|
||||||
|
print(f" · 品牌方 TVC 进度>200% → 蓝红双色进度条")
|
||||||
|
print(f" · 星际漫游/TVC/甲方测试有超期里程碑 → 工时损耗")
|
||||||
|
print(f" · 原创风格测试全测试 → 高 waste_rate")
|
||||||
|
print(f" · 5个项目覆盖4种类型, created_at设为过去日期")
|
||||||
|
print(f" · 前期结束后才有制作提交")
|
||||||
|
print(f" · owners/制片无提交")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@ -47,16 +47,16 @@ export const authApi = {
|
|||||||
|
|
||||||
// ── 用户 ──
|
// ── 用户 ──
|
||||||
export const userApi = {
|
export const userApi = {
|
||||||
list: () => api.get('/users/'),
|
list: () => api.get('/users'),
|
||||||
create: (data) => api.post('/users/', data),
|
create: (data) => api.post('/users', data),
|
||||||
update: (id, data) => api.put(`/users/${id}`, data),
|
update: (id, data) => api.put(`/users/${id}`, data),
|
||||||
get: (id) => api.get(`/users/${id}`),
|
get: (id) => api.get(`/users/${id}`),
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 项目 ──
|
// ── 项目 ──
|
||||||
export const projectApi = {
|
export const projectApi = {
|
||||||
list: (params) => api.get('/projects/', { params }),
|
list: (params) => api.get('/projects', { params }),
|
||||||
create: (data) => api.post('/projects/', data),
|
create: (data) => api.post('/projects', data),
|
||||||
update: (id, data) => api.put(`/projects/${id}`, data),
|
update: (id, data) => api.put(`/projects/${id}`, data),
|
||||||
get: (id) => api.get(`/projects/${id}`),
|
get: (id) => api.get(`/projects/${id}`),
|
||||||
delete: (id) => api.delete(`/projects/${id}`),
|
delete: (id) => api.delete(`/projects/${id}`),
|
||||||
@ -66,13 +66,14 @@ export const projectApi = {
|
|||||||
milestones: (id) => api.get(`/projects/${id}/milestones`),
|
milestones: (id) => api.get(`/projects/${id}/milestones`),
|
||||||
addMilestone: (id, data) => api.post(`/projects/${id}/milestones`, data),
|
addMilestone: (id, data) => api.post(`/projects/${id}/milestones`, data),
|
||||||
toggleMilestone: (milestoneId) => api.put(`/projects/milestones/${milestoneId}/toggle`),
|
toggleMilestone: (milestoneId) => api.put(`/projects/milestones/${milestoneId}/toggle`),
|
||||||
|
updateMilestone: (milestoneId, data) => api.put(`/projects/milestones/${milestoneId}`, data),
|
||||||
deleteMilestone: (milestoneId) => api.delete(`/projects/milestones/${milestoneId}`),
|
deleteMilestone: (milestoneId) => api.delete(`/projects/milestones/${milestoneId}`),
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 内容提交 ──
|
// ── 内容提交 ──
|
||||||
export const submissionApi = {
|
export const submissionApi = {
|
||||||
list: (params) => api.get('/submissions/', { params }),
|
list: (params) => api.get('/submissions', { params }),
|
||||||
create: (data) => api.post('/submissions/', data),
|
create: (data) => api.post('/submissions', data),
|
||||||
update: (id, data) => api.put(`/submissions/${id}`, data),
|
update: (id, data) => api.put(`/submissions/${id}`, data),
|
||||||
history: (id) => api.get(`/submissions/${id}/history`),
|
history: (id) => api.get(`/submissions/${id}/history`),
|
||||||
}
|
}
|
||||||
@ -94,8 +95,8 @@ export const costApi = {
|
|||||||
|
|
||||||
// ── 角色 ──
|
// ── 角色 ──
|
||||||
export const roleApi = {
|
export const roleApi = {
|
||||||
list: () => api.get('/roles/'),
|
list: () => api.get('/roles'),
|
||||||
create: (data) => api.post('/roles/', data),
|
create: (data) => api.post('/roles', data),
|
||||||
update: (id, data) => api.put(`/roles/${id}`, data),
|
update: (id, data) => api.put(`/roles/${id}`, data),
|
||||||
delete: (id) => api.delete(`/roles/${id}`),
|
delete: (id) => api.delete(`/roles/${id}`),
|
||||||
permissions: () => api.get('/roles/permissions'),
|
permissions: () => api.get('/roles/permissions'),
|
||||||
|
|||||||
@ -61,6 +61,7 @@
|
|||||||
{{ data.total_waste_rate || 0 }}%
|
{{ data.total_waste_rate || 0 }}%
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-label">总损耗率({{ formatSecs(data.total_waste_seconds) }})</div>
|
<div class="stat-label">总损耗率({{ formatSecs(data.total_waste_seconds) }})</div>
|
||||||
|
<div class="stat-sub" v-if="data.total_waste_hours > 0">工时损耗 {{ data.total_waste_hours }}h</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -118,12 +119,23 @@
|
|||||||
<el-tag size="small" :type="typeTagMap[p.project_type]">{{ p.project_type }}</el-tag>
|
<el-tag size="small" :type="typeTagMap[p.project_type]">{{ p.project_type }}</el-tag>
|
||||||
<el-tag v-if="p.is_overdue" size="small" type="danger">超期</el-tag>
|
<el-tag v-if="p.is_overdue" size="small" type="danger">超期</el-tag>
|
||||||
</div>
|
</div>
|
||||||
<span class="progress-pct">{{ p.progress_percent }}%</span>
|
<span class="progress-pct" :style="{ color: p.progress_percent > 100 ? '#FF3B30' : 'var(--primary)' }">{{ p.progress_percent }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="dual-progress-bar">
|
||||||
|
<template v-if="p.progress_percent <= 100">
|
||||||
|
<div class="dual-progress-fill blue" :style="{ width: p.progress_percent + '%' }"></div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="dual-progress-fill blue" :style="{ width: (100 / p.progress_percent * 100) + '%' }"></div>
|
||||||
|
<div class="dual-progress-fill red" :style="{ width: ((p.progress_percent - 100) / p.progress_percent * 100) + '%' }"></div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<el-progress :percentage="Math.min(p.progress_percent, 100)" :color="p.is_overdue ? '#FF3B30' : '#3370FF'" :stroke-width="6" :show-text="false" />
|
|
||||||
<div class="progress-meta">
|
<div class="progress-meta">
|
||||||
<span>{{ formatSecs(p.submitted_seconds) }} / {{ formatSecs(p.target_seconds) }}</span>
|
<span>{{ formatSecs(p.submitted_seconds) }} / {{ formatSecs(p.target_seconds) }}</span>
|
||||||
<span v-if="p.waste_rate > 0" :style="{color: p.waste_rate > 30 ? '#FF3B30' : '#8F959E'}">损耗 {{ p.waste_rate }}%</span>
|
<span v-if="p.waste_rate > 0" :style="{color: p.waste_rate > 30 ? '#FF3B30' : '#8F959E'}">
|
||||||
|
损耗 {{ p.waste_rate }}%
|
||||||
|
<template v-if="p.waste_hours > 0"> · 工时{{ p.waste_hours }}h</template>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<el-empty v-if="!data.projects?.length" description="暂无进行中的项目" :image-size="80" />
|
<el-empty v-if="!data.projects?.length" description="暂无进行中的项目" :image-size="80" />
|
||||||
@ -295,7 +307,13 @@ function initWasteChart(ranking) {
|
|||||||
const sorted = [...ranking].sort((a, b) => a.waste_rate - b.waste_rate)
|
const sorted = [...ranking].sort((a, b) => a.waste_rate - b.waste_rate)
|
||||||
const names = sorted.map(r => r.project_name.length > 8 ? r.project_name.slice(0,8) + '…' : r.project_name)
|
const names = sorted.map(r => r.project_name.length > 8 ? r.project_name.slice(0,8) + '…' : r.project_name)
|
||||||
wasteChart.setOption({
|
wasteChart.setOption({
|
||||||
tooltip: { trigger: 'axis', formatter: p => `${p[0].name}<br/>损耗率 <b>${p[0].value}%</b>` },
|
tooltip: { trigger: 'axis', formatter: p => {
|
||||||
|
const item = sorted[p[0].dataIndex]
|
||||||
|
let tip = `${item.project_name}<br/>损耗率 <b>${p[0].value}%</b>`
|
||||||
|
if (item.waste_seconds > 0) tip += `<br/>秒数损耗 <b>${formatSecs(item.waste_seconds)}</b>`
|
||||||
|
if (item.waste_hours > 0) tip += `<br/>工时损耗 <b>${item.waste_hours}h</b>`
|
||||||
|
return tip
|
||||||
|
} },
|
||||||
grid: { left: 12, right: 24, top: 16, bottom: 16, containLabel: true },
|
grid: { left: 12, right: 24, top: 16, bottom: 16, containLabel: true },
|
||||||
xAxis: { type: 'value', max: v => Math.max(v.max * 1.2, 10), axisLabel: { fontSize: 11, color: '#8F959E', formatter: v => v + '%' }, splitLine: { lineStyle: { color: '#F0F1F2' } } },
|
xAxis: { type: 'value', max: v => Math.max(v.max * 1.2, 10), axisLabel: { fontSize: 11, color: '#8F959E', formatter: v => v + '%' }, splitLine: { lineStyle: { color: '#F0F1F2' } } },
|
||||||
yAxis: { type: 'category', data: names, axisLabel: { fontSize: 12, color: '#3B3F46' }, axisLine: { show: false }, axisTick: { show: false } },
|
yAxis: { type: 'category', data: names, axisLabel: { fontSize: 12, color: '#3B3F46' }, axisLine: { show: false }, axisTick: { show: false } },
|
||||||
@ -420,6 +438,18 @@ onUnmounted(() => {
|
|||||||
.progress-pct { font-size: 14px; font-weight: 600; color: var(--primary); }
|
.progress-pct { font-size: 14px; font-weight: 600; color: var(--primary); }
|
||||||
.progress-meta { display: flex; justify-content: space-between; font-size: 12px; color: var(--text-secondary); margin-top: 6px; }
|
.progress-meta { display: flex; justify-content: space-between; font-size: 12px; color: var(--text-secondary); margin-top: 6px; }
|
||||||
|
|
||||||
|
/* 双色进度条 */
|
||||||
|
.dual-progress-bar {
|
||||||
|
display: flex; width: 100%; height: 6px;
|
||||||
|
border-radius: 4px; background: #F0F1F2; overflow: hidden;
|
||||||
|
}
|
||||||
|
.dual-progress-fill { height: 100%; transition: width 0.3s ease; }
|
||||||
|
.dual-progress-fill.blue { background: #3370FF; border-radius: 4px 0 0 4px; }
|
||||||
|
.dual-progress-fill.red { background: #FF3B30; border-radius: 0 4px 4px 0; }
|
||||||
|
.dual-progress-fill.blue:only-child { border-radius: 4px; }
|
||||||
|
|
||||||
|
.stat-sub { font-size: 11px; color: var(--text-secondary); margin-top: 2px; }
|
||||||
|
|
||||||
.stat-value.profit { color: #34C759; }
|
.stat-value.profit { color: #34C759; }
|
||||||
.stat-value.loss { color: #FF3B30; }
|
.stat-value.loss { color: #FF3B30; }
|
||||||
.profit-text { font-weight: 600; color: #34C759; }
|
.profit-text { font-weight: 600; color: #34C759; }
|
||||||
|
|||||||
@ -63,6 +63,7 @@
|
|||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-label">损耗率</div>
|
<div class="stat-label">损耗率</div>
|
||||||
<div class="stat-value" :style="{color: project.waste_rate > 30 ? '#FF3B30' : '#34C759'}">{{ project.waste_rate }}%</div>
|
<div class="stat-value" :style="{color: project.waste_rate > 30 ? '#FF3B30' : '#34C759'}">{{ project.waste_rate }}%</div>
|
||||||
|
<div v-if="project.waste_hours > 0" class="stat-sub">工时损耗 {{ project.waste_hours }}h</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -111,7 +112,10 @@
|
|||||||
<div class="milestone-columns">
|
<div class="milestone-columns">
|
||||||
<!-- 前期 -->
|
<!-- 前期 -->
|
||||||
<div class="milestone-col">
|
<div class="milestone-col">
|
||||||
<div class="milestone-col-header">前期</div>
|
<div class="milestone-col-header">
|
||||||
|
<span>前期</span>
|
||||||
|
<span v-if="preWasteHours > 0" class="ms-waste-tag">损耗 {{ preWasteHours }}h</span>
|
||||||
|
</div>
|
||||||
<div v-for="m in preMilestones" :key="m.id" class="milestone-item">
|
<div v-for="m in preMilestones" :key="m.id" class="milestone-item">
|
||||||
<el-checkbox
|
<el-checkbox
|
||||||
:model-value="m.is_completed"
|
:model-value="m.is_completed"
|
||||||
@ -119,6 +123,11 @@
|
|||||||
:disabled="!authStore.hasPermission('project:edit')"
|
:disabled="!authStore.hasPermission('project:edit')"
|
||||||
/>
|
/>
|
||||||
<span class="milestone-name" :class="{ completed: m.is_completed }">{{ m.name }}</span>
|
<span class="milestone-name" :class="{ completed: m.is_completed }">{{ m.name }}</span>
|
||||||
|
<span v-if="m.estimated_days" class="ms-badge est">{{ m.estimated_days }}天</span>
|
||||||
|
<span v-if="m.is_overdue" class="ms-badge overdue">超{{ (m.actual_days || 0) - (m.estimated_days || 0) }}天</span>
|
||||||
|
<span v-else-if="m.actual_days != null && m.start_date" class="ms-badge actual">{{ m.actual_days }}天</span>
|
||||||
|
<el-button v-if="authStore.hasPermission('project:edit')" text size="small" class="milestone-edit"
|
||||||
|
@click="openMilestoneEdit(m)"><el-icon size="12"><EditPen /></el-icon></el-button>
|
||||||
<el-button v-if="authStore.hasPermission('project:edit')" text type="danger" size="small" class="milestone-del"
|
<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>
|
@click="deleteMilestone(m.id)"><el-icon size="12"><Close /></el-icon></el-button>
|
||||||
</div>
|
</div>
|
||||||
@ -133,7 +142,7 @@
|
|||||||
|
|
||||||
<!-- 制作 -->
|
<!-- 制作 -->
|
||||||
<div class="milestone-col production-col">
|
<div class="milestone-col production-col">
|
||||||
<div class="milestone-col-header">制作</div>
|
<div class="milestone-col-header"><span>制作</span></div>
|
||||||
<div class="production-ring-layout">
|
<div class="production-ring-layout">
|
||||||
<div ref="progressChartRef" style="width:180px;height:180px;flex-shrink:0"></div>
|
<div ref="progressChartRef" style="width:180px;height:180px;flex-shrink:0"></div>
|
||||||
<div class="production-info">
|
<div class="production-info">
|
||||||
@ -146,8 +155,12 @@
|
|||||||
<span class="prod-info-value">{{ formatSecs(project.target_total_seconds) }}</span>
|
<span class="prod-info-value">{{ formatSecs(project.target_total_seconds) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="prod-info-row">
|
<div class="prod-info-row">
|
||||||
<span class="prod-info-label">损耗</span>
|
<span class="prod-info-label">测试损耗</span>
|
||||||
<span class="prod-info-value" :style="{color: project.waste_rate > 30 ? '#FF3B30' : 'inherit'}">{{ formatSecs(project.waste_seconds) }}</span>
|
<span class="prod-info-value" :style="{color: prodWaste.test_waste_seconds > 0 ? '#FF9500' : 'inherit'}">{{ formatSecs(prodWaste.test_waste_seconds) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="prod-info-row">
|
||||||
|
<span class="prod-info-label">超产损耗</span>
|
||||||
|
<span class="prod-info-value" :style="{color: prodWaste.overproduction_waste_seconds > 0 ? '#FF3B30' : 'inherit'}">{{ formatSecs(prodWaste.overproduction_waste_seconds) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="prod-info-row">
|
<div class="prod-info-row">
|
||||||
<span class="prod-info-label">损耗率</span>
|
<span class="prod-info-label">损耗率</span>
|
||||||
@ -159,7 +172,10 @@
|
|||||||
|
|
||||||
<!-- 后期 -->
|
<!-- 后期 -->
|
||||||
<div class="milestone-col">
|
<div class="milestone-col">
|
||||||
<div class="milestone-col-header">后期</div>
|
<div class="milestone-col-header">
|
||||||
|
<span>后期</span>
|
||||||
|
<span v-if="postWasteHours > 0" class="ms-waste-tag">损耗 {{ postWasteHours }}h</span>
|
||||||
|
</div>
|
||||||
<div v-for="m in postMilestones" :key="m.id" class="milestone-item">
|
<div v-for="m in postMilestones" :key="m.id" class="milestone-item">
|
||||||
<el-checkbox
|
<el-checkbox
|
||||||
:model-value="m.is_completed"
|
:model-value="m.is_completed"
|
||||||
@ -167,6 +183,11 @@
|
|||||||
:disabled="!authStore.hasPermission('project:edit')"
|
:disabled="!authStore.hasPermission('project:edit')"
|
||||||
/>
|
/>
|
||||||
<span class="milestone-name" :class="{ completed: m.is_completed }">{{ m.name }}</span>
|
<span class="milestone-name" :class="{ completed: m.is_completed }">{{ m.name }}</span>
|
||||||
|
<span v-if="m.estimated_days" class="ms-badge est">{{ m.estimated_days }}天</span>
|
||||||
|
<span v-if="m.is_overdue" class="ms-badge overdue">超{{ (m.actual_days || 0) - (m.estimated_days || 0) }}天</span>
|
||||||
|
<span v-else-if="m.actual_days != null && m.start_date" class="ms-badge actual">{{ m.actual_days }}天</span>
|
||||||
|
<el-button v-if="authStore.hasPermission('project:edit')" text size="small" class="milestone-edit"
|
||||||
|
@click="openMilestoneEdit(m)"><el-icon size="12"><EditPen /></el-icon></el-button>
|
||||||
<el-button v-if="authStore.hasPermission('project:edit')" text type="danger" size="small" class="milestone-del"
|
<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>
|
@click="deleteMilestone(m.id)"><el-icon size="12"><Close /></el-icon></el-button>
|
||||||
</div>
|
</div>
|
||||||
@ -365,6 +386,28 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 里程碑编辑对话框 -->
|
||||||
|
<el-dialog v-model="showMilestoneEdit" title="编辑里程碑" width="400px" destroy-on-close>
|
||||||
|
<el-form :model="msEditForm" label-width="100px" label-position="left">
|
||||||
|
<el-form-item label="里程碑">
|
||||||
|
<span style="font-weight:600">{{ msEditForm.name }}</span>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="预估工作日">
|
||||||
|
<div class="inline-field">
|
||||||
|
<el-input-number v-model="msEditForm.estimated_days" :min="1" :max="365" style="width:160px" />
|
||||||
|
<span class="field-unit">天</span>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="开始日期">
|
||||||
|
<el-date-picker v-model="msEditForm.start_date" value-format="YYYY-MM-DD" placeholder="选择开始日期" style="width:100%" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="showMilestoneEdit = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="savingMilestone" @click="handleSaveMilestone">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
<!-- 提交记录 -->
|
<!-- 提交记录 -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
@ -476,8 +519,37 @@ const prePercent = computed(() => phasePre.value.total > 0 ? Math.round(phasePre
|
|||||||
const postPercent = computed(() => phasePost.value.total > 0 ? Math.round(phasePost.value.completed / phasePost.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 preMilestones = computed(() => (project.value.milestones || []).filter(m => m.phase === '前期'))
|
||||||
const postMilestones = computed(() => (project.value.milestones || []).filter(m => m.phase === '后期'))
|
const postMilestones = computed(() => (project.value.milestones || []).filter(m => m.phase === '后期'))
|
||||||
|
const preWasteHours = computed(() => project.value.phase_summary?.pre?.waste_hours || 0)
|
||||||
|
const postWasteHours = computed(() => project.value.phase_summary?.post?.waste?.days_waste_hours || 0)
|
||||||
|
const prodWaste = computed(() => project.value.phase_summary?.production?.waste || {})
|
||||||
const newMilestone = reactive({ pre: '', post: '' })
|
const newMilestone = reactive({ pre: '', post: '' })
|
||||||
|
|
||||||
|
// ── 里程碑编辑 ──
|
||||||
|
const showMilestoneEdit = ref(false)
|
||||||
|
const savingMilestone = ref(false)
|
||||||
|
const msEditForm = reactive({ id: null, name: '', estimated_days: null, start_date: null })
|
||||||
|
|
||||||
|
function openMilestoneEdit(m) {
|
||||||
|
msEditForm.id = m.id
|
||||||
|
msEditForm.name = m.name
|
||||||
|
msEditForm.estimated_days = m.estimated_days
|
||||||
|
msEditForm.start_date = m.start_date
|
||||||
|
showMilestoneEdit.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveMilestone() {
|
||||||
|
savingMilestone.value = true
|
||||||
|
try {
|
||||||
|
await projectApi.updateMilestone(msEditForm.id, {
|
||||||
|
estimated_days: msEditForm.estimated_days,
|
||||||
|
start_date: msEditForm.start_date,
|
||||||
|
})
|
||||||
|
ElMessage.success('里程碑已更新')
|
||||||
|
showMilestoneEdit.value = false
|
||||||
|
load()
|
||||||
|
} finally { savingMilestone.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
async function toggleMilestone(m) {
|
async function toggleMilestone(m) {
|
||||||
try {
|
try {
|
||||||
await projectApi.toggleMilestone(m.id)
|
await projectApi.toggleMilestone(m.id)
|
||||||
@ -826,16 +898,25 @@ onUnmounted(() => {
|
|||||||
.milestone-col-header {
|
.milestone-col-header {
|
||||||
font-size: 13px; font-weight: 600; color: var(--text-primary);
|
font-size: 13px; font-weight: 600; color: var(--text-primary);
|
||||||
margin-bottom: 12px; padding-bottom: 8px; border-bottom: 1px solid var(--border-light, #f0f1f2);
|
margin-bottom: 12px; padding-bottom: 8px; border-bottom: 1px solid var(--border-light, #f0f1f2);
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
}
|
}
|
||||||
.milestone-item {
|
.milestone-item {
|
||||||
display: flex; align-items: center; gap: 6px; padding: 4px 0;
|
display: flex; align-items: center; gap: 6px; padding: 4px 0;
|
||||||
}
|
}
|
||||||
.milestone-name { font-size: 13px; color: var(--text-primary); flex: 1; }
|
.milestone-name { font-size: 13px; color: var(--text-primary); flex: 1; }
|
||||||
.milestone-name.completed { color: var(--text-placeholder, #C0C4CC); text-decoration: line-through; }
|
.milestone-name.completed { color: var(--text-placeholder, #C0C4CC); text-decoration: line-through; }
|
||||||
.milestone-del { opacity: 0; transition: opacity 0.15s; padding: 2px !important; }
|
.milestone-del, .milestone-edit { opacity: 0; transition: opacity 0.15s; padding: 2px !important; }
|
||||||
.milestone-item:hover .milestone-del { opacity: 1; }
|
.milestone-item:hover .milestone-del, .milestone-item:hover .milestone-edit { opacity: 1; }
|
||||||
.milestone-add { margin-top: 8px; }
|
.milestone-add { margin-top: 8px; }
|
||||||
|
|
||||||
|
/* 里程碑调度标签 */
|
||||||
|
.ms-badge { font-size: 11px; padding: 1px 6px; border-radius: 3px; white-space: nowrap; }
|
||||||
|
.ms-badge.est { background: #F2F3F5; color: #8F959E; }
|
||||||
|
.ms-badge.actual { background: #E8F5E9; color: #34C759; }
|
||||||
|
.ms-badge.overdue { background: #FFE8E7; color: #FF3B30; font-weight: 600; }
|
||||||
|
.ms-waste-tag { font-size: 11px; color: #FF3B30; font-weight: 500; }
|
||||||
|
.stat-sub { font-size: 12px; color: #FF9500; margin-top: 4px; font-weight: 500; }
|
||||||
|
|
||||||
/* 制作阶段 — 圆环 + 数据 */
|
/* 制作阶段 — 圆环 + 数据 */
|
||||||
.production-col { display: flex; flex-direction: column; }
|
.production-col { display: flex; flex-direction: column; }
|
||||||
.production-col .milestone-col-header { margin-bottom: 4px; }
|
.production-col .milestone-col-header { margin-bottom: 4px; }
|
||||||
|
|||||||
@ -62,14 +62,46 @@
|
|||||||
<!-- 损耗分析 -->
|
<!-- 损耗分析 -->
|
||||||
<el-card class="section-card">
|
<el-card class="section-card">
|
||||||
<template #header><span class="section-title">损耗分析</span></template>
|
<template #header><span class="section-title">损耗分析</span></template>
|
||||||
<el-descriptions :column="2" border size="small">
|
<!-- 分阶段损耗 -->
|
||||||
<el-descriptions-item label="测试损耗">{{ formatSecs(data.test_waste_seconds) }}</el-descriptions-item>
|
<el-row :gutter="16" class="waste-phase-row">
|
||||||
<el-descriptions-item label="超产损耗">{{ formatSecs(data.overproduction_waste_seconds) }}</el-descriptions-item>
|
<el-col :span="8">
|
||||||
<el-descriptions-item label="总损耗">{{ formatSecs(data.total_waste_seconds) }}</el-descriptions-item>
|
<div class="waste-phase-card">
|
||||||
<el-descriptions-item label="损耗率">
|
<div class="waste-phase-title">前期(工时制)</div>
|
||||||
<span :style="{color: data.waste_rate > 30 ? '#f56c6c' : '#333', fontWeight:600}">{{ data.waste_rate }}%</span>
|
<div class="waste-phase-value" :class="{ danger: preWasteHours > 0 }">{{ preWasteHours }}h</div>
|
||||||
</el-descriptions-item>
|
<div class="waste-phase-detail" v-for="d in (data.pre_waste?.details || [])" :key="d.milestone">
|
||||||
</el-descriptions>
|
{{ d.milestone }}:预估{{ d.estimated_days }}天 / 实际{{ d.actual_days }}天,超{{ d.overrun_days }}天 = {{ d.waste_hours }}h
|
||||||
|
</div>
|
||||||
|
<div v-if="!(data.pre_waste?.details || []).length" class="waste-phase-empty">无超期</div>
|
||||||
|
</div>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="8">
|
||||||
|
<div class="waste-phase-card">
|
||||||
|
<div class="waste-phase-title">制作(秒数制)</div>
|
||||||
|
<div class="waste-phase-sub">
|
||||||
|
<span>测试损耗</span>
|
||||||
|
<span :class="{ danger: data.test_waste_seconds > 0 }">{{ formatSecs(data.test_waste_seconds) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="waste-phase-sub">
|
||||||
|
<span>超产损耗</span>
|
||||||
|
<span :class="{ danger: data.overproduction_waste_seconds > 0 }">{{ formatSecs(data.overproduction_waste_seconds) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="waste-phase-sub" style="font-weight:600; border-top:1px solid #eee; padding-top:6px; margin-top:4px">
|
||||||
|
<span>秒数损耗率</span>
|
||||||
|
<span :class="{ danger: data.waste_rate > 30 }">{{ data.waste_rate }}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="8">
|
||||||
|
<div class="waste-phase-card">
|
||||||
|
<div class="waste-phase-title">后期(工时制)</div>
|
||||||
|
<div class="waste-phase-value" :class="{ danger: postWasteHours > 0 }">{{ postWasteHours }}h</div>
|
||||||
|
<div class="waste-phase-detail" v-for="d in (data.post_waste?.details || [])" :key="d.milestone">
|
||||||
|
{{ d.milestone }}:预估{{ d.estimated_days }}天 / 实际{{ d.actual_days }}天,超{{ d.overrun_days }}天 = {{ d.waste_hours }}h
|
||||||
|
</div>
|
||||||
|
<div v-if="!(data.post_waste?.details || []).length" class="waste-phase-empty">无超期</div>
|
||||||
|
</div>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<!-- 团队效率 -->
|
<!-- 团队效率 -->
|
||||||
@ -99,7 +131,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { projectApi } from '../api'
|
import { projectApi } from '../api'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
@ -110,6 +142,9 @@ const route = useRoute()
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const data = ref({})
|
const data = ref({})
|
||||||
|
|
||||||
|
const preWasteHours = computed(() => data.value.pre_waste?.waste_hours || 0)
|
||||||
|
const postWasteHours = computed(() => data.value.post_waste?.days_waste_hours || 0)
|
||||||
|
|
||||||
function fmt(n) { return (n || 0).toLocaleString('zh-CN', { maximumFractionDigits: 0 }) }
|
function fmt(n) { return (n || 0).toLocaleString('zh-CN', { maximumFractionDigits: 0 }) }
|
||||||
function formatSecs(s) {
|
function formatSecs(s) {
|
||||||
if (!s) return '0秒'
|
if (!s) return '0秒'
|
||||||
@ -134,4 +169,15 @@ onMounted(async () => {
|
|||||||
.stat-label { font-size: 12px; color: var(--text-secondary); margin-bottom: 6px; }
|
.stat-label { font-size: 12px; color: var(--text-secondary); margin-bottom: 6px; }
|
||||||
.stat-value { font-size: 22px; font-weight: 700; color: var(--text-primary); }
|
.stat-value { font-size: 22px; font-weight: 700; color: var(--text-primary); }
|
||||||
.big-stat { text-align: center; padding: 16px 0; }
|
.big-stat { text-align: center; padding: 16px 0; }
|
||||||
|
|
||||||
|
/* 分阶段损耗 */
|
||||||
|
.waste-phase-row { margin-top: 4px; }
|
||||||
|
.waste-phase-card { background: #F7F8FA; border-radius: 8px; padding: 14px 16px; min-height: 120px; }
|
||||||
|
.waste-phase-title { font-size: 13px; font-weight: 600; color: var(--text-primary); margin-bottom: 8px; }
|
||||||
|
.waste-phase-value { font-size: 20px; font-weight: 700; color: var(--text-primary); margin-bottom: 6px; }
|
||||||
|
.waste-phase-value.danger { color: #f56c6c; }
|
||||||
|
.waste-phase-sub { display: flex; justify-content: space-between; font-size: 13px; color: var(--text-primary); padding: 3px 0; }
|
||||||
|
.waste-phase-sub .danger { color: #f56c6c; font-weight: 600; }
|
||||||
|
.waste-phase-detail { font-size: 12px; color: var(--text-secondary); line-height: 1.6; }
|
||||||
|
.waste-phase-empty { font-size: 12px; color: #C0C4CC; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -67,9 +67,22 @@
|
|||||||
</el-row>
|
</el-row>
|
||||||
<el-form-item label="内容类型" required>
|
<el-form-item label="内容类型" required>
|
||||||
<el-select v-model="form.content_type" placeholder="本次提交的产出类型" style="width:100%">
|
<el-select v-model="form.content_type" placeholder="本次提交的产出类型" style="width:100%">
|
||||||
<el-option label="内容制作 — 动画/视频制作" value="内容制作" />
|
<el-option-group label="前期">
|
||||||
<el-option label="设定策划 — 剧本/方案/设定图" value="设定策划" />
|
<el-option label="策划案" value="策划案" />
|
||||||
<el-option label="剪辑后期 — 剪辑/调色/合成" value="剪辑后期" />
|
<el-option label="剧本" value="剧本" />
|
||||||
|
<el-option label="分镜" value="分镜" />
|
||||||
|
<el-option label="人设图" value="人设图" />
|
||||||
|
<el-option label="场景图" value="场景图" />
|
||||||
|
</el-option-group>
|
||||||
|
<el-option-group label="制作">
|
||||||
|
<el-option label="动画制作" value="动画制作" />
|
||||||
|
</el-option-group>
|
||||||
|
<el-option-group label="后期">
|
||||||
|
<el-option label="配音" value="配音" />
|
||||||
|
<el-option label="音效" value="音效" />
|
||||||
|
<el-option label="修补镜头" value="修补镜头" />
|
||||||
|
<el-option label="剪辑" value="剪辑" />
|
||||||
|
</el-option-group>
|
||||||
<el-option label="其他" value="其他" />
|
<el-option label="其他" value="其他" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@ -97,6 +110,14 @@
|
|||||||
<el-option label="外部 — 提交给甲方/客户" value="外部" />
|
<el-option label="外部 — 提交给甲方/客户" value="外部" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item v-if="isMilestoneOverdue" required>
|
||||||
|
<template #label><span style="color:#FF3B30">延期原因</span></template>
|
||||||
|
<el-alert v-if="currentMilestone"
|
||||||
|
:title="`里程碑「${currentMilestone.name}」已超期(预估${currentMilestone.estimated_days}天,实际${currentMilestone.actual_days}天)`"
|
||||||
|
type="warning" :closable="false" show-icon style="margin-bottom:8px" />
|
||||||
|
<el-input v-model="form.delay_reason" type="textarea" :rows="2"
|
||||||
|
placeholder="该里程碑已超期,请说明延期原因(必填)" />
|
||||||
|
</el-form-item>
|
||||||
<el-form-item label="制作描述">
|
<el-form-item label="制作描述">
|
||||||
<el-input v-model="form.description" type="textarea" :rows="2" placeholder="简要描述本次提交内容" />
|
<el-input v-model="form.description" type="textarea" :rows="2" placeholder="简要描述本次提交内容" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@ -113,10 +134,16 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, reactive } from 'vue'
|
import { ref, computed, onMounted, reactive, watch } from 'vue'
|
||||||
import { submissionApi, projectApi } from '../api'
|
import { submissionApi, projectApi } from '../api'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
|
const CONTENT_PHASE_MAP = {
|
||||||
|
'策划案': '前期', '剧本': '前期', '分镜': '前期', '人设图': '前期', '场景图': '前期',
|
||||||
|
'动画制作': '制作',
|
||||||
|
'配音': '后期', '音效': '后期', '修补镜头': '后期', '剪辑': '后期',
|
||||||
|
}
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const creating = ref(false)
|
const creating = ref(false)
|
||||||
const showCreate = ref(false)
|
const showCreate = ref(false)
|
||||||
@ -130,8 +157,31 @@ const activeProjects = computed(() => projects.value.filter(p => p.status === '
|
|||||||
const today = new Date().toISOString().split('T')[0]
|
const today = new Date().toISOString().split('T')[0]
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
project_id: null, project_phase: '制作', work_type: '制作',
|
project_id: null, project_phase: '制作', work_type: '制作',
|
||||||
content_type: '内容制作', duration_minutes: 0, duration_seconds: 0,
|
content_type: '动画制作', duration_minutes: 0, duration_seconds: 0,
|
||||||
hours_spent: null, submit_to: '组长', description: '', submit_date: today,
|
hours_spent: null, submit_to: '组长', description: '', submit_date: today,
|
||||||
|
delay_reason: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
// 选择内容类型时自动设置对应的项目阶段
|
||||||
|
watch(() => form.content_type, (val) => {
|
||||||
|
if (CONTENT_PHASE_MAP[val]) {
|
||||||
|
form.project_phase = CONTENT_PHASE_MAP[val]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 里程碑超期检测(利用 projectApi.list 返回的 milestones 数据)
|
||||||
|
const selectedProject = computed(() =>
|
||||||
|
projects.value.find(p => p.id === form.project_id) || null
|
||||||
|
)
|
||||||
|
|
||||||
|
const currentMilestone = computed(() => {
|
||||||
|
if (!selectedProject.value || !form.content_type) return null
|
||||||
|
return (selectedProject.value.milestones || []).find(m => m.name === form.content_type) || null
|
||||||
|
})
|
||||||
|
|
||||||
|
const isMilestoneOverdue = computed(() => {
|
||||||
|
const ms = currentMilestone.value
|
||||||
|
return ms?.is_overdue === true
|
||||||
})
|
})
|
||||||
|
|
||||||
function formatSecs(s) {
|
function formatSecs(s) {
|
||||||
@ -154,6 +204,10 @@ async function load() {
|
|||||||
|
|
||||||
async function handleCreate() {
|
async function handleCreate() {
|
||||||
if (!form.project_id) { ElMessage.warning('请选择项目'); return }
|
if (!form.project_id) { ElMessage.warning('请选择项目'); return }
|
||||||
|
if (isMilestoneOverdue.value && !form.delay_reason?.trim()) {
|
||||||
|
ElMessage.warning('该里程碑已超期,请填写延期原因')
|
||||||
|
return
|
||||||
|
}
|
||||||
creating.value = true
|
creating.value = true
|
||||||
try {
|
try {
|
||||||
await submissionApi.create(form)
|
await submissionApi.create(form)
|
||||||
@ -164,6 +218,7 @@ async function handleCreate() {
|
|||||||
form.duration_seconds = 0
|
form.duration_seconds = 0
|
||||||
form.hours_spent = null
|
form.hours_spent = null
|
||||||
form.description = ''
|
form.description = ''
|
||||||
|
form.delay_reason = ''
|
||||||
load()
|
load()
|
||||||
} finally { creating.value = false }
|
} finally { creating.value = false }
|
||||||
}
|
}
|
||||||
|
|||||||
139
项目总结文档.md
139
项目总结文档.md
@ -1,7 +1,7 @@
|
|||||||
# AirLabs Project 项目总结文档
|
# AirLabs Project 项目总结文档
|
||||||
|
|
||||||
> 内容组 · 项目制周期 / 成本 / 产出管理系统
|
> 内容组 · 项目制周期 / 成本 / 产出管理系统
|
||||||
> 更新日期:2025-02-11
|
> 更新日期:2026-02-14
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -35,7 +35,7 @@
|
|||||||
|------|----------|------|
|
|------|----------|------|
|
||||||
| 前端 | Vue 3 + Element Plus + Vite | 后台管理 UI |
|
| 前端 | Vue 3 + Element Plus + Vite | 后台管理 UI |
|
||||||
| 后端 | Python FastAPI | 高性能 API |
|
| 后端 | Python FastAPI | 高性能 API |
|
||||||
| 数据库 | SQLite | 零配置,MVP 适用 |
|
| 数据库 | MySQL 8.0(阿里云 RDS) | 生产环境;本地开发可回退 SQLite |
|
||||||
| 图表 | ECharts | 仪表盘可视化 |
|
| 图表 | ECharts | 仪表盘可视化 |
|
||||||
| 认证 | JWT Token | 登录鉴权与权限控制 |
|
| 认证 | JWT Token | 登录鉴权与权限控制 |
|
||||||
|
|
||||||
@ -53,7 +53,7 @@ AirLabs Project/
|
|||||||
├── backend/ # FastAPI 后端
|
├── backend/ # FastAPI 后端
|
||||||
│ ├── main.py # 入口、静态文件托管、角色初始化
|
│ ├── main.py # 入口、静态文件托管、角色初始化
|
||||||
│ ├── models.py # ORM 模型、枚举、权限定义
|
│ ├── models.py # ORM 模型、枚举、权限定义
|
||||||
│ ├── calculations.py # 成本分摊、损耗、效率、结算计算引擎
|
│ ├── calculations.py # 成本分摊、三阶段损耗、效率、结算计算引擎
|
||||||
│ ├── config.py # 配置
|
│ ├── config.py # 配置
|
||||||
│ ├── database.py # SQLAlchemy 引擎与会话
|
│ ├── database.py # SQLAlchemy 引擎与会话
|
||||||
│ ├── auth.py # 密码哈希、JWT
|
│ ├── auth.py # 密码哈希、JWT
|
||||||
@ -107,11 +107,27 @@ AirLabs Project/
|
|||||||
### 3.3 内容提交(Submission)
|
### 3.3 内容提交(Submission)
|
||||||
|
|
||||||
- 提交人、所属项目、项目阶段、工作类型(制作/测试/方案)
|
- 提交人、所属项目、项目阶段、工作类型(制作/测试/方案)
|
||||||
- 内容制作类型(内容制作/设定策划/剪辑后期/其他)
|
- 内容制作类型(11 种,按阶段分组)
|
||||||
|
- 前期:策划案 / 剧本 / 分镜 / 人设图 / 场景图
|
||||||
|
- 制作:动画制作
|
||||||
|
- 后期:配音 / 音效 / 修补镜头 / 剪辑
|
||||||
|
- 其他
|
||||||
- 产出时长(分钟/秒,换算为 total_seconds)
|
- 产出时长(分钟/秒,换算为 total_seconds)
|
||||||
- 投入时长(小时,可选)
|
- 投入时长(小时,可选)
|
||||||
- 提交对象(组长/制片/内部/外部)
|
- 提交对象(组长/制片/内部/外部)
|
||||||
- 提交日期
|
- 提交日期
|
||||||
|
- **milestone_id**(系统自动关联:根据内容类型匹配同名里程碑)
|
||||||
|
- **delay_reason**(延期原因:里程碑超期时必填)
|
||||||
|
|
||||||
|
### 3.4 项目里程碑(ProjectMilestone)
|
||||||
|
|
||||||
|
- 所属项目、名称、所属阶段(前期/后期)
|
||||||
|
- is_completed(是否完成)、completed_at(完成日期)
|
||||||
|
- **estimated_days**(预估工作日)
|
||||||
|
- **start_date**(开始日期)
|
||||||
|
- actual_days(计算值 = 开始到完成/今天的工作日数,排除周末)
|
||||||
|
- is_overdue(计算值 = 实际 > 预估)
|
||||||
|
- 默认里程碑:前期 5 个(策划案/剧本/分镜/人设图/场景图)+ 后期 5 个(配音/音效/修补镜头/剪辑/杂项)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -130,16 +146,24 @@ AirLabs Project/
|
|||||||
|
|
||||||
**概念区分:**
|
**概念区分:**
|
||||||
|
|
||||||
- **损耗**:生产效率指标(测试试错 + 超产废片),盈利项目也有
|
- **损耗**:生产效率指标,盈利项目也有
|
||||||
- **亏损**:财务结果(总成本 > 回款),仅客户正式项目
|
- **亏损**:财务结果(总成本 > 回款),仅客户正式项目
|
||||||
|
|
||||||
**损耗计算:**
|
**三阶段损耗计算体系:**
|
||||||
|
|
||||||
- **测试损耗**:工作类型为「测试」的提交秒数
|
| 阶段 | 计算方式 | 损耗指标 |
|
||||||
- **超产损耗**:累计提交秒数 − 目标秒数(>0 部分)
|
|------|----------|----------|
|
||||||
|
| **前期** | 工时制 | 里程碑超期天数 × 8h |
|
||||||
|
| **制作** | 秒数制 | 测试损耗 + 超产损耗 |
|
||||||
|
| **后期** | 工时制 | 里程碑超期天数 × 8h |
|
||||||
|
|
||||||
|
- **前期损耗**:遍历前期里程碑(策划案/剧本/分镜/人设图/场景图),有预估工期且已完成/超期的,超出天数 × 8h = 损耗工时
|
||||||
|
- **制作损耗**:测试损耗(work_type=测试且phase=制作的秒数)+ 超产损耗(制作产出+修补镜头 − 目标秒数的超出部分)
|
||||||
|
- **后期损耗**:剪辑等里程碑按工时计算;修补镜头秒数已计入制作损耗;配音/音效不计损耗
|
||||||
- **废弃项目**:全部产出直接记为损耗,损耗率 100%
|
- **废弃项目**:全部产出直接记为损耗,损耗率 100%
|
||||||
|
|
||||||
**损耗率** = 总损耗秒数 ÷ 目标秒数 × 100%
|
**秒数损耗率** = (测试损耗 + 超产损耗) ÷ 目标秒数 × 100%
|
||||||
|
**工时损耗** = 前期超期工时 + 后期超期工时
|
||||||
|
|
||||||
### 4.3 团队效率(人均基准对比法)
|
### 4.3 团队效率(人均基准对比法)
|
||||||
|
|
||||||
@ -284,21 +308,112 @@ FastAPI 后端
|
|||||||
| 用户管理 | ✅ |
|
| 用户管理 | ✅ |
|
||||||
| AI 自动报告 + 飞书推送 | ✅ V2 新增 |
|
| AI 自动报告 + 飞书推送 | ✅ V2 新增 |
|
||||||
| 项目风险预警 | ✅ V2 新增 |
|
| 项目风险预警 | ✅ V2 新增 |
|
||||||
|
| 三阶段损耗计算(前期工时/制作秒数/后期工时) | ✅ V1.1 新增 |
|
||||||
|
| 内容类型细化(4→11 种) | ✅ V1.1 新增 |
|
||||||
|
| 里程碑管理(预估/实际天数、超期检测) | ✅ V1.1 新增 |
|
||||||
|
| 提交自动关联里程碑 + 超期延期原因 | ✅ V1.1 新增 |
|
||||||
|
| 仪表盘双色进度条(超产可视化) | ✅ V2.1 新增 |
|
||||||
|
| 工时损耗展示(仪表盘+项目进度+tooltip) | ✅ V2.1 新增 |
|
||||||
|
| 提交页延期原因条件显示修复 | ✅ V2.1 修复 |
|
||||||
|
| 后端 SPA 路由与 API 路由冲突修复 | ✅ V2.1 修复 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 12. 已知待办 / 待完善
|
## 12. V1.1 三阶段损耗计算体系(近期新增)
|
||||||
|
|
||||||
|
### 12.1 背景
|
||||||
|
|
||||||
|
V1 损耗计算仅用「产出秒数 vs 目标秒数」一种方式,无法覆盖前期(策划/人设等)和后期(剪辑等)无法用秒数衡量的工作,导致这些阶段的损耗无法量化。
|
||||||
|
|
||||||
|
### 12.2 改动概要
|
||||||
|
|
||||||
|
| 改动项 | 说明 |
|
||||||
|
|--------|------|
|
||||||
|
| **内容类型扩展** | 从 4 种(内容制作/设定策划/剪辑后期/其他)扩展为 11 种,按前期/制作/后期分组 |
|
||||||
|
| **里程碑增强** | 新增 estimated_days(预估天数)、start_date(开始日期),系统自动计算 actual_days 和 is_overdue |
|
||||||
|
| **三阶段损耗** | 前期/后期按工时(天数超期 × 8h),制作保持秒数制(测试 + 超产) |
|
||||||
|
| **提交关联里程碑** | 提交时根据 content_type 自动匹配同名里程碑,超期时必须填写延期原因 |
|
||||||
|
| **数据库迁移** | 启动时幂等 ALTER TABLE 添加 4 个新字段 + 旧 content_type 值映射 |
|
||||||
|
|
||||||
|
### 12.3 涉及文件
|
||||||
|
|
||||||
|
**后端**:
|
||||||
|
- `models.py` — ContentType 枚举扩展、ProjectMilestone 新字段、Submission 新字段、CONTENT_PHASE_MAP
|
||||||
|
- `schemas.py` — MilestoneOut/Create/Update、SubmissionCreate/Out 新字段、ProjectOut 新增 waste_hours
|
||||||
|
- `calculations.py` — 重写 calc_waste_for_project() 三阶段逻辑,新增 _working_days_between()
|
||||||
|
- `routers/projects.py` — enrich_project() 用集中损耗函数、_build_milestone_out()、PUT /milestones/{id}
|
||||||
|
- `routers/submissions.py` — 自动关联里程碑、超期校验 delay_reason
|
||||||
|
- `routers/dashboard.py` — 损耗汇总兼容 waste_hours
|
||||||
|
- `main.py` — 启动迁移(ALTER TABLE + ENUM 扩展 + 旧值映射)
|
||||||
|
|
||||||
|
**前端**:
|
||||||
|
- `views/Submissions.vue` — 内容类型分组下拉、自动设置阶段、延期原因字段
|
||||||
|
- `views/ProjectDetail.vue` — 里程碑显示预估/实际天数和超期标记、编辑弹窗、损耗拆分展示
|
||||||
|
- `views/Settlement.vue` — 损耗分析改为三列分阶段展示
|
||||||
|
- `api/index.js` — 新增 updateMilestone 方法
|
||||||
|
|
||||||
|
### 12.4 旧数据兼容
|
||||||
|
|
||||||
|
- 旧 content_type 自动迁移:`内容制作→动画制作`、`设定策划→策划案`、`剪辑后期→剪辑`
|
||||||
|
- 已有项目自动补充"剪辑"里程碑
|
||||||
|
- 无 estimated_days 的里程碑不参与损耗计算
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. V2.1 前端增强与路由修复(2026-02-14)
|
||||||
|
|
||||||
|
### 13.1 仪表盘双色进度条
|
||||||
|
|
||||||
|
进度超过 100% 的项目(如品牌方 TVC 258.3%),进度条分为蓝色(目标内)和红色(超出部分),直观反映超产程度。百分比数字在超 100% 时变为红色。
|
||||||
|
|
||||||
|
### 13.2 工时损耗展示
|
||||||
|
|
||||||
|
- **总损耗卡片**:在总损耗率下方显示工时损耗(如 `工时损耗 192h`)
|
||||||
|
- **项目进度条下方**:每个项目显示损耗率 + 工时损耗(如 `损耗 158.3% · 工时48h`)
|
||||||
|
- **损耗排行图表 tooltip**:鼠标悬浮显示秒数损耗和工时损耗
|
||||||
|
|
||||||
|
### 13.3 提交页延期原因修复
|
||||||
|
|
||||||
|
修复 `Submissions.vue` 中延期原因字段始终显示的 Bug(原条件 `!== undefined` 恒为 true)。改为根据所选项目+内容类型匹配里程碑,仅在里程碑超期时显示红色标签 + 警告 + 必填延期原因。
|
||||||
|
|
||||||
|
### 13.4 后端路由修复
|
||||||
|
|
||||||
|
- **catch-all SPA 路由拦截 API 请求**:`main.py` 的 `/{full_path:path}` 会匹配 `/api/*` 路径(因 API 路由注册时带尾部斜杠 `/api/projects/`,而请求不带斜杠 `/api/projects`)。修复:catch-all 对 `api/` 开头路径返回 404 + 所有路由改为无尾部斜杠(`@router.get("")`)+ 前端 API 调用同步去掉尾部斜杠。
|
||||||
|
|
||||||
|
### 13.5 演示数据(seed_demo.py)
|
||||||
|
|
||||||
|
重写种子数据,覆盖 5 个项目、4 种类型、32 个里程碑、124 条提交。所有 `created_at` 设为过去日期,前期结束后才有制作提交,owners/制片不提交,生产速率参照真实情况(60-120s/人/天)。
|
||||||
|
|
||||||
|
### 13.6 涉及文件
|
||||||
|
|
||||||
|
**后端**:
|
||||||
|
- `main.py` — 导入 HTTPException,catch-all 增加 API 路径判断
|
||||||
|
- `routers/projects.py` — `@router.get("")` 去尾部斜杠
|
||||||
|
- `routers/users.py` — 同上
|
||||||
|
- `routers/submissions.py` — 同上
|
||||||
|
- `routers/roles.py` — 同上
|
||||||
|
- `routers/dashboard.py` — project_summaries 补充 `waste_hours` 字段
|
||||||
|
- `seed_demo.py` — 全面重写
|
||||||
|
|
||||||
|
**前端**:
|
||||||
|
- `api/index.js` — 所有 list/create 请求去尾部斜杠
|
||||||
|
- `views/Dashboard.vue` — 双色进度条、工时损耗展示、chart tooltip 增强
|
||||||
|
- `views/Submissions.vue` — 延期原因计算属性 + 前端校验
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. 已知待办 / 待完善
|
||||||
|
|
||||||
- **历史修改记录**:后端已有 `SubmissionHistory`,前端完整展示与 diff 对比可完善
|
- **历史修改记录**:后端已有 `SubmissionHistory`,前端完整展示与 diff 对比可完善
|
||||||
- **数据导出**:PRD 提到 V2 支持 Excel/PDF,当前未实现
|
- **数据导出**:PRD 提到 V2 支持 Excel/PDF,当前未实现
|
||||||
- **移动端适配**:V2 计划
|
- **移动端适配**:V2 计划
|
||||||
- **数据库升级**:若用户量增长,可迁移至 PostgreSQL
|
- **数据库迁移工具**:当前用 `main.py` startup 里的幂等 ALTER TABLE,大规模重构时建议引入 Alembic
|
||||||
- **AI 智能问答**:V3 计划,前端聊天页面 + 自然语言查询
|
- **AI 智能问答**:V3 计划,前端聊天页面 + 自然语言查询
|
||||||
- **按权限分级推送**:V3 计划,不同角色收到不同报告内容
|
- **按权限分级推送**:V3 计划,不同角色收到不同报告内容
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 13. 默认账号
|
## 15. 默认账号
|
||||||
|
|
||||||
- 首次启动自动创建:`admin` / `admin123`(超级管理员)
|
- 首次启动自动创建:`admin` / `admin123`(超级管理员)
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user