diff --git a/backend/calculations.py b/backend/calculations.py index b955524..18cd0b4 100644 --- a/backend/calculations.py +++ b/backend/calculations.py @@ -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: diff --git a/backend/main.py b/backend/main.py index 7975efe..c762d65 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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() diff --git a/backend/models.py b/backend/models.py index 4c0f2ad..3a57534 100644 --- a/backend/models.py +++ b/backend/models.py @@ -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 = {"策划案", "大纲/梗概", "概念设计图", "测试片"} diff --git a/backend/routers/projects.py b/backend/routers/projects.py index a29896d..6efd5a4 100644 --- a/backend/routers/projects.py +++ b/backend/routers/projects.py @@ -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]) ], }) diff --git a/backend/routers/submissions.py b/backend/routers/submissions.py index 6068b69..e01907f 100644 --- a/backend/routers/submissions.py +++ b/backend/routers/submissions.py @@ -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), + } diff --git a/backend/schemas.py b/backend/schemas.py index edd3389..7adfd50 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -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 diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index 0f6e8b8..ecccf34 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -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 }), } // ── 成本 ── diff --git a/frontend/src/views/ProjectDetail.vue b/frontend/src/views/ProjectDetail.vue index 36f0fc8..ba0ac78 100644 --- a/frontend/src/views/ProjectDetail.vue +++ b/frontend/src/views/ProjectDetail.vue @@ -93,7 +93,7 @@
- 制作 + 中期 {{ project.progress_percent }}%
@@ -140,9 +140,9 @@ - +
-
制作
+
中期
@@ -409,7 +409,7 @@ - + @@ -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, }) diff --git a/frontend/src/views/Projects.vue b/frontend/src/views/Projects.vue index ec55b03..3a5bc5e 100644 --- a/frontend/src/views/Projects.vue +++ b/frontend/src/views/Projects.vue @@ -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 { diff --git a/frontend/src/views/Settlement.vue b/frontend/src/views/Settlement.vue index a039d30..3fbdada 100644 --- a/frontend/src/views/Settlement.vue +++ b/frontend/src/views/Settlement.vue @@ -76,7 +76,7 @@
-
制作(秒数制)
+
中期(秒数制)
测试损耗 {{ formatSecs(data.test_waste_seconds) }} diff --git a/frontend/src/views/Submissions.vue b/frontend/src/views/Submissions.vue index 5860bfc..caad223 100644 --- a/frontend/src/views/Submissions.vue +++ b/frontend/src/views/Submissions.vue @@ -8,6 +8,9 @@ + + + @@ -16,6 +19,18 @@ + +
+ 今日工时:{{ dailyHours.filled }}h / {{ dailyHours.target }}h + +
+ @@ -27,7 +42,7 @@ {{ row.work_type }} - + @@ -46,7 +61,7 @@ - + @@ -60,7 +75,7 @@ - + @@ -71,39 +86,22 @@ - - - + + + + + + + - - - - - - - - - - - - - - - - - - - - - - +
@@ -112,11 +110,13 @@ 无产出秒数的工作可填 0
- +
小时 - (选填)实际花费的工作时间 + + 今日已填 {{ dialogDailyHours.filled }}h / 8h,还剩 {{ dialogDailyHours.remaining }}h +
@@ -135,8 +135,8 @@ - - + + @@ -159,7 +159,7 @@ - + @@ -170,39 +170,22 @@ -
- - + + + + + + + - - - - - - - - - - - - - - - - - - - - - - +
@@ -210,7 +193,7 @@
- +
小时 @@ -224,7 +207,7 @@ - + @@ -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; } diff --git a/frontend/src/views/Users.vue b/frontend/src/views/Users.vue index 2718497..03f46be 100644 --- a/frontend/src/views/Users.vue +++ b/frontend/src/views/Users.vue @@ -54,7 +54,7 @@ - + @@ -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, })