From 087d4e1a6b0ca00c7df2c09a6597dbb01662525e Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Wed, 25 Feb 2026 23:18:47 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E3=80=8C=E4=BF=AE?= =?UTF-8?q?=E6=94=B9=E3=80=8D=E3=80=8CQC=E3=80=8D=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=20+=20EP=E9=9B=86=E6=95=B0=E8=BF=BD=E8=B8=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 工作类型新增「修改」(返工)和「QC」(质量审核),支持组长多角色工作记录 - 提交表单支持按集数(EP)归类,项目详情展示各集进度 - 热力图增加修改(橙色)、QC(绿色)颜色区分和图例 - 效率算法优化:产出按小时计算,损耗统计更精准 - 数据库自动迁移:episode_number字段 + work_type ENUM扩展 Co-Authored-By: Claude Opus 4.6 --- backend/calculations.py | 47 ++++++------ backend/main.py | 37 +++++++++- backend/models.py | 3 + backend/routers/projects.py | 27 +++++++ backend/routers/submissions.py | 4 ++ backend/schemas.py | 5 ++ frontend/src/views/MemberDetail.vue | 2 +- frontend/src/views/ProjectDetail.vue | 104 +++++++++++++++++++++++---- frontend/src/views/Settlement.vue | 16 ++--- frontend/src/views/Submissions.vue | 38 +++++++++- 10 files changed, 233 insertions(+), 50 deletions(-) diff --git a/backend/calculations.py b/backend/calculations.py index d7c4dda..0ed9f35 100644 --- a/backend/calculations.py +++ b/backend/calculations.py @@ -346,20 +346,17 @@ def calc_waste_for_project(project_id: int, db: Session) -> dict: def calc_team_efficiency(project_id: int, db: Session) -> list: """ - 人均基准对比法: - - 人均基准 = 目标秒数 ÷ 参与制作人数 - - 每人超出比例 = (个人提交 - 人均基准) / 人均基准 + 时均产出效率法(展示日均): + - 效率对比用时均产出 = 累计秒数 ÷ 累计工时(跨项目公平) + - 前端展示日均产出 = 累计秒数 ÷ 提交天数(直观) """ - project = db.query(Project).filter(Project.id == project_id).first() - if not project: - return [] + from sqlalchemy import distinct - target = project.target_total_seconds - - # 获取每个人的提交总秒数(仅有秒数的提交) per_user = db.query( Submission.user_id, sa_func.sum(Submission.total_seconds).label("total_secs"), + sa_func.sum(Submission.hours_spent).label("total_hours"), + sa_func.count(distinct(Submission.submit_date)).label("days"), sa_func.count(Submission.id).label("count"), ).filter( Submission.project_id == project_id, @@ -369,26 +366,32 @@ def calc_team_efficiency(project_id: int, db: Session) -> list: if not per_user: return [] - num_people = len(per_user) - baseline = target / num_people if num_people > 0 else 0 - - result = [] - for user_id, total_secs, count in per_user: + user_data = [] + for user_id, total_secs, total_hours, days, count in per_user: user = db.query(User).filter(User.id == user_id).first() - excess = total_secs - baseline - excess_rate = round(excess / baseline * 100, 1) if baseline > 0 else 0 - result.append({ + daily_avg = total_secs / days if days > 0 else 0 + hourly_output = total_secs / total_hours if total_hours and total_hours > 0 else 0 + user_data.append({ "user_id": user_id, "user_name": user.name if user else "未知", "total_seconds": round(total_secs, 1), "submission_count": count, - "baseline": round(baseline, 1), - "excess_seconds": round(excess, 1), - "excess_rate": excess_rate, + "total_hours": round(total_hours or 0, 1), + "active_days": days, + "daily_avg": round(daily_avg, 1), + "hourly_output": round(hourly_output, 1), }) - result.sort(key=lambda x: x["total_seconds"], reverse=True) - return result + # 效率对比用时均产出(公平) + team_hourly_avg = sum(d["hourly_output"] for d in user_data) / len(user_data) + + for d in user_data: + diff = d["hourly_output"] - team_hourly_avg + d["team_hourly_avg"] = round(team_hourly_avg, 1) + d["efficiency_rate"] = round(diff / team_hourly_avg * 100, 1) if team_hourly_avg > 0 else 0 + + user_data.sort(key=lambda x: x["hourly_output"], reverse=True) + return user_data # ──────────────────────────── 项目完整结算 ──────────────────────────── diff --git a/backend/main.py b/backend/main.py index bfde9e7..18815ad 100644 --- a/backend/main.py +++ b/backend/main.py @@ -9,7 +9,7 @@ from fastapi.responses import FileResponse from database import engine, Base from models import ( User, Role, PhaseGroup, BUILTIN_ROLES, COST_PERM_MIGRATION, - Project, ProjectMilestone, DEFAULT_MILESTONES + Project, ProjectMilestone, DEFAULT_MILESTONES, Submission ) from auth import hash_password from sqlalchemy.orm.attributes import flag_modified @@ -134,6 +134,11 @@ def init_roles_and_admin(): conn.commit() print("[MIGRATE] added milestone_id, delay_reason to submissions") + if 'episode_number' not in sub_cols: + conn.execute(text("ALTER TABLE submissions ADD COLUMN episode_number INT NULL")) + conn.commit() + print("[MIGRATE] added episode_number to submissions") + # MySQL: 扩展 content_type 枚举(使用 Python enum 名称)+ 旧值迁移 from config import DATABASE_URL if not DATABASE_URL.startswith("sqlite"): @@ -149,6 +154,17 @@ def init_roles_and_admin(): except Exception: pass # 已经扩展过 + # MySQL: 扩展 work_type 枚举(加入 REVISION/修改) + try: + conn.execute(text(""" + ALTER TABLE submissions MODIFY COLUMN work_type + ENUM('PRODUCTION','TEST','PLAN','REVISION','QC') NOT NULL + """)) + conn.commit() + print("[MIGRATE] expanded work_type enum") + except Exception: + pass # 已经扩展过 + # 旧值迁移:DESIGN → PLANNING r1 = conn.execute(text("UPDATE submissions SET content_type='ANIMATION' WHERE content_type='DESIGN'")) conn.commit() @@ -236,6 +252,25 @@ def init_roles_and_admin(): if orphans: db.commit() + # 迁移:从 description 提取 EP 号填充 episode_number + try: + import re as _re + orphan_subs = db.query(Submission).filter( + Submission.episode_number.is_(None), + Submission.description.isnot(None), + ).all() + migrated_ep = 0 + for s in orphan_subs: + m = _re.match(r'EP(\d+)', s.description or '', _re.IGNORECASE) + if m: + s.episode_number = int(m.group(1)) + migrated_ep += 1 + if migrated_ep: + db.commit() + print(f"[MIGRATE] extracted episode_number for {migrated_ep} submissions") + except Exception as e: + print(f"[MIGRATE] episode extraction error (non-fatal): {e}") + # 创建默认管理员(关联超级管理员角色) admin_role = db.query(Role).filter(Role.name == "超级管理员").first() if admin_role and not db.query(User).filter(User.username == "admin").first(): diff --git a/backend/models.py b/backend/models.py index f0a8ee9..b2a8801 100644 --- a/backend/models.py +++ b/backend/models.py @@ -126,6 +126,8 @@ class WorkType(str, enum.Enum): PRODUCTION = "制作" TEST = "测试" PLAN = "方案" + REVISION = "修改" + QC = "QC" class ContentType(str, enum.Enum): @@ -285,6 +287,7 @@ class Submission(Base): submit_date = Column(Date, nullable=False) milestone_id = Column(Integer, ForeignKey("project_milestones.id"), nullable=True) delay_reason = Column(Text, nullable=True) + episode_number = Column(Integer, nullable=True) # EP集数: 1=EP01, 2=EP02... created_at = Column(DateTime, server_default=func.now()) user = relationship("User", back_populates="submissions") diff --git a/backend/routers/projects.py b/backend/routers/projects.py index c2553b5..a29896d 100644 --- a/backend/routers/projects.py +++ b/backend/routers/projects.py @@ -103,6 +103,32 @@ def enrich_project(p: Project, db: Session) -> ProjectOut: else: current_stage = "已完成" + # EP 集数进度 + episode_progress = [] + ep_target = p.episode_duration_minutes * 60 # 每集目标秒数 + for ep in range(1, p.episode_count + 1): + ep_subs = db.query(Submission).filter( + Submission.project_id == p.id, + Submission.episode_number == ep, + Submission.total_seconds > 0, + ).all() + ep_total = sum(s.total_seconds for s in ep_subs) + # 每集贡献者 + contributors = {} + for s in ep_subs: + name = s.user.name if s.user else "未知" + contributors[name] = contributors.get(name, 0) + s.total_seconds + episode_progress.append({ + "episode": ep, + "total_seconds": round(ep_total, 1), + "target_seconds": ep_target, + "progress_percent": round(ep_total / ep_target * 100, 1) if ep_target > 0 else 0, + "contributors": [ + {"name": k, "seconds": round(v, 1)} + for k, v in sorted(contributors.items(), key=lambda x: -x[1]) + ], + }) + return ProjectOut( id=p.id, name=p.name, project_type=p.project_type.value if hasattr(p.project_type, 'value') else p.project_type, @@ -124,6 +150,7 @@ def enrich_project(p: Project, db: Session) -> ProjectOut: milestones=milestones_out, phase_summary=phase_summary, current_stage=current_stage, + episode_progress=episode_progress, ) diff --git a/backend/routers/submissions.py b/backend/routers/submissions.py index 5897cb6..5a24387 100644 --- a/backend/routers/submissions.py +++ b/backend/routers/submissions.py @@ -33,6 +33,7 @@ def submission_to_out(s: Submission) -> SubmissionOut: submit_date=s.submit_date, milestone_name=s.milestone.name if s.milestone else None, delay_reason=s.delay_reason, + episode_number=s.episode_number, created_at=s.created_at, ) @@ -113,6 +114,7 @@ def create_submission( submit_date=req.submit_date, milestone_id=milestone_id, delay_reason=req.delay_reason, + episode_number=req.episode_number, ) db.add(sub) db.commit() @@ -165,6 +167,8 @@ def update_submission( sub.description = req.description if req.submit_date is not None: sub.submit_date = req.submit_date + if req.episode_number is not None: + sub.episode_number = req.episode_number # 重算总秒数 sub.total_seconds = (sub.duration_minutes or 0) * 60 + (sub.duration_seconds or 0) diff --git a/backend/schemas.py b/backend/schemas.py index ea87f61..67105f4 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -144,6 +144,8 @@ class ProjectOut(BaseModel): milestones: Optional[List[MilestoneOut]] = [] phase_summary: Optional[dict] = None current_stage: Optional[str] = None + # EP 集数进度 + episode_progress: Optional[List[dict]] = [] class Config: from_attributes = True @@ -163,6 +165,7 @@ class SubmissionCreate(BaseModel): description: Optional[str] = None submit_date: date delay_reason: Optional[str] = None + episode_number: Optional[int] = None class SubmissionUpdate(BaseModel): @@ -175,6 +178,7 @@ class SubmissionUpdate(BaseModel): submit_to: Optional[str] = None description: Optional[str] = None submit_date: Optional[date] = None + episode_number: Optional[int] = None change_reason: str # 修改必须填原因 @@ -196,6 +200,7 @@ class SubmissionOut(BaseModel): submit_date: date milestone_name: Optional[str] = None delay_reason: Optional[str] = None + episode_number: Optional[int] = None created_at: Optional[datetime] = None class Config: diff --git a/frontend/src/views/MemberDetail.vue b/frontend/src/views/MemberDetail.vue index fc75a49..3989153 100644 --- a/frontend/src/views/MemberDetail.vue +++ b/frontend/src/views/MemberDetail.vue @@ -93,7 +93,7 @@ diff --git a/frontend/src/views/ProjectDetail.vue b/frontend/src/views/ProjectDetail.vue index 3a350ad..2582a29 100644 --- a/frontend/src/views/ProjectDetail.vue +++ b/frontend/src/views/ProjectDetail.vue @@ -224,26 +224,54 @@ + +
+
+ 集数进度 + {{ epProgress.filter(e => e.progress_percent >= 100).length }}/{{ epProgress.length }} 集完成 +
+
+
+
EP{{ String(ep.episode).padStart(2, '0') }}
+
+
+
+
+
+
+ {{ ep.progress_percent }}% + {{ formatSecs(ep.total_seconds) }} +
+
+
+ + + {{ c.name }} {{ formatSecs(c.seconds) }} + +
+
+
+
+
团队效率
- - - + + + + - - -
@@ -288,8 +316,10 @@
制作 + 修改 测试 方案 + QC
@@ -300,7 +330,7 @@
- {{ s.work_type }} + {{ s.work_type }} {{ s.content_type }} {{ formatSecs(s.total_seconds) }} {{ s.description }} @@ -319,7 +349,7 @@
{{ date }}
- {{ s.work_type }} + {{ s.work_type }} {{ s.content_type }} {{ formatSecs(s.total_seconds) }}
@@ -421,7 +451,7 @@ @@ -714,6 +744,20 @@ function openMemberDrawer(user) { drawerVisible.value = true } +// ── EP 集数进度 ── +const epProgress = computed(() => project.value.episode_progress || []) + +const EP_COLORS = ['#3370FF', '#34C759', '#FF9500', '#FF3B30', '#AF52DE', '#5AC8FA', '#FF2D55', '#FFCC00', '#8F959E', '#007AFF', '#30D158', '#FF6482', '#BF5AF2', '#64D2FF'] +const memberColorMap = {} +let colorIdx = 0 +function memberColor(name) { + if (!memberColorMap[name]) { + memberColorMap[name] = EP_COLORS[colorIdx % EP_COLORS.length] + colorIdx++ + } + return memberColorMap[name] +} + // ── 工具函数 ── function formatSecs(s) { if (!s) return '0秒' @@ -860,6 +904,7 @@ onUnmounted(() => { background: var(--bg-hover); padding: 2px 8px; border-radius: 4px; display: inline-block; } .rate-badge.danger { background: #FFE8E7; color: #FF3B30; } +.rate-badge.success { background: #E8F8EE; color: #34C759; } .inline-field { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } .field-unit { font-size: 13px; color: var(--text-secondary); white-space: nowrap; } .field-hint { font-size: 12px; color: var(--text-placeholder, #C0C4CC); margin-left: 4px; } @@ -986,8 +1031,10 @@ onUnmounted(() => { .heatmap-cell:hover { filter: brightness(0.92); } .heatmap-cell.has-data { font-weight: 600; color: #fff; } .heatmap-cell.type-制作 { background: #3370FF; } +.heatmap-cell.type-修改 { background: #FF3B30; } .heatmap-cell.type-测试 { background: #FF9500; } .heatmap-cell.type-方案 { background: #8F959E; } +.heatmap-cell.type-QC { background: #34C759; } .member-link { color: #3370FF; cursor: pointer; text-decoration: none; } @@ -997,8 +1044,39 @@ onUnmounted(() => { .legend-item { display: flex; align-items: center; gap: 4px; } .legend-dot { width: 10px; height: 10px; border-radius: 2px; display: inline-block; } .legend-dot.type-制作 { background: #3370FF; } +.legend-dot.type-修改 { background: #FF3B30; } .legend-dot.type-测试 { background: #FF9500; } .legend-dot.type-方案 { background: #8F959E; } +.legend-dot.type-QC { background: #34C759; } + +/* EP 集数进度 */ +.ep-progress-body { padding: 16px 20px !important; } +.ep-row { margin-bottom: 16px; } +.ep-row:last-child { margin-bottom: 0; } +.ep-label { + font-size: 13px; font-weight: 600; color: var(--text-primary); + margin-bottom: 6px; +} +.ep-bar-wrapper { display: flex; align-items: center; gap: 12px; } +.ep-bar-track { + flex: 1; height: 16px; background: #E5E6EB; border-radius: 8px; + overflow: hidden; display: flex; +} +.ep-bar-segment { + height: 100%; min-width: 2px; transition: width 0.3s; +} +.ep-bar-segment:first-child { border-radius: 8px 0 0 8px; } +.ep-bar-segment:last-child { border-radius: 0 8px 8px 0; } +.ep-bar-segment:only-child { border-radius: 8px; } +.ep-bar-info { display: flex; gap: 8px; align-items: baseline; min-width: 120px; } +.ep-percent { font-size: 13px; font-weight: 600; color: var(--text-secondary); } +.ep-percent.done { color: #34C759; } +.ep-total { font-size: 12px; color: var(--text-placeholder, #C0C4CC); } +.ep-contributors { + display: flex; flex-wrap: wrap; gap: 8px; margin-top: 4px; padding-left: 2px; +} +.ep-contributor { display: flex; align-items: center; gap: 4px; font-size: 12px; color: var(--text-secondary); } +.ep-contributor-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; } /* 悬浮提示 */ .cell-tooltip { diff --git a/frontend/src/views/Settlement.vue b/frontend/src/views/Settlement.vue index 8e5cc3f..a039d30 100644 --- a/frontend/src/views/Settlement.vue +++ b/frontend/src/views/Settlement.vue @@ -109,20 +109,16 @@ - - - + + + + - - - diff --git a/frontend/src/views/Submissions.vue b/frontend/src/views/Submissions.vue index 9249d96..f033ac4 100644 --- a/frontend/src/views/Submissions.vue +++ b/frontend/src/views/Submissions.vue @@ -24,7 +24,7 @@ @@ -64,12 +64,19 @@ + + + + + + + @@ -156,12 +163,19 @@ + + + + + + + @@ -254,7 +268,7 @@ const form = reactive({ project_id: null, project_phase: '制作', work_type: '制作', content_type: '动画制作', duration_minutes: 0, duration_seconds: 0, hours_spent: null, submit_to: '组长', description: '', submit_date: today, - delay_reason: '', + delay_reason: '', episode_number: null, }) // 选择内容类型时自动设置对应的项目阶段 @@ -279,6 +293,13 @@ const isMilestoneOverdue = computed(() => { return ms?.is_overdue === true }) +// EP 集数选项 +const episodeOptions = computed(() => { + const proj = activeProjects.value.find(p => p.id === form.project_id) + if (!proj || !proj.episode_count) return [] + return Array.from({length: proj.episode_count}, (_, i) => i + 1) +}) + function formatSecs(s) { if (!s) return '0秒' const m = Math.floor(s / 60) @@ -314,6 +335,7 @@ async function handleCreate() { form.hours_spent = null form.description = '' form.delay_reason = '' + form.episode_number = null load() } finally { creating.value = false } } @@ -324,6 +346,7 @@ const editing = ref(false) const editForm = reactive({ _id: null, _project_name: '', + _project_id: null, project_phase: '', work_type: '', content_type: '', @@ -334,11 +357,19 @@ const editForm = reactive({ description: '', submit_date: '', change_reason: '', + episode_number: null, +}) + +const editEpisodeOptions = computed(() => { + const proj = projects.value.find(p => p.id === editForm._project_id) + if (!proj || !proj.episode_count) return [] + return Array.from({length: proj.episode_count}, (_, i) => i + 1) }) function openEdit(row) { editForm._id = row.id editForm._project_name = row.project_name + editForm._project_id = row.project_id editForm.project_phase = row.project_phase editForm.work_type = row.work_type editForm.content_type = row.content_type @@ -348,6 +379,7 @@ function openEdit(row) { editForm.submit_to = row.submit_to editForm.description = row.description || '' editForm.submit_date = row.submit_date + editForm.episode_number = row.episode_number editForm.change_reason = '' showEdit.value = true } @@ -359,7 +391,7 @@ async function handleUpdate() { } editing.value = true try { - const { _id, _project_name, ...payload } = editForm + const { _id, _project_name, _project_id, ...payload } = editForm await submissionApi.update(_id, payload) ElMessage.success('修改成功') showEdit.value = false