feat: 项目详情显示修补镜头 + 产出时长必填验证
All checks were successful
Build and Deploy Backend / build-and-deploy (push) Successful in 3m4s
Build and Deploy Web / build-and-deploy (push) Successful in 17m17s

- 项目详情中期模块显示修补镜头(后期秒数,归入制作产出计算)
- 产出时长改为必填项(非前期内容必须 > 0)
- 前端+后端双重验证,防止提交0秒产出
- 删除"无产出秒数的工作可填 0"过时提示

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
seaislee1209 2026-03-02 22:41:20 +08:00
parent fe6136555b
commit f9016ab2af
4 changed files with 38 additions and 4 deletions

View File

@ -7,7 +7,7 @@ from datetime import datetime
from database import get_db
from models import (
User, Project, Submission, ProjectType,
ProjectStatus, PhaseGroup, WorkType,
ProjectStatus, PhaseGroup, WorkType, ContentType,
ProjectMilestone, DEFAULT_MILESTONES
)
from schemas import (
@ -59,6 +59,13 @@ def enrich_project(p: Project, db: Session) -> ProjectOut:
Submission.project_phase == PhaseGroup.PRODUCTION,
).scalar() or 0
# 修补镜头(后期秒数,归入制作目标对比)
shot_repair_secs = db.query(sa_func.sum(Submission.total_seconds)).filter(
Submission.project_id == p.id,
Submission.content_type == ContentType.SHOT_REPAIR,
Submission.total_seconds > 0,
).scalar() or 0
# 后期产出:按内容类型分组
post_rows = db.query(
Submission.content_type,
@ -107,6 +114,7 @@ def enrich_project(p: Project, db: Session) -> ProjectOut:
"production": {
"progress_percent": progress,
"submitted_seconds": round(animation_secs, 1),
"shot_repair_seconds": round(shot_repair_secs, 1),
"target_seconds": target,
"waste": waste_data.get("production_waste", {}),
},

View File

@ -88,6 +88,14 @@ def create_submission(
if req.hours_spent is None or req.hours_spent <= 0:
raise HTTPException(status_code=422, detail="请填写投入时长")
# 产出时长校验:前期内容不需要,中期/后期内容必须 > 0
PRE_PHASE_TYPES = {'策划案', '大纲/梗概', '概念设计图', '测试片', '剧本', '分镜', '人设图', '场景图', '道具图'}
content_val = req.content_type.value if hasattr(req.content_type, 'value') else req.content_type
if content_val not in PRE_PHASE_TYPES:
total_secs = (req.duration_minutes or 0) * 60 + (req.duration_seconds or 0)
if total_secs <= 0:
raise HTTPException(status_code=422, detail="请填写产出时长")
# 确定集数列表
from models import PROJECT_LEVEL_TYPES
content_val = req.content_type.value if hasattr(req.content_type, 'value') else req.content_type
@ -211,6 +219,12 @@ def update_submission(
# 重算总秒数
sub.total_seconds = (sub.duration_minutes or 0) * 60 + (sub.duration_seconds or 0)
# 产出时长校验:前期内容不需要,中期/后期内容必须 > 0
PRE_PHASE_TYPES = {'策划案', '大纲/梗概', '概念设计图', '测试片', '剧本', '分镜', '人设图', '场景图', '道具图'}
content_val = sub.content_type.value if hasattr(sub.content_type, 'value') else sub.content_type
if content_val not in PRE_PHASE_TYPES and sub.total_seconds <= 0:
raise HTTPException(status_code=422, detail="请填写产出时长")
# 保存新数据
new_data = {
"project_phase": sub.project_phase.value if hasattr(sub.project_phase, 'value') else sub.project_phase,

View File

@ -167,6 +167,10 @@
<span class="prod-info-label">目标</span>
<span class="prod-info-value">{{ formatSecs(project.target_total_seconds) }}</span>
</div>
<div class="prod-info-row" v-if="shotRepairSeconds > 0">
<span class="prod-info-label">修补镜头</span>
<span class="prod-info-value" :style="{color: '#34C759'}">{{ formatSecs(shotRepairSeconds) }}</span>
</div>
<div class="prod-info-row">
<span class="prod-info-label">测试损耗</span>
<span class="prod-info-value" :style="{color: prodWaste.test_waste_seconds > 0 ? '#FF9500' : 'inherit'}">{{ formatSecs(prodWaste.test_waste_seconds) }}</span>
@ -605,6 +609,7 @@ const postMilestones = computed(() => (project.value.milestones || []).filter(m
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 shotRepairSeconds = computed(() => project.value.phase_summary?.production?.shot_repair_seconds || 0)
const newMilestone = reactive({ pre: '', post: '' })
//

View File

@ -105,13 +105,12 @@
已选 {{ form.episode_numbers.length }} 投入时长将平均分配到每集
</div>
</el-form-item>
<el-form-item label="产出时长" v-if="showDuration(form.content_type)">
<el-form-item label="产出时长" v-if="showDuration(form.content_type)" required>
<div class="inline-field">
<el-input-number v-model="form.duration_minutes" :min="0" :step="1" style="width:120px" />
<span class="field-unit"></span>
<el-input-number v-model="form.duration_seconds" :min="0" :max="59" :step="5" style="width:120px" />
<span class="field-unit"></span>
<span class="field-hint">无产出秒数的工作可填 0</span>
</div>
</el-form-item>
<el-form-item label="投入时长" required>
@ -190,7 +189,7 @@
<el-option :key="0" label="全集通用" :value="0" />
</el-select>
</el-form-item>
<el-form-item label="产出时长" v-if="showDuration(editForm.content_type)">
<el-form-item label="产出时长" v-if="showDuration(editForm.content_type)" required>
<div class="inline-field">
<el-input-number v-model="editForm.duration_minutes" :min="0" :step="1" style="width:120px" />
<span class="field-unit"></span>
@ -434,6 +433,10 @@ async function handleCreate() {
if (!form.project_id) { ElMessage.warning('请选择项目'); return }
if (needsEpisode(form.content_type) && form.episode_numbers.length === 0) { ElMessage.warning('请选择集数'); return }
if (!form.description?.trim()) { ElMessage.warning('请填写描述'); return }
if (showDuration(form.content_type)) {
const totalSecs = (form.duration_minutes || 0) * 60 + (form.duration_seconds || 0)
if (totalSecs <= 0) { ElMessage.warning('请填写产出时长'); return }
}
if (!form.hours_spent || form.hours_spent <= 0) { ElMessage.warning('请填写投入时长'); return }
if (isMilestoneOverdue.value && !form.delay_reason?.trim()) {
ElMessage.warning('该里程碑已超期,请填写延期原因')
@ -491,6 +494,10 @@ async function handleUpdate() {
ElMessage.warning('请填写修改原因')
return
}
if (showDuration(editForm.content_type)) {
const totalSecs = (editForm.duration_minutes || 0) * 60 + (editForm.duration_seconds || 0)
if (totalSecs <= 0) { ElMessage.warning('请填写产出时长'); return }
}
editing.value = true
try {
const { _id, _project_name, _project_id, ...payload } = editForm