feat: V2.1 三阶段损耗前端增强 + 路由修复 + 演示数据重写
All checks were successful
Build and Deploy Backend / build-and-deploy (push) Successful in 1m22s
Build and Deploy Web / build-and-deploy (push) Successful in 55s

- 仪表盘双色进度条(超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:
seaislee1209 2026-02-14 18:32:07 +08:00
parent 2b990f06fb
commit dc42306c24
17 changed files with 1347 additions and 440 deletions

View File

@ -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 框架 |
| 数据库 | SQLiteMVP | 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 - V3AI 智能问答助手(自然语言查询系统数据
--- ---

View File

@ -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,
} }

View File

@ -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:

View File

@ -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 = {
"策划案": "前期", "剧本": "前期", "分镜": "前期",
"人设图": "前期", "场景图": "前期",
"动画制作": "制作",
"配音": "后期", "音效": "后期", "修补镜头": "后期", "剪辑": "后期",
}

View File

@ -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,

View File

@ -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,

View File

@ -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),

View File

@ -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()

View File

@ -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),

View File

@ -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:

View File

@ -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,
))
# --- 项目DAI 短剧原创 --- # ────────────────────────────────────────────
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__":

View File

@ -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'),

View File

@ -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; }

View File

@ -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; }

View File

@ -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>

View File

@ -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 }
} }

View File

@ -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` — 导入 HTTPExceptioncatch-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`(超级管理员)