diff --git a/AirLabs Project PRD.md b/AirLabs Project PRD.md index fd21357..116a027 100644 --- a/AirLabs Project PRD.md +++ b/AirLabs Project PRD.md @@ -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 秒 -测试阶段提交:500 秒(测试损耗) -制作 + 后期补拍提交:4,800 秒 -超产损耗:4,800 − 3,900 = 900 秒 -项目总损耗:500 + 900 = 1,400 秒 -损耗率:1,400 ÷ 3,900 = 35.9% +**示例:** +项目目标:13 集 × 5 分钟 = 3,900 秒 +前期:策划案预估 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 生成,不计损耗 | --- @@ -408,13 +470,14 @@ V1 不做逐条通过率标记(太复杂),采用**人均基准对比法** ### 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 框架 | -| 数据库 | SQLite(MVP) | 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) +- V3:AI 智能问答助手(自然语言查询系统数据) --- diff --git a/backend/calculations.py b/backend/calculations.py index 6f35517..d7c4dda 100644 --- a/backend/calculations.py +++ b/backend/calculations.py @@ -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, } diff --git a/backend/main.py b/backend/main.py index 58c8408..bfde9e7 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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: diff --git a/backend/models.py b/backend/models.py index 64ee4f4..372ee7a 100644 --- a/backend/models.py +++ b/backend/models.py @@ -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 = { + "策划案": "前期", "剧本": "前期", "分镜": "前期", + "人设图": "前期", "场景图": "前期", + "动画制作": "制作", + "配音": "后期", "音效": "后期", "修补镜头": "后期", "剪辑": "后期", +} diff --git a/backend/routers/dashboard.py b/backend/routers/dashboard.py index ea1c1d7..e2b1d47 100644 --- a/backend/routers/dashboard.py +++ b/backend/routers/dashboard.py @@ -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, diff --git a/backend/routers/projects.py b/backend/routers/projects.py index c3c9d13..c2553b5 100644 --- a/backend/routers/projects.py +++ b/backend/routers/projects.py @@ -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, diff --git a/backend/routers/roles.py b/backend/routers/roles.py index 12ead9a..cf4f560 100644 --- a/backend/routers/roles.py +++ b/backend/routers/roles.py @@ -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), diff --git a/backend/routers/submissions.py b/backend/routers/submissions.py index fb86a63..5897cb6 100644 --- a/backend/routers/submissions.py +++ b/backend/routers/submissions.py @@ -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() diff --git a/backend/routers/users.py b/backend/routers/users.py index 99ab23e..c03916d 100644 --- a/backend/routers/users.py +++ b/backend/routers/users.py @@ -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), diff --git a/backend/schemas.py b/backend/schemas.py index 73361da..e357828 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -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: diff --git a/backend/seed_demo.py b/backend/seed_demo.py index b40c6fe..6f89240 100644 --- a/backend/seed_demo.py +++ b/backend/seed_demo.py @@ -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) - # --- 项目D:AI 短剧原创 --- - for i in range(5): - d = base + timedelta(days=i) - subs.append(Submission( - user_id=huangrongying.id, project_id=proj_d.id, - project_phase=PhaseGroup.PRE, work_type=WorkType.PLAN, - content_type=ContentType.DESIGN, total_seconds=0, - submit_to=SubmitTo.INTERNAL, description=f"原创剧本第{i+1}集大纲", - submit_date=d, - )) - for i in range(6): - d = base + timedelta(days=i + 8) - secs = 60 + (i % 3) * 25 - subs.append(Submission( - user_id=weichunli.id, project_id=proj_d.id, - project_phase=PhaseGroup.PRODUCTION, work_type=WorkType.PRODUCTION, - content_type=ContentType.ANIMATION, total_seconds=secs, - duration_minutes=secs // 60, duration_seconds=secs % 60, - submit_to=SubmitTo.INTERNAL, description=f"原创第1集片段{i+1}", - submit_date=d, - )) - for i in range(5): - d = base + timedelta(days=i + 10) - secs = 50 + (i % 2) * 35 - subs.append(Submission( - user_id=huangqiuxia.id, project_id=proj_d.id, - project_phase=PhaseGroup.PRODUCTION, work_type=WorkType.PRODUCTION, - content_type=ContentType.ANIMATION, total_seconds=secs, - duration_minutes=secs // 60, duration_seconds=secs % 60, - submit_to=SubmitTo.LEADER, description=f"原创第2集动画{i+1}", - submit_date=d, - )) - for i in range(4): - d = base + timedelta(days=i + 12) - secs = 45 + i * 15 - subs.append(Submission( - user_id=lijing.id, project_id=proj_d.id, - project_phase=PhaseGroup.PRODUCTION, work_type=WorkType.PRODUCTION, - content_type=ContentType.ANIMATION, total_seconds=secs, - duration_minutes=secs // 60, duration_seconds=secs % 60, - submit_to=SubmitTo.LEADER, description=f"原创第3集片段{i+1}", - submit_date=d, - )) - for i in range(3): - d = base + timedelta(days=i + 14) - secs = 40 + i * 20 - subs.append(Submission( - user_id=yemeilian.id, project_id=proj_d.id, - project_phase=PhaseGroup.PRODUCTION, work_type=WorkType.PRODUCTION, - content_type=ContentType.ANIMATION, total_seconds=secs, - duration_minutes=secs // 60, duration_seconds=secs % 60, - submit_to=SubmitTo.LEADER, description=f"原创第4集动画{i+1}", - submit_date=d, - )) - for i in range(3): - d = base + timedelta(days=i + 15) - secs = 55 + (i % 2) * 20 - subs.append(Submission( - user_id=chenxuanying.id, project_id=proj_d.id, - project_phase=PhaseGroup.PRODUCTION, work_type=WorkType.PRODUCTION, - content_type=ContentType.ANIMATION, total_seconds=secs, - duration_minutes=secs // 60, duration_seconds=secs % 60, - submit_to=SubmitTo.LEADER, description=f"原创第5集场景{i+1}", - submit_date=d, - )) + # ──────────────────────────────────────────── + # 星际漫游 第一季 (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__": diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index da9f5e4..f114818 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -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'), diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue index 3d71117..8c71fb5 100644 --- a/frontend/src/views/Dashboard.vue +++ b/frontend/src/views/Dashboard.vue @@ -61,6 +61,7 @@ {{ data.total_waste_rate || 0 }}%
总损耗率({{ formatSecs(data.total_waste_seconds) }})
+
工时损耗 {{ data.total_waste_hours }}h
@@ -118,12 +119,23 @@ {{ p.project_type }} 超期 - {{ p.progress_percent }}% + {{ p.progress_percent }}% + +
+ +
-
{{ formatSecs(p.submitted_seconds) }} / {{ formatSecs(p.target_seconds) }} - 损耗 {{ p.waste_rate }}% + + 损耗 {{ p.waste_rate }}% + +
@@ -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}
损耗率 ${p[0].value}%` }, + tooltip: { trigger: 'axis', formatter: p => { + const item = sorted[p[0].dataIndex] + let tip = `${item.project_name}
损耗率 ${p[0].value}%` + if (item.waste_seconds > 0) tip += `
秒数损耗 ${formatSecs(item.waste_seconds)}` + if (item.waste_hours > 0) tip += `
工时损耗 ${item.waste_hours}h` + 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; } diff --git a/frontend/src/views/ProjectDetail.vue b/frontend/src/views/ProjectDetail.vue index c322a08..2e7be58 100644 --- a/frontend/src/views/ProjectDetail.vue +++ b/frontend/src/views/ProjectDetail.vue @@ -63,6 +63,7 @@
损耗率
{{ project.waste_rate }}%
+
工时损耗 {{ project.waste_hours }}h
@@ -111,7 +112,10 @@
-
前期
+
+ 前期 + 损耗 {{ preWasteHours }}h +
{{ m.name }} + {{ m.estimated_days }}天 + 超{{ (m.actual_days || 0) - (m.estimated_days || 0) }}天 + {{ m.actual_days }}天 +
@@ -133,7 +142,7 @@
-
制作
+
制作
@@ -146,8 +155,12 @@ {{ formatSecs(project.target_total_seconds) }}
- 损耗 - {{ formatSecs(project.waste_seconds) }} + 测试损耗 + {{ formatSecs(prodWaste.test_waste_seconds) }} +
+
+ 超产损耗 + {{ formatSecs(prodWaste.overproduction_waste_seconds) }}
损耗率 @@ -159,7 +172,10 @@
-
后期
+
+ 后期 + 损耗 {{ postWasteHours }}h +
{{ m.name }} + {{ m.estimated_days }}天 + 超{{ (m.actual_days || 0) - (m.estimated_days || 0) }}天 + {{ m.actual_days }}天 +
@@ -365,6 +386,28 @@ + + + + + {{ msEditForm.name }} + + +
+ + +
+
+ + + +
+ +
+
@@ -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; } diff --git a/frontend/src/views/Settlement.vue b/frontend/src/views/Settlement.vue index 0bdf213..8e5cc3f 100644 --- a/frontend/src/views/Settlement.vue +++ b/frontend/src/views/Settlement.vue @@ -62,14 +62,46 @@ - - {{ formatSecs(data.test_waste_seconds) }} - {{ formatSecs(data.overproduction_waste_seconds) }} - {{ formatSecs(data.total_waste_seconds) }} - - {{ data.waste_rate }}% - - + + + +
+
前期(工时制)
+
{{ preWasteHours }}h
+
+ {{ d.milestone }}:预估{{ d.estimated_days }}天 / 实际{{ d.actual_days }}天,超{{ d.overrun_days }}天 = {{ d.waste_hours }}h +
+
无超期
+
+
+ +
+
制作(秒数制)
+
+ 测试损耗 + {{ formatSecs(data.test_waste_seconds) }} +
+
+ 超产损耗 + {{ formatSecs(data.overproduction_waste_seconds) }} +
+
+ 秒数损耗率 + {{ data.waste_rate }}% +
+
+
+ +
+
后期(工时制)
+
{{ postWasteHours }}h
+
+ {{ d.milestone }}:预估{{ d.estimated_days }}天 / 实际{{ d.actual_days }}天,超{{ d.overrun_days }}天 = {{ d.waste_hours }}h +
+
无超期
+
+
+
@@ -99,7 +131,7 @@