seaislee1209 a5d3739eef
All checks were successful
Build and Deploy Backend / build-and-deploy (push) Successful in 1m19s
Build and Deploy Web / build-and-deploy (push) Successful in 1m9s
feat: 内部事务成本按比例分摊到所有项目 + UI优化
- 内部事务成本池:非管理层用户的 时薪×投入时长,按产出秒数比例分摊
- 管理层用户提交内部事务不重复计算(已通过管理成本分摊)
- 人力成本排除内部事务提交,避免重复
- 项目管理页面隐藏"内部事务"项目
- 阶段列宽度调整适配"内部事务"四字显示

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 11:11:15 +08:00

256 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>