- 内部事务成本池:非管理层用户的 时薪×投入时长,按产出秒数比例分摊 - 管理层用户提交内部事务不重复计算(已通过管理成本分摊) - 人力成本排除内部事务提交,避免重复 - 项目管理页面隐藏"内部事务"项目 - 阶段列宽度调整适配"内部事务"四字显示 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
256 lines
11 KiB
Vue
256 lines
11 KiB
Vue
<template>
|
||
<div>
|
||
<div class="page-header">
|
||
<div></div>
|
||
<el-button v-if="authStore.hasPermission('project:create')" type="primary" @click="showCreate = true">
|
||
<el-icon><Plus /></el-icon> 新建项目
|
||
</el-button>
|
||
</div>
|
||
|
||
<!-- 筛选 -->
|
||
<div class="filter-bar">
|
||
<el-select v-model="filter.status" placeholder="状态" clearable style="width:120px" @change="load">
|
||
<el-option label="制作中" value="制作中" />
|
||
<el-option label="已完成" value="已完成" />
|
||
<el-option label="废弃" value="废弃" />
|
||
</el-select>
|
||
<el-select v-model="filter.project_type" placeholder="项目类型" clearable style="width:150px" @change="load">
|
||
<el-option v-for="t in projectTypes" :key="t" :label="t" :value="t" />
|
||
</el-select>
|
||
</div>
|
||
|
||
<!-- 项目列表 -->
|
||
<div class="card">
|
||
<div class="card-body">
|
||
<el-table :data="projects" v-loading="loading" @row-click="row => $router.push(`/projects/${row.id}`)" style="cursor:pointer">
|
||
<el-table-column prop="name" label="项目名称" min-width="160">
|
||
<template #default="{ row }">
|
||
<span class="cell-bold">{{ row.name }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="类型" width="140">
|
||
<template #default="{ row }">
|
||
<el-tag size="small" :type="typeTagMap[row.project_type]">{{ row.project_type }}</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="状态" width="90">
|
||
<template #default="{ row }">
|
||
<el-tag size="small" :type="row.status === '已完成' ? 'success' : row.status === '废弃' ? 'danger' : 'info'">{{ row.status }}</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="leader_name" label="负责人" width="90" />
|
||
<el-table-column label="目标" width="130">
|
||
<template #default="{ row }">{{ row.episode_count }}集 × {{ row.episode_duration_minutes }}分</template>
|
||
</el-table-column>
|
||
<el-table-column label="进度" width="180">
|
||
<template #default="{ row }">
|
||
<div class="cell-stage">
|
||
<span class="stage-tag" :class="'stage-' + (row.current_stage || '')">{{ stageLabel(row) }}</span>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="损耗率" width="90" align="right">
|
||
<template #default="{ row }">
|
||
<span class="rate-badge" :class="{ danger: row.waste_rate > 30 }">{{ row.waste_rate }}%</span>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 新建项目对话框 -->
|
||
<el-dialog v-model="showCreate" title="新建项目" width="560px" destroy-on-close>
|
||
<el-form :model="form" label-width="110px" label-position="left">
|
||
<el-form-item label="项目名称" required>
|
||
<el-input v-model="form.name" placeholder="输入项目名称" />
|
||
</el-form-item>
|
||
<el-form-item label="项目类型" required>
|
||
<el-select v-model="form.project_type" style="width:100%">
|
||
<el-option v-for="t in projectTypes" :key="t" :label="t" :value="t" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="负责人">
|
||
<el-select v-model="form.leader_id" placeholder="选择负责人" clearable style="width:100%">
|
||
<el-option v-for="u in users" :key="u.id" :label="u.name" :value="u.id" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="单集时长" required>
|
||
<div class="inline-field">
|
||
<el-input-number v-model="form.episode_duration_minutes" :min="0.1" :step="0.5" style="width:140px" />
|
||
<span class="field-unit">分钟/集</span>
|
||
</div>
|
||
</el-form-item>
|
||
<el-form-item label="集数" required>
|
||
<div class="inline-field">
|
||
<el-input-number v-model="form.episode_count" :min="1" style="width:140px" />
|
||
<span class="field-unit">集</span>
|
||
<span class="field-hint">目标总时长 = {{ form.episode_duration_minutes }} 分 × {{ form.episode_count }} 集 = {{ (form.episode_duration_minutes * form.episode_count).toFixed(1) }} 分钟</span>
|
||
</div>
|
||
</el-form-item>
|
||
<el-form-item label="预估完成日期">
|
||
<el-date-picker v-model="form.estimated_completion_date" value-format="YYYY-MM-DD" placeholder="选择预计交付日期" style="width:100%" />
|
||
</el-form-item>
|
||
<el-form-item v-if="form.project_type === '客户正式项目' && authStore.hasPermission('project:view_contract')" label="合同金额">
|
||
<el-input-number v-model="form.contract_amount" :min="0" :step="10000" :controls="false" placeholder="甲方合同总金额(元)" style="width:100%" />
|
||
</el-form-item>
|
||
|
||
<!-- 里程碑模板 -->
|
||
<el-form-item label="里程碑">
|
||
<div class="ms-template">
|
||
<div class="ms-phase-group">
|
||
<div class="ms-phase-label">前期</div>
|
||
<div v-for="(ms, i) in msTemplate.filter(m => m.phase === '前期')" :key="'pre'+i" class="ms-check-item">
|
||
<el-checkbox v-model="ms.checked" :label="ms.name" />
|
||
</div>
|
||
</div>
|
||
<div class="ms-phase-group">
|
||
<div class="ms-phase-label">后期</div>
|
||
<div v-for="(ms, i) in msTemplate.filter(m => m.phase === '后期')" :key="'post'+i" class="ms-check-item">
|
||
<el-checkbox v-model="ms.checked" :label="ms.name" />
|
||
</div>
|
||
</div>
|
||
<div class="ms-custom-add">
|
||
<el-input v-model="customMs.name" size="small" placeholder="自定义里程碑" style="flex:1">
|
||
<template #prepend>
|
||
<el-select v-model="customMs.phase" style="width:80px" size="small">
|
||
<el-option label="前期" value="前期" /><el-option label="后期" value="后期" />
|
||
</el-select>
|
||
</template>
|
||
<template #append>
|
||
<el-button @click="addCustomMs"><el-icon><Plus /></el-icon></el-button>
|
||
</template>
|
||
</el-input>
|
||
</div>
|
||
</div>
|
||
</el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="showCreate = false">取消</el-button>
|
||
<el-button type="primary" :loading="creating" @click="handleCreate">创建</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, onMounted, reactive } from 'vue'
|
||
import { projectApi, userApi } from '../api'
|
||
import { useAuthStore } from '../stores/auth'
|
||
import { ElMessage } from 'element-plus'
|
||
|
||
const authStore = useAuthStore()
|
||
const loading = ref(false)
|
||
const creating = ref(false)
|
||
const showCreate = ref(false)
|
||
const projects = ref([])
|
||
const users = ref([])
|
||
const projectTypes = ['客户正式项目', '客户测试项目', '内部原创项目', '内部测试项目']
|
||
const typeTagMap = { '客户正式项目': 'success', '客户测试项目': 'warning', '内部原创项目': '', '内部测试项目': 'info' }
|
||
const filter = reactive({ status: null, project_type: null })
|
||
const form = reactive({
|
||
name: '', project_type: '客户正式项目', leader_id: null,
|
||
episode_duration_minutes: 5, episode_count: 1,
|
||
estimated_completion_date: null, contract_amount: null,
|
||
})
|
||
|
||
// 里程碑模板
|
||
const defaultMsTemplate = [
|
||
{ name: '策划案', phase: '前期', sort_order: 1, checked: true },
|
||
{ name: '剧本', phase: '前期', sort_order: 2, checked: true },
|
||
{ name: '分镜', phase: '前期', sort_order: 3, checked: true },
|
||
{ name: '人设图', phase: '前期', sort_order: 4, checked: true },
|
||
{ name: '场景图', phase: '前期', sort_order: 5, checked: true },
|
||
{ name: '配音', phase: '后期', sort_order: 1, checked: true },
|
||
{ name: '音效', phase: '后期', sort_order: 2, checked: true },
|
||
{ name: '修补镜头', phase: '后期', sort_order: 3, checked: true },
|
||
{ name: '杂项', phase: '后期', sort_order: 4, checked: true },
|
||
]
|
||
const msTemplate = reactive([...defaultMsTemplate.map(m => ({...m}))])
|
||
const customMs = reactive({ name: '', phase: '前期' })
|
||
|
||
function addCustomMs() {
|
||
const name = customMs.name.trim()
|
||
if (!name) return
|
||
const phase = customMs.phase
|
||
const samePhase = msTemplate.filter(m => m.phase === phase)
|
||
const maxOrder = samePhase.length > 0 ? Math.max(...samePhase.map(m => m.sort_order)) : 0
|
||
msTemplate.push({ name, phase, sort_order: maxOrder + 1, checked: true })
|
||
customMs.name = ''
|
||
}
|
||
|
||
function stageLabel(row) {
|
||
const s = row.phase_summary
|
||
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 `后期 ${s.post.completed}/${s.post.total}`
|
||
if (stage === '已完成') return '已完成'
|
||
return row.progress_percent + '%'
|
||
}
|
||
|
||
async function load() {
|
||
loading.value = true
|
||
try {
|
||
const all = await projectApi.list(filter)
|
||
projects.value = all.filter(p => p.name !== '内部事务')
|
||
} finally { loading.value = false }
|
||
}
|
||
|
||
async function handleCreate() {
|
||
if (!form.name) { ElMessage.warning('请输入项目名称'); return }
|
||
creating.value = true
|
||
try {
|
||
const checkedMs = msTemplate.filter(m => m.checked).map(m => ({
|
||
name: m.name, phase: m.phase, sort_order: m.sort_order,
|
||
}))
|
||
await projectApi.create({ ...form, milestones: checkedMs })
|
||
ElMessage.success('项目已创建')
|
||
showCreate.value = false
|
||
// reset milestone template
|
||
msTemplate.splice(0, msTemplate.length, ...defaultMsTemplate.map(m => ({...m})))
|
||
load()
|
||
} finally { creating.value = false }
|
||
}
|
||
|
||
onMounted(async () => {
|
||
load()
|
||
try { users.value = await userApi.brief() } catch {}
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
|
||
.filter-bar { display: flex; gap: 8px; margin-bottom: 16px; }
|
||
.card {
|
||
background: var(--bg-card); border: 1px solid var(--border-color);
|
||
border-radius: var(--radius-md);
|
||
}
|
||
.card-body { padding: 4px 0; }
|
||
.cell-bold { font-weight: 500; color: var(--text-primary); }
|
||
.cell-stage { display: flex; align-items: center; }
|
||
.stage-tag {
|
||
font-size: 12px; font-weight: 600; padding: 2px 10px; border-radius: 4px;
|
||
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: #FFF3E0; color: #FF9500; }
|
||
.stage-tag.stage-已完成 { background: #E8F8EE; color: #34C759; }
|
||
.rate-badge {
|
||
font-size: 12px; font-weight: 600; color: var(--text-secondary);
|
||
background: var(--bg-hover); padding: 2px 8px; border-radius: 4px; display: inline-block;
|
||
}
|
||
.rate-badge.danger { background: #FFE8E7; color: #FF3B30; }
|
||
.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); margin-left: 4px; }
|
||
|
||
/* 里程碑模板 */
|
||
.ms-template { width: 100%; }
|
||
.ms-phase-group { margin-bottom: 12px; }
|
||
.ms-phase-label { font-size: 12px; font-weight: 600; color: var(--text-secondary); margin-bottom: 4px; }
|
||
.ms-check-item { padding: 2px 0; }
|
||
.ms-custom-add { margin-top: 8px; }
|
||
</style>
|