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

@ -109,6 +109,7 @@
- 项目状态
- 制作中
- 已完成(结算)
- 废弃(中途停止,全部产出记为损耗)
- 项目负责人
- 项目阶段结构
- 前期 / 制作 / 后期
@ -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 秒
测试阶段提交500 秒(测试损耗)
制作 + 后期补拍提交4,800 秒
超产损耗4,800 3,900 = 900 秒
项目总损耗500 + 900 = 1,400 秒
损耗率1,400 ÷ 3,900 = 35.9%
前期:策划案预估 5 天 / 实际 8 天 → 超 3 天 = 24h 损耗
制作:测试 500 秒 + 制作产出 4,800 秒 → 超产 900 秒 → 秒数损耗 1,400 秒
后期:剪辑预估 3 天 / 实际 3 天 → 无超期
秒数损耗率1,400 ÷ 3,900 = 35.9%
工时损耗24h
---
@ -381,12 +442,13 @@ V1 不做逐条通过率标记(太复杂),采用**人均基准对比法**
### 8.5 各阶段组损耗说明
| 阶段组 | 是否计算秒数损耗 | 说明 |
|--------|:---:|------|
| 前期组 | ❌ | 方案推翻重做属于正常创作流程,不按秒数计损耗 |
| 制作组 | ✅ | 核心损耗来源,超出目标秒数的部分即为损耗 |
| 后期组 — 剪辑 | ❌ | 剪辑为组装工作,无新增秒数 |
| 后期组 — 补拍 | ✅ | 补拍秒数计入损耗(属于返工) |
| 阶段组 | 损耗方式 | 说明 |
|--------|----------|------|
| 前期组 | **工时制** | 按里程碑预估 vs 实际天数,超期部分 × 8h 计为损耗 |
| 制作组 | **秒数制** | 核心损耗来源:测试损耗 + 超出目标秒数的超产损耗 |
| 后期组 — 剪辑 | **工时制** | 按里程碑预估 vs 实际天数 |
| 后期组 — 补拍 | **秒数制** | 补拍秒数计入制作损耗(与目标对比) |
| 后期组 — 配音/音效 | ❌ | 外包或 AI 生成,不计损耗 |
---
@ -413,8 +475,9 @@ V1 不做逐条通过率标记(太复杂),采用**人均基准对比法**
- 当前时间位置实时标注
#### 颜色规则
- 预估周期内:绿色
- 超出预估周期:红色延展
- 进度 ≤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 框架 |
| 数据库 | SQLiteMVP | 30 人以内完全够用,零配置 |
| 数据库 | MySQL 8.0(阿里云 RDS | 生产环境;本地开发可回退 SQLite |
| 图表 | ECharts | 仪表盘可视化 |
| 认证 | JWT Token | 登录鉴权与权限控制 |
| AI 模型 | 豆包(火山引擎 ARK | 报告摘要生成 |
| 消息推送 | 飞书自建应用 | 报告卡片消息 |
| 定时任务 | APScheduler | 日报/周报/月报定时触发 |
### 部署方式
- 阿里云轻量服务器
- 前端静态文件 + 后端 API 服务
- SQLite 数据库文件定期备份
- **CI/CD**Gitea Actions 自动构建,推代码即部署
- **镜像**Docker 构建,推送到华为云 SWR
- **运行**K3s 集群,通过 Ingress 暴露服务
- **前端**:静态构建后由后端统一托管(`/` 路径返回 index.html
- **数据库**:阿里云 RDS MySQL通过环境变量配置连接
### 后续扩展路径
- V2移动端响应式适配
- V2数据库升级至 PostgreSQL如用户量增长
- V2数据导出Excel / PDF
- V3移动端响应式适配
- V3数据导出Excel / PDF
- V3AI 智能问答助手(自然语言查询系统数据
---

View File

@ -167,15 +167,75 @@ def calc_overhead_cost_for_project(project_id: int, db: Session) -> float:
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:
"""
计算项目损耗
返回: {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()
if not project:
return {}
@ -188,31 +248,97 @@ def calc_waste_for_project(project_id: int, db: Session) -> dict:
Submission.total_seconds > 0,
).scalar() or 0
# 废弃项目:全部产出记为损耗
# ── 废弃项目:全部产出记为损耗 ──
if project.status == ProjectStatus.ABANDONED:
total_waste = total_submitted
test_waste = 0.0
overproduction_waste = total_submitted
waste_rate = 100.0 if total_submitted > 0 else 0.0
else:
# 测试损耗:工作类型为"测试"的全部秒数
test_waste = db.query(sa_func.sum(Submission.total_seconds)).filter(
Submission.project_id == project_id,
Submission.work_type == WorkType.TEST,
).scalar() or 0
# 超产损耗(仅计算生产性提交超出目标的部分,排除测试秒数避免双重计数)
production_submitted = total_submitted - test_waste
overproduction_waste = max(0, production_submitted - target)
total_waste = test_waste + overproduction_waste
waste_rate = round(total_waste / target * 100, 1) if target > 0 else 0
return {
"target_seconds": target,
"total_submitted_seconds": round(total_submitted, 1),
"pre_waste": {"waste_hours": 0, "details": []},
"production_waste": {
"test_waste_seconds": 0,
"overproduction_waste_seconds": round(total_submitted, 1),
"total_waste_seconds": round(total_submitted, 1),
},
"post_waste": {"days_waste_hours": 0, "details": []},
"total_waste_seconds": round(total_submitted, 1),
"total_waste_hours": 0,
"waste_rate": 100.0 if total_submitted > 0 else 0.0,
"test_waste_seconds": 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 {
"target_seconds": target,
"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),
"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
load_dotenv()
from fastapi import FastAPI
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
@ -13,6 +13,7 @@ from models import (
)
from auth import hash_password
from sqlalchemy.orm.attributes import flag_modified
from sqlalchemy import text, inspect
import os
import logging
@ -76,6 +77,9 @@ if os.path.exists(frontend_dir):
@app.get("/{full_path:path}")
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)
if os.path.isfile(file_path):
return FileResponse(file_path)
@ -109,6 +113,50 @@ def init_roles_and_admin():
from database import SessionLocal
db = SessionLocal()
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():
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}")
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成员角色
member_role = db.query(Role).filter(Role.name == "成员").first()
if member_role:

View File

@ -124,9 +124,20 @@ class WorkType(str, enum.Enum):
class ContentType(str, enum.Enum):
ANIMATION = "内容制作"
DESIGN = "设定策划"
EDITING = "剪辑后期"
# 前期
PLANNING = "策划案"
SCRIPT = "剧本"
STORYBOARD = "分镜"
CHARACTER_DESIGN = "人设图"
SCENE_DESIGN = "场景图"
# 制作
ANIMATION = "动画制作"
# 后期
DUBBING = "配音"
SOUND_EFFECTS = "音效"
SHOT_REPAIR = "修补镜头"
EDITING = "剪辑"
# 通用
OTHER = "其他"
@ -267,10 +278,13 @@ class Submission(Base):
submit_to = Column(SAEnum(SubmitTo), nullable=False)
description = Column(Text, nullable=True)
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())
user = relationship("User", back_populates="submissions")
project = relationship("Project", back_populates="submissions")
milestone = relationship("ProjectMilestone")
history = relationship("SubmissionHistory", back_populates="submission")
@ -379,6 +393,8 @@ class ProjectMilestone(Base):
is_completed = Column(Integer, nullable=False, default=0) # 0/1
completed_at = Column(DateTime, nullable=True)
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")
@ -395,5 +411,14 @@ DEFAULT_MILESTONES = [
{"name": "配音", "phase": "后期", "sort_order": 1},
{"name": "音效", "phase": "后期", "sort_order": 2},
{"name": "修补镜头", "phase": "后期", "sort_order": 3},
{"name": "杂项", "phase": "后期", "sort_order": 4},
{"name": "剪辑", "phase": "后期", "sort_order": 4},
{"name": "杂项", "phase": "后期", "sort_order": 5},
]
# 内容类型 → 阶段映射(用于自动设置阶段和关联里程碑)
CONTENT_PHASE_MAP = {
"策划案": "前期", "剧本": "前期", "分镜": "前期",
"人设图": "前期", "场景图": "前期",
"动画制作": "制作",
"配音": "后期", "音效": "后期", "修补镜头": "后期", "剪辑": "后期",
}

View File

@ -88,6 +88,7 @@ def get_dashboard(
"target_seconds": target,
"submitted_seconds": total_secs,
"waste_rate": waste.get("waste_rate", 0),
"waste_hours": waste.get("total_waste_hours", 0),
"is_overdue": bool(is_overdue),
"estimated_completion_date": str(p.estimated_completion_date) if p.estimated_completion_date else None,
})
@ -95,16 +96,19 @@ def get_dashboard(
# 损耗排行(含废弃项目,废弃项目全部产出记为损耗)
waste_ranking = []
total_waste_seconds_all = 0.0
total_waste_hours_all = 0.0
total_target_seconds_all = 0.0
for p in active + completed + abandoned:
w = calc_waste_for_project(p.id, db)
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
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({
"project_id": p.id,
"project_name": p.name,
"waste_seconds": w["total_waste_seconds"],
"waste_hours": w.get("total_waste_hours", 0),
"waste_rate": w["waste_rate"],
})
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,
"projects": project_summaries,
"total_waste_seconds": round(total_waste_seconds_all, 0),
"total_waste_hours": round(total_waste_hours_all, 0),
"total_waste_rate": total_waste_rate,
"waste_ranking": waste_ranking,
"settled_projects": settled,

View File

@ -12,16 +12,41 @@ from models import (
)
from schemas import (
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 datetime import date as date_type
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:
"""将项目对象转为带计算字段的输出"""
# 累计提交秒数(仅有秒数的提交)
# 累计提交秒数
total_secs = db.query(sa_func.sum(Submission.total_seconds)).filter(
Submission.project_id == p.id,
Submission.total_seconds > 0
@ -30,15 +55,11 @@ def enrich_project(p: Project, db: Session) -> ProjectOut:
target = p.target_total_seconds
progress = round(total_secs / target * 100, 1) if target > 0 else 0
# 损耗 = 测试损耗 + 超产损耗(排除测试秒数避免双重计数)
test_secs = db.query(sa_func.sum(Submission.total_seconds)).filter(
Submission.project_id == p.id,
Submission.work_type == WorkType.TEST
).scalar() or 0
production_secs = total_secs - test_secs
overproduction = max(0, production_secs - target)
waste = test_secs + overproduction
waste_rate = round(waste / target * 100, 1) if target > 0 else 0
# 集中损耗计算
waste_data = calc_waste_for_project(p.id, db)
waste_seconds = waste_data.get("total_waste_seconds", 0)
waste_hours = waste_data.get("total_waste_hours", 0)
waste_rate = waste_data.get("waste_rate", 0)
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
).order_by(ProjectMilestone.phase, ProjectMilestone.sort_order).all()
milestones_out = [
MilestoneOut(
id=m.id, name=m.name,
phase=m.phase.value if hasattr(m.phase, 'value') else m.phase,
is_completed=bool(m.is_completed),
completed_at=m.completed_at,
sort_order=m.sort_order,
)
for m in ms_rows
]
milestones_out = [_build_milestone_out(m) 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) == "前期"]
@ -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)
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": {
"progress_percent": progress,
"submitted_seconds": round(total_secs, 1),
"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,
total_submitted_seconds=round(total_secs, 1),
progress_percent=progress,
waste_seconds=round(waste, 1),
waste_seconds=round(waste_seconds, 1),
waste_hours=waste_hours,
waste_rate=waste_rate,
milestones=milestones_out,
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(
status: 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]
@router.post("/", response_model=ProjectOut)
@router.post("", response_model=ProjectOut)
def create_project(
req: ProjectCreate,
db: Session = Depends(get_db),
@ -260,16 +280,7 @@ def list_milestones(
ms = db.query(ProjectMilestone).filter(
ProjectMilestone.project_id == project_id
).order_by(ProjectMilestone.phase, ProjectMilestone.sort_order).all()
return [
MilestoneOut(
id=m.id, name=m.name,
phase=m.phase.value if hasattr(m.phase, 'value') else m.phase,
is_completed=bool(m.is_completed),
completed_at=m.completed_at,
sort_order=m.sort_order,
)
for m in ms
]
return [_build_milestone_out(m) for m in ms]
@router.post("/{project_id}/milestones", response_model=MilestoneOut)
@ -292,15 +303,12 @@ def add_milestone(
name=req.name,
phase=PhaseGroup(req.phase),
sort_order=max_order + 1,
estimated_days=req.estimated_days,
)
db.add(m)
db.commit()
db.refresh(m)
return MilestoneOut(
id=m.id, name=m.name,
phase=m.phase.value, is_completed=False,
completed_at=None, sort_order=m.sort_order,
)
return _build_milestone_out(m)
@router.put("/milestones/{milestone_id}/toggle")
@ -318,6 +326,25 @@ def toggle_milestone(
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}")
def delete_milestone(
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()]
@router.get("/")
@router.get("")
def list_roles(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
@ -40,7 +40,7 @@ def list_roles(
]
@router.post("/")
@router.post("")
def create_role(
req: dict,
db: Session = Depends(get_db),

View File

@ -5,9 +5,10 @@ from typing import List, Optional
from datetime import date
from database import get_db
from models import (
User, Submission, SubmissionHistory, Project,
PhaseGroup, WorkType, ContentType, SubmitTo
User, Submission, SubmissionHistory, Project, ProjectMilestone,
PhaseGroup, WorkType, ContentType, SubmitTo, CONTENT_PHASE_MAP
)
from datetime import timedelta
from schemas import SubmissionCreate, SubmissionUpdate, SubmissionOut
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,
description=s.description,
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,
)
@router.get("/", response_model=List[SubmissionOut])
@router.get("", response_model=List[SubmissionOut])
def list_submissions(
project_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]
@router.post("/", response_model=SubmissionOut)
@router.post("", response_model=SubmissionOut)
def create_submission(
req: SubmissionCreate,
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)
# 自动关联里程碑:根据 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(
user_id=current_user.id,
project_id=req.project_id,
@ -89,6 +111,8 @@ def create_submission(
submit_to=SubmitTo(req.submit_to),
description=req.description,
submit_date=req.submit_date,
milestone_id=milestone_id,
delay_reason=req.delay_reason,
)
db.add(sub)
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(
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("user:view"))
@ -33,7 +33,7 @@ def list_users(
return [user_to_out(u) for u in users]
@router.post("/", response_model=UserOut)
@router.post("", response_model=UserOut)
def create_user(
req: UserCreate,
db: Session = Depends(get_db),

View File

@ -68,6 +68,10 @@ class MilestoneOut(BaseModel):
is_completed: bool
completed_at: Optional[datetime] = None
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:
from_attributes = True
@ -76,6 +80,12 @@ class MilestoneOut(BaseModel):
class MilestoneCreate(BaseModel):
name: str
phase: str # 前期/后期
estimated_days: Optional[int] = None
class MilestoneUpdate(BaseModel):
estimated_days: Optional[int] = None
start_date: Optional[date] = None
class ProjectCreate(BaseModel):
@ -122,6 +132,7 @@ class ProjectOut(BaseModel):
total_submitted_seconds: Optional[float] = 0
progress_percent: Optional[float] = 0
waste_seconds: Optional[float] = 0
waste_hours: Optional[float] = 0
waste_rate: Optional[float] = 0
# 里程碑
milestones: Optional[List[MilestoneOut]] = []
@ -145,6 +156,7 @@ class SubmissionCreate(BaseModel):
submit_to: str
description: Optional[str] = None
submit_date: date
delay_reason: Optional[str] = None
class SubmissionUpdate(BaseModel):
@ -176,6 +188,8 @@ class SubmissionOut(BaseModel):
submit_to: str
description: Optional[str] = None
submit_date: date
milestone_name: Optional[str] = None
delay_reason: Optional[str] = None
created_at: Optional[datetime] = None
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 *
db = SessionLocal()
@ -13,6 +22,11 @@ def get_user(username):
return u
def dt(d):
"""date → datetime用于 completed_at"""
return datetime.combine(d, datetime.min.time())
def seed_demo():
# 清除旧的项目相关数据(不动 users 和 roles
db.query(SubmissionHistory).delete()
@ -21,341 +35,555 @@ def seed_demo():
db.query(CostOverride).delete()
db.query(AIToolCost).delete()
db.query(OverheadCost).delete()
db.query(ProjectMilestone).delete()
db.query(Project).delete()
db.commit()
print("[1] Cleared old project data")
# 获取真实用户
huhaonan = get_user("huhaonan") # 主管/总导演
dengqingrui = get_user("dengqingrui") # 主管/AI导演
qiushaohui = get_user("qiushaohui") # 主管/制片
# ── 获取真实用户 ──
# owners / 制片:不提交任何东西
huhaonan = get_user("huhaonan") # owner/总导演
dengqingrui = get_user("dengqingrui") # owner/AI导演
qiushaohui = get_user("qiushaohui") # 制片
# 组长:可以提交
chenbaodan = get_user("chenbaodan") # 组长/动画制作
maruoqing = get_user("maruoqing") # 组长/AI导演
weichunli = get_user("weichunli") # 组长/AI导演
panziyan = get_user("panziyan") # 组长/剪辑
daixiaoqian = get_user("daixiaoqian") # 组员/动画制作
tanruping = get_user("tanruping") # 组员/动画制作
zhengyiqing = get_user("zhengyiqing") # 组员/动画制作
huangxuewen = get_user("huangxuewen") # 组员/动画制作
liushiqi = get_user("liushiqi") # 组员/动画制作
daiwei = get_user("daiwei") # 组员/动画制作
huangrongying = get_user("huangrongying") # 组员/编剧
jiahaozheng = get_user("jiahaozheng") # 组员/剪辑
wangyansen = get_user("wangyansen") # 组员/剪辑
huangqiuxia = get_user("huangqiuxia") # 组员/动画制作
lijing = get_user("lijing") # 组员/动画制作
yemeilian = get_user("yemeilian") # 组员/动画制作
chenxuanying = get_user("chenxuanying") # 组员/动画制作
# ── 项目 ──
proj_a = Project(
name="星际漫游 第一季", project_type=ProjectType.CLIENT_FORMAL,
leader_id=huhaonan.id, current_phase=PhaseGroup.PRODUCTION,
episode_duration_minutes=5, episode_count=13,
estimated_completion_date=date.today() + timedelta(days=60),
contract_amount=100000,
)
proj_b = Project(
# 组员
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") # 动画制作
today = date.today()
# ══════════════════════════════════════════════════════════════
# 项目 1: 品牌方 TVC 宣传片
# 客户正式 | 后期 | target=180s | 创建于 12/8
# 进度>200% → 双色进度条
# 分镜+剪辑超期 → waste_hours
# ══════════════════════════════════════════════════════════════
proj_tvc = Project(
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,
estimated_completion_date=date.today() + timedelta(days=20),
estimated_completion_date=date(2026, 2, 20),
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,
leader_id=maruoqing.id, current_phase=PhaseGroup.PRE,
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 天的数据) ──
base = date.today() - timedelta(days=20)
db.add_all([proj_tvc, proj_star, proj_orig, proj_orig_test, proj_client_test])
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 = []
# --- 项目A星际漫游 ---
# 黄溶莹 - 编剧 - 前期方案
for i in range(6):
d = base + timedelta(days=i)
def add(user, proj, phase, wt, ct, secs, d, desc, hours=None):
"""快捷添加提交"""
subs.append(Submission(
user_id=huangrongying.id, project_id=proj_a.id,
project_phase=PhaseGroup.PRE, work_type=WorkType.PLAN,
content_type=ContentType.DESIGN, total_seconds=0,
submit_to=SubmitTo.INTERNAL, description=f"{i+1}集剧本初稿",
submit_date=d,
user_id=user.id, project_id=proj.id,
project_phase=phase, work_type=wt,
content_type=ct, total_seconds=secs,
duration_minutes=secs // 60 if secs else 0,
duration_seconds=secs % 60 if secs else 0,
hours_spent=hours, submit_to=SubmitTo.INTERNAL,
description=desc, submit_date=d,
))
# 陈保丹 - 组长 - 动画制作
for i in range(12):
d = base + timedelta(days=i + 3)
secs = 55 + (i % 3) * 20
wt = WorkType.TEST if i < 2 else WorkType.PRODUCTION
subs.append(Submission(
user_id=chenbaodan.id, project_id=proj_a.id,
project_phase=PhaseGroup.PRODUCTION, work_type=wt,
content_type=ContentType.ANIMATION, total_seconds=secs,
duration_minutes=secs // 60, duration_seconds=secs % 60,
submit_to=SubmitTo.INTERNAL, description=f"第1集场景{i+1}动画",
submit_date=d,
))
PRE = PhaseGroup.PRE
PROD = PhaseGroup.PRODUCTION
POST = PhaseGroup.POST
PLAN = WorkType.PLAN
MFG = WorkType.PRODUCTION
TEST = WorkType.TEST
# 代晓倩 - 动画制作
for i in range(10):
d = base + timedelta(days=i + 2)
secs = 40 + (i % 4) * 15
subs.append(Submission(
user_id=daixiaoqian.id, project_id=proj_a.id,
project_phase=PhaseGroup.PRODUCTION, work_type=WorkType.PRODUCTION,
content_type=ContentType.ANIMATION, total_seconds=secs,
duration_minutes=secs // 60, duration_seconds=secs % 60,
submit_to=SubmitTo.LEADER, description=f"第2集片段{i+1}",
submit_date=d,
))
# ────────────────────────────────────────────
# 品牌方 TVC 宣传片 (target=180s, 创建 12/8)
# 前期: 12/8 - 12/18
# 制作: 12/22 - 1/10
# 后期: 1/27 - now
# ────────────────────────────────────────────
# 谭如平 - 动画制作
for i in range(8):
d = base + timedelta(days=i + 4)
secs = 35 + (i % 3) * 25
wt = WorkType.TEST if i == 0 else WorkType.PRODUCTION
subs.append(Submission(
user_id=tanruping.id, project_id=proj_a.id,
project_phase=PhaseGroup.PRODUCTION, work_type=wt,
content_type=ContentType.ANIMATION, total_seconds=secs,
duration_minutes=secs // 60, duration_seconds=secs % 60,
submit_to=SubmitTo.LEADER, description=f"第3集镜头{i+1}",
submit_date=d,
))
# 前期 - 策划案 (12/8-9)
add(huangrongying, proj_tvc, PRE, PLAN, ContentType.PLANNING, 0, date(2025, 12, 8), "TVC 策划案初稿", 4)
add(huangrongying, proj_tvc, PRE, PLAN, ContentType.PLANNING, 0, date(2025, 12, 9), "TVC 策划案定稿", 3)
# 郑奕晴 - 动画制作
for i in range(9):
d = base + timedelta(days=i + 3)
secs = 45 + (i % 2) * 30
subs.append(Submission(
user_id=zhengyiqing.id, project_id=proj_a.id,
project_phase=PhaseGroup.PRODUCTION, work_type=WorkType.PRODUCTION,
content_type=ContentType.ANIMATION, total_seconds=secs,
duration_minutes=secs // 60, duration_seconds=secs % 60,
submit_to=SubmitTo.LEADER, description=f"第4集场景动画{i+1}",
submit_date=d,
))
# 前期 - 分镜 (12/10-15, 比预期多1天)
add(daixiaoqian, proj_tvc, PRE, PLAN, ContentType.STORYBOARD, 0, date(2025, 12, 10), "TVC 分镜初稿", 5)
add(daixiaoqian, proj_tvc, PRE, PLAN, ContentType.STORYBOARD, 0, date(2025, 12, 11), "TVC 分镜修改", 4)
add(daixiaoqian, proj_tvc, PRE, PLAN, ContentType.STORYBOARD, 0, date(2025, 12, 15), "TVC 分镜终稿", 3)
# 黄雪雯 - 动画制作
for i in range(7):
d = base + timedelta(days=i + 5)
secs = 30 + (i % 3) * 20
subs.append(Submission(
user_id=huangxuewen.id, project_id=proj_a.id,
project_phase=PhaseGroup.PRODUCTION, work_type=WorkType.PRODUCTION,
content_type=ContentType.ANIMATION, total_seconds=secs,
duration_minutes=secs // 60, duration_seconds=secs % 60,
submit_to=SubmitTo.LEADER, description=f"第5集片段{i+1}",
submit_date=d,
))
# 前期 - 人设图 (12/15-16)
add(huangxuewen, proj_tvc, PRE, PLAN, ContentType.CHARACTER_DESIGN, 0, date(2025, 12, 15), "TVC 人设图初稿", 5)
add(huangxuewen, proj_tvc, PRE, PLAN, ContentType.CHARACTER_DESIGN, 0, date(2025, 12, 16), "TVC 人设图定稿", 3)
# 潘梓彦 - 剪辑 - 后期
for i in range(4):
d = base + timedelta(days=i + 14)
subs.append(Submission(
user_id=panziyan.id, project_id=proj_a.id,
project_phase=PhaseGroup.POST, work_type=WorkType.PRODUCTION,
content_type=ContentType.EDITING, total_seconds=0,
submit_to=SubmitTo.PRODUCER, description=f"{i+1}集粗剪",
submit_date=d,
))
# 前期 - 场景图 (12/17)
add(huangxuewen, proj_tvc, PRE, PLAN, ContentType.SCENE_DESIGN, 0, date(2025, 12, 17), "TVC 场景图", 4)
# 贾浩正 - 剪辑
for i in range(3):
d = base + timedelta(days=i + 15)
subs.append(Submission(
user_id=jiahaozheng.id, project_id=proj_a.id,
project_phase=PhaseGroup.POST, work_type=WorkType.PRODUCTION,
content_type=ContentType.EDITING, total_seconds=0,
submit_to=SubmitTo.PRODUCER, description=f"{i+5}集粗剪",
submit_date=d,
))
# 制作 - 马若情 (12/22 - 12/26 第一轮, 1/8-9 修改轮)
add(maruoqing, proj_tvc, PROD, TEST, ContentType.ANIMATION, 35, date(2025, 12, 22), "TVC 片段测试", 3)
add(maruoqing, proj_tvc, PROD, MFG, ContentType.ANIMATION, 70, date(2025, 12, 23), "TVC 第1集场景1", 4)
add(maruoqing, proj_tvc, PROD, MFG, ContentType.ANIMATION, 65, date(2025, 12, 24), "TVC 第1集场景2", 4)
add(maruoqing, proj_tvc, PROD, MFG, ContentType.ANIMATION, 75, date(2025, 12, 25), "TVC 第2集场景1", 4)
add(maruoqing, proj_tvc, PROD, MFG, ContentType.ANIMATION, 60, date(2025, 12, 26), "TVC 第2-3集", 4)
# 甲方反馈后修改
add(maruoqing, proj_tvc, PROD, MFG, ContentType.ANIMATION, 55, date(2026, 1, 8), "TVC 修改-第1集重做", 3)
add(maruoqing, proj_tvc, PROD, MFG, ContentType.ANIMATION, 50, date(2026, 1, 9), "TVC 修改-第3集调整", 3)
# --- 项目B品牌方 TVC ---
# 马若情 - AI导演
for i in range(6):
d = base + timedelta(days=i + 5)
secs = 20 + i * 10
subs.append(Submission(
user_id=maruoqing.id, project_id=proj_b.id,
project_phase=PhaseGroup.PRODUCTION, work_type=WorkType.PRODUCTION,
content_type=ContentType.ANIMATION, total_seconds=secs,
duration_minutes=secs // 60, duration_seconds=secs % 60,
submit_to=SubmitTo.INTERNAL, description=f"TVC 片段{i+1}",
submit_date=d,
))
# 后期 - 修补镜头 (秒数,归入制作计算) 1/27-28
add(liushiqi, proj_tvc, POST, MFG, ContentType.SHOT_REPAIR, 25, date(2026, 1, 27), "TVC 修补镜头1", 1.5)
add(liushiqi, proj_tvc, POST, MFG, ContentType.SHOT_REPAIR, 30, date(2026, 1, 28), "TVC 修补镜头2", 1.5)
# 刘诗琪 - 动画制作
for i in range(5):
d = base + timedelta(days=i + 7)
secs = 15 + (i % 3) * 10
subs.append(Submission(
user_id=liushiqi.id, project_id=proj_b.id,
project_phase=PhaseGroup.PRODUCTION, work_type=WorkType.PRODUCTION,
content_type=ContentType.ANIMATION, total_seconds=secs,
duration_minutes=secs // 60, duration_seconds=secs % 60,
submit_to=SubmitTo.LEADER, description=f"TVC 补充镜头{i+1}",
submit_date=d,
))
# 后期 - 配音 (1/27-29, 每天1集)
add(panziyan, proj_tvc, POST, MFG, ContentType.DUBBING, 0, date(2026, 1, 27), "TVC 第1集配音", 4)
add(panziyan, proj_tvc, POST, MFG, ContentType.DUBBING, 0, date(2026, 1, 28), "TVC 第2集配音", 4)
add(panziyan, proj_tvc, POST, MFG, ContentType.DUBBING, 0, date(2026, 1, 29), "TVC 第3集配音", 3)
# 王炎森 - 剪辑
for i in range(3):
d = base + timedelta(days=i + 13)
subs.append(Submission(
user_id=wangyansen.id, project_id=proj_b.id,
project_phase=PhaseGroup.POST, work_type=WorkType.PRODUCTION,
content_type=ContentType.EDITING, total_seconds=0,
submit_to=SubmitTo.PRODUCER, description=f"TVC 第{i+1}版剪辑",
submit_date=d,
))
# 后期 - 音效 (1/29-30)
add(panziyan, proj_tvc, POST, MFG, ContentType.SOUND_EFFECTS, 0, date(2026, 1, 29), "TVC 音效设计", 5)
add(panziyan, proj_tvc, POST, MFG, ContentType.SOUND_EFFECTS, 0, date(2026, 1, 30), "TVC 音效终混", 3)
# --- 项目C甲方风格测试 ---
for i in range(3):
d = base + timedelta(days=i + 1)
subs.append(Submission(
user_id=huangrongying.id, project_id=proj_c.id,
project_phase=PhaseGroup.PRE, work_type=WorkType.PLAN,
content_type=ContentType.DESIGN, total_seconds=0,
submit_to=SubmitTo.INTERNAL, description=f"风格方案{i+1}",
submit_date=d,
))
for i in range(4):
d = base + timedelta(days=i + 4)
secs = 10 + i * 5
subs.append(Submission(
user_id=daiwei.id, project_id=proj_c.id,
project_phase=PhaseGroup.PRODUCTION, work_type=WorkType.TEST,
content_type=ContentType.ANIMATION, total_seconds=secs,
duration_minutes=secs // 60, duration_seconds=secs % 60,
submit_to=SubmitTo.INTERNAL, description=f"风格测试片段{i+1}",
submit_date=d,
))
# 后期 - 剪辑 (2/3-now, 多轮甲方反馈导致超期)
add(wangyansen, proj_tvc, POST, MFG, ContentType.EDITING, 0, date(2026, 2, 3), "TVC 第1-2集粗剪", 6)
add(wangyansen, proj_tvc, POST, MFG, ContentType.EDITING, 0, date(2026, 2, 4), "TVC 第3集粗剪+精剪", 5)
add(wangyansen, proj_tvc, POST, MFG, ContentType.EDITING, 0, date(2026, 2, 10), "TVC 甲方反馈后重剪", 5)
add(wangyansen, proj_tvc, POST, MFG, ContentType.EDITING, 0, date(2026, 2, 12), "TVC 第二轮反馈修改", 4)
# --- 项目DAI 短剧原创 ---
for i in range(5):
d = base + timedelta(days=i)
subs.append(Submission(
user_id=huangrongying.id, project_id=proj_d.id,
project_phase=PhaseGroup.PRE, work_type=WorkType.PLAN,
content_type=ContentType.DESIGN, total_seconds=0,
submit_to=SubmitTo.INTERNAL, description=f"原创剧本第{i+1}集大纲",
submit_date=d,
))
for i in range(6):
d = base + timedelta(days=i + 8)
secs = 60 + (i % 3) * 25
subs.append(Submission(
user_id=weichunli.id, project_id=proj_d.id,
project_phase=PhaseGroup.PRODUCTION, work_type=WorkType.PRODUCTION,
content_type=ContentType.ANIMATION, total_seconds=secs,
duration_minutes=secs // 60, duration_seconds=secs % 60,
submit_to=SubmitTo.INTERNAL, description=f"原创第1集片段{i+1}",
submit_date=d,
))
for i in range(5):
d = base + timedelta(days=i + 10)
secs = 50 + (i % 2) * 35
subs.append(Submission(
user_id=huangqiuxia.id, project_id=proj_d.id,
project_phase=PhaseGroup.PRODUCTION, work_type=WorkType.PRODUCTION,
content_type=ContentType.ANIMATION, total_seconds=secs,
duration_minutes=secs // 60, duration_seconds=secs % 60,
submit_to=SubmitTo.LEADER, description=f"原创第2集动画{i+1}",
submit_date=d,
))
for i in range(4):
d = base + timedelta(days=i + 12)
secs = 45 + i * 15
subs.append(Submission(
user_id=lijing.id, project_id=proj_d.id,
project_phase=PhaseGroup.PRODUCTION, work_type=WorkType.PRODUCTION,
content_type=ContentType.ANIMATION, total_seconds=secs,
duration_minutes=secs // 60, duration_seconds=secs % 60,
submit_to=SubmitTo.LEADER, description=f"原创第3集片段{i+1}",
submit_date=d,
))
for i in range(3):
d = base + timedelta(days=i + 14)
secs = 40 + i * 20
subs.append(Submission(
user_id=yemeilian.id, project_id=proj_d.id,
project_phase=PhaseGroup.PRODUCTION, work_type=WorkType.PRODUCTION,
content_type=ContentType.ANIMATION, total_seconds=secs,
duration_minutes=secs // 60, duration_seconds=secs % 60,
submit_to=SubmitTo.LEADER, description=f"原创第4集动画{i+1}",
submit_date=d,
))
for i in range(3):
d = base + timedelta(days=i + 15)
secs = 55 + (i % 2) * 20
subs.append(Submission(
user_id=chenxuanying.id, project_id=proj_d.id,
project_phase=PhaseGroup.PRODUCTION, work_type=WorkType.PRODUCTION,
content_type=ContentType.ANIMATION, total_seconds=secs,
duration_minutes=secs // 60, duration_seconds=secs % 60,
submit_to=SubmitTo.LEADER, description=f"原创第5集场景{i+1}",
submit_date=d,
))
# ────────────────────────────────────────────
# 星际漫游 第一季 (target=3900s, 创建 12/22)
# 前期: 12/22 - 1/13
# 制作: 1/14 - now
# ────────────────────────────────────────────
# 前期 - 策划案 (12/22-23)
add(huangrongying, proj_star, PRE, PLAN, ContentType.PLANNING, 0, date(2025, 12, 22), "星际漫游 世界观策划案", 4)
add(huangrongying, proj_star, PRE, PLAN, ContentType.PLANNING, 0, date(2025, 12, 23), "星际漫游 策划案终稿", 3)
# 前期 - 剧本 (12/24-30, 5个工作日)
for i, d in enumerate([date(2025, 12, 24), date(2025, 12, 25), date(2025, 12, 26),
date(2025, 12, 29), date(2025, 12, 30)]):
add(huangrongying, proj_star, PRE, PLAN, ContentType.SCRIPT, 0, d,
f"{i*3+1}-{i*3+3}集剧本", 6)
# 前期 - 分镜 (12/31-1/7, 比预期多, 导致超期 → 16h waste)
for i, d in enumerate([date(2025, 12, 31), date(2026, 1, 2), date(2026, 1, 5),
date(2026, 1, 6), date(2026, 1, 7)]):
add(daixiaoqian, proj_star, PRE, PLAN, ContentType.STORYBOARD, 0, d,
f"{i+1}批分镜({i*3+1}-{i*3+3}集)", 5)
# 前期 - 人设图 (1/8-9)
add(huangxuewen, proj_star, PRE, PLAN, ContentType.CHARACTER_DESIGN, 0, date(2026, 1, 8), "主要角色人设图", 5)
add(huangxuewen, proj_star, PRE, PLAN, ContentType.CHARACTER_DESIGN, 0, date(2026, 1, 9), "配角人设图", 4)
# 前期 - 场景图 (1/10-13)
add(huangxuewen, proj_star, PRE, PLAN, ContentType.SCENE_DESIGN, 0, date(2026, 1, 12), "太空站场景图", 5)
add(huangxuewen, proj_star, PRE, PLAN, ContentType.SCENE_DESIGN, 0, date(2026, 1, 13), "星球表面场景图", 4)
# 制作 - 1/14 起 (~22个工作日到今天)
# 3个动画师,不是每天都在这个项目上
# 陈保丹: 14次提交 (2测试 + 12制作)
star_anim_dates_chen = [
date(2026, 1, 14), date(2026, 1, 15), date(2026, 1, 16), date(2026, 1, 19),
date(2026, 1, 20), date(2026, 1, 22), date(2026, 1, 23), date(2026, 1, 26),
date(2026, 1, 28), date(2026, 1, 30), date(2026, 2, 3), date(2026, 2, 5),
date(2026, 2, 9), date(2026, 2, 11),
]
star_secs_chen = [70, 80, 85, 75, 90, 80, 95, 70, 85, 90, 80, 75, 85, 80]
for i, (d, s) in enumerate(zip(star_anim_dates_chen, star_secs_chen)):
wt = TEST if i < 2 else MFG
add(chenbaodan, proj_star, PROD, wt, ContentType.ANIMATION, s, d,
f"{(i//2)+1}集 场景{(i%4)+1}{'(测试)' if wt == TEST else ''}", 3.5)
# 谭如平: 12次提交 (1测试 + 11制作)
star_anim_dates_tan = [
date(2026, 1, 15), date(2026, 1, 16), date(2026, 1, 19), date(2026, 1, 21),
date(2026, 1, 23), date(2026, 1, 27), date(2026, 1, 29), date(2026, 2, 2),
date(2026, 2, 4), date(2026, 2, 6), date(2026, 2, 10), date(2026, 2, 12),
]
star_secs_tan = [75, 70, 80, 65, 85, 70, 75, 80, 90, 70, 75, 85]
for i, (d, s) in enumerate(zip(star_anim_dates_tan, star_secs_tan)):
wt = TEST if i < 1 else MFG
add(tanruping, proj_star, PROD, wt, ContentType.ANIMATION, s, d,
f"{(i//2)+3}集 镜头{(i%3)+1}{'(测试)' if wt == TEST else ''}", 3)
# 郑奕晴: 11次提交 (1测试 + 10制作)
star_anim_dates_zheng = [
date(2026, 1, 16), date(2026, 1, 20), date(2026, 1, 22), date(2026, 1, 26),
date(2026, 1, 28), date(2026, 2, 2), date(2026, 2, 4), date(2026, 2, 6),
date(2026, 2, 10), date(2026, 2, 12), date(2026, 2, 13),
]
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)
print(f"[3] Created {len(subs)} submissions")
# ── AI 工具成本 ──
# ══════════════════════════════════════════════════════════════
# 成本数据
# ══════════════════════════════════════════════════════════════
# AI 工具成本
db.add(AIToolCost(
tool_name="Midjourney", subscription_period=SubscriptionPeriod.MONTHLY,
amount=200, allocation_type=CostAllocationType.TEAM,
recorded_by=qiushaohui.id, record_date=date.today().replace(day=1),
recorded_by=qiushaohui.id, record_date=today.replace(day=1),
))
db.add(AIToolCost(
tool_name="Runway", subscription_period=SubscriptionPeriod.MONTHLY,
amount=600, allocation_type=CostAllocationType.PROJECT,
project_id=proj_a.id,
recorded_by=qiushaohui.id, record_date=date.today().replace(day=1),
project_id=proj_star.id,
recorded_by=qiushaohui.id, record_date=today.replace(day=1),
))
db.add(AIToolCost(
tool_name="ChatGPT Plus", subscription_period=SubscriptionPeriod.MONTHLY,
amount=150, allocation_type=CostAllocationType.TEAM,
recorded_by=qiushaohui.id, record_date=date.today().replace(day=1),
recorded_by=qiushaohui.id, record_date=today.replace(day=1),
))
print("[4] Created 3 AI tool costs")
# ── 外包成本 ──
# 外包成本
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,
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")
# ── 固定开支 ──
# 固定开支
db.add(OverheadCost(
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="办公室月租",
))
db.add(OverheadCost(
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="水电费",
))
print("[6] Created 2 overhead costs")
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" 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__":

View File

@ -47,16 +47,16 @@ export const authApi = {
// ── 用户 ──
export const userApi = {
list: () => api.get('/users/'),
create: (data) => api.post('/users/', data),
list: () => api.get('/users'),
create: (data) => api.post('/users', data),
update: (id, data) => api.put(`/users/${id}`, data),
get: (id) => api.get(`/users/${id}`),
}
// ── 项目 ──
export const projectApi = {
list: (params) => api.get('/projects/', { params }),
create: (data) => api.post('/projects/', data),
list: (params) => api.get('/projects', { params }),
create: (data) => api.post('/projects', data),
update: (id, data) => api.put(`/projects/${id}`, data),
get: (id) => api.get(`/projects/${id}`),
delete: (id) => api.delete(`/projects/${id}`),
@ -66,13 +66,14 @@ export const projectApi = {
milestones: (id) => api.get(`/projects/${id}/milestones`),
addMilestone: (id, data) => api.post(`/projects/${id}/milestones`, data),
toggleMilestone: (milestoneId) => api.put(`/projects/milestones/${milestoneId}/toggle`),
updateMilestone: (milestoneId, data) => api.put(`/projects/milestones/${milestoneId}`, data),
deleteMilestone: (milestoneId) => api.delete(`/projects/milestones/${milestoneId}`),
}
// ── 内容提交 ──
export const submissionApi = {
list: (params) => api.get('/submissions/', { params }),
create: (data) => api.post('/submissions/', data),
list: (params) => api.get('/submissions', { params }),
create: (data) => api.post('/submissions', data),
update: (id, data) => api.put(`/submissions/${id}`, data),
history: (id) => api.get(`/submissions/${id}/history`),
}
@ -94,8 +95,8 @@ export const costApi = {
// ── 角色 ──
export const roleApi = {
list: () => api.get('/roles/'),
create: (data) => api.post('/roles/', data),
list: () => api.get('/roles'),
create: (data) => api.post('/roles', data),
update: (id, data) => api.put(`/roles/${id}`, data),
delete: (id) => api.delete(`/roles/${id}`),
permissions: () => api.get('/roles/permissions'),

View File

@ -61,6 +61,7 @@
{{ data.total_waste_rate || 0 }}%
</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>
@ -118,12 +119,23 @@
<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>
</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>
<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">
<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>
<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 names = sorted.map(r => r.project_name.length > 8 ? r.project_name.slice(0,8) + '…' : r.project_name)
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 },
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 } },
@ -420,6 +438,18 @@ onUnmounted(() => {
.progress-pct { font-size: 14px; font-weight: 600; color: var(--primary); }
.progress-meta { display: flex; justify-content: space-between; font-size: 12px; color: var(--text-secondary); margin-top: 6px; }
/* 双色进度条 */
.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.loss { color: #FF3B30; }
.profit-text { font-weight: 600; color: #34C759; }

View File

@ -63,6 +63,7 @@
<div class="stat-card">
<div class="stat-label">损耗率</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>
@ -111,7 +112,10 @@
<div class="milestone-columns">
<!-- 前期 -->
<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">
<el-checkbox
:model-value="m.is_completed"
@ -119,6 +123,11 @@
:disabled="!authStore.hasPermission('project:edit')"
/>
<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"
@click="deleteMilestone(m.id)"><el-icon size="12"><Close /></el-icon></el-button>
</div>
@ -133,7 +142,7 @@
<!-- 制作 -->
<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 ref="progressChartRef" style="width:180px;height:180px;flex-shrink:0"></div>
<div class="production-info">
@ -146,8 +155,12 @@
<span class="prod-info-value">{{ formatSecs(project.target_total_seconds) }}</span>
</div>
<div class="prod-info-row">
<span class="prod-info-label">损耗</span>
<span class="prod-info-value" :style="{color: project.waste_rate > 30 ? '#FF3B30' : 'inherit'}">{{ formatSecs(project.waste_seconds) }}</span>
<span class="prod-info-label">测试损耗</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 class="prod-info-row">
<span class="prod-info-label">损耗率</span>
@ -159,7 +172,10 @@
<!-- 后期 -->
<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">
<el-checkbox
:model-value="m.is_completed"
@ -167,6 +183,11 @@
:disabled="!authStore.hasPermission('project:edit')"
/>
<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"
@click="deleteMilestone(m.id)"><el-icon size="12"><Close /></el-icon></el-button>
</div>
@ -365,6 +386,28 @@
</template>
</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-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 preMilestones = 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 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) {
try {
await projectApi.toggleMilestone(m.id)
@ -826,16 +898,25 @@ onUnmounted(() => {
.milestone-col-header {
font-size: 13px; font-weight: 600; color: var(--text-primary);
margin-bottom: 12px; padding-bottom: 8px; border-bottom: 1px solid var(--border-light, #f0f1f2);
display: flex; justify-content: space-between; align-items: center;
}
.milestone-item {
display: flex; align-items: center; gap: 6px; padding: 4px 0;
}
.milestone-name { font-size: 13px; color: var(--text-primary); flex: 1; }
.milestone-name.completed { color: var(--text-placeholder, #C0C4CC); text-decoration: line-through; }
.milestone-del { opacity: 0; transition: opacity 0.15s; padding: 2px !important; }
.milestone-item:hover .milestone-del { opacity: 1; }
.milestone-del, .milestone-edit { opacity: 0; transition: opacity 0.15s; padding: 2px !important; }
.milestone-item:hover .milestone-del, .milestone-item:hover .milestone-edit { opacity: 1; }
.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 .milestone-col-header { margin-bottom: 4px; }

View File

@ -62,14 +62,46 @@
<!-- 损耗分析 -->
<el-card class="section-card">
<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-descriptions-item label="超产损耗">{{ formatSecs(data.overproduction_waste_seconds) }}</el-descriptions-item>
<el-descriptions-item label="总损耗">{{ formatSecs(data.total_waste_seconds) }}</el-descriptions-item>
<el-descriptions-item label="损耗率">
<span :style="{color: data.waste_rate > 30 ? '#f56c6c' : '#333', fontWeight:600}">{{ data.waste_rate }}%</span>
</el-descriptions-item>
</el-descriptions>
<!-- 分阶段损耗 -->
<el-row :gutter="16" class="waste-phase-row">
<el-col :span="8">
<div class="waste-phase-card">
<div class="waste-phase-title">前期工时制</div>
<div class="waste-phase-value" :class="{ danger: preWasteHours > 0 }">{{ preWasteHours }}h</div>
<div class="waste-phase-detail" v-for="d in (data.pre_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.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>
<!-- 团队效率 -->
@ -99,7 +131,7 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { projectApi } from '../api'
import { useAuthStore } from '../stores/auth'
@ -110,6 +142,9 @@ const route = useRoute()
const loading = ref(false)
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 formatSecs(s) {
if (!s) return '0秒'
@ -134,4 +169,15 @@ onMounted(async () => {
.stat-label { font-size: 12px; color: var(--text-secondary); margin-bottom: 6px; }
.stat-value { font-size: 22px; font-weight: 700; color: var(--text-primary); }
.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>

View File

@ -67,9 +67,22 @@
</el-row>
<el-form-item label="内容类型" required>
<el-select v-model="form.content_type" placeholder="本次提交的产出类型" style="width:100%">
<el-option label="内容制作 — 动画/视频制作" value="内容制作" />
<el-option label="设定策划 — 剧本/方案/设定图" value="设定策划" />
<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-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-select>
</el-form-item>
@ -97,6 +110,14 @@
<el-option label="外部 — 提交给甲方/客户" value="外部" />
</el-select>
</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-input v-model="form.description" type="textarea" :rows="2" placeholder="简要描述本次提交内容" />
</el-form-item>
@ -113,10 +134,16 @@
</template>
<script setup>
import { ref, computed, onMounted, reactive } from 'vue'
import { ref, computed, onMounted, reactive, watch } from 'vue'
import { submissionApi, projectApi } from '../api'
import { ElMessage } from 'element-plus'
const CONTENT_PHASE_MAP = {
'策划案': '前期', '剧本': '前期', '分镜': '前期', '人设图': '前期', '场景图': '前期',
'动画制作': '制作',
'配音': '后期', '音效': '后期', '修补镜头': '后期', '剪辑': '后期',
}
const loading = ref(false)
const creating = 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 form = reactive({
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,
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) {
@ -154,6 +204,10 @@ async function load() {
async function handleCreate() {
if (!form.project_id) { ElMessage.warning('请选择项目'); return }
if (isMilestoneOverdue.value && !form.delay_reason?.trim()) {
ElMessage.warning('该里程碑已超期,请填写延期原因')
return
}
creating.value = true
try {
await submissionApi.create(form)
@ -164,6 +218,7 @@ async function handleCreate() {
form.duration_seconds = 0
form.hours_spent = null
form.description = ''
form.delay_reason = ''
load()
} finally { creating.value = false }
}

View File

@ -1,7 +1,7 @@
# AirLabs Project 项目总结文档
> 内容组 · 项目制周期 / 成本 / 产出管理系统
> 更新日期2025-02-11
> 更新日期2026-02-14
---
@ -35,7 +35,7 @@
|------|----------|------|
| 前端 | Vue 3 + Element Plus + Vite | 后台管理 UI |
| 后端 | Python FastAPI | 高性能 API |
| 数据库 | SQLite | 零配置MVP 适用 |
| 数据库 | MySQL 8.0(阿里云 RDS | 生产环境;本地开发可回退 SQLite |
| 图表 | ECharts | 仪表盘可视化 |
| 认证 | JWT Token | 登录鉴权与权限控制 |
@ -53,7 +53,7 @@ AirLabs Project/
├── backend/ # FastAPI 后端
│ ├── main.py # 入口、静态文件托管、角色初始化
│ ├── models.py # ORM 模型、枚举、权限定义
│ ├── calculations.py # 成本分摊、损耗、效率、结算计算引擎
│ ├── calculations.py # 成本分摊、三阶段损耗、效率、结算计算引擎
│ ├── config.py # 配置
│ ├── database.py # SQLAlchemy 引擎与会话
│ ├── auth.py # 密码哈希、JWT
@ -107,11 +107,27 @@ AirLabs Project/
### 3.3 内容提交Submission
- 提交人、所属项目、项目阶段、工作类型(制作/测试/方案)
- 内容制作类型(内容制作/设定策划/剪辑后期/其他)
- 内容制作类型11 种,按阶段分组)
- 前期:策划案 / 剧本 / 分镜 / 人设图 / 场景图
- 制作:动画制作
- 后期:配音 / 音效 / 修补镜头 / 剪辑
- 其他
- 产出时长(分钟/秒,换算为 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%
**工时损耗** = 前期超期工时 + 后期超期工时
### 4.3 团队效率(人均基准对比法)
@ -284,21 +308,112 @@ FastAPI 后端
| 用户管理 | ✅ |
| AI 自动报告 + 飞书推送 | ✅ 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 对比可完善
- **数据导出**PRD 提到 V2 支持 Excel/PDF当前未实现
- **移动端适配**V2 计划
- **数据库升级**:若用户量增长,可迁移至 PostgreSQL
- **数据库迁移工具**:当前用 `main.py` startup 里的幂等 ALTER TABLE大规模重构时建议引入 Alembic
- **AI 智能问答**V3 计划,前端聊天页面 + 自然语言查询
- **按权限分级推送**V3 计划,不同角色收到不同报告内容
---
## 13. 默认账号
## 15. 默认账号
- 首次启动自动创建:`admin` / `admin123`(超级管理员)