feat: 新增「修改」「QC」工作类型 + EP集数追踪
- 工作类型新增「修改」(返工)和「QC」(质量审核),支持组长多角色工作记录 - 提交表单支持按集数(EP)归类,项目详情展示各集进度 - 热力图增加修改(橙色)、QC(绿色)颜色区分和图例 - 效率算法优化:产出按小时计算,损耗统计更精准 - 数据库自动迁移:episode_number字段 + work_type ENUM扩展 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
32658fa608
commit
087d4e1a6b
@ -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
|
||||
|
||||
|
||||
# ──────────────────────────── 项目完整结算 ────────────────────────────
|
||||
|
||||
@ -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():
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -93,7 +93,7 @@
|
||||
<el-table-column prop="submit_date" label="日期" width="110" />
|
||||
<el-table-column label="工作类型" width="80">
|
||||
<template #default="{row}">
|
||||
<el-tag :type="row.work_type === '测试' ? 'warning' : row.work_type === '方案' ? 'info' : ''" size="small">{{ row.work_type }}</el-tag>
|
||||
<el-tag :type="row.work_type === '测试' ? 'warning' : row.work_type === '方案' ? 'info' : row.work_type === '修改' ? 'danger' : row.work_type === 'QC' ? 'success' : ''" size="small">{{ row.work_type }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="content_type" label="内容类型" width="100" />
|
||||
|
||||
@ -224,26 +224,54 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 集数进度 -->
|
||||
<div v-if="epProgress.length && epProgress.some(e => e.total_seconds > 0)" class="card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">集数进度</span>
|
||||
<span class="card-count">{{ epProgress.filter(e => e.progress_percent >= 100).length }}/{{ epProgress.length }} 集完成</span>
|
||||
</div>
|
||||
<div class="card-body ep-progress-body">
|
||||
<div v-for="ep in epProgress" :key="ep.episode" class="ep-row">
|
||||
<div class="ep-label">EP{{ String(ep.episode).padStart(2, '0') }}</div>
|
||||
<div class="ep-bar-wrapper">
|
||||
<div class="ep-bar-track">
|
||||
<div v-for="(c, ci) in ep.contributors" :key="ci"
|
||||
class="ep-bar-segment"
|
||||
:style="{width: (c.seconds / ep.target_seconds * 100) + '%', background: memberColor(c.name)}"
|
||||
:title="c.name + ' ' + formatSecs(c.seconds)">
|
||||
</div>
|
||||
</div>
|
||||
<div class="ep-bar-info">
|
||||
<span class="ep-percent" :class="{done: ep.progress_percent >= 100}">{{ ep.progress_percent }}%</span>
|
||||
<span class="ep-total">{{ formatSecs(ep.total_seconds) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ep-contributors" v-if="ep.contributors.length">
|
||||
<span v-for="(c, ci) in ep.contributors" :key="ci" class="ep-contributor">
|
||||
<span class="ep-contributor-dot" :style="{background: memberColor(c.name)}"></span>
|
||||
{{ c.name }} {{ formatSecs(c.seconds) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 团队效率 -->
|
||||
<div v-if="authStore.hasPermission('efficiency:view') && efficiency.length" class="card">
|
||||
<div class="card-header"><span class="card-title">团队效率</span></div>
|
||||
<div class="card-body">
|
||||
<el-table :data="efficiency" size="small">
|
||||
<el-table-column prop="user_name" label="成员" width="100" />
|
||||
<el-table-column label="提交总量" align="right"><template #default="{row}">{{ formatSecs(row.total_seconds) }}</template></el-table-column>
|
||||
<el-table-column label="人均基准" align="right"><template #default="{row}">{{ formatSecs(row.baseline) }}</template></el-table-column>
|
||||
<el-table-column label="超出基准" align="right">
|
||||
<el-table-column label="累计产出" align="right"><template #default="{row}">{{ formatSecs(row.total_seconds) }}</template></el-table-column>
|
||||
<el-table-column label="工时" align="right" width="70"><template #default="{row}">{{ row.total_hours }}h</template></el-table-column>
|
||||
<el-table-column label="日均产出" align="right"><template #default="{row}"><strong>{{ formatSecs(row.daily_avg) }}</strong></template></el-table-column>
|
||||
<el-table-column label="效率" align="right" width="100">
|
||||
<template #default="{row}">
|
||||
<span :style="{color: row.excess_seconds > 0 ? '#FF3B30' : '#34C759', fontWeight: 600}">
|
||||
{{ row.excess_seconds > 0 ? '+' : '' }}{{ formatSecs(row.excess_seconds) }}
|
||||
<span class="rate-badge" :class="{success: row.efficiency_rate >= 0, danger: row.efficiency_rate < 0}">
|
||||
{{ row.efficiency_rate > 0 ? '+' : '' }}{{ row.efficiency_rate }}%
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="超出比例" align="right" width="100">
|
||||
<template #default="{row}">
|
||||
<span class="rate-badge" :class="{danger: row.excess_rate > 20}">{{ row.excess_rate > 0 ? '+' : '' }}{{ row.excess_rate }}%</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
@ -288,8 +316,10 @@
|
||||
<!-- 图例 -->
|
||||
<div class="heatmap-legend">
|
||||
<span class="legend-item"><span class="legend-dot type-制作"></span>制作</span>
|
||||
<span class="legend-item"><span class="legend-dot type-修改"></span>修改</span>
|
||||
<span class="legend-item"><span class="legend-dot type-测试"></span>测试</span>
|
||||
<span class="legend-item"><span class="legend-dot type-方案"></span>方案</span>
|
||||
<span class="legend-item"><span class="legend-dot type-QC"></span>QC</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body" v-else>
|
||||
@ -300,7 +330,7 @@
|
||||
<!-- 悬浮提示 -->
|
||||
<div class="cell-tooltip" v-if="tooltip.visible" :style="{ top: tooltip.y + 'px', left: tooltip.x + 'px' }">
|
||||
<div v-for="(s, i) in tooltip.items" :key="i" class="tooltip-row">
|
||||
<el-tag :type="s.work_type === '测试' ? 'warning' : s.work_type === '方案' ? 'info' : ''" size="small">{{ s.work_type }}</el-tag>
|
||||
<el-tag :type="s.work_type === '测试' ? 'warning' : s.work_type === '方案' ? 'info' : s.work_type === '修改' ? 'danger' : s.work_type === 'QC' ? 'success' : ''" size="small">{{ s.work_type }}</el-tag>
|
||||
<span class="tooltip-type">{{ s.content_type }}</span>
|
||||
<span class="tooltip-secs" v-if="s.total_seconds > 0">{{ formatSecs(s.total_seconds) }}</span>
|
||||
<span class="tooltip-desc" v-if="s.description">{{ s.description }}</span>
|
||||
@ -319,7 +349,7 @@
|
||||
<div class="drawer-date-header">{{ date }}</div>
|
||||
<div v-for="s in group" :key="s.id" class="drawer-sub-item">
|
||||
<div class="drawer-sub-top">
|
||||
<el-tag :type="s.work_type === '测试' ? 'warning' : s.work_type === '方案' ? 'info' : ''" size="small">{{ s.work_type }}</el-tag>
|
||||
<el-tag :type="s.work_type === '测试' ? 'warning' : s.work_type === '方案' ? 'info' : s.work_type === '修改' ? 'danger' : s.work_type === 'QC' ? 'success' : ''" size="small">{{ s.work_type }}</el-tag>
|
||||
<span>{{ s.content_type }}</span>
|
||||
<span v-if="s.total_seconds > 0" class="drawer-sub-secs">{{ formatSecs(s.total_seconds) }}</span>
|
||||
</div>
|
||||
@ -421,7 +451,7 @@
|
||||
<el-table-column prop="project_phase" label="阶段" width="70" />
|
||||
<el-table-column label="工作类型" width="80">
|
||||
<template #default="{row}">
|
||||
<el-tag :type="row.work_type === '测试' ? 'warning' : row.work_type === '方案' ? 'info' : ''" size="small">{{ row.work_type }}</el-tag>
|
||||
<el-tag :type="row.work_type === '测试' ? 'warning' : row.work_type === '方案' ? 'info' : row.work_type === '修改' ? 'danger' : row.work_type === 'QC' ? 'success' : ''" size="small">{{ row.work_type }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="content_type" label="内容类型" width="90" />
|
||||
@ -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 {
|
||||
|
||||
@ -109,20 +109,16 @@
|
||||
<template #header><span class="section-title">团队效率排行</span></template>
|
||||
<el-table :data="data.team_efficiency" size="small" stripe>
|
||||
<el-table-column prop="user_name" label="成员" width="100" />
|
||||
<el-table-column label="提交总量" align="right"><template #default="{row}">{{ formatSecs(row.total_seconds) }}</template></el-table-column>
|
||||
<el-table-column label="人均基准" align="right"><template #default="{row}">{{ formatSecs(row.baseline) }}</template></el-table-column>
|
||||
<el-table-column label="超出" align="right">
|
||||
<el-table-column label="累计产出" align="right"><template #default="{row}">{{ formatSecs(row.total_seconds) }}</template></el-table-column>
|
||||
<el-table-column label="工时" align="right" width="70"><template #default="{row}">{{ row.total_hours }}h</template></el-table-column>
|
||||
<el-table-column label="日均产出" align="right"><template #default="{row}"><strong>{{ formatSecs(row.daily_avg) }}</strong></template></el-table-column>
|
||||
<el-table-column label="效率" align="right" width="100">
|
||||
<template #default="{row}">
|
||||
<span :style="{color: row.excess_seconds > 0 ? '#f56c6c' : '#67c23a'}">
|
||||
{{ row.excess_seconds > 0 ? '+' : '' }}{{ formatSecs(row.excess_seconds) }}
|
||||
<span :style="{color: row.efficiency_rate >= 0 ? '#34C759' : '#FF3B30', fontWeight: 600}">
|
||||
{{ row.efficiency_rate > 0 ? '+' : '' }}{{ row.efficiency_rate }}%
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="超出比例" align="right" width="100">
|
||||
<template #default="{row}">
|
||||
<span :style="{color: row.excess_rate > 20 ? '#f56c6c' : '#333'}">{{ row.excess_rate > 0 ? '+' : '' }}{{ row.excess_rate }}%</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="submission_count" label="提交次数" width="90" align="right" />
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
@ -24,7 +24,7 @@
|
||||
<el-table-column prop="project_phase" label="阶段" width="70" />
|
||||
<el-table-column label="工作类型" width="80">
|
||||
<template #default="{row}">
|
||||
<el-tag :type="row.work_type === '测试' ? 'warning' : row.work_type === '方案' ? 'info' : ''" size="small">{{ row.work_type }}</el-tag>
|
||||
<el-tag :type="row.work_type === '测试' ? 'warning' : row.work_type === '方案' ? 'info' : row.work_type === '修改' ? 'danger' : row.work_type === 'QC' ? 'success' : ''" size="small">{{ row.work_type }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="content_type" label="内容类型" width="90" />
|
||||
@ -64,12 +64,19 @@
|
||||
<el-form-item label="工作类型" required>
|
||||
<el-select v-model="form.work_type" style="width:100%">
|
||||
<el-option label="制作" value="制作" />
|
||||
<el-option label="修改" value="修改" />
|
||||
<el-option label="测试" value="测试" />
|
||||
<el-option label="方案" value="方案" />
|
||||
<el-option label="QC" value="QC" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="集数" v-if="episodeOptions.length > 0">
|
||||
<el-select v-model="form.episode_number" placeholder="选择集数(选填)" clearable style="width:100%">
|
||||
<el-option v-for="ep in episodeOptions" :key="ep" :label="'EP' + String(ep).padStart(2,'0')" :value="ep" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="内容类型" required>
|
||||
<el-select v-model="form.content_type" placeholder="本次提交的产出类型" style="width:100%">
|
||||
<el-option-group label="前期">
|
||||
@ -156,12 +163,19 @@
|
||||
<el-form-item label="工作类型" required>
|
||||
<el-select v-model="editForm.work_type" style="width:100%">
|
||||
<el-option label="制作" value="制作" />
|
||||
<el-option label="修改" value="修改" />
|
||||
<el-option label="测试" value="测试" />
|
||||
<el-option label="方案" value="方案" />
|
||||
<el-option label="QC" value="QC" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="集数" v-if="editEpisodeOptions.length > 0">
|
||||
<el-select v-model="editForm.episode_number" placeholder="选择集数(选填)" clearable style="width:100%">
|
||||
<el-option v-for="ep in editEpisodeOptions" :key="ep" :label="'EP' + String(ep).padStart(2,'0')" :value="ep" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="内容类型" required>
|
||||
<el-select v-model="editForm.content_type" style="width:100%">
|
||||
<el-option-group label="前期">
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user