airlabs-manage/frontend/src/views/ProjectDetail.vue
seaislee1209 2b990f06fb
All checks were successful
Build and Deploy Backend / build-and-deploy (push) Successful in 1m18s
Build and Deploy Web / build-and-deploy (push) Successful in 53s
feat: 合同金额权限控制 + 内置角色权限自动同步
- 新增 project:view_contract 权限,仅超级管理员可查看合同金额
- 项目详情、仪表盘、结算页、创建项目表单均受权限保护
- 启动时自动同步内置角色权限,新增权限无需手动更新数据库

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 12:10:02 +08:00

958 lines
40 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 v-loading="loading">
<div class="page-header">
<div class="page-header-left">
<el-button text @click="$router.push('/projects')" class="back-btn"><el-icon><ArrowLeft /></el-icon></el-button>
<h2>{{ project.name }}</h2>
<el-tag :type="typeTagMap[project.project_type]" size="small">{{ project.project_type }}</el-tag>
<el-tag :type="project.status === '已完成' ? 'success' : project.status === '废弃' ? 'danger' : 'info'" size="small">{{ project.status }}</el-tag>
</div>
<el-space>
<el-button v-if="authStore.hasPermission('project:edit')" @click="openEdit">编辑项目</el-button>
<el-button v-if="authStore.hasPermission('project:delete')" type="danger" text @click="handleDelete">删除项目</el-button>
<el-button v-if="authStore.hasPermission('project:complete') && project.status === '制作中'" type="danger" plain @click="handleComplete">确认完成</el-button>
<el-button v-if="authStore.hasPermission('settlement:view') && project.status === '已完成'" type="primary" @click="$router.push(`/settlement/${project.id}`)">查看结算</el-button>
<el-button v-if="authStore.hasPermission('project:edit') && project.status === '制作中'" type="warning" plain @click="handleAbandon">标记废弃</el-button>
</el-space>
</div>
<!-- 项目信息 -->
<div class="card info-card">
<div class="info-grid">
<div class="info-item" v-if="authStore.hasPermission('project:view_contract')">
<span class="info-label">合同金额</span>
<span class="info-value price">{{ project.contract_amount ? '¥' + project.contract_amount.toLocaleString() : '未设置' }}</span>
</div>
<div class="info-item">
<span class="info-label">负责人</span>
<span class="info-value">{{ project.leader_name || '未指定' }}</span>
</div>
<div class="info-item">
<span class="info-label">当前阶段</span>
<span class="info-value">{{ project.current_phase || '—' }}</span>
</div>
<div class="info-item">
<span class="info-label">创建时间</span>
<span class="info-value">{{ project.created_at ? project.created_at.slice(0,10) : '—' }}</span>
</div>
<div class="info-item">
<span class="info-label">预计完成</span>
<span class="info-value">{{ project.estimated_completion_date || '未设置' }}</span>
</div>
<div class="info-item">
<span class="info-label">目标时长</span>
<span class="info-value">{{ project.episode_count }} × {{ project.episode_duration_minutes }} = {{ formatSecs(project.target_total_seconds) }}</span>
</div>
</div>
</div>
<!-- 概览卡片 -->
<div class="stat-grid">
<div class="stat-card">
<div class="stat-label">目标时长</div>
<div class="stat-value">{{ formatSecs(project.target_total_seconds) }}</div>
</div>
<div class="stat-card">
<div class="stat-label">已提交</div>
<div class="stat-value">{{ formatSecs(project.total_submitted_seconds) }}</div>
</div>
<div class="stat-card">
<div class="stat-label">当前阶段</div>
<div class="stat-value" :style="{color: stageColor}">{{ project.current_stage || '—' }}</div>
</div>
<div class="stat-card">
<div class="stat-label">损耗率</div>
<div class="stat-value" :style="{color: project.waste_rate > 30 ? '#FF3B30' : '#34C759'}">{{ project.waste_rate }}%</div>
</div>
</div>
<!-- 里程碑进度 -->
<div class="card">
<div class="card-header">
<span class="card-title">项目进度</span>
<span class="card-count" v-if="remainingDays !== null">
{{ remainingDays > 0 ? `剩余 ${remainingDays}` : remainingDays === 0 ? '今天截止' : `已超期 ${Math.abs(remainingDays)}` }}
</span>
</div>
<div class="card-body">
<!-- 三阶段分段进度条 -->
<div class="milestone-pipeline" v-if="project.phase_summary">
<div class="segmented-bar">
<div class="seg-group" style="flex:2">
<div class="seg-track">
<div class="seg-fill pre" :style="{width: prePercent + '%'}"></div>
</div>
<div class="seg-info">
<span class="seg-name" :class="{active: project.current_stage === '前期'}">前期</span>
<span class="seg-stat">{{ phasePre.completed }}/{{ phasePre.total }}</span>
</div>
</div>
<div class="seg-group" style="flex:5">
<div class="seg-track">
<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-stat">{{ project.progress_percent }}%</span>
</div>
</div>
<div class="seg-group" style="flex:2">
<div class="seg-track">
<div class="seg-fill post" :style="{width: postPercent + '%'}"></div>
</div>
<div class="seg-info">
<span class="seg-name" :class="{active: project.current_stage === '后期'}">后期</span>
<span class="seg-stat">{{ phasePost.completed }}/{{ phasePost.total }}</span>
</div>
</div>
</div>
<!-- 三列里程碑清单 -->
<div class="milestone-columns">
<!-- 前期 -->
<div class="milestone-col">
<div class="milestone-col-header">前期</div>
<div v-for="m in preMilestones" :key="m.id" class="milestone-item">
<el-checkbox
:model-value="m.is_completed"
@change="toggleMilestone(m)"
:disabled="!authStore.hasPermission('project:edit')"
/>
<span class="milestone-name" :class="{ completed: m.is_completed }">{{ m.name }}</span>
<el-button v-if="authStore.hasPermission('project:edit')" text type="danger" size="small" class="milestone-del"
@click="deleteMilestone(m.id)"><el-icon size="12"><Close /></el-icon></el-button>
</div>
<div v-if="authStore.hasPermission('project:edit')" class="milestone-add">
<el-input v-model="newMilestone.pre" size="small" placeholder="添加前期里程碑" @keyup.enter="addMilestone('前期', 'pre')">
<template #append>
<el-button @click="addMilestone('前期', 'pre')"><el-icon><Plus /></el-icon></el-button>
</template>
</el-input>
</div>
</div>
<!-- 制作 -->
<div class="milestone-col production-col">
<div class="milestone-col-header">制作</div>
<div class="production-ring-layout">
<div ref="progressChartRef" style="width:180px;height:180px;flex-shrink:0"></div>
<div class="production-info">
<div class="prod-info-row">
<span class="prod-info-label">已产出</span>
<span class="prod-info-value">{{ formatSecs(project.total_submitted_seconds) }}</span>
</div>
<div class="prod-info-row">
<span class="prod-info-label">目标</span>
<span class="prod-info-value">{{ formatSecs(project.target_total_seconds) }}</span>
</div>
<div class="prod-info-row">
<span class="prod-info-label">损耗</span>
<span class="prod-info-value" :style="{color: project.waste_rate > 30 ? '#FF3B30' : 'inherit'}">{{ formatSecs(project.waste_seconds) }}</span>
</div>
<div class="prod-info-row">
<span class="prod-info-label">损耗率</span>
<span class="prod-info-value" :style="{color: project.waste_rate > 30 ? '#FF3B30' : 'inherit'}">{{ project.waste_rate }}%</span>
</div>
</div>
</div>
</div>
<!-- 后期 -->
<div class="milestone-col">
<div class="milestone-col-header">后期</div>
<div v-for="m in postMilestones" :key="m.id" class="milestone-item">
<el-checkbox
:model-value="m.is_completed"
@change="toggleMilestone(m)"
:disabled="!authStore.hasPermission('project:edit')"
/>
<span class="milestone-name" :class="{ completed: m.is_completed }">{{ m.name }}</span>
<el-button v-if="authStore.hasPermission('project:edit')" text type="danger" size="small" class="milestone-del"
@click="deleteMilestone(m.id)"><el-icon size="12"><Close /></el-icon></el-button>
</div>
<div v-if="authStore.hasPermission('project:edit')" class="milestone-add">
<el-input v-model="newMilestone.post" size="small" placeholder="添加后期里程碑" @keyup.enter="addMilestone('后期', 'post')">
<template #append>
<el-button @click="addMilestone('后期', 'post')"><el-icon><Plus /></el-icon></el-button>
</template>
</el-input>
</div>
</div>
</div>
</div>
<!-- 时间轴 -->
<div class="timeline-section" v-if="project.created_at && project.estimated_completion_date">
<div class="progress-label-row">
<span>时间进度</span>
<span :style="{color: timePercent > 100 ? '#FF3B30' : '#8F959E', fontWeight: 600}">{{ timePercent }}%</span>
</div>
<div class="timeline-bar-wrapper">
<div class="timeline-bar">
<div class="timeline-elapsed" :style="{width: Math.min(timePercent, 100) + '%'}"></div>
<div class="timeline-today-marker" :style="{left: Math.min(timePercent, 100) + '%'}" v-if="timePercent <= 100">
<span class="timeline-today-label">今天</span>
</div>
</div>
<div class="timeline-labels">
<span>{{ project.created_at ? project.created_at.slice(0,10) : '' }}</span>
<span>{{ project.estimated_completion_date }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- 团队效率 -->
<div v-if="authStore.hasPermission('efficiency:view') && efficiency.length" class="card">
<div class="card-header"><span class="card-title">团队效率</span></div>
<div class="card-body">
<el-table :data="efficiency" size="small">
<el-table-column prop="user_name" label="成员" width="100" />
<el-table-column label="提交总量" align="right"><template #default="{row}">{{ formatSecs(row.total_seconds) }}</template></el-table-column>
<el-table-column label="人均基准" align="right"><template #default="{row}">{{ formatSecs(row.baseline) }}</template></el-table-column>
<el-table-column label="超出基准" align="right">
<template #default="{row}">
<span :style="{color: row.excess_seconds > 0 ? '#FF3B30' : '#34C759', fontWeight: 600}">
{{ row.excess_seconds > 0 ? '+' : '' }}{{ formatSecs(row.excess_seconds) }}
</span>
</template>
</el-table-column>
<el-table-column label="超出比例" align="right" width="100">
<template #default="{row}">
<span class="rate-badge" :class="{danger: row.excess_rate > 20}">{{ row.excess_rate > 0 ? '+' : '' }}{{ row.excess_rate }}%</span>
</template>
</el-table-column>
</el-table>
</div>
</div>
<!-- 提交概览热力图 -->
<div class="card">
<div class="card-header">
<span class="card-title">提交概览</span>
<div class="heatmap-controls">
<el-radio-group v-model="heatmapRange" size="small">
<el-radio-button label="30">最近30天</el-radio-button>
<el-radio-button label="all">项目全周期</el-radio-button>
</el-radio-group>
</div>
</div>
<div class="card-body heatmap-body" v-if="heatmapUsers.length">
<div class="heatmap-scroll">
<table class="heatmap-table">
<thead>
<tr>
<th class="heatmap-name-col">成员</th>
<th v-for="d in heatmapDates" :key="d" class="heatmap-date-col" :class="{ today: d === todayStr }">
{{ d.slice(5) }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="user in heatmapUsers" :key="user.id">
<td class="heatmap-name-col">
<a class="member-link" @click="openMemberDrawer(user)">{{ user.name }}</a>
</td>
<td v-for="d in heatmapDates" :key="d" class="heatmap-cell"
:class="getCellClass(user.id, d)"
@mouseenter="showCellTooltip($event, user.id, d)"
@mouseleave="hideCellTooltip">
<span v-if="getCellData(user.id, d)">{{ getCellData(user.id, d).totalSecs }}s</span>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 图例 -->
<div class="heatmap-legend">
<span class="legend-item"><span class="legend-dot type-制作"></span>制作</span>
<span class="legend-item"><span class="legend-dot type-测试"></span>测试</span>
<span class="legend-item"><span class="legend-dot type-方案"></span>方案</span>
</div>
</div>
<div class="card-body" v-else>
<el-empty description="暂无提交记录" :image-size="60" />
</div>
</div>
<!-- 悬浮提示 -->
<div class="cell-tooltip" v-if="tooltip.visible" :style="{ top: tooltip.y + 'px', left: tooltip.x + 'px' }">
<div v-for="(s, i) in tooltip.items" :key="i" class="tooltip-row">
<el-tag :type="s.work_type === '测试' ? 'warning' : s.work_type === '方案' ? 'info' : ''" size="small">{{ s.work_type }}</el-tag>
<span class="tooltip-type">{{ s.content_type }}</span>
<span class="tooltip-secs" v-if="s.total_seconds > 0">{{ formatSecs(s.total_seconds) }}</span>
<span class="tooltip-desc" v-if="s.description">{{ s.description }}</span>
</div>
</div>
<!-- 成员提交抽屉 -->
<el-drawer v-model="drawerVisible" :title="drawerUser?.name + ' 的提交记录'" size="480px">
<div v-if="drawerUser" class="drawer-content">
<div class="drawer-summary">
<span>总提交 <strong>{{ drawerSubmissions.length }}</strong> </span>
<span>总产出 <strong>{{ formatSecs(drawerTotalSecs) }}</strong></span>
<router-link :to="`/users/${drawerUser.id}/detail`" class="drawer-link">查看完整详情 </router-link>
</div>
<div v-for="(group, date) in drawerGrouped" :key="date" class="drawer-date-group">
<div class="drawer-date-header">{{ date }}</div>
<div v-for="s in group" :key="s.id" class="drawer-sub-item">
<div class="drawer-sub-top">
<el-tag :type="s.work_type === '测试' ? 'warning' : s.work_type === '方案' ? 'info' : ''" size="small">{{ s.work_type }}</el-tag>
<span>{{ s.content_type }}</span>
<span v-if="s.total_seconds > 0" class="drawer-sub-secs">{{ formatSecs(s.total_seconds) }}</span>
</div>
<div v-if="s.description" class="drawer-sub-desc">{{ s.description }}</div>
</div>
</div>
</div>
</el-drawer>
<!-- 编辑项目对话框 -->
<el-dialog v-model="showEdit" title="编辑项目" width="560px" destroy-on-close>
<el-form :model="editForm" label-width="110px" label-position="left">
<el-form-item label="项目名称" required>
<el-input v-model="editForm.name" placeholder="输入项目名称" />
</el-form-item>
<el-form-item label="项目类型" required>
<el-select v-model="editForm.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="editForm.status" style="width:100%">
<el-option label="制作中" value="制作中" />
<el-option label="已完成" value="已完成" />
<el-option label="废弃" value="废弃" />
</el-select>
<div class="field-hint" style="margin-top:4px">可将已完成项目改回制作中误触确认完成时可在此恢复</div>
</el-form-item>
<el-form-item label="负责人">
<el-select v-model="editForm.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="当前阶段">
<el-select v-model="editForm.current_phase" style="width:100%">
<el-option label="前期" value="前期" />
<el-option label="制作" value="制作" />
<el-option label="后期" value="后期" />
</el-select>
</el-form-item>
<el-form-item label="单集时长" required>
<div class="inline-field">
<el-input-number v-model="editForm.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="editForm.episode_count" :min="1" style="width:140px" />
<span class="field-unit"></span>
<span class="field-hint">目标总时长 = {{ editForm.episode_duration_minutes }} × {{ editForm.episode_count }} = {{ (editForm.episode_duration_minutes * editForm.episode_count).toFixed(1) }} 分钟</span>
</div>
</el-form-item>
<el-form-item label="预估完成日期">
<el-date-picker v-model="editForm.estimated_completion_date" value-format="YYYY-MM-DD" placeholder="选择预计交付日期" style="width:100%" />
</el-form-item>
<el-form-item v-if="editForm.project_type === '客户正式项目' && authStore.hasPermission('project:view_contract')" label="合同金额">
<el-input-number v-model="editForm.contract_amount" :min="0" :step="10000" :controls="false" placeholder="甲方合同总金额(元)" style="width:100%" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showEdit = false">取消</el-button>
<el-button type="primary" :loading="editing" @click="handleSaveEdit">保存</el-button>
</template>
</el-dialog>
<!-- 提交记录 -->
<div class="card">
<div class="card-header">
<span class="card-title">提交记录</span>
<span class="card-count">{{ submissions.length }} </span>
</div>
<div class="card-body">
<el-table :data="submissions" size="small">
<el-table-column prop="submit_date" label="日期" width="110" />
<el-table-column prop="user_name" label="提交人" width="80" />
<el-table-column prop="project_phase" label="阶段" width="70" />
<el-table-column label="工作类型" width="80">
<template #default="{row}">
<el-tag :type="row.work_type === '测试' ? 'warning' : row.work_type === '方案' ? 'info' : ''" size="small">{{ row.work_type }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="content_type" label="内容类型" width="90" />
<el-table-column label="产出时长" width="90" align="right">
<template #default="{row}">{{ row.total_seconds > 0 ? formatSecs(row.total_seconds) : '—' }}</template>
</el-table-column>
<el-table-column prop="description" label="描述" show-overflow-tooltip />
</el-table>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, reactive, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { projectApi, submissionApi, userApi } from '../api'
import { useAuthStore } from '../stores/auth'
import { ElMessage, ElMessageBox } from 'element-plus'
import * as echarts from 'echarts'
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const loading = ref(false)
const project = ref({})
const submissions = ref([])
const efficiency = ref([])
const users = ref([])
const showEdit = ref(false)
const editing = ref(false)
const projectTypes = ['客户正式项目', '客户测试项目', '内部原创项目', '内部测试项目']
const typeTagMap = { '客户正式项目': 'success', '客户测试项目': 'warning', '内部原创项目': '', '内部测试项目': 'info' }
// ── 圆环进度图 ──
const progressChartRef = ref(null)
let progressChart = null
function initProgressChart() {
if (!progressChartRef.value) return
const p = project.value
const pct = p.progress_percent || 0
const isOver = pct > 100
if (progressChart) progressChart.dispose()
progressChart = echarts.init(progressChartRef.value)
progressChart.setOption({
series: [{
type: 'pie',
radius: ['55%', '78%'],
center: ['50%', '50%'],
silent: true,
label: { show: false },
data: [
{ value: Math.min(pct, 100), itemStyle: { color: isOver ? '#FF9500' : '#3370FF' } },
{ value: Math.max(0, 100 - pct), itemStyle: { color: '#E5E6EB' } },
],
}],
graphic: [{
type: 'text',
left: 'center', top: 'center',
style: {
text: pct + '%',
fontSize: 22, fontWeight: 700,
fill: isOver ? '#FF9500' : '#3370FF',
textAlign: 'center',
},
}],
})
}
function handleProgressResize() { progressChart?.resize() }
const editForm = reactive({
name: '', project_type: '客户正式项目', status: '制作中', leader_id: null, current_phase: '制作',
episode_duration_minutes: 5, episode_count: 1,
estimated_completion_date: null, contract_amount: null,
})
const progressColor = computed(() => project.value.progress_percent > 100 ? '#FF9500' : '#3370FF')
const stageColor = computed(() => {
const s = project.value.current_stage
if (s === '已完成') return '#34C759'
if (s === '前期') return '#8F959E'
if (s === '制作') return '#3370FF'
if (s === '后期') return '#FF9500'
return 'var(--text-primary)'
})
// ── 里程碑 ──
const phasePre = computed(() => project.value.phase_summary?.pre || { total: 0, completed: 0 })
const phasePost = computed(() => project.value.phase_summary?.post || { total: 0, completed: 0 })
const prePercent = computed(() => phasePre.value.total > 0 ? Math.round(phasePre.value.completed / phasePre.value.total * 100) : 0)
const postPercent = computed(() => phasePost.value.total > 0 ? Math.round(phasePost.value.completed / phasePost.value.total * 100) : 0)
const preMilestones = computed(() => (project.value.milestones || []).filter(m => m.phase === '前期'))
const postMilestones = computed(() => (project.value.milestones || []).filter(m => m.phase === '后期'))
const newMilestone = reactive({ pre: '', post: '' })
async function toggleMilestone(m) {
try {
await projectApi.toggleMilestone(m.id)
load()
} catch {}
}
async function addMilestone(phase, key) {
const name = newMilestone[key]?.trim()
if (!name) return
try {
await projectApi.addMilestone(route.params.id, { name, phase })
newMilestone[key] = ''
load()
} catch {}
}
async function deleteMilestone(id) {
try {
await ElMessageBox.confirm('确认删除此里程碑?', '删除', { type: 'warning' })
await projectApi.deleteMilestone(id)
load()
} catch {}
}
// ── 时间轴计算 ──
const todayStr = new Date().toISOString().slice(0, 10)
const remainingDays = computed(() => {
if (!project.value.estimated_completion_date) return null
const end = new Date(project.value.estimated_completion_date)
const now = new Date()
return Math.ceil((end - now) / (1000 * 60 * 60 * 24))
})
const timePercent = computed(() => {
const p = project.value
if (!p.created_at || !p.estimated_completion_date) return 0
const start = new Date(p.created_at)
const end = new Date(p.estimated_completion_date)
const now = new Date()
const total = end - start
if (total <= 0) return 100
return Math.round((now - start) / total * 100)
})
// ── 热力图 ──
const heatmapRange = ref('30')
const heatmapDates = computed(() => {
const subs = submissions.value
if (!subs.length) return []
if (heatmapRange.value === 'all') {
// 项目全周期:从最早提交到今天
const dates = subs.map(s => s.submit_date).sort()
const start = new Date(dates[0])
const end = new Date(todayStr)
const result = []
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
result.push(d.toISOString().slice(0, 10))
}
return result
} else {
// 最近30天
const result = []
const today = new Date()
for (let i = 29; i >= 0; i--) {
const d = new Date(today)
d.setDate(d.getDate() - i)
result.push(d.toISOString().slice(0, 10))
}
return result
}
})
const heatmapUsers = computed(() => {
const subs = submissions.value
const userMap = new Map()
subs.forEach(s => {
if (!userMap.has(s.user_id)) {
userMap.set(s.user_id, { id: s.user_id, name: s.user_name })
}
})
return Array.from(userMap.values())
})
// 按 userId + date 索引提交数据
const heatmapIndex = computed(() => {
const idx = {}
submissions.value.forEach(s => {
const key = `${s.user_id}_${s.submit_date}`
if (!idx[key]) idx[key] = { items: [], totalSecs: 0, mainType: '' }
idx[key].items.push(s)
idx[key].totalSecs += s.total_seconds || 0
})
// 确定主要工作类型(按产出秒数最多的类型)
Object.values(idx).forEach(cell => {
const typeMap = {}
cell.items.forEach(s => {
typeMap[s.work_type] = (typeMap[s.work_type] || 0) + (s.total_seconds || 1)
})
cell.mainType = Object.entries(typeMap).sort((a, b) => b[1] - a[1])[0]?.[0] || '制作'
cell.totalSecs = Math.round(cell.totalSecs)
})
return idx
})
function getCellData(userId, date) {
return heatmapIndex.value[`${userId}_${date}`] || null
}
function getCellClass(userId, date) {
const cell = getCellData(userId, date)
if (!cell) return ''
return `has-data type-${cell.mainType}`
}
// ── 悬浮提示 ──
const tooltip = reactive({ visible: false, x: 0, y: 0, items: [] })
function showCellTooltip(e, userId, date) {
const cell = getCellData(userId, date)
if (!cell) return
const rect = e.target.getBoundingClientRect()
tooltip.x = rect.left
tooltip.y = rect.bottom + 4
tooltip.items = cell.items
tooltip.visible = true
}
function hideCellTooltip() {
tooltip.visible = false
}
// ── 成员抽屉 ──
const drawerVisible = ref(false)
const drawerUser = ref(null)
const drawerSubmissions = computed(() => {
if (!drawerUser.value) return []
return submissions.value.filter(s => s.user_id === drawerUser.value.id)
})
const drawerTotalSecs = computed(() => {
return drawerSubmissions.value.reduce((sum, s) => sum + (s.total_seconds || 0), 0)
})
const drawerGrouped = computed(() => {
const groups = {}
drawerSubmissions.value.forEach(s => {
if (!groups[s.submit_date]) groups[s.submit_date] = []
groups[s.submit_date].push(s)
})
// 按日期降序
const sorted = {}
Object.keys(groups).sort((a, b) => b.localeCompare(a)).forEach(k => { sorted[k] = groups[k] })
return sorted
})
function openMemberDrawer(user) {
drawerUser.value = user
drawerVisible.value = true
}
// ── 工具函数 ──
function formatSecs(s) {
if (!s) return '0秒'
const abs = Math.abs(s)
const m = Math.floor(abs / 60)
const sec = Math.round(abs % 60)
const sign = s < 0 ? '-' : ''
return m > 0 ? `${sign}${m}${sec > 0 ? sec + '秒' : ''}` : `${sign}${sec}`
}
async function openEdit() {
const p = project.value
if (authStore.hasPermission('user:view') && !users.value.length) {
try { users.value = await userApi.list() } catch {}
}
Object.assign(editForm, {
name: p.name, project_type: p.project_type, status: p.status || '制作中', leader_id: p.leader_id,
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,
})
showEdit.value = true
}
async function handleSaveEdit() {
if (!editForm.name) { ElMessage.warning('请输入项目名称'); return }
editing.value = true
try {
await projectApi.update(route.params.id, {
name: editForm.name, project_type: editForm.project_type, status: editForm.status, leader_id: editForm.leader_id,
current_phase: editForm.current_phase,
episode_duration_minutes: editForm.episode_duration_minutes, episode_count: editForm.episode_count,
estimated_completion_date: editForm.estimated_completion_date, contract_amount: editForm.contract_amount,
})
ElMessage.success('项目已更新')
showEdit.value = false
load()
} finally { editing.value = false }
}
async function load() {
loading.value = true
try {
const id = route.params.id
project.value = await projectApi.get(id)
submissions.value = await submissionApi.list({ project_id: id })
if (authStore.hasPermission('efficiency:view')) {
try { efficiency.value = await projectApi.efficiency(id) } catch {}
}
if (authStore.hasPermission('user:view') && !users.value.length) {
try { users.value = await userApi.list() } catch {}
}
await nextTick()
initProgressChart()
window.addEventListener('resize', handleProgressResize)
} finally { loading.value = false }
}
async function handleDelete() {
try {
await ElMessageBox.confirm(
'删除后项目及其所有提交记录、成本数据将被永久清除,此操作不可撤销。',
'确认删除项目',
{ type: 'error', confirmButtonText: '确认删除', cancelButtonText: '取消' }
)
await projectApi.delete(route.params.id)
ElMessage.success('项目已删除')
router.push('/projects')
} catch {}
}
async function handleComplete() {
try {
await ElMessageBox.confirm('确认将此项目标记为完成并进行结算?', '确认完成', { type: 'warning' })
await projectApi.complete(route.params.id)
ElMessage.success('项目已完成')
load()
} catch {}
}
async function handleAbandon() {
try {
await ElMessageBox.confirm(
'确定将此项目标记为废弃?项目停止制作,全部产出将记为损耗。可在编辑中改回制作中。',
'标记废弃',
{ type: 'warning', confirmButtonText: '确定废弃', cancelButtonText: '取消' }
)
await projectApi.update(route.params.id, { status: '废弃' })
ElMessage.success('项目已标记为废弃')
load()
} catch {}
}
onMounted(load)
onUnmounted(() => {
window.removeEventListener('resize', handleProgressResize)
progressChart?.dispose()
})
</script>
<style scoped>
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
.page-header-left { display: flex; align-items: center; gap: 8px; }
.page-header-left h2 { font-size: 18px; font-weight: 600; }
.back-btn { font-size: 16px !important; padding: 4px !important; }
/* 项目信息卡片 */
.info-card { margin-bottom: 16px; }
.info-grid {
display: grid; grid-template-columns: repeat(3, 1fr); gap: 0;
padding: 16px 20px;
}
.info-item {
display: flex; flex-direction: column; padding: 8px 0;
border-bottom: 1px solid var(--border-light, #f0f1f2);
}
.info-item:nth-last-child(-n+3) { border-bottom: none; }
.info-label { font-size: 12px; color: var(--text-secondary); margin-bottom: 4px; }
.info-value { font-size: 14px; color: var(--text-primary); font-weight: 500; }
.info-value.price { color: #FF9500; font-weight: 700; font-size: 16px; }
.stat-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 16px; }
.stat-card {
background: var(--bg-card); border: 1px solid var(--border-color);
border-radius: var(--radius-md); padding: 20px; text-align: center;
}
.stat-label { font-size: 12px; color: var(--text-secondary); margin-bottom: 6px; }
.stat-value { font-size: 22px; font-weight: 700; color: var(--text-primary); }
.card {
background: var(--bg-card); border: 1px solid var(--border-color);
border-radius: var(--radius-md); margin-bottom: 16px;
}
.card-header {
padding: 16px 20px; border-bottom: 1px solid var(--border-light);
display: flex; justify-content: space-between; align-items: center;
}
.card-title { font-size: 14px; font-weight: 600; }
.card-count { font-size: 12px; color: var(--text-secondary); }
.card-body { padding: 20px; }
.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; }
/* 分段进度条 */
.milestone-pipeline { margin-bottom: 24px; }
.segmented-bar { display: flex; gap: 6px; margin-bottom: 24px; }
.seg-group { display: flex; flex-direction: column; gap: 6px; }
.seg-track {
height: 8px; background: #E5E6EB; border-radius: 4px;
overflow: hidden; position: relative;
}
.seg-fill {
height: 100%; border-radius: 4px; transition: width 0.4s ease;
}
.seg-fill.pre { background: #8F959E; }
.seg-fill.production { background: #3370FF; }
.seg-fill.post { background: #FF9500; }
.seg-info {
display: flex; justify-content: space-between; align-items: center;
padding: 0 2px;
}
.seg-name {
font-size: 12px; font-weight: 500; color: var(--text-secondary);
transition: color 0.2s;
}
.seg-name.active { color: #3370FF; font-weight: 600; }
.seg-stat { font-size: 12px; color: var(--text-placeholder, #C0C4CC); font-weight: 600; }
.milestone-columns {
display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 20px;
}
.milestone-col {
background: #F7F8FA; border-radius: 8px; padding: 12px 14px;
}
.milestone-col-header {
font-size: 13px; font-weight: 600; color: var(--text-primary);
margin-bottom: 12px; padding-bottom: 8px; border-bottom: 1px solid var(--border-light, #f0f1f2);
}
.milestone-item {
display: flex; align-items: center; gap: 6px; padding: 4px 0;
}
.milestone-name { font-size: 13px; color: var(--text-primary); flex: 1; }
.milestone-name.completed { color: var(--text-placeholder, #C0C4CC); text-decoration: line-through; }
.milestone-del { opacity: 0; transition: opacity 0.15s; padding: 2px !important; }
.milestone-item:hover .milestone-del { opacity: 1; }
.milestone-add { margin-top: 8px; }
/* 制作阶段 — 圆环 + 数据 */
.production-col { display: flex; flex-direction: column; }
.production-col .milestone-col-header { margin-bottom: 4px; }
.production-ring-layout {
flex: 1; display: flex; align-items: center; gap: 12px;
}
.production-info {
flex: 1; display: flex; flex-direction: column; gap: 12px;
}
.prod-info-row {
display: flex; justify-content: space-between; align-items: baseline;
}
.prod-info-label { font-size: 12px; color: var(--text-secondary); white-space: nowrap; }
.prod-info-value { font-size: 14px; font-weight: 600; color: var(--text-primary); white-space: nowrap; }
.production-info { max-width: 140px; }
/* 进度时间轴 */
.progress-label-row {
display: flex; justify-content: space-between; align-items: center;
font-size: 13px; color: var(--text-secondary); margin-bottom: 8px;
}
.timeline-section { margin-top: 4px; }
.timeline-bar-wrapper { position: relative; margin-top: 24px; }
.timeline-bar {
height: 8px; background: #E5E6EB; border-radius: 4px; position: relative; overflow: visible;
}
.timeline-elapsed {
height: 100%; background: #3370FF; border-radius: 4px 0 0 4px;
transition: width 0.3s;
}
.timeline-today-marker {
position: absolute; top: -4px; transform: translateX(-50%);
width: 16px; height: 16px; background: #3370FF; border: 2px solid #fff;
border-radius: 50%; box-shadow: 0 1px 4px rgba(0,0,0,0.15);
}
.timeline-today-label {
position: absolute; top: -20px; left: 50%; transform: translateX(-50%);
font-size: 11px; color: #3370FF; font-weight: 600; white-space: nowrap;
}
.timeline-labels {
display: flex; justify-content: space-between; font-size: 12px; color: var(--text-secondary); margin-top: 8px;
}
/* 热力图 */
.heatmap-controls { display: flex; gap: 8px; }
.heatmap-body { padding: 16px 20px !important; }
.heatmap-scroll { overflow-x: auto; margin-bottom: 12px; }
.heatmap-table {
border-collapse: collapse; width: max-content; min-width: 100%;
}
.heatmap-table th, .heatmap-table td {
border: 1px solid var(--border-light, #f0f1f2); padding: 0; text-align: center;
font-size: 11px; height: 32px; min-width: 44px;
}
.heatmap-table thead th {
background: #F7F8FA; color: var(--text-secondary); font-weight: 500;
position: sticky; top: 0; z-index: 1;
}
.heatmap-table thead th.today { background: #E8F0FE; color: #3370FF; font-weight: 600; }
.heatmap-name-col {
position: sticky; left: 0; z-index: 2; background: #fff;
min-width: 80px; max-width: 80px; text-align: left; padding: 0 8px !important;
font-weight: 500; font-size: 12px; color: var(--text-primary);
}
.heatmap-table thead .heatmap-name-col { background: #F7F8FA; }
.heatmap-cell { cursor: default; transition: background 0.15s; }
.heatmap-cell:hover { filter: brightness(0.92); }
.heatmap-cell.has-data { font-weight: 600; color: #fff; }
.heatmap-cell.type-制作 { background: #3370FF; }
.heatmap-cell.type-测试 { background: #FF9500; }
.heatmap-cell.type-方案 { background: #8F959E; }
.member-link {
color: #3370FF; cursor: pointer; text-decoration: none;
}
.member-link:hover { text-decoration: underline; }
.heatmap-legend { display: flex; gap: 16px; font-size: 12px; color: var(--text-secondary); }
.legend-item { display: flex; align-items: center; gap: 4px; }
.legend-dot { width: 10px; height: 10px; border-radius: 2px; display: inline-block; }
.legend-dot.type-制作 { background: #3370FF; }
.legend-dot.type-测试 { background: #FF9500; }
.legend-dot.type-方案 { background: #8F959E; }
/* 悬浮提示 */
.cell-tooltip {
position: fixed; z-index: 9999;
background: #fff; border: 1px solid var(--border-color); border-radius: 8px;
box-shadow: 0 4px 16px rgba(0,0,0,0.12); padding: 10px 14px;
max-width: 320px; pointer-events: none;
}
.tooltip-row { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; flex-wrap: wrap; }
.tooltip-row:last-child { margin-bottom: 0; }
.tooltip-type { font-size: 12px; color: var(--text-secondary); }
.tooltip-secs { font-size: 12px; font-weight: 600; color: var(--text-primary); }
.tooltip-desc { font-size: 11px; color: #8F959E; width: 100%; margin-top: 2px; }
/* 成员抽屉 */
.drawer-content { padding: 0 4px; }
.drawer-summary {
display: flex; gap: 16px; align-items: center; flex-wrap: wrap;
padding: 12px 16px; background: #F7F8FA; border-radius: 8px;
font-size: 13px; color: var(--text-secondary); margin-bottom: 16px;
}
.drawer-summary strong { color: var(--text-primary); }
.drawer-link { color: #3370FF; font-size: 13px; margin-left: auto; }
.drawer-date-group { margin-bottom: 16px; }
.drawer-date-header {
font-size: 13px; font-weight: 600; color: var(--text-primary);
padding-bottom: 8px; border-bottom: 1px solid var(--border-light, #f0f1f2);
margin-bottom: 8px;
}
.drawer-sub-item {
padding: 6px 0; border-bottom: 1px solid var(--border-light, #f0f1f2);
}
.drawer-sub-item:last-child { border-bottom: none; }
.drawer-sub-top { display: flex; align-items: center; gap: 8px; font-size: 13px; }
.drawer-sub-secs { font-weight: 600; color: var(--text-primary); margin-left: auto; }
.drawer-sub-desc { font-size: 12px; color: #8F959E; margin-top: 4px; }
</style>