feat: 内容提交表单全面优化 — 阶段重命名+内容类型扩展+集数条件必填
All checks were successful
Build and Deploy Backend / build-and-deploy (push) Successful in 2m4s
Build and Deploy Web / build-and-deploy (push) Successful in 1m13s

- 阶段重命名: "制作" → "中期",全前后端同步
- 新增前期内容类型: 大纲/梗概、概念设计图、测试片
- 配音改名为AI配音(零迁移,DB存英文NAME)
- 集数按内容类型条件显示: 项目级隐藏,集数级必填
- 工作类型全阶段统一: 制作/修改/测试/QC
- 前期内容隐藏产出时长字段
- 必填校验: 描述+投入时长+集数(条件)
- 今日工时进度条(8h目标)+提交后提醒
- 效率计算只统计中期阶段
- EP集数进度查询优化(N+1→批量GROUP BY)
- 提交列表增加按提交人筛选

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
seaislee1209 2026-02-28 17:34:11 +08:00
parent e4ff7763b5
commit f07126e0ca
12 changed files with 302 additions and 132 deletions

View File

@ -8,7 +8,7 @@ from collections import defaultdict
from datetime import date, timedelta
from models import (
User, Project, Submission, AIToolCost, AIToolCostAllocation,
OutsourceCost, CostOverride, OverheadCost, WorkType, CostAllocationType
OutsourceCost, CostOverride, OverheadCost, WorkType, PhaseGroup, CostAllocationType
)
from config import WORKING_DAYS_PER_MONTH
@ -371,6 +371,7 @@ def calc_team_efficiency(project_id: int, db: Session) -> list:
).filter(
Submission.project_id == project_id,
Submission.total_seconds > 0,
Submission.project_phase == PhaseGroup.PRODUCTION, # 只算中期
).group_by(Submission.user_id).all()
if not per_user:

View File

@ -145,8 +145,10 @@ def init_roles_and_admin():
try:
conn.execute(text("""
ALTER TABLE submissions MODIFY COLUMN content_type
ENUM('PLANNING','SCRIPT','STORYBOARD','CHARACTER_DESIGN','SCENE_DESIGN',
'ANIMATION','DUBBING','SOUND_EFFECTS','SHOT_REPAIR','EDITING','OTHER',
ENUM('PLANNING','SYNOPSIS','CONCEPT_DESIGN','TEST_FOOTAGE',
'SCRIPT','STORYBOARD','CHARACTER_DESIGN','SCENE_DESIGN',
'PROP_DESIGN','ANIMATION','DUBBING','AI_DUBBING','SOUND_EFFECTS',
'SHOT_REPAIR','EDITING','MUSIC','SUBTITLE','OTHER',
'DESIGN') NOT NULL
"""))
conn.commit()
@ -173,6 +175,24 @@ def init_roles_and_admin():
except Exception as e:
print(f"[MIGRATE] schema migration error (non-fatal): {e}")
# 一次性迁移:阶段"制作"→"中期"
try:
phase_tables = [
("submissions", "project_phase"),
("projects", "current_phase"),
("users", "phase_group"),
("project_milestones", "phase"),
]
total_migrated = 0
for table, col in phase_tables:
r = conn.execute(text(f"UPDATE {table} SET {col} = '中期' WHERE {col} = '制作'"))
total_migrated += r.rowcount or 0
conn.commit()
if total_migrated > 0:
print(f"[MIGRATE] renamed phase '制作''中期' in {total_migrated} rows")
except Exception as e:
print(f"[MIGRATE] phase rename error (non-fatal): {e}")
# 初始化 / 同步内置角色权限
for role_name, role_def in BUILTIN_ROLES.items():
existing = db.query(Role).filter(Role.name == role_name).first()

View File

