feat: 内部事务提交功能(培训/招聘面试/内部其他)
- 新增 ContentType: TRAINING, RECRUITMENT, INTERNAL_OTHER - 新增 PhaseGroup: INTERNAL (内部事务) - 前端选"内部事务"项目时隐藏项目阶段/工作类型/产出时长 - 内容类型改显示为"事务类型"(培训/招聘面试/内部其他) - 报告和仪表盘中排除内部事务项目 - 内部事务成本后续按管理成本逻辑分摊到所有项目 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
bc9fa5a798
commit
becfd74efd
@ -161,13 +161,24 @@ def init_roles_and_admin():
|
|||||||
'SCRIPT','STORYBOARD','CHARACTER_DESIGN','SCENE_DESIGN',
|
'SCRIPT','STORYBOARD','CHARACTER_DESIGN','SCENE_DESIGN',
|
||||||
'PROP_DESIGN','ANIMATION','DUBBING','AI_DUBBING','SOUND_EFFECTS',
|
'PROP_DESIGN','ANIMATION','DUBBING','AI_DUBBING','SOUND_EFFECTS',
|
||||||
'SHOT_REPAIR','EDITING','MUSIC','SUBTITLE','OTHER',
|
'SHOT_REPAIR','EDITING','MUSIC','SUBTITLE','OTHER',
|
||||||
'DESIGN') NOT NULL
|
'DESIGN','TRAINING','RECRUITMENT','INTERNAL_OTHER') NOT NULL
|
||||||
"""))
|
"""))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
print("[MIGRATE] expanded content_type enum")
|
print("[MIGRATE] expanded content_type enum")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # 已经扩展过
|
pass # 已经扩展过
|
||||||
|
|
||||||
|
# MySQL: 扩展 project_phase 枚举(加入 INTERNAL/内部事务)
|
||||||
|
try:
|
||||||
|
conn.execute(text("""
|
||||||
|
ALTER TABLE submissions MODIFY COLUMN project_phase
|
||||||
|
ENUM('PRE','PRODUCTION','POST','INTERNAL') NOT NULL
|
||||||
|
"""))
|
||||||
|
conn.commit()
|
||||||
|
print("[MIGRATE] expanded project_phase enum")
|
||||||
|
except Exception:
|
||||||
|
pass # 已经扩展过
|
||||||
|
|
||||||
# MySQL: 扩展 work_type 枚举(加入 REVISION/修改)
|
# MySQL: 扩展 work_type 枚举(加入 REVISION/修改)
|
||||||
try:
|
try:
|
||||||
conn.execute(text("""
|
conn.execute(text("""
|
||||||
|
|||||||
@ -126,6 +126,7 @@ class PhaseGroup(str, enum.Enum):
|
|||||||
PRE = "前期"
|
PRE = "前期"
|
||||||
PRODUCTION = "中期"
|
PRODUCTION = "中期"
|
||||||
POST = "后期"
|
POST = "后期"
|
||||||
|
INTERNAL = "内部事务"
|
||||||
|
|
||||||
|
|
||||||
class WorkType(str, enum.Enum):
|
class WorkType(str, enum.Enum):
|
||||||
@ -159,6 +160,10 @@ class ContentType(str, enum.Enum):
|
|||||||
SUBTITLE = "字幕"
|
SUBTITLE = "字幕"
|
||||||
# 通用
|
# 通用
|
||||||
OTHER = "其他"
|
OTHER = "其他"
|
||||||
|
# 内部事务
|
||||||
|
TRAINING = "培训"
|
||||||
|
RECRUITMENT = "招聘面试"
|
||||||
|
INTERNAL_OTHER = "内部其他"
|
||||||
|
|
||||||
|
|
||||||
class SubmitTo(str, enum.Enum):
|
class SubmitTo(str, enum.Enum):
|
||||||
@ -449,7 +454,8 @@ CONTENT_PHASE_MAP = {
|
|||||||
"动画制作": "中期",
|
"动画制作": "中期",
|
||||||
"AI配音": "后期", "音效": "后期",
|
"AI配音": "后期", "音效": "后期",
|
||||||
"修补镜头": "后期", "剪辑": "后期", "音乐/BGM": "后期", "字幕": "后期",
|
"修补镜头": "后期", "剪辑": "后期", "音乐/BGM": "后期", "字幕": "后期",
|
||||||
|
"培训": "内部事务", "招聘面试": "内部事务", "内部其他": "内部事务",
|
||||||
}
|
}
|
||||||
|
|
||||||
# 项目级内容类型(不需要选集数)
|
# 项目级内容类型(不需要选集数)
|
||||||
PROJECT_LEVEL_TYPES = {"策划案", "大纲/梗概", "概念设计图", "测试片"}
|
PROJECT_LEVEL_TYPES = {"策划案", "大纲/梗概", "概念设计图", "测试片", "培训", "招聘面试", "内部其他"}
|
||||||
|
|||||||
@ -26,7 +26,10 @@ def get_dashboard(
|
|||||||
):
|
):
|
||||||
"""全局仪表盘数据"""
|
"""全局仪表盘数据"""
|
||||||
# 项目概览
|
# 项目概览
|
||||||
active = db.query(Project).filter(Project.status == ProjectStatus.IN_PROGRESS).all()
|
active = db.query(Project).filter(
|
||||||
|
Project.status == ProjectStatus.IN_PROGRESS,
|
||||||
|
Project.name != "内部事务",
|
||||||
|
).all()
|
||||||
completed = db.query(Project).filter(Project.status == ProjectStatus.COMPLETED).all()
|
completed = db.query(Project).filter(Project.status == ProjectStatus.COMPLETED).all()
|
||||||
abandoned = db.query(Project).filter(Project.status == ProjectStatus.ABANDONED).all()
|
abandoned = db.query(Project).filter(Project.status == ProjectStatus.ABANDONED).all()
|
||||||
|
|
||||||
|
|||||||
@ -88,9 +88,9 @@ def create_submission(
|
|||||||
raise HTTPException(status_code=422, detail="请填写投入时长")
|
raise HTTPException(status_code=422, detail="请填写投入时长")
|
||||||
|
|
||||||
# 产出时长校验:前期内容不需要,中期/后期内容必须 > 0
|
# 产出时长校验:前期内容不需要,中期/后期内容必须 > 0
|
||||||
PRE_PHASE_TYPES = {'策划案', '大纲/梗概', '概念设计图', '测试片', '剧本', '分镜', '人设图', '场景图', '道具图'}
|
NO_DURATION_TYPES = {'策划案', '大纲/梗概', '概念设计图', '测试片', '剧本', '分镜', '人设图', '场景图', '道具图', '培训', '招聘面试', '内部其他'}
|
||||||
content_val = req.content_type.value if hasattr(req.content_type, 'value') else req.content_type
|
content_val = req.content_type.value if hasattr(req.content_type, 'value') else req.content_type
|
||||||
if content_val not in PRE_PHASE_TYPES:
|
if content_val not in NO_DURATION_TYPES:
|
||||||
total_secs = (req.duration_minutes or 0) * 60 + (req.duration_seconds or 0)
|
total_secs = (req.duration_minutes or 0) * 60 + (req.duration_seconds or 0)
|
||||||
if total_secs <= 0:
|
if total_secs <= 0:
|
||||||
raise HTTPException(status_code=422, detail="请填写产出时长")
|
raise HTTPException(status_code=422, detail="请填写产出时长")
|
||||||
@ -219,7 +219,7 @@ def update_submission(
|
|||||||
sub.total_seconds = (sub.duration_minutes or 0) * 60 + (sub.duration_seconds or 0)
|
sub.total_seconds = (sub.duration_minutes or 0) * 60 + (sub.duration_seconds or 0)
|
||||||
|
|
||||||
# 产出时长校验:前期内容不需要,中期/后期内容必须 > 0
|
# 产出时长校验:前期内容不需要,中期/后期内容必须 > 0
|
||||||
PRE_PHASE_TYPES = {'策划案', '大纲/梗概', '概念设计图', '测试片', '剧本', '分镜', '人设图', '场景图', '道具图'}
|
NO_DURATION_TYPES = {'策划案', '大纲/梗概', '概念设计图', '测试片', '剧本', '分镜', '人设图', '场景图', '道具图', '培训', '招聘面试', '内部其他'}
|
||||||
content_val = sub.content_type.value if hasattr(sub.content_type, 'value') else sub.content_type
|
content_val = sub.content_type.value if hasattr(sub.content_type, 'value') else sub.content_type
|
||||||
if content_val not in PRE_PHASE_TYPES and sub.total_seconds <= 0:
|
if content_val not in PRE_PHASE_TYPES and sub.total_seconds <= 0:
|
||||||
raise HTTPException(status_code=422, detail="请填写产出时长")
|
raise HTTPException(status_code=422, detail="请填写产出时长")
|
||||||
|
|||||||
@ -19,6 +19,8 @@ from calculations import (
|
|||||||
)
|
)
|
||||||
from services.ai_service import generate_report_summary
|
from services.ai_service import generate_report_summary
|
||||||
|
|
||||||
|
INTERNAL_PROJECT_NAME = "内部事务"
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@ -88,7 +90,8 @@ def generate_daily_report(db: Session) -> dict:
|
|||||||
|
|
||||||
# 进行中项目
|
# 进行中项目
|
||||||
active_projects = db.query(Project).filter(
|
active_projects = db.query(Project).filter(
|
||||||
Project.status == ProjectStatus.IN_PROGRESS
|
Project.status == ProjectStatus.IN_PROGRESS,
|
||||||
|
Project.name != INTERNAL_PROJECT_NAME,
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
projects_data = []
|
projects_data = []
|
||||||
@ -190,7 +193,8 @@ def generate_weekly_report(db: Session) -> dict:
|
|||||||
avg_daily = round(week_total_secs / max(1, len(week_submitter_ids)) / max(1, working_days), 1)
|
avg_daily = round(week_total_secs / max(1, len(week_submitter_ids)) / max(1, working_days), 1)
|
||||||
|
|
||||||
active_projects = db.query(Project).filter(
|
active_projects = db.query(Project).filter(
|
||||||
Project.status == ProjectStatus.IN_PROGRESS
|
Project.status == ProjectStatus.IN_PROGRESS,
|
||||||
|
Project.name != INTERNAL_PROJECT_NAME,
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
projects_data = []
|
projects_data = []
|
||||||
@ -340,7 +344,8 @@ def generate_monthly_report(db: Session) -> dict:
|
|||||||
month_submitters = set(s.user_id for s in month_subs)
|
month_submitters = set(s.user_id for s in month_subs)
|
||||||
|
|
||||||
all_projects = db.query(Project).filter(
|
all_projects = db.query(Project).filter(
|
||||||
Project.status.in_([ProjectStatus.IN_PROGRESS, ProjectStatus.COMPLETED])
|
Project.status.in_([ProjectStatus.IN_PROGRESS, ProjectStatus.COMPLETED]),
|
||||||
|
Project.name != INTERNAL_PROJECT_NAME,
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
completed_this_month = [
|
completed_this_month = [
|
||||||
@ -467,7 +472,8 @@ def analyze_project_risks(db: Session) -> list:
|
|||||||
"""
|
"""
|
||||||
today = date.today()
|
today = date.today()
|
||||||
active_projects = db.query(Project).filter(
|
active_projects = db.query(Project).filter(
|
||||||
Project.status == ProjectStatus.IN_PROGRESS
|
Project.status == ProjectStatus.IN_PROGRESS,
|
||||||
|
Project.name != INTERNAL_PROJECT_NAME,
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
risks = []
|
risks = []
|
||||||
|
|||||||
@ -70,6 +70,7 @@
|
|||||||
<el-option v-for="p in activeProjects" :key="p.id" :label="p.name" :value="p.id" />
|
<el-option v-for="p in activeProjects" :key="p.id" :label="p.name" :value="p.id" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<template v-if="!isInternalProject">
|
||||||
<el-row :gutter="16">
|
<el-row :gutter="16">
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<el-form-item label="项目阶段" required>
|
<el-form-item label="项目阶段" required>
|
||||||
@ -91,8 +92,9 @@
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
<el-form-item label="内容类型" required>
|
</template>
|
||||||
<el-select v-model="form.content_type" placeholder="本次提交的产出类型" style="width:100%">
|
<el-form-item :label="isInternalProject ? '事务类型' : '内容类型'" required>
|
||||||
|
<el-select v-model="form.content_type" :placeholder="isInternalProject ? '选择事务类型' : '本次提交的产出类型'" style="width:100%">
|
||||||
<el-option v-for="ct in filteredContentTypes" :key="ct" :label="ct" :value="ct" />
|
<el-option v-for="ct in filteredContentTypes" :key="ct" :label="ct" :value="ct" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@ -157,6 +159,7 @@
|
|||||||
<el-form-item label="所属项目">
|
<el-form-item label="所属项目">
|
||||||
<el-input :model-value="editForm._project_name" disabled />
|
<el-input :model-value="editForm._project_name" disabled />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<template v-if="!isEditInternalProject">
|
||||||
<el-row :gutter="16">
|
<el-row :gutter="16">
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<el-form-item label="项目阶段" required>
|
<el-form-item label="项目阶段" required>
|
||||||
@ -178,7 +181,8 @@
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
<el-form-item label="内容类型" required>
|
</template>
|
||||||
|
<el-form-item :label="isEditInternalProject ? '事务类型' : '内容类型'" required>
|
||||||
<el-select v-model="editForm.content_type" 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-option v-for="ct in editFilteredContentTypes" :key="ct" :label="ct" :value="ct" />
|
||||||
</el-select>
|
</el-select>
|
||||||
@ -250,6 +254,7 @@ const CONTENT_PHASE_MAP = {
|
|||||||
'剧本': '前期', '分镜': '前期', '人设图': '前期', '场景图': '前期', '道具图': '前期',
|
'剧本': '前期', '分镜': '前期', '人设图': '前期', '场景图': '前期', '道具图': '前期',
|
||||||
'动画制作': '中期',
|
'动画制作': '中期',
|
||||||
'AI配音': '后期', '音效': '后期', '修补镜头': '后期', '剪辑': '后期', '音乐/BGM': '后期', '字幕': '后期',
|
'AI配音': '后期', '音效': '后期', '修补镜头': '后期', '剪辑': '后期', '音乐/BGM': '后期', '字幕': '后期',
|
||||||
|
'培训': '内部事务', '招聘面试': '内部事务', '内部其他': '内部事务',
|
||||||
}
|
}
|
||||||
|
|
||||||
// 按阶段分组的内容类型(有序)
|
// 按阶段分组的内容类型(有序)
|
||||||
@ -257,16 +262,17 @@ const PHASE_CONTENT_TYPES = {
|
|||||||
'前期': ['策划案', '大纲/梗概', '概念设计图', '测试片', '剧本', '分镜', '人设图', '场景图', '道具图'],
|
'前期': ['策划案', '大纲/梗概', '概念设计图', '测试片', '剧本', '分镜', '人设图', '场景图', '道具图'],
|
||||||
'中期': ['动画制作'],
|
'中期': ['动画制作'],
|
||||||
'后期': ['AI配音', '音效', '修补镜头', '剪辑', '音乐/BGM', '字幕'],
|
'后期': ['AI配音', '音效', '修补镜头', '剪辑', '音乐/BGM', '字幕'],
|
||||||
|
'内部事务': ['培训', '招聘面试', '内部其他'],
|
||||||
}
|
}
|
||||||
|
|
||||||
// 项目级内容类型(不需要选集数)
|
// 项目级内容类型(不需要选集数)
|
||||||
const PROJECT_LEVEL_TYPES = new Set(['策划案', '大纲/梗概', '概念设计图', '测试片'])
|
const PROJECT_LEVEL_TYPES = new Set(['策划案', '大纲/梗概', '概念设计图', '测试片', '培训', '招聘面试', '内部其他'])
|
||||||
function needsEpisode(contentType) {
|
function needsEpisode(contentType) {
|
||||||
return !PROJECT_LEVEL_TYPES.has(contentType)
|
return !PROJECT_LEVEL_TYPES.has(contentType)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 前期内容不显示产出时长
|
// 前期 + 内部事务不显示产出时长
|
||||||
const NO_DURATION_TYPES = new Set(PHASE_CONTENT_TYPES['前期'])
|
const NO_DURATION_TYPES = new Set([...PHASE_CONTENT_TYPES['前期'], ...PHASE_CONTENT_TYPES['内部事务']])
|
||||||
function showDuration(contentType) {
|
function showDuration(contentType) {
|
||||||
return !NO_DURATION_TYPES.has(contentType)
|
return !NO_DURATION_TYPES.has(contentType)
|
||||||
}
|
}
|
||||||
@ -310,9 +316,23 @@ const editForm = reactive({
|
|||||||
episode_number: null,
|
episode_number: null,
|
||||||
})
|
})
|
||||||
|
|
||||||
// 根据阶段过滤内容类型
|
// 内部事务项目检测
|
||||||
const filteredContentTypes = computed(() => PHASE_CONTENT_TYPES[form.project_phase] || [])
|
const INTERNAL_PROJECT_NAME = '内部事务'
|
||||||
|
const isInternalProject = computed(() => {
|
||||||
|
const proj = activeProjects.value.find(p => p.id === form.project_id)
|
||||||
|
return proj?.name === INTERNAL_PROJECT_NAME
|
||||||
|
})
|
||||||
|
const isEditInternalProject = computed(() => {
|
||||||
|
return editForm._project_name === INTERNAL_PROJECT_NAME
|
||||||
|
})
|
||||||
|
|
||||||
|
// 根据阶段过滤内容类型(内部事务项目只显示内部事务类型)
|
||||||
|
const filteredContentTypes = computed(() => {
|
||||||
|
if (isInternalProject.value) return PHASE_CONTENT_TYPES['内部事务']
|
||||||
|
return PHASE_CONTENT_TYPES[form.project_phase] || []
|
||||||
|
})
|
||||||
const editFilteredContentTypes = computed(() => {
|
const editFilteredContentTypes = computed(() => {
|
||||||
|
if (isEditInternalProject.value) return PHASE_CONTENT_TYPES['内部事务']
|
||||||
const types = PHASE_CONTENT_TYPES[editForm.project_phase] || []
|
const types = PHASE_CONTENT_TYPES[editForm.project_phase] || []
|
||||||
// 编辑时如果当前值不在列表中(旧数据兼容),也加入
|
// 编辑时如果当前值不在列表中(旧数据兼容),也加入
|
||||||
if (editForm.content_type && !types.includes(editForm.content_type)) {
|
if (editForm.content_type && !types.includes(editForm.content_type)) {
|
||||||
@ -321,6 +341,17 @@ const editFilteredContentTypes = computed(() => {
|
|||||||
return types
|
return types
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 选择内部事务项目时自动设置默认值
|
||||||
|
watch(() => form.project_id, () => {
|
||||||
|
if (isInternalProject.value) {
|
||||||
|
form.project_phase = '内部事务'
|
||||||
|
form.work_type = '制作'
|
||||||
|
form.content_type = PHASE_CONTENT_TYPES['内部事务'][0]
|
||||||
|
form.duration_minutes = 0
|
||||||
|
form.duration_seconds = 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// 切换阶段时重置内容类型为该阶段第一个
|
// 切换阶段时重置内容类型为该阶段第一个
|
||||||
watch(() => form.project_phase, (phase) => {
|
watch(() => form.project_phase, (phase) => {
|
||||||
const types = PHASE_CONTENT_TYPES[phase]
|
const types = PHASE_CONTENT_TYPES[phase]
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user