feat: 内容提交表单全面优化 — 阶段重命名+内容类型扩展+集数条件必填
- 阶段重命名: "制作" → "中期",全前后端同步 - 新增前期内容类型: 大纲/梗概、概念设计图、测试片 - 配音改名为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:
parent
e4ff7763b5
commit
f07126e0ca
@ -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:
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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 = {"策划案", "大纲/梗概", "概念设计图", "测试片"}
|
||||
|
||||
@ -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 # 每集目标秒数
|
||||
for ep in range(1, p.episode_count + 1):
|
||||
ep_subs = db.query(Submission).filter(
|
||||
# 一次查出所有有提交的集数数据
|
||||
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 == ep,
|
||||
Submission.episode_number.isnot(None),
|
||||
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
|
||||
).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):
|
||||
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])
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
@ -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),
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 }),
|
||||
}
|
||||
|
||||
// ── 成本 ──
|
||||
|
||||
@ -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,
|
||||
})
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
})
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user