feat: 制作产出/后期产出分离 + 全集通用选项 + 概览卡片布局优化
- 项目详情:拆分"已提交"为"制作产出"(中期)和"后期产出"(后期按类型细分) - 进度百分比仅计算中期动画产出,EP集数进度只统计中期 - 新增"全集通用"(episode=0)选项,与具体集数互斥 - 概览卡片改为上4下1布局,后期产出独立全宽卡片展示明细 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
41c2b9cd89
commit
ac350e763b
@ -46,14 +46,37 @@ def _build_milestone_out(m) -> MilestoneOut:
|
|||||||
|
|
||||||
def enrich_project(p: Project, db: Session) -> ProjectOut:
|
def enrich_project(p: Project, db: Session) -> ProjectOut:
|
||||||
"""将项目对象转为带计算字段的输出"""
|
"""将项目对象转为带计算字段的输出"""
|
||||||
# 累计提交秒数
|
# 累计提交秒数(全部)
|
||||||
total_secs = db.query(sa_func.sum(Submission.total_seconds)).filter(
|
total_secs = db.query(sa_func.sum(Submission.total_seconds)).filter(
|
||||||
Submission.project_id == p.id,
|
Submission.project_id == p.id,
|
||||||
Submission.total_seconds > 0
|
Submission.total_seconds > 0
|
||||||
).scalar() or 0
|
).scalar() or 0
|
||||||
|
|
||||||
|
# 中期产出(动画制作)— 对标目标时长
|
||||||
|
animation_secs = db.query(sa_func.sum(Submission.total_seconds)).filter(
|
||||||
|
Submission.project_id == p.id,
|
||||||
|
Submission.total_seconds > 0,
|
||||||
|
Submission.project_phase == PhaseGroup.PRODUCTION,
|
||||||
|
).scalar() or 0
|
||||||
|
|
||||||
|
# 后期产出:按内容类型分组
|
||||||
|
post_rows = db.query(
|
||||||
|
Submission.content_type,
|
||||||
|
sa_func.sum(Submission.total_seconds).label("secs"),
|
||||||
|
).filter(
|
||||||
|
Submission.project_id == p.id,
|
||||||
|
Submission.total_seconds > 0,
|
||||||
|
Submission.project_phase == PhaseGroup.POST,
|
||||||
|
).group_by(Submission.content_type).all()
|
||||||
|
|
||||||
|
post_secs = sum(row.secs for row in post_rows)
|
||||||
|
post_breakdown = [
|
||||||
|
{"type": ct.value if hasattr(ct, 'value') else ct, "seconds": round(secs, 1)}
|
||||||
|
for ct, secs in post_rows
|
||||||
|
]
|
||||||
|
|
||||||
target = p.target_total_seconds
|
target = p.target_total_seconds
|
||||||
progress = round(total_secs / target * 100, 1) if target > 0 else 0
|
progress = round(animation_secs / target * 100, 1) if target > 0 else 0
|
||||||
|
|
||||||
# 集中损耗计算
|
# 集中损耗计算
|
||||||
waste_data = calc_waste_for_project(p.id, db)
|
waste_data = calc_waste_for_project(p.id, db)
|
||||||
@ -83,7 +106,7 @@ def enrich_project(p: Project, db: Session) -> ProjectOut:
|
|||||||
},
|
},
|
||||||
"production": {
|
"production": {
|
||||||
"progress_percent": progress,
|
"progress_percent": progress,
|
||||||
"submitted_seconds": round(total_secs, 1),
|
"submitted_seconds": round(animation_secs, 1),
|
||||||
"target_seconds": target,
|
"target_seconds": target,
|
||||||
"waste": waste_data.get("production_waste", {}),
|
"waste": waste_data.get("production_waste", {}),
|
||||||
},
|
},
|
||||||
@ -103,10 +126,9 @@ def enrich_project(p: Project, db: Session) -> ProjectOut:
|
|||||||
else:
|
else:
|
||||||
current_stage = "已完成"
|
current_stage = "已完成"
|
||||||
|
|
||||||
# EP 集数进度(批量查询,避免 N+1)
|
# EP 集数进度(批量查询,避免 N+1)— 只统计中期产出
|
||||||
episode_progress = []
|
episode_progress = []
|
||||||
ep_target = p.episode_duration_minutes * 60 # 每集目标秒数
|
ep_target = p.episode_duration_minutes * 60 # 每集目标秒数
|
||||||
# 一次查出所有有提交的集数数据
|
|
||||||
ep_rows = db.query(
|
ep_rows = db.query(
|
||||||
Submission.episode_number,
|
Submission.episode_number,
|
||||||
User.name,
|
User.name,
|
||||||
@ -115,6 +137,7 @@ def enrich_project(p: Project, db: Session) -> ProjectOut:
|
|||||||
Submission.project_id == p.id,
|
Submission.project_id == p.id,
|
||||||
Submission.episode_number.isnot(None),
|
Submission.episode_number.isnot(None),
|
||||||
Submission.total_seconds > 0,
|
Submission.total_seconds > 0,
|
||||||
|
Submission.project_phase == PhaseGroup.PRODUCTION,
|
||||||
).group_by(Submission.episode_number, User.name).all()
|
).group_by(Submission.episode_number, User.name).all()
|
||||||
|
|
||||||
# 按集数聚合
|
# 按集数聚合
|
||||||
@ -154,6 +177,9 @@ def enrich_project(p: Project, db: Session) -> ProjectOut:
|
|||||||
contract_amount=p.contract_amount,
|
contract_amount=p.contract_amount,
|
||||||
created_at=p.created_at,
|
created_at=p.created_at,
|
||||||
total_submitted_seconds=round(total_secs, 1),
|
total_submitted_seconds=round(total_secs, 1),
|
||||||
|
animation_seconds=round(animation_secs, 1),
|
||||||
|
post_production_seconds=round(post_secs, 1),
|
||||||
|
post_production_breakdown=post_breakdown,
|
||||||
progress_percent=progress,
|
progress_percent=progress,
|
||||||
waste_seconds=round(waste_seconds, 1),
|
waste_seconds=round(waste_seconds, 1),
|
||||||
waste_hours=waste_hours,
|
waste_hours=waste_hours,
|
||||||
|
|||||||
@ -95,8 +95,8 @@ def create_submission(
|
|||||||
episode_list = [None] # 项目级,单条无集数
|
episode_list = [None] # 项目级,单条无集数
|
||||||
elif req.episode_numbers and len(req.episode_numbers) > 0:
|
elif req.episode_numbers and len(req.episode_numbers) > 0:
|
||||||
episode_list = req.episode_numbers # 批量多集
|
episode_list = req.episode_numbers # 批量多集
|
||||||
elif req.episode_number:
|
elif req.episode_number is not None:
|
||||||
episode_list = [req.episode_number] # 单集
|
episode_list = [req.episode_number] # 单集(含0=全集通用)
|
||||||
else:
|
else:
|
||||||
raise HTTPException(status_code=422, detail="请选择集数")
|
raise HTTPException(status_code=422, detail="请选择集数")
|
||||||
|
|
||||||
|
|||||||
@ -136,6 +136,9 @@ class ProjectOut(BaseModel):
|
|||||||
created_at: Optional[datetime] = None
|
created_at: Optional[datetime] = None
|
||||||
# 动态计算字段
|
# 动态计算字段
|
||||||
total_submitted_seconds: Optional[float] = 0
|
total_submitted_seconds: Optional[float] = 0
|
||||||
|
animation_seconds: Optional[float] = 0 # 中期动画产出
|
||||||
|
post_production_seconds: Optional[float] = 0 # 后期产出合计
|
||||||
|
post_production_breakdown: Optional[List[dict]] = [] # [{"type":"剪辑","seconds":300}, ...]
|
||||||
progress_percent: Optional[float] = 0
|
progress_percent: Optional[float] = 0
|
||||||
waste_seconds: Optional[float] = 0
|
waste_seconds: Optional[float] = 0
|
||||||
waste_hours: Optional[float] = 0
|
waste_hours: Optional[float] = 0
|
||||||
|
|||||||
@ -53,8 +53,8 @@
|
|||||||
<div class="stat-value">{{ formatSecs(project.target_total_seconds) }}</div>
|
<div class="stat-value">{{ formatSecs(project.target_total_seconds) }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-label">已提交</div>
|
<div class="stat-label">制作产出</div>
|
||||||
<div class="stat-value">{{ formatSecs(project.total_submitted_seconds) }}</div>
|
<div class="stat-value">{{ formatSecs(project.animation_seconds) }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-label">当前阶段</div>
|
<div class="stat-label">当前阶段</div>
|
||||||
@ -67,6 +67,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 后期产出 -->
|
||||||
|
<div class="post-card" v-if="project.post_production_seconds > 0">
|
||||||
|
<div class="post-main">
|
||||||
|
<span class="post-label">后期产出</span>
|
||||||
|
<span class="post-value">{{ formatSecs(project.post_production_seconds) }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="project.post_production_breakdown && project.post_production_breakdown.length" class="post-breakdown">
|
||||||
|
<span v-for="item in project.post_production_breakdown" :key="item.type" class="post-breakdown-item">
|
||||||
|
{{ item.type }} {{ formatSecs(item.seconds) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 里程碑进度 -->
|
<!-- 里程碑进度 -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
@ -148,7 +161,7 @@
|
|||||||
<div class="production-info">
|
<div class="production-info">
|
||||||
<div class="prod-info-row">
|
<div class="prod-info-row">
|
||||||
<span class="prod-info-label">已产出</span>
|
<span class="prod-info-label">已产出</span>
|
||||||
<span class="prod-info-value">{{ formatSecs(project.total_submitted_seconds) }}</span>
|
<span class="prod-info-value">{{ formatSecs(project.animation_seconds) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="prod-info-row">
|
<div class="prod-info-row">
|
||||||
<span class="prod-info-label">目标</span>
|
<span class="prod-info-label">目标</span>
|
||||||
@ -911,6 +924,18 @@ onUnmounted(() => {
|
|||||||
.stat-label { font-size: 12px; color: var(--text-secondary); margin-bottom: 6px; }
|
.stat-label { font-size: 12px; color: var(--text-secondary); margin-bottom: 6px; }
|
||||||
.stat-value { font-size: 22px; font-weight: 700; color: var(--text-primary); }
|
.stat-value { font-size: 22px; font-weight: 700; color: var(--text-primary); }
|
||||||
|
|
||||||
|
/* 后期产出全宽卡片 */
|
||||||
|
.post-card {
|
||||||
|
display: flex; flex-direction: column; align-items: center;
|
||||||
|
background: var(--bg-card); border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md); padding: 16px 24px; margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.post-main { display: flex; align-items: baseline; gap: 12px; }
|
||||||
|
.post-label { font-size: 12px; color: var(--text-secondary); }
|
||||||
|
.post-value { font-size: 20px; font-weight: 700; color: var(--text-primary); }
|
||||||
|
.post-breakdown { display: flex; flex-wrap: wrap; gap: 4px 16px; justify-content: center; margin-top: 6px; }
|
||||||
|
.post-breakdown-item { font-size: 12px; color: var(--text-secondary); white-space: nowrap; }
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
background: var(--bg-card); border: 1px solid var(--border-color);
|
background: var(--bg-card); border: 1px solid var(--border-color);
|
||||||
border-radius: var(--radius-md); margin-bottom: 16px;
|
border-radius: var(--radius-md); margin-bottom: 16px;
|
||||||
|
|||||||
@ -99,8 +99,9 @@
|
|||||||
<el-form-item label="集数" v-if="needsEpisode(form.content_type) && episodeOptions.length > 0" required>
|
<el-form-item label="集数" v-if="needsEpisode(form.content_type) && episodeOptions.length > 0" required>
|
||||||
<el-select v-model="form.episode_numbers" multiple collapse-tags collapse-tags-tooltip placeholder="选择集数(可多选)" style="width:100%">
|
<el-select v-model="form.episode_numbers" multiple collapse-tags collapse-tags-tooltip placeholder="选择集数(可多选)" style="width:100%">
|
||||||
<el-option v-for="ep in episodeOptions" :key="ep" :label="'EP' + String(ep).padStart(2,'0')" :value="ep" />
|
<el-option v-for="ep in episodeOptions" :key="ep" :label="'EP' + String(ep).padStart(2,'0')" :value="ep" />
|
||||||
|
<el-option :key="0" label="全集通用" :value="0" />
|
||||||
</el-select>
|
</el-select>
|
||||||
<div v-if="form.episode_numbers.length > 1" class="field-hint" style="margin-top:4px;color:#E6A23C">
|
<div v-if="form.episode_numbers.length > 1 && !form.episode_numbers.includes(0)" class="field-hint" style="margin-top:4px;color:#E6A23C">
|
||||||
已选 {{ form.episode_numbers.length }} 集,投入时长将平均分配到每集
|
已选 {{ form.episode_numbers.length }} 集,投入时长将平均分配到每集
|
||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@ -186,6 +187,7 @@
|
|||||||
<el-form-item label="集数" v-if="needsEpisode(editForm.content_type) && editEpisodeOptions.length > 0" required>
|
<el-form-item label="集数" v-if="needsEpisode(editForm.content_type) && editEpisodeOptions.length > 0" required>
|
||||||
<el-select v-model="editForm.episode_number" placeholder="选择集数(必填)" style="width:100%">
|
<el-select v-model="editForm.episode_number" placeholder="选择集数(必填)" style="width:100%">
|
||||||
<el-option v-for="ep in editEpisodeOptions" :key="ep" :label="'EP' + String(ep).padStart(2,'0')" :value="ep" />
|
<el-option v-for="ep in editEpisodeOptions" :key="ep" :label="'EP' + String(ep).padStart(2,'0')" :value="ep" />
|
||||||
|
<el-option :key="0" label="全集通用" :value="0" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="产出时长" v-if="showDuration(editForm.content_type)">
|
<el-form-item label="产出时长" v-if="showDuration(editForm.content_type)">
|
||||||
@ -346,6 +348,20 @@ watch(() => form.content_type, (val) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// "全集通用"(0) 与具体集数互斥
|
||||||
|
watch(() => form.episode_numbers, (val, old) => {
|
||||||
|
if (!val || val.length < 2) return
|
||||||
|
const hadZero = old && old.includes(0)
|
||||||
|
const hasZero = val.includes(0)
|
||||||
|
if (hasZero && !hadZero) {
|
||||||
|
// 刚选了全集通用 → 只保留0
|
||||||
|
form.episode_numbers = [0]
|
||||||
|
} else if (hasZero && hadZero && val.length > 1) {
|
||||||
|
// 有全集通用又选了具体集 → 去掉0
|
||||||
|
form.episode_numbers = val.filter(v => v !== 0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
watch(() => editForm.content_type, (val) => {
|
watch(() => editForm.content_type, (val) => {
|
||||||
if (NO_DURATION_TYPES.has(val)) {
|
if (NO_DURATION_TYPES.has(val)) {
|
||||||
editForm.duration_minutes = 0
|
editForm.duration_minutes = 0
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user