@ -118,7 +118,7 @@ class ProjectStatus(str, enum.Enum):
class PhaseGroup(str, enum.Enum):
PRE = "前期"
PRODUCTION = "制作"
PRODUCTION = "中期"
POST = "后期"
@ -131,19 +131,26 @@ class WorkType(str, enum.Enum):
class ContentType(str, enum.Enum):
# 前期
# 前期(项目级)
PLANNING = "策划案"
SYNOPSIS = "大纲/梗概"
CONCEPT_DESIGN = "概念设计图"
TEST_FOOTAGE = "测试片"
# 前期(集数级)
SCRIPT = "剧本"
STORYBOARD = "分镜"
CHARACTER_DESIGN = "人设图"
SCENE_DESIGN = "场景图"
# 制作
PROP_DESIGN = "道具图"
# 中期
ANIMATION = "动画制作"
# 后期
DUBBING = "配音"
DUBBING = "AI配音"
SOUND_EFFECTS = "音效"
SHOT_REPAIR = "修补镜头"
EDITING = "剪辑"
MUSIC = "音乐/BGM"
SUBTITLE = "字幕"
# 通用
OTHER = "其他"
@ -415,18 +422,27 @@ DEFAULT_MILESTONES = [
{"name": "分镜", "phase": "前期", "sort_order": 3},
{"name": "人设图", "phase": "前期", "sort_order": 4},
{"name": "场景图", "phase": "前期", "sort_order": 5},
{"name": "道具图", "phase": "前期", "sort_order": 6},
# 后期
{"name": "配音", "phase": "后期", "sort_order": 1},
{"name": "音效", "phase": "后期", "sort_order": 2},
{"name": "修补镜头", "phase": "后期", "sort_order": 3},
{"name": "剪辑", "phase": "后期", "sort_order": 4},
{"name": "杂项", "phase": "后期", "sort_order": 5},
{"name": "AI配音", "phase": "后期", "sort_order": 2},
{"name": "音效", "phase": "后期", "sort_order": 3},
{"name": "修补镜头", "phase": "后期", "sort_order": 4},
{"name": "剪辑", "phase": "后期", "sort_order": 5},
{"name": "音乐/BGM", "phase": "后期", "sort_order": 6},
{"name": "字幕", "phase": "后期", "sort_order": 7},
{"name": "杂项", "phase": "后期", "sort_order": 8},
]
# 内容类型 → 阶段映射(用于自动设置阶段和关联里程碑)
CONTENT_PHASE_MAP = {
"策划案": "前期", "剧本": "前期", "分镜": "前期",
"人设图": "前期", "场景图": "前期",
"动画制作": "制作",
"配音": "后期", "音效": "后期", "修补镜头": "后期", "剪辑": "后期",
"策划案": "前期", "大纲/梗概": "前期", "概念设计图": "前期", "测试片": "前期",
"剧本": "前期", "分镜": "前期",
"人设图": "前期", "场景图": "前期", "道具图": "前期",
"动画制作": "中期",
"AI配音": "后期", "音效": "后期",
"修补镜头": "后期", "剪辑": "后期", "音乐/BGM": "后期", "字幕": "后期",
}
# 项目级内容类型(不需要选集数)
PROJECT_LEVEL_TYPES = {"策划案", "大纲/梗概", "概念设计图", "测试片"}

View File

