feat: 批量多集提交 — 集数多选+投入时长自动均分
All checks were successful
Build and Deploy Backend / build-and-deploy (push) Successful in 2m5s
Build and Deploy Web / build-and-deploy (push) Successful in 1m32s

- 后端 create_submission 支持 episode_numbers 批量创建,hours_spent 按集数均分
- 前端集数选择器改为多选(collapse-tags),显示分配提示
- 后端 API 端口统一改为 8001(.env.development + vite.config.js)

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

View File

@ -66,7 +66,7 @@ def list_submissions(
return [submission_to_out(s) for s in subs]
@router.post("", response_model=SubmissionOut)
@router.post("")
def create_submission(
req: SubmissionCreate,
db: Session = Depends(get_db),
@ -88,12 +88,16 @@ def create_submission(
if req.hours_spent is None or req.hours_spent <= 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
if content_val in PROJECT_LEVEL_TYPES:
req.episode_number = None
elif not req.episode_number:
episode_list = [None] # 项目级,单条无集数
elif req.episode_numbers and len(req.episode_numbers) > 0:
episode_list = req.episode_numbers # 批量多集
elif req.episode_number:
episode_list = [req.episode_number] # 单集
else:
raise HTTPException(status_code=422, detail="请选择集数")
# 校验项目存在
@ -101,10 +105,10 @@ def create_submission(
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
# 自动计算总秒数
# 自动计算总秒数(每集)
total_seconds = (req.duration_minutes or 0) * 60 + (req.duration_seconds or 0)
# 自动关联里程碑:根据 content_type 匹配同名里程碑
# 自动关联里程碑
milestone_id = None
content_val = req.content_type
if content_val in CONTENT_PHASE_MAP:
@ -114,7 +118,6 @@ def create_submission(
).first()
if ms:
milestone_id = ms.id
# 超期校验:如果里程碑已超期,必须填写延期原因
if ms.estimated_days and ms.start_date:
expected_end = ms.start_date + timedelta(days=ms.estimated_days)
if req.submit_date > expected_end and not req.delay_reason:
@ -123,27 +126,38 @@ def create_submission(
detail=f"里程碑「{ms.name}」已超期(预估{ms.estimated_days}天),请填写延期原因"
)
sub = Submission(
user_id=target_user_id,
project_id=req.project_id,
project_phase=PhaseGroup(req.project_phase),
work_type=WorkType(req.work_type),
content_type=ContentType(req.content_type),
duration_minutes=req.duration_minutes or 0,
duration_seconds=req.duration_seconds or 0,
total_seconds=total_seconds,
hours_spent=req.hours_spent,
submit_to=SubmitTo(req.submit_to),
description=req.description,
submit_date=req.submit_date,
milestone_id=milestone_id,
delay_reason=req.delay_reason,
episode_number=req.episode_number,
)
db.add(sub)
# 投入时长平均分配到每集
ep_count = len(episode_list)
hours_per_ep = round(req.hours_spent / ep_count, 2)
results = []
for ep in episode_list:
sub = Submission(
user_id=target_user_id,
project_id=req.project_id,
project_phase=PhaseGroup(req.project_phase),
work_type=WorkType(req.work_type),
content_type=ContentType(req.content_type),
duration_minutes=req.duration_minutes or 0,
duration_seconds=req.duration_seconds or 0,
total_seconds=total_seconds,
hours_spent=hours_per_ep,
submit_to=SubmitTo(req.submit_to),
description=req.description,
submit_date=req.submit_date,
milestone_id=milestone_id,
delay_reason=req.delay_reason,
episode_number=ep,
)
db.add(sub)
results.append(sub)
db.commit()
db.refresh(sub)
return submission_to_out(sub)
for s in results:
db.refresh(s)
if len(results) == 1:
return submission_to_out(results[0])
return [submission_to_out(s) for s in results]
@router.put("/{submission_id}", response_model=SubmissionOut)

View File

@ -166,6 +166,7 @@ class SubmissionCreate(BaseModel):
submit_date: date
delay_reason: Optional[str] = None
episode_number: Optional[int] = None
episode_numbers: Optional[List[int]] = None # 批量多集提交
user_id: Optional[int] = None # 代人提交时指定目标用户

View File

@ -1 +1 @@
VITE_API_BASE_URL=http://localhost:8000/api
VITE_API_BASE_URL=http://localhost:8001/api

View File

@ -97,9 +97,12 @@
</el-select>
</el-form-item>
<el-form-item label="集数" v-if="needsEpisode(form.content_type) && episodeOptions.length > 0" required>
<el-select v-model="form.episode_number" 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-select>
<div v-if="form.episode_numbers.length > 1" class="field-hint" style="margin-top:4px;color:#E6A23C">
已选 {{ form.episode_numbers.length }} 投入时长将平均分配到每集
</div>
</el-form-item>
<el-form-item label="产出时长" v-if="showDuration(form.content_type)">
<div class="inline-field">
@ -283,7 +286,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: '', episode_number: null, user_id: null,
delay_reason: '', episode_numbers: [], user_id: null,
})
//
@ -339,7 +342,7 @@ watch(() => form.content_type, (val) => {
form.duration_seconds = 0
}
if (!needsEpisode(val)) {
form.episode_number = null
form.episode_numbers = []
}
})
@ -413,7 +416,7 @@ async function load() {
async function handleCreate() {
if (!form.project_id) { ElMessage.warning('请选择项目'); return }
if (needsEpisode(form.content_type) && !form.episode_number) { ElMessage.warning('请选择集数'); return }
if (needsEpisode(form.content_type) && form.episode_numbers.length === 0) { ElMessage.warning('请选择集数'); return }
if (!form.description?.trim()) { ElMessage.warning('请填写描述'); return }
if (!form.hours_spent || form.hours_spent <= 0) { ElMessage.warning('请填写投入时长'); return }
if (isMilestoneOverdue.value && !form.delay_reason?.trim()) {
@ -431,7 +434,7 @@ async function handleCreate() {
form.hours_spent = null
form.description = ''
form.delay_reason = ''
form.episode_number = null
form.episode_numbers = []
form.user_id = null
load()
//

View File

@ -7,7 +7,7 @@ export default defineConfig({
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8000',
target: 'http://localhost:8001',
changeOrigin: true,
}
}