feat: 制作产出/后期产出分离 + 全集通用选项 + 概览卡片布局优化
All checks were successful
Build and Deploy Backend / build-and-deploy (push) Successful in 2m24s
Build and Deploy Web / build-and-deploy (push) Successful in 4m11s

- 项目详情:拆分"已提交"为"制作产出"(中期)和"后期产出"(后期按类型细分)
- 进度百分比仅计算中期动画产出,EP集数进度只统计中期
- 新增"全集通用"(episode=0)选项,与具体集数互斥
- 概览卡片改为上4下1布局,后期产出独立全宽卡片展示明细

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
seaislee1209 2026-02-28 21:20:49 +08:00
parent 41c2b9cd89
commit ac350e763b
5 changed files with 81 additions and 11 deletions

View File

@ -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,

View File

@ -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="请选择集数")

View File

@ -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

View File

@ -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;

View File

@ -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