@ -97,27 +97,38 @@ def enrich_project(p: Project, db: Session) -> ProjectOut:
if len(pre_ms) > 0 and pre_completed < len(pre_ms):
current_stage = "前期"
elif progress < 100:
current_stage = "制作"
current_stage = "中期"
elif len(post_ms) > 0 and post_completed < len(post_ms):
current_stage = "后期"
else:
current_stage = "已完成"
# EP 集数进度
# EP 集数进度(批量查询,避免 N+1
episode_progress = []
ep_target = p.episode_duration_minutes * 60 # 每集目标秒数
# 一次查出所有有提交的集数数据
ep_rows = db.query(
Submission.episode_number,
User.name,
sa_func.sum(Submission.total_seconds).label("secs"),
).join(User, User.id == Submission.user_id, isouter=True).filter(
Submission.project_id == p.id,
Submission.episode_number.isnot(None),
Submission.total_seconds > 0,
).group_by(Submission.episode_number, User.name).all()
# 按集数聚合
ep_data = {} # {ep: {total, contributors: {name: secs}}}
for ep_num, user_name, secs in ep_rows:
if ep_num not in ep_data:
ep_data[ep_num] = {"total": 0, "contributors": {}}
ep_data[ep_num]["total"] += secs
name = user_name or "未知"
ep_data[ep_num]["contributors"][name] = ep_data[ep_num]["contributors"].get(name, 0) + secs
for ep in range(1, p.episode_count + 1):
ep_subs = db.query(Submission).filter(
Submission.project_id == p.id,
Submission.episode_number == ep,
Submission.total_seconds > 0,
).all()
ep_total = sum(s.total_seconds for s in ep_subs)
# 每集贡献者
contributors = {}
for s in ep_subs:
name = s.user.name if s.user else "未知"
contributors[name] = contributors.get(name, 0) + s.total_seconds
info = ep_data.get(ep, {"total": 0, "contributors": {}})
ep_total = info["total"]
episode_progress.append({
"episode": ep,
"total_seconds": round(ep_total, 1),
@ -125,7 +136,7 @@ def enrich_project(p: Project, db: Session) -> ProjectOut:
"progress_percent": round(ep_total / ep_target * 100, 1) if ep_target > 0 else 0,
"contributors": [
{"name": k, "seconds": round(v, 1)}
for k, v in sorted(contributors.items(), key=lambda x: -x[1])
for k, v in sorted(info["contributors"].items(), key=lambda x: -x[1])
],
})

View File

