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 datetime import date, timedelta
|
||||||
from models import (
|
from models import (
|
||||||
User, Project, Submission, AIToolCost, AIToolCostAllocation,
|
User, Project, Submission, AIToolCost, AIToolCostAllocation,
|
||||||
OutsourceCost, CostOverride, OverheadCost, WorkType, CostAllocationType
|
OutsourceCost, CostOverride, OverheadCost, WorkType, PhaseGroup, CostAllocationType
|
||||||
)
|
)
|
||||||
from config import WORKING_DAYS_PER_MONTH
|
from config import WORKING_DAYS_PER_MONTH
|
||||||
|
|
||||||
@ -371,6 +371,7 @@ def calc_team_efficiency(project_id: int, db: Session) -> list:
|
|||||||
).filter(
|
).filter(
|
||||||
Submission.project_id == project_id,
|
Submission.project_id == project_id,
|
||||||
Submission.total_seconds > 0,
|
Submission.total_seconds > 0,
|
||||||
|
Submission.project_phase == PhaseGroup.PRODUCTION, # 只算中期
|
||||||
).group_by(Submission.user_id).all()
|
).group_by(Submission.user_id).all()
|
||||||
|
|
||||||
if not per_user:
|
if not per_user:
|
||||||
|
|||||||
@ -145,8 +145,10 @@ def init_roles_and_admin():
|
|||||||
try:
|
try:
|
||||||
conn.execute(text("""
|
conn.execute(text("""
|
||||||
ALTER TABLE submissions MODIFY COLUMN content_type
|
ALTER TABLE submissions MODIFY COLUMN content_type
|
||||||
ENUM('PLANNING','SCRIPT','STORYBOARD','CHARACTER_DESIGN','SCENE_DESIGN',
|
ENUM('PLANNING','SYNOPSIS','CONCEPT_DESIGN','TEST_FOOTAGE',
|
||||||
'ANIMATION','DUBBING','SOUND_EFFECTS','SHOT_REPAIR','EDITING','OTHER',
|
'SCRIPT','STORYBOARD','CHARACTER_DESIGN','SCENE_DESIGN',
|
||||||
|
'PROP_DESIGN','ANIMATION','DUBBING','AI_DUBBING','SOUND_EFFECTS',
|
||||||
|
'SHOT_REPAIR','EDITING','MUSIC','SUBTITLE','OTHER',
|
||||||
'DESIGN') NOT NULL
|
'DESIGN') NOT NULL
|
||||||
"""))
|
"""))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
@ -173,6 +175,24 @@ def init_roles_and_admin():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[MIGRATE] schema migration error (non-fatal): {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():
|
for role_name, role_def in BUILTIN_ROLES.items():
|
||||||
existing = db.query(Role).filter(Role.name == role_name).first()
|
existing = db.query(Role).filter(Role.name == role_name).first()
|
||||||
|
|||||||
@ -118,7 +118,7 @@ class ProjectStatus(str, enum.Enum):
|
|||||||
|
|
||||||
class PhaseGroup(str, enum.Enum):
|
class PhaseGroup(str, enum.Enum):
|
||||||
PRE = "前期"
|
PRE = "前期"
|
||||||
PRODUCTION = "制作"
|
PRODUCTION = "中期"
|
||||||
POST = "后期"
|
POST = "后期"
|
||||||
|
|
||||||
|
|
||||||
@ -131,19 +131,26 @@ class WorkType(str, enum.Enum):
|
|||||||
|
|
||||||
|
|
||||||
class ContentType(str, enum.Enum):
|
class ContentType(str, enum.Enum):
|
||||||
# 前期
|
# 前期(项目级)
|
||||||
PLANNING = "策划案"
|
PLANNING = "策划案"
|
||||||
|
SYNOPSIS = "大纲/梗概"
|
||||||
|
CONCEPT_DESIGN = "概念设计图"
|
||||||
|
TEST_FOOTAGE = "测试片"
|
||||||
|
# 前期(集数级)
|
||||||
SCRIPT = "剧本"
|
SCRIPT = "剧本"
|
||||||
STORYBOARD = "分镜"
|
STORYBOARD = "分镜"
|
||||||
CHARACTER_DESIGN = "人设图"
|
CHARACTER_DESIGN = "人设图"
|
||||||
SCENE_DESIGN = "场景图"
|
SCENE_DESIGN = "场景图"
|
||||||
# 制作
|
PROP_DESIGN = "道具图"
|
||||||
|
# 中期
|
||||||
ANIMATION = "动画制作"
|
ANIMATION = "动画制作"
|
||||||
# 后期
|
# 后期
|
||||||
DUBBING = "配音"
|
DUBBING = "AI配音"
|
||||||
SOUND_EFFECTS = "音效"
|
SOUND_EFFECTS = "音效"
|
||||||
SHOT_REPAIR = "修补镜头"
|
SHOT_REPAIR = "修补镜头"
|
||||||
EDITING = "剪辑"
|
EDITING = "剪辑"
|
||||||
|
MUSIC = "音乐/BGM"
|
||||||
|
SUBTITLE = "字幕"
|
||||||
# 通用
|
# 通用
|
||||||
OTHER = "其他"
|
OTHER = "其他"
|
||||||
|
|
||||||
@ -415,18 +422,27 @@ DEFAULT_MILESTONES = [
|
|||||||
{"name": "分镜", "phase": "前期", "sort_order": 3},
|
{"name": "分镜", "phase": "前期", "sort_order": 3},
|
||||||
{"name": "人设图", "phase": "前期", "sort_order": 4},
|
{"name": "人设图", "phase": "前期", "sort_order": 4},
|
||||||
{"name": "场景图", "phase": "前期", "sort_order": 5},
|
{"name": "场景图", "phase": "前期", "sort_order": 5},
|
||||||
|
{"name": "道具图", "phase": "前期", "sort_order": 6},
|
||||||
# 后期
|
# 后期
|
||||||
{"name": "配音", "phase": "后期", "sort_order": 1},
|
{"name": "配音", "phase": "后期", "sort_order": 1},
|
||||||
{"name": "音效", "phase": "后期", "sort_order": 2},
|
{"name": "AI配音", "phase": "后期", "sort_order": 2},
|
||||||
{"name": "修补镜头", "phase": "后期", "sort_order": 3},
|
{"name": "音效", "phase": "后期", "sort_order": 3},
|
||||||
{"name": "剪辑", "phase": "后期", "sort_order": 4},
|
{"name": "修补镜头", "phase": "后期", "sort_order": 4},
|
||||||
{"name": "杂项", "phase": "后期", "sort_order": 5},
|
{"name": "剪辑", "phase": "后期", "sort_order": 5},
|
||||||
|
{"name": "音乐/BGM", "phase": "后期", "sort_order": 6},
|
||||||
|
{"name": "字幕", "phase": "后期", "sort_order": 7},
|
||||||
|
{"name": "杂项", "phase": "后期", "sort_order": 8},
|
||||||
]
|
]
|
||||||
|
|
||||||
# 内容类型 → 阶段映射(用于自动设置阶段和关联里程碑)
|
# 内容类型 → 阶段映射(用于自动设置阶段和关联里程碑)
|
||||||
CONTENT_PHASE_MAP = {
|
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):
|
if len(pre_ms) > 0 and pre_completed < len(pre_ms):
|
||||||
current_stage = "前期"
|
current_stage = "前期"
|
||||||
elif progress < 100:
|
elif progress < 100:
|
||||||
current_stage = "制作"
|
current_stage = "中期"
|
||||||
elif len(post_ms) > 0 and post_completed < len(post_ms):
|
elif len(post_ms) > 0 and post_completed < len(post_ms):
|
||||||
current_stage = "后期"
|
current_stage = "后期"
|
||||||
else:
|
else:
|
||||||
current_stage = "已完成"
|
current_stage = "已完成"
|
||||||
|
|
||||||
# EP 集数进度
|
# EP 集数进度(批量查询,避免 N+1)
|
||||||
episode_progress = []
|
episode_progress = []
|
||||||
ep_target = p.episode_duration_minutes * 60 # 每集目标秒数
|
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):
|
for ep in range(1, p.episode_count + 1):
|
||||||
ep_subs = db.query(Submission).filter(
|
info = ep_data.get(ep, {"total": 0, "contributors": {}})
|
||||||
Submission.project_id == p.id,
|
ep_total = info["total"]
|
||||||
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
|
|
||||||
episode_progress.append({
|
episode_progress.append({
|
||||||
"episode": ep,
|
"episode": ep,
|
||||||
"total_seconds": round(ep_total, 1),
|
"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,
|
"progress_percent": round(ep_total / ep_target * 100, 1) if ep_target > 0 else 0,
|
||||||
"contributors": [
|
"contributors": [
|
||||||
{"name": k, "seconds": round(v, 1)}
|
{"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="目标用户不存在")
|
raise HTTPException(status_code=404, detail="目标用户不存在")
|
||||||
target_user_id = req.user_id
|
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()
|
project = db.query(Project).filter(Project.id == req.project_id).first()
|
||||||
if not project:
|
if not project:
|
||||||
@ -251,3 +265,32 @@ def delete_submission(
|
|||||||
db.delete(sub)
|
db.delete(sub)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"detail": "删除成功"}
|
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
|
username: str
|
||||||
password: str
|
password: str
|
||||||
name: str
|
name: str
|
||||||
phase_group: str # 前期/制作/后期
|
phase_group: str # 前期/中期/后期
|
||||||
role_id: int
|
role_id: int
|
||||||
monthly_salary: float = 0
|
monthly_salary: float = 0
|
||||||
bonus: float = 0
|
bonus: float = 0
|
||||||
|
|||||||
@ -79,6 +79,7 @@ export const submissionApi = {
|
|||||||
update: (id, data) => api.put(`/submissions/${id}`, data),
|
update: (id, data) => api.put(`/submissions/${id}`, data),
|
||||||
delete: (id) => api.delete(`/submissions/${id}`),
|
delete: (id) => api.delete(`/submissions/${id}`),
|
||||||
history: (id) => api.get(`/submissions/${id}/history`),
|
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 class="seg-fill production" :style="{width: Math.min(project.progress_percent, 100) + '%'}"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="seg-info">
|
<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>
|
<span class="seg-stat">{{ project.progress_percent }}%</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -140,9 +140,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 制作 -->
|
<!-- 中期 -->
|
||||||
<div class="milestone-col production-col">
|
<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 class="production-ring-layout">
|
||||||
<div ref="progressChartRef" style="width:180px;height:180px;flex-shrink:0"></div>
|
<div ref="progressChartRef" style="width:180px;height:180px;flex-shrink:0"></div>
|
||||||
<div class="production-info">
|
<div class="production-info">
|
||||||
@ -409,7 +409,7 @@
|
|||||||
<el-form-item label="当前阶段">
|
<el-form-item label="当前阶段">
|
||||||
<el-select v-model="editForm.current_phase" style="width:100%">
|
<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-option label="后期" value="后期" />
|
<el-option label="后期" value="后期" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@ -550,7 +550,7 @@ function initProgressChart() {
|
|||||||
function handleProgressResize() { progressChart?.resize() }
|
function handleProgressResize() { progressChart?.resize() }
|
||||||
|
|
||||||
const editForm = reactive({
|
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,
|
episode_duration_minutes: 5, episode_count: 1,
|
||||||
estimated_completion_date: null, contract_amount: null,
|
estimated_completion_date: null, contract_amount: null,
|
||||||
})
|
})
|
||||||
@ -560,7 +560,7 @@ const stageColor = computed(() => {
|
|||||||
const s = project.value.current_stage
|
const s = project.value.current_stage
|
||||||
if (s === '已完成') return '#34C759'
|
if (s === '已完成') return '#34C759'
|
||||||
if (s === '前期') return '#8F959E'
|
if (s === '前期') return '#8F959E'
|
||||||
if (s === '制作') return '#3370FF'
|
if (s === '中期') return '#3370FF'
|
||||||
if (s === '后期') return '#FF9500'
|
if (s === '后期') return '#FF9500'
|
||||||
return 'var(--text-primary)'
|
return 'var(--text-primary)'
|
||||||
})
|
})
|
||||||
@ -798,7 +798,7 @@ async function openEdit() {
|
|||||||
}
|
}
|
||||||
Object.assign(editForm, {
|
Object.assign(editForm, {
|
||||||
name: p.name, project_type: p.project_type, status: p.status || '制作中', leader_id: p.leader_id,
|
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,
|
episode_duration_minutes: p.episode_duration_minutes, episode_count: p.episode_count,
|
||||||
estimated_completion_date: p.estimated_completion_date, contract_amount: p.contract_amount,
|
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 + '%'
|
if (!s) return row.progress_percent + '%'
|
||||||
const stage = row.current_stage
|
const stage = row.current_stage
|
||||||
if (stage === '前期') return `前期 ${s.pre.completed}/${s.pre.total}`
|
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 `后期 ${s.post.completed}/${s.post.total}`
|
||||||
if (stage === '已完成') return '已完成'
|
if (stage === '已完成') return '已完成'
|
||||||
return row.progress_percent + '%'
|
return row.progress_percent + '%'
|
||||||
@ -231,7 +231,7 @@ onMounted(async () => {
|
|||||||
background: var(--bg-hover); color: var(--text-secondary);
|
background: var(--bg-hover); color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
.stage-tag.stage-前期 { background: #F0F1F5; color: #8F959E; }
|
.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: #FFF3E0; color: #FF9500; }
|
||||||
.stage-tag.stage-已完成 { background: #E8F8EE; color: #34C759; }
|
.stage-tag.stage-已完成 { background: #E8F8EE; color: #34C759; }
|
||||||
.rate-badge {
|
.rate-badge {
|
||||||
|
|||||||
@ -76,7 +76,7 @@
|
|||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="8">
|
<el-col :span="8">
|
||||||
<div class="waste-phase-card">
|
<div class="waste-phase-card">
|
||||||
<div class="waste-phase-title">制作(秒数制)</div>
|
<div class="waste-phase-title">中期(秒数制)</div>
|
||||||
<div class="waste-phase-sub">
|
<div class="waste-phase-sub">
|
||||||
<span>测试损耗</span>
|
<span>测试损耗</span>
|
||||||
<span :class="{ danger: data.test_waste_seconds > 0 }">{{ formatSecs(data.test_waste_seconds) }}</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-card class="filter-card">
|
||||||
<el-space wrap>
|
<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-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-option v-for="p in projects" :key="p.id" :label="p.name" :value="p.id" />
|
||||||
</el-select>
|
</el-select>
|
||||||
@ -16,6 +19,18 @@
|
|||||||
</el-space>
|
</el-space>
|
||||||
</el-card>
|
</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 :data="submissions" v-loading="loading" stripe>
|
||||||
<el-table-column prop="submit_date" label="日期" width="110" sortable />
|
<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>
|
<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>
|
</template>
|
||||||
</el-table-column>
|
</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">
|
<el-table-column label="产出时长" width="100" align="right">
|
||||||
<template #default="{row}">{{ row.total_seconds > 0 ? formatSecs(row.total_seconds) : '—' }}</template>
|
<template #default="{row}">{{ row.total_seconds > 0 ? formatSecs(row.total_seconds) : '—' }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@ -46,7 +61,7 @@
|
|||||||
<el-dialog v-model="showCreate" title="新增内容提交" width="580px" destroy-on-close>
|
<el-dialog v-model="showCreate" title="新增内容提交" width="580px" destroy-on-close>
|
||||||
<el-form :model="form" label-width="110px" label-position="left">
|
<el-form :model="form" label-width="110px" label-position="left">
|
||||||
<el-form-item v-if="authStore.hasPermission('submission:proxy')" label="提交人">
|
<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-option v-for="u in users" :key="u.id" :label="u.name" :value="u.id" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@ -60,7 +75,7 @@
|
|||||||
<el-form-item label="项目阶段" required>
|
<el-form-item label="项目阶段" required>
|
||||||
<el-select v-model="form.project_phase" style="width:100%">
|
<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-option label="后期" value="后期" />
|
<el-option label="后期" value="后期" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</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="测试" value="测试" />
|
<el-option label="测试" value="测试" />
|
||||||
<el-option label="方案" value="方案" />
|
|
||||||
<el-option label="QC" value="QC" />
|
<el-option label="QC" value="QC" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
<el-form-item label="集数" v-if="episodeOptions.length > 0">
|
<el-form-item label="内容类型" required>
|
||||||
<el-select v-model="form.episode_number" placeholder="选择集数(选填)" clearable style="width:100%">
|
<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-option v-for="ep in episodeOptions" :key="ep" :label="'EP' + String(ep).padStart(2,'0')" :value="ep" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="内容类型" required>
|
<el-form-item label="产出时长" v-if="showDuration(form.content_type)">
|
||||||
<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="产出时长">
|
|
||||||
<div class="inline-field">
|
<div class="inline-field">
|
||||||
<el-input-number v-model="form.duration_minutes" :min="0" :step="1" style="width:120px" />
|
<el-input-number v-model="form.duration_minutes" :min="0" :step="1" style="width:120px" />
|
||||||
<span class="field-unit">分</span>
|
<span class="field-unit">分</span>
|
||||||
@ -112,11 +110,13 @@
|
|||||||
<span class="field-hint">无产出秒数的工作可填 0</span>
|
<span class="field-hint">无产出秒数的工作可填 0</span>
|
||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="投入时长">
|
<el-form-item label="投入时长" required>
|
||||||
<div class="inline-field">
|
<div class="inline-field">
|
||||||
<el-input-number v-model="form.hours_spent" :min="0" :step="0.5" :precision="1" style="width:140px" />
|
<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-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>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="提交对象" required>
|
<el-form-item label="提交对象" required>
|
||||||
@ -135,8 +135,8 @@
|
|||||||
<el-input v-model="form.delay_reason" type="textarea" :rows="2"
|
<el-input v-model="form.delay_reason" type="textarea" :rows="2"
|
||||||
placeholder="该里程碑已超期,请说明延期原因(必填)" />
|
placeholder="该里程碑已超期,请说明延期原因(必填)" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="制作描述">
|
<el-form-item label="描述" required>
|
||||||
<el-input v-model="form.description" type="textarea" :rows="2" placeholder="简要描述本次提交内容" />
|
<el-input v-model="form.description" type="textarea" :rows="2" placeholder="简要描述本次提交内容(必填)" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="提交日期" required>
|
<el-form-item label="提交日期" required>
|
||||||
<el-date-picker v-model="form.submit_date" value-format="YYYY-MM-DD" style="width:100%" />
|
<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-form-item label="项目阶段" required>
|
||||||
<el-select v-model="editForm.project_phase" style="width:100%">
|
<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-option label="后期" value="后期" />
|
<el-option label="后期" value="后期" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</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="测试" value="测试" />
|
<el-option label="测试" value="测试" />
|
||||||
<el-option label="方案" value="方案" />
|
|
||||||
<el-option label="QC" value="QC" />
|
<el-option label="QC" value="QC" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
<el-form-item label="集数" v-if="editEpisodeOptions.length > 0">
|
<el-form-item label="内容类型" required>
|
||||||
<el-select v-model="editForm.episode_number" placeholder="选择集数(选填)" clearable style="width:100%">
|
<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-option v-for="ep in editEpisodeOptions" :key="ep" :label="'EP' + String(ep).padStart(2,'0')" :value="ep" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="内容类型" required>
|
<el-form-item label="产出时长" v-if="showDuration(editForm.content_type)">
|
||||||
<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="产出时长">
|
|
||||||
<div class="inline-field">
|
<div class="inline-field">
|
||||||
<el-input-number v-model="editForm.duration_minutes" :min="0" :step="1" style="width:120px" />
|
<el-input-number v-model="editForm.duration_minutes" :min="0" :step="1" style="width:120px" />
|
||||||
<span class="field-unit">分</span>
|
<span class="field-unit">分</span>
|
||||||
@ -210,7 +193,7 @@
|
|||||||
<span class="field-unit">秒</span>
|
<span class="field-unit">秒</span>
|
||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="投入时长">
|
<el-form-item label="投入时长" required>
|
||||||
<div class="inline-field">
|
<div class="inline-field">
|
||||||
<el-input-number v-model="editForm.hours_spent" :min="0" :step="0.5" :precision="1" style="width:140px" />
|
<el-input-number v-model="editForm.hours_spent" :min="0" :step="0.5" :precision="1" style="width:140px" />
|
||||||
<span class="field-unit">小时</span>
|
<span class="field-unit">小时</span>
|
||||||
@ -224,7 +207,7 @@
|
|||||||
<el-option label="外部 — 提交给甲方/客户" value="外部" />
|
<el-option label="外部 — 提交给甲方/客户" value="外部" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="制作描述">
|
<el-form-item label="描述" required>
|
||||||
<el-input v-model="editForm.description" type="textarea" :rows="2" />
|
<el-input v-model="editForm.description" type="textarea" :rows="2" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="提交日期" required>
|
<el-form-item label="提交日期" required>
|
||||||
@ -259,9 +242,29 @@ import { useAuthStore } from '../stores/auth'
|
|||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
const CONTENT_PHASE_MAP = {
|
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)
|
const loading = ref(false)
|
||||||
@ -271,26 +274,86 @@ const submissions = ref([])
|
|||||||
const projects = ref([])
|
const projects = ref([])
|
||||||
const users = ref([])
|
const users = ref([])
|
||||||
const dateRange = ref(null)
|
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 activeProjects = computed(() => projects.value.filter(p => p.status === '制作中'))
|
||||||
|
|
||||||
const today = new Date().toISOString().split('T')[0]
|
const today = new Date().toISOString().split('T')[0]
|
||||||
const form = reactive({
|
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_number: null, user_id: null,
|
||||||
})
|
})
|
||||||
|
|
||||||
// 选择内容类型时自动设置对应的项目阶段
|
// ── 编辑逻辑 ──
|
||||||
watch(() => form.content_type, (val) => {
|
const showEdit = ref(false)
|
||||||
if (CONTENT_PHASE_MAP[val]) {
|
const editing = ref(false)
|
||||||
form.project_phase = CONTENT_PHASE_MAP[val]
|
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(() =>
|
const selectedProject = computed(() =>
|
||||||
projects.value.find(p => p.id === form.project_id) || null
|
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)
|
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) {
|
function formatSecs(s) {
|
||||||
if (!s) return '0秒'
|
if (!s) return '0秒'
|
||||||
const m = Math.floor(s / 60)
|
const m = Math.floor(s / 60)
|
||||||
@ -332,6 +413,9 @@ 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 (!form.description?.trim()) { 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()) {
|
||||||
ElMessage.warning('该里程碑已超期,请填写延期原因')
|
ElMessage.warning('该里程碑已超期,请填写延期原因')
|
||||||
return
|
return
|
||||||
@ -350,29 +434,15 @@ async function handleCreate() {
|
|||||||
form.episode_number = null
|
form.episode_number = null
|
||||||
form.user_id = null
|
form.user_id = null
|
||||||
load()
|
load()
|
||||||
|
// 刷新工时进度
|
||||||
|
loadListDailyHours()
|
||||||
|
const updated = await submissionApi.dailyHours({ date: today })
|
||||||
|
if (updated && updated.remaining > 0) {
|
||||||
|
ElMessage.info(`今日还有 ${updated.remaining}h 工时未填报`)
|
||||||
|
}
|
||||||
} finally { creating.value = false }
|
} 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 editEpisodeOptions = computed(() => {
|
||||||
const proj = projects.value.find(p => p.id === editForm._project_id)
|
const proj = projects.value.find(p => p.id === editForm._project_id)
|
||||||
if (!proj || !proj.episode_count) return []
|
if (!proj || !proj.episode_count) return []
|
||||||
@ -432,8 +502,14 @@ async function handleDeleteFromEdit() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 打开新增弹窗时加载工时
|
||||||
|
watch(showCreate, (val) => {
|
||||||
|
if (val) loadDailyHours()
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
load()
|
load()
|
||||||
|
loadListDailyHours()
|
||||||
try { projects.value = await projectApi.list({}) } catch {}
|
try { projects.value = await projectApi.list({}) } catch {}
|
||||||
if (authStore.hasPermission('submission:proxy')) {
|
if (authStore.hasPermission('submission:proxy')) {
|
||||||
try { users.value = await userApi.brief() } catch {}
|
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; }
|
.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-unit { font-size: 13px; color: var(--text-secondary); white-space: nowrap; }
|
||||||
.field-hint { font-size: 12px; color: var(--text-placeholder, #C0C4CC); }
|
.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>
|
</style>
|
||||||
|
|||||||
@ -54,7 +54,7 @@
|
|||||||
<el-form-item label="所属阶段">
|
<el-form-item label="所属阶段">
|
||||||
<el-select v-model="form.phase_group" placeholder="该成员主要参与的制作阶段" style="width:100%">
|
<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-option label="后期 — 剪辑/合成/调色" value="后期" />
|
<el-option label="后期 — 剪辑/合成/调色" value="后期" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@ -119,7 +119,7 @@ const sortedUsers = computed(() => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
const form = reactive({
|
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,
|
monthly_salary: 0, bonus: 0, social_insurance: 0, is_active: 1,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user