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

View File

@ -166,6 +166,7 @@ class SubmissionCreate(BaseModel):
submit_date: date submit_date: date
delay_reason: Optional[str] = None delay_reason: Optional[str] = None
episode_number: Optional[int] = None episode_number: Optional[int] = None
episode_numbers: Optional[List[int]] = None # 批量多集提交
user_id: Optional[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-select>
</el-form-item> </el-form-item>
<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_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-option v-for="ep in episodeOptions" :key="ep" :label="'EP' + String(ep).padStart(2,'0')" :value="ep" />
</el-select> </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>
<el-form-item label="产出时长" v-if="showDuration(form.content_type)"> <el-form-item label="产出时长" v-if="showDuration(form.content_type)">
<div class="inline-field"> <div class="inline-field">
@ -283,7 +286,7 @@ const form = reactive({
project_id: null, project_phase: '中期', work_type: '制作', project_id: null, project_phase: '中期', work_type: '制作',
content_type: '动画制作', duration_minutes: 0, duration_seconds: 0, content_type: '动画制作', duration_minutes: 0, duration_seconds: 0,
hours_spent: null, submit_to: '组长', description: '', submit_date: today, 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 form.duration_seconds = 0
} }
if (!needsEpisode(val)) { if (!needsEpisode(val)) {
form.episode_number = null form.episode_numbers = []
} }
}) })
@ -413,7 +416,7 @@ async function load() {
async function handleCreate() { async function handleCreate() {
if (!form.project_id) { ElMessage.warning('请选择项目'); return } 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.description?.trim()) { ElMessage.warning('请填写描述'); return }
if (!form.hours_spent || form.hours_spent <= 0) { ElMessage.warning('请填写投入时长'); return } if (!form.hours_spent || form.hours_spent <= 0) { ElMessage.warning('请填写投入时长'); return }
if (isMilestoneOverdue.value && !form.delay_reason?.trim()) { if (isMilestoneOverdue.value && !form.delay_reason?.trim()) {
@ -431,7 +434,7 @@ async function handleCreate() {
form.hours_spent = null form.hours_spent = null
form.description = '' form.description = ''
form.delay_reason = '' form.delay_reason = ''
form.episode_number = null form.episode_numbers = []
form.user_id = null form.user_id = null
load() load()
// //

View File

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