@ -82,6 +82,20 @@ def create_submission(
raise HTTPException(status_code=404, detail="目标用户不存在")
target_user_id = req.user_id
# 必填校验
if not req.description or not req.description.strip():
raise HTTPException(status_code=422, detail="请填写描述")
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:
raise HTTPException(status_code=422, detail="请选择集数")
# 校验项目存在
project = db.query(Project).filter(Project.id == req.project_id).first()
if not project:
@ -251,3 +265,32 @@ def delete_submission(
db.delete(sub)
db.commit()
return {"detail": "删除成功"}
@router.get("/daily-hours")
def get_daily_hours(
user_id: Optional[int] = Query(None),
target_date: Optional[date] = Query(None, alias="date"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""查询指定用户某日已填工时"""
from sqlalchemy import func
uid = user_id or current_user.id
# 非本人查询需要 proxy 权限
if uid != current_user.id and not current_user.has_permission("submission:proxy"):
raise HTTPException(status_code=403, detail="无权查看他人工时")
d = target_date or date.today()
filled = db.query(func.sum(Submission.hours_spent)).filter(
Submission.user_id == uid,
Submission.submit_date == d,
).scalar() or 0.0
target = 8.0
return {
"filled": round(filled, 1),
"target": target,
"remaining": round(max(target - filled, 0), 1),
}

View File

@ -22,7 +22,7 @@ class UserCreate(BaseModel):
username: str
password: str
name: str
phase_group: str # 前期/制作/后期
phase_group: str # 前期/中期/后期
role_id: int
monthly_salary: float = 0
bonus: float = 0

View File

@ -79,6 +79,7 @@ export const submissionApi = {
update: (id, data) => api.put(`/submissions/${id}`, data),
delete: (id) => api.delete(`/submissions/${id}`),
history: (id) => api.get(`/submissions/${id}/history`),
dailyHours: (params) => api.get('/submissions/daily-hours', { params }),
}
// ── 成本 ──

View File

@ -93,7 +93,7 @@
<div class="seg-fill production" :style="{width: Math.min(project.progress_percent, 100) + '%'}"></div>
</div>
<div class="seg-info">
<span class="seg-name" :class="{active: project.current_stage === '制作'}">制作</span>
<span class="seg-name" :class="{active: project.current_stage === '中期'}">中期</span>
<span class="seg-stat">{{ project.progress_percent }}%</span>
</div>
</div>
@ -140,9 +140,9 @@
</div>
</div>
<!-- 制作 -->
<!-- 中期 -->
<div class="milestone-col production-col">
<div class="milestone-col-header"><span>制作</span></div>
<div class="milestone-col-header"><span>中期</span></div>
<div class="production-ring-layout">
<div ref="progressChartRef" style="width:180px;height:180px;flex-shrink:0"></div>
<div class="production-info">
@ -409,7 +409,7 @@
<el-form-item label="当前阶段">
<el-select v-model="editForm.current_phase" style="width:100%">
<el-option label="前期" value="前期" />
<el-option label="制作" value="制作" />
<el-option label="中期" value="中期" />
<el-option label="后期" value="后期" />
</el-select>
</el-form-item>
@ -550,7 +550,7 @@ function initProgressChart() {
function handleProgressResize() { progressChart?.resize() }
const editForm = reactive({
name: '', project_type: '客户正式项目', status: '制作中', leader_id: null, current_phase: '制作',
name: '', project_type: '客户正式项目', status: '制作中', leader_id: null, current_phase: '中期',
episode_duration_minutes: 5, episode_count: 1,
estimated_completion_date: null, contract_amount: null,
})
@ -560,7 +560,7 @@ const stageColor = computed(() => {
const s = project.value.current_stage
if (s === '已完成') return '#34C759'
if (s === '前期') return '#8F959E'
if (s === '制作') return '#3370FF'
if (s === '中期') return '#3370FF'
if (s === '后期') return '#FF9500'
return 'var(--text-primary)'
})
@ -798,7 +798,7 @@ async function openEdit() {
}
Object.assign(editForm, {
name: p.name, project_type: p.project_type, status: p.status || '制作中', leader_id: p.leader_id,
current_phase: p.current_phase || '制作',
current_phase: p.current_phase || '中期',
episode_duration_minutes: p.episode_duration_minutes, episode_count: p.episode_count,
estimated_completion_date: p.estimated_completion_date, contract_amount: p.contract_amount,
})

View File

@ -183,7 +183,7 @@ function stageLabel(row) {
if (!s) return row.progress_percent + '%'
const stage = row.current_stage
if (stage === '前期') return `前期 ${s.pre.completed}/${s.pre.total}`
if (stage === '制作') return `制作 ${row.progress_percent}%`
if (stage === '中期') return `中期 ${row.progress_percent}%`
if (stage === '后期') return `后期 ${s.post.completed}/${s.post.total}`
if (stage === '已完成') return '已完成'
return row.progress_percent + '%'
@ -231,7 +231,7 @@ onMounted(async () => {
background: var(--bg-hover); color: var(--text-secondary);
}
.stage-tag.stage-前期 { background: #F0F1F5; color: #8F959E; }
.stage-tag.stage-制作 { background: #EBF1FF; color: #3370FF; }
.stage-tag.stage-中期 { background: #EBF1FF; color: #3370FF; }
.stage-tag.stage-后期 { background: #FFF3E0; color: #FF9500; }
.stage-tag.stage-已完成 { background: #E8F8EE; color: #34C759; }
.rate-badge {

View File

@ -76,7 +76,7 @@
</el-col>
<el-col :span="8">
<div class="waste-phase-card">
<div class="waste-phase-title">制作秒数制</div>
<div class="waste-phase-title">中期秒数制</div>
<div class="waste-phase-sub">
<span>测试损耗</span>
<span :class="{ danger: data.test_waste_seconds > 0 }">{{ formatSecs(data.test_waste_seconds) }}</span>

View File

@ -8,6 +8,9 @@
<!-- 筛选 -->
<el-card class="filter-card">
<el-space wrap>
<el-select v-model="filter.user_id" placeholder="按提交人筛选" clearable filterable style="width:180px" @change="load">
<el-option v-for="u in users" :key="u.id" :label="u.name" :value="u.id" />
</el-select>
<el-select v-model="filter.project_id" placeholder="按项目筛选" clearable style="width:200px" @change="load">
<el-option v-for="p in projects" :key="p.id" :label="p.name" :value="p.id" />
</el-select>
@ -16,6 +19,18 @@
</el-space>
</el-card>
<!-- 今日工时进度 -->
<div class="daily-hours-bar" v-if="dailyHours">
<span class="daily-label">今日工时{{ dailyHours.filled }}h / {{ dailyHours.target }}h</span>
<el-progress
:percentage="Math.min(dailyHours.filled / dailyHours.target * 100, 100)"
:stroke-width="14"
:color="dailyHours.filled >= dailyHours.target ? '#67C23A' : '#E6A23C'"
:format="() => dailyHours.remaining > 0 ? `剩${dailyHours.remaining}h` : '已满'"
style="flex:1;min-width:120px"
/>
</div>
<!-- 列表 -->
<el-table :data="submissions" v-loading="loading" stripe>
<el-table-column prop="submit_date" label="日期" width="110" sortable />
@ -27,7 +42,7 @@
<el-tag :type="row.work_type === '测试' ? 'warning' : row.work_type === '方案' ? 'info' : row.work_type === '修改' ? 'danger' : row.work_type === 'QC' ? 'success' : ''" size="small">{{ row.work_type }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="content_type" label="内容类型" width="90" />
<el-table-column prop="content_type" label="内容类型" width="100" />
<el-table-column label="产出时长" width="100" align="right">
<template #default="{row}">{{ row.total_seconds > 0 ? formatSecs(row.total_seconds) : '—' }}</template>
</el-table-column>
@ -46,7 +61,7 @@
<el-dialog v-model="showCreate" title="新增内容提交" width="580px" destroy-on-close>
<el-form :model="form" label-width="110px" label-position="left">
<el-form-item v-if="authStore.hasPermission('submission:proxy')" label="提交人">
<el-select v-model="form.user_id" placeholder="默认为自己" clearable filterable style="width:100%">
<el-select v-model="form.user_id" placeholder="默认为自己" clearable filterable style="width:100%" @change="loadDailyHours">
<el-option v-for="u in users" :key="u.id" :label="u.name" :value="u.id" />
</el-select>
</el-form-item>
@ -60,7 +75,7 @@
<el-form-item label="项目阶段" required>
<el-select v-model="form.project_phase" style="width:100%">
<el-option label="前期" value="前期" />
<el-option label="制作" value="制作" />
<el-option label="中期" value="中期" />
<el-option label="后期" value="后期" />
</el-select>
</el-form-item>
@ -71,39 +86,22 @@
<el-option label="制作" value="制作" />
<el-option label="修改" value="修改" />
<el-option label="测试" value="测试" />
<el-option label="方案" value="方案" />
<el-option label="QC" value="QC" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="集数" v-if="episodeOptions.length > 0">
<el-select v-model="form.episode_number" placeholder="选择集数(选填)" clearable style="width:100%">
<el-form-item label="内容类型" required>
<el-select v-model="form.content_type" placeholder="本次提交的产出类型" style="width:100%">
<el-option v-for="ct in filteredContentTypes" :key="ct" :label="ct" :value="ct" />
</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-option v-for="ep in episodeOptions" :key="ep" :label="'EP' + String(ep).padStart(2,'0')" :value="ep" />
</el-select>
</el-form-item>
<el-form-item label="内容类型" required>
<el-select v-model="form.content_type" placeholder="本次提交的产出类型" style="width:100%">
<el-option-group label="前期">
<el-option label="策划案" value="策划案" />
<el-option label="剧本" value="剧本" />
<el-option label="分镜" value="分镜" />
<el-option label="人设图" value="人设图" />
<el-option label="场景图" value="场景图" />
</el-option-group>
<el-option-group label="制作">
<el-option label="动画制作" value="动画制作" />
</el-option-group>
<el-option-group label="后期">
<el-option label="配音" value="配音" />
<el-option label="音效" value="音效" />
<el-option label="修补镜头" value="修补镜头" />
<el-option label="剪辑" value="剪辑" />
</el-option-group>
<el-option label="其他" value="其他" />
</el-select>
</el-form-item>
<el-form-item label="产出时长">
<el-form-item label="产出时长" v-if="showDuration(form.content_type)">
<div class="inline-field">
<el-input-number v-model="form.duration_minutes" :min="0" :step="1" style="width:120px" />
<span class="field-unit"></span>
@ -112,11 +110,13 @@
<span class="field-hint">无产出秒数的工作可填 0</span>
</div>
</el-form-item>
<el-form-item label="投入时长">
<el-form-item label="投入时长" required>
<div class="inline-field">
<el-input-number v-model="form.hours_spent" :min="0" :step="0.5" :precision="1" style="width:140px" />
<span class="field-unit">小时</span>
<span class="field-hint">选填实际花费的工作时间</span>
<el-tag v-if="dialogDailyHours" :type="dialogDailyHours.remaining > 0 ? 'warning' : 'success'" size="small" style="margin-left:4px">
今日已填 {{ dialogDailyHours.filled }}h / 8h还剩 {{ dialogDailyHours.remaining }}h
</el-tag>
</div>
</el-form-item>
<el-form-item label="提交对象" required>
@ -135,8 +135,8 @@
<el-input v-model="form.delay_reason" type="textarea" :rows="2"
placeholder="该里程碑已超期,请说明延期原因(必填)" />
</el-form-item>
<el-form-item label="制作描述">
<el-input v-model="form.description" type="textarea" :rows="2" placeholder="简要描述本次提交内容" />
<el-form-item label="描述" required>
<el-input v-model="form.description" type="textarea" :rows="2" placeholder="简要描述本次提交内容(必填)" />
</el-form-item>
<el-form-item label="提交日期" required>
<el-date-picker v-model="form.submit_date" value-format="YYYY-MM-DD" style="width:100%" />
@ -159,7 +159,7 @@
<el-form-item label="项目阶段" required>
<el-select v-model="editForm.project_phase" style="width:100%">
<el-option label="前期" value="前期" />
<el-option label="制作" value="制作" />
<el-option label="中期" value="中期" />
<el-option label="后期" value="后期" />
</el-select>
</el-form-item>
@ -170,39 +170,22 @@
<el-option label="制作" value="制作" />
<el-option label="修改" value="修改" />
<el-option label="测试" value="测试" />
<el-option label="方案" value="方案" />
<el-option label="QC" value="QC" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="集数" v-if="editEpisodeOptions.length > 0">
<el-select v-model="editForm.episode_number" placeholder="选择集数(选填)" clearable style="width:100%">
<el-form-item label="内容类型" required>
<el-select v-model="editForm.content_type" style="width:100%">
<el-option v-for="ct in editFilteredContentTypes" :key="ct" :label="ct" :value="ct" />
</el-select>
</el-form-item>
<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-option v-for="ep in editEpisodeOptions" :key="ep" :label="'EP' + String(ep).padStart(2,'0')" :value="ep" />
</el-select>
</el-form-item>
<el-form-item label="内容类型" required>
<el-select v-model="editForm.content_type" style="width:100%">
<el-option-group label="前期">
<el-option label="策划案" value="策划案" />
<el-option label="剧本" value="剧本" />
<el-option label="分镜" value="分镜" />
<el-option label="人设图" value="人设图" />
<el-option label="场景图" value="场景图" />
</el-option-group>
<el-option-group label="制作">
<el-option label="动画制作" value="动画制作" />
</el-option-group>
<el-option-group label="后期">
<el-option label="配音" value="配音" />
<el-option label="音效" value="音效" />
<el-option label="修补镜头" value="修补镜头" />
<el-option label="剪辑" value="剪辑" />
</el-option-group>
<el-option label="其他" value="其他" />
</el-select>
</el-form-item>
<el-form-item label="产出时长">
<el-form-item label="产出时长" v-if="showDuration(editForm.content_type)">
<div class="inline-field">
<el-input-number v-model="editForm.duration_minutes" :min="0" :step="1" style="width:120px" />
<span class="field-unit"></span>
@ -210,7 +193,7 @@
<span class="field-unit"></span>
</div>
</el-form-item>
<el-form-item label="投入时长">
<el-form-item label="投入时长" required>
<div class="inline-field">
<el-input-number v-model="editForm.hours_spent" :min="0" :step="0.5" :precision="1" style="width:140px" />
<span class="field-unit">小时</span>
@ -224,7 +207,7 @@
<el-option label="外部 — 提交给甲方/客户" value="外部" />
</el-select>
</el-form-item>
<el-form-item label="制作描述">
<el-form-item label="描述" required>
<el-input v-model="editForm.description" type="textarea" :rows="2" />
</el-form-item>
<el-form-item label="提交日期" required>
@ -259,9 +242,29 @@ import { useAuthStore } from '../stores/auth'
const authStore = useAuthStore()
const CONTENT_PHASE_MAP = {
'策划案': '前期', '剧本': '前期', '分镜': '前期', '人设图': '前期', '场景图': '前期',
'动画制作': '制作',
'配音': '后期', '音效': '后期', '修补镜头': '后期', '剪辑': '后期',
'策划案': '前期', '大纲/梗概': '前期', '概念设计图': '前期', '测试片': '前期',
'剧本': '前期', '分镜': '前期', '人设图': '前期', '场景图': '前期', '道具图': '前期',
'动画制作': '中期',
'AI配音': '后期', '音效': '后期', '修补镜头': '后期', '剪辑': '后期', '音乐/BGM': '后期', '字幕': '后期',
}
//
const PHASE_CONTENT_TYPES = {
'前期': ['策划案', '大纲/梗概', '概念设计图', '测试片', '剧本', '分镜', '人设图', '场景图', '道具图'],
'中期': ['动画制作'],
'后期': ['AI配音', '音效', '修补镜头', '剪辑', '音乐/BGM', '字幕'],
}
//
const PROJECT_LEVEL_TYPES = new Set(['策划案', '大纲/梗概', '概念设计图', '测试片'])
function needsEpisode(contentType) {
return !PROJECT_LEVEL_TYPES.has(contentType)
}
//
const NO_DURATION_TYPES = new Set(PHASE_CONTENT_TYPES['前期'])
function showDuration(contentType) {
return !NO_DURATION_TYPES.has(contentType)
}
const loading = ref(false)
@ -271,26 +274,86 @@ const submissions = ref([])
const projects = ref([])
const users = ref([])
const dateRange = ref(null)
const filter = reactive({ project_id: null, start_date: null, end_date: null })
const filter = reactive({ project_id: null, user_id: null, start_date: null, end_date: null })
const activeProjects = computed(() => projects.value.filter(p => p.status === '制作中'))
const today = new Date().toISOString().split('T')[0]
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,
hours_spent: null, submit_to: '组长', description: '', submit_date: today,
delay_reason: '', episode_number: null, user_id: null,
})
//
watch(() => form.content_type, (val) => {
if (CONTENT_PHASE_MAP[val]) {
form.project_phase = CONTENT_PHASE_MAP[val]
//
const showEdit = ref(false)
const editing = ref(false)
const editForm = reactive({
_id: null,
_project_name: '',
_project_id: null,
project_phase: '',
work_type: '',
content_type: '',
duration_minutes: 0,
duration_seconds: 0,
hours_spent: null,
submit_to: '',
description: '',
submit_date: '',
change_reason: '',
episode_number: null,
})
//
const filteredContentTypes = computed(() => PHASE_CONTENT_TYPES[form.project_phase] || [])
const editFilteredContentTypes = computed(() => {
const types = PHASE_CONTENT_TYPES[editForm.project_phase] || []
//
if (editForm.content_type && !types.includes(editForm.content_type)) {
return [editForm.content_type, ...types]
}
return types
})
//
watch(() => form.project_phase, (phase) => {
const types = PHASE_CONTENT_TYPES[phase]
if (types && !types.includes(form.content_type)) {
form.content_type = types[0]
}
})
// projectApi.list milestones
watch(() => editForm.project_phase, (phase) => {
const types = PHASE_CONTENT_TYPES[phase]
if (types && !types.includes(editForm.content_type)) {
editForm.content_type = types[0]
}
})
// +
watch(() => form.content_type, (val) => {
if (NO_DURATION_TYPES.has(val)) {
form.duration_minutes = 0
form.duration_seconds = 0
}
if (!needsEpisode(val)) {
form.episode_number = null
}
})
watch(() => editForm.content_type, (val) => {
if (NO_DURATION_TYPES.has(val)) {
editForm.duration_minutes = 0
editForm.duration_seconds = 0
}
if (!needsEpisode(val)) {
editForm.episode_number = null
}
})
//
const selectedProject = computed(() =>
projects.value.find(p => p.id === form.project_id) || null
)
@ -312,6 +375,24 @@ const episodeOptions = computed(() => {
return Array.from({length: proj.episode_count}, (_, i) => i + 1)
})
//
const dailyHours = ref(null)
const dialogDailyHours = ref(null)
async function loadDailyHours() {
try {
const params = { date: today }
if (form.user_id) params.user_id = form.user_id
dialogDailyHours.value = await submissionApi.dailyHours(params)
} catch { /* ignore */ }
}
async function loadListDailyHours() {
try {
dailyHours.value = await submissionApi.dailyHours({ date: today })
} catch { /* ignore */ }
}
function formatSecs(s) {
if (!s) return '0秒'
const m = Math.floor(s / 60)
@ -332,6 +413,9 @@ 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 (!form.description?.trim()) { ElMessage.warning('请填写描述'); return }
if (!form.hours_spent || form.hours_spent <= 0) { ElMessage.warning('请填写投入时长'); return }
if (isMilestoneOverdue.value && !form.delay_reason?.trim()) {
ElMessage.warning('该里程碑已超期,请填写延期原因')
return
@ -350,29 +434,15 @@ async function handleCreate() {
form.episode_number = null
form.user_id = null
load()
//
loadListDailyHours()
const updated = await submissionApi.dailyHours({ date: today })
if (updated && updated.remaining > 0) {
ElMessage.info(`今日还有 ${updated.remaining}h 工时未填报`)
}
} finally { creating.value = false }
}
//
const showEdit = ref(false)
const editing = ref(false)
const editForm = reactive({
_id: null,
_project_name: '',
_project_id: null,
project_phase: '',
work_type: '',
content_type: '',
duration_minutes: 0,
duration_seconds: 0,
hours_spent: null,
submit_to: '',
description: '',
submit_date: '',
change_reason: '',
episode_number: null,
})
const editEpisodeOptions = computed(() => {
const proj = projects.value.find(p => p.id === editForm._project_id)
if (!proj || !proj.episode_count) return []
@ -432,8 +502,14 @@ async function handleDeleteFromEdit() {
}
}
//
watch(showCreate, (val) => {
if (val) loadDailyHours()
})
onMounted(async () => {
load()
loadListDailyHours()
try { projects.value = await projectApi.list({}) } catch {}
if (authStore.hasPermission('submission:proxy')) {
try { users.value = await userApi.brief() } catch {}
@ -448,4 +524,6 @@ onMounted(async () => {
.inline-field { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.field-unit { font-size: 13px; color: var(--text-secondary); white-space: nowrap; }
.field-hint { font-size: 12px; color: var(--text-placeholder, #C0C4CC); }
.daily-hours-bar { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; padding: 10px 16px; background: var(--bg-card); border: 1px solid var(--border-color); border-radius: var(--radius-md); }
.daily-label { font-size: 13px; color: var(--text-secondary); white-space: nowrap; }
</style>

View File

@ -54,7 +54,7 @@
<el-form-item label="所属阶段">
<el-select v-model="form.phase_group" placeholder="该成员主要参与的制作阶段" style="width:100%">
<el-option label="前期 — 剧本/策划/设定" value="前期" />
<el-option label="制作 — 动画/图片制作" value="制作" />
<el-option label="中期 — 动画/图片制作" value="中期" />
<el-option label="后期 — 剪辑/合成/调色" value="后期" />
</el-select>
</el-form-item>
@ -119,7 +119,7 @@ const sortedUsers = computed(() => {
})
})
const form = reactive({
username: '', password: '', name: '', phase_group: '制作', role_id: null,
username: '', password: '', name: '', phase_group: '中期', role_id: null,
monthly_salary: 0, bonus: 0, social_insurance: 0, is_active: 1,
})