- 项目详情中期模块显示修补镜头(后期秒数,归入制作产出计算) - 产出时长改为必填项(非前期内容必须 > 0) - 前端+后端双重验证,防止提交0秒产出 - 删除"无产出秒数的工作可填 0"过时提示 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1218 lines
53 KiB
Vue
1218 lines
53 KiB
Vue
<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.animation_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 v-if="project.waste_hours > 0" class="stat-sub">工时损耗 {{ project.waste_hours }}h</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 后期产出 -->
|
||
<div class="post-card" v-if="project.post_production_seconds > 0">
|
||
<div class="post-main">
|
||
<span class="post-label">后期产出</span>
|
||
<span class="post-value">{{ formatSecs(project.post_production_seconds) }}</span>
|
||
</div>
|
||
<div v-if="project.post_production_breakdown && project.post_production_breakdown.length" class="post-breakdown">
|
||
<span v-for="item in project.post_production_breakdown" :key="item.type" class="post-breakdown-item">
|
||
{{ item.type }} {{ formatSecs(item.seconds) }}
|
||
</span>
|
||
</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">
|
||
<span>前期</span>
|
||
<span v-if="preWasteHours > 0" class="ms-waste-tag">损耗 {{ preWasteHours }}h</span>
|
||
</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>
|
||
<span v-if="m.estimated_days" class="ms-badge est">{{ m.estimated_days }}天</span>
|
||
<span v-if="m.is_overdue" class="ms-badge overdue">超{{ (m.actual_days || 0) - (m.estimated_days || 0) }}天</span>
|
||
<span v-else-if="m.actual_days != null && m.start_date" class="ms-badge actual">{{ m.actual_days }}天</span>
|
||
<el-button v-if="authStore.hasPermission('project:edit')" text size="small" class="milestone-edit"
|
||
@click="openMilestoneEdit(m)"><el-icon size="12"><EditPen /></el-icon></el-button>
|
||
<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"><span>中期</span></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.animation_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" v-if="shotRepairSeconds > 0">
|
||
<span class="prod-info-label">修补镜头</span>
|
||
<span class="prod-info-value" :style="{color: '#34C759'}">{{ formatSecs(shotRepairSeconds) }}</span>
|
||
</div>
|
||
<div class="prod-info-row">
|
||
<span class="prod-info-label">测试损耗</span>
|
||
<span class="prod-info-value" :style="{color: prodWaste.test_waste_seconds > 0 ? '#FF9500' : 'inherit'}">{{ formatSecs(prodWaste.test_waste_seconds) }}</span>
|
||
</div>
|
||
<div class="prod-info-row">
|
||
<span class="prod-info-label">超产损耗</span>
|
||
<span class="prod-info-value" :style="{color: prodWaste.overproduction_waste_seconds > 0 ? '#FF3B30' : 'inherit'}">{{ formatSecs(prodWaste.overproduction_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">
|
||
<span>后期</span>
|
||
<span v-if="postWasteHours > 0" class="ms-waste-tag">损耗 {{ postWasteHours }}h</span>
|
||
</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>
|
||
<span v-if="m.estimated_days" class="ms-badge est">{{ m.estimated_days }}天</span>
|
||
<span v-if="m.is_overdue" class="ms-badge overdue">超{{ (m.actual_days || 0) - (m.estimated_days || 0) }}天</span>
|
||
<span v-else-if="m.actual_days != null && m.start_date" class="ms-badge actual">{{ m.actual_days }}天</span>
|
||
<el-button v-if="authStore.hasPermission('project:edit')" text size="small" class="milestone-edit"
|
||
@click="openMilestoneEdit(m)"><el-icon size="12"><EditPen /></el-icon></el-button>
|
||
<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="epProgress.length && epProgress.some(e => e.total_seconds > 0)" class="card">
|
||
<div class="card-header">
|
||
<span class="card-title">集数进度</span>
|
||
<span class="card-count">{{ epProgress.filter(e => e.progress_percent >= 100).length }}/{{ epProgress.length }} 集完成</span>
|
||
</div>
|
||
<div class="card-body ep-progress-body">
|
||
<div v-for="ep in epProgress" :key="ep.episode" class="ep-row">
|
||
<div class="ep-label">EP{{ String(ep.episode).padStart(2, '0') }}</div>
|
||
<div class="ep-bar-wrapper">
|
||
<div class="ep-bar-track">
|
||
<div v-for="(c, ci) in ep.contributors" :key="ci"
|
||
class="ep-bar-segment"
|
||
:style="{width: (c.seconds / ep.target_seconds * 100) + '%', background: memberColor(c.name)}"
|
||
:title="c.name + ' ' + formatSecs(c.seconds)">
|
||
</div>
|
||
</div>
|
||
<div class="ep-bar-info">
|
||
<span class="ep-percent" :class="{done: ep.progress_percent >= 100}">{{ ep.progress_percent }}%</span>
|
||
<span class="ep-total">{{ formatSecs(ep.total_seconds) }}</span>
|
||
</div>
|
||
</div>
|
||
<div class="ep-contributors" v-if="ep.contributors.length">
|
||
<span v-for="(c, ci) in ep.contributors" :key="ci" class="ep-contributor">
|
||
<span class="ep-contributor-dot" :style="{background: memberColor(c.name)}"></span>
|
||
{{ c.name }} {{ formatSecs(c.seconds) }}
|
||
</span>
|
||
</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="成员" min-width="80" />
|
||
<el-table-column label="制作" align="right" min-width="90" prop="production_seconds">
|
||
<template #default="{row}">{{ formatSecs(row.production_seconds) }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="修改" align="right" min-width="80" prop="revision_seconds">
|
||
<template #default="{row}">{{ formatSecs(row.revision_seconds) }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="通过率" align="right" min-width="75" prop="first_pass_rate">
|
||
<template #default="{row}">
|
||
<span :style="{color: row.first_pass_rate < 75 ? '#F56C6C' : row.first_pass_rate >= 90 ? '#67C23A' : ''}">
|
||
{{ row.first_pass_rate }}%
|
||
</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="工时" align="right" min-width="60" prop="total_hours">
|
||
<template #default="{row}">{{ row.total_hours }}h</template>
|
||
</el-table-column>
|
||
<el-table-column label="日均产出" align="right" min-width="90" prop="daily_net_output">
|
||
<template #default="{row}">{{ formatSecs(row.daily_net_output) }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="效率" align="right" min-width="75" prop="efficiency_rate">
|
||
<template #default="{row}">
|
||
<span class="rate-badge" :class="{success: row.efficiency_rate >= 0, danger: row.efficiency_rate < 0}">
|
||
{{ row.efficiency_rate > 0 ? '+' : '' }}{{ row.efficiency_rate }}%
|
||
</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="熟练度" align="center" min-width="70" prop="proficiency_grade">
|
||
<template #default="{row}">
|
||
<el-tag size="small" :type="row.proficiency_grade === 'S+' || row.proficiency_grade === 'S' ? 'success' : row.proficiency_grade === 'A' ? '' : row.proficiency_grade === 'B' ? 'warning' : 'danger'">
|
||
{{ row.proficiency_grade }}
|
||
</el-tag>
|
||
</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>
|
||
<span class="legend-item"><span class="legend-dot type-方案"></span>方案</span>
|
||
<span class="legend-item"><span class="legend-dot type-QC"></span>QC</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' : s.work_type === '修改' ? 'danger' : s.work_type === 'QC' ? 'success' : ''" 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' : s.work_type === '修改' ? 'danger' : s.work_type === 'QC' ? 'success' : ''" 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>
|
||
|
||
<!-- 里程碑编辑对话框 -->
|
||
<el-dialog v-model="showMilestoneEdit" title="编辑里程碑" width="400px" destroy-on-close>
|
||
<el-form :model="msEditForm" label-width="100px" label-position="left">
|
||
<el-form-item label="里程碑">
|
||
<span style="font-weight:600">{{ msEditForm.name }}</span>
|
||
</el-form-item>
|
||
<el-form-item label="预估工作日">
|
||
<div class="inline-field">
|
||
<el-input-number v-model="msEditForm.estimated_days" :min="1" :max="365" style="width:160px" />
|
||
<span class="field-unit">天</span>
|
||
</div>
|
||
</el-form-item>
|
||
<el-form-item label="开始日期">
|
||
<el-date-picker v-model="msEditForm.start_date" value-format="YYYY-MM-DD" placeholder="选择开始日期" style="width:100%" />
|
||
</el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="showMilestoneEdit = false">取消</el-button>
|
||
<el-button type="primary" :loading="savingMilestone" @click="handleSaveMilestone">保存</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' : row.work_type === '修改' ? 'danger' : row.work_type === 'QC' ? 'success' : ''" 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 target = p.target_total_seconds || 1
|
||
const animSecs = p.animation_seconds || 0
|
||
const w = prodWaste.value
|
||
const testSecs = w.test_waste_seconds || 0
|
||
const overSecs = w.overproduction_waste_seconds || 0
|
||
const netSecs = Math.max(0, animSecs - testSecs - overSecs)
|
||
const pct = p.progress_percent || 0
|
||
|
||
// 各段占目标的百分比(以目标为100%参考)
|
||
const netPct = Math.min(netSecs / target * 100, 100)
|
||
const testPct = testSecs / target * 100
|
||
const overPct = overSecs / target * 100
|
||
const remaining = Math.max(0, 100 - netPct - testPct - overPct)
|
||
|
||
if (progressChart) progressChart.dispose()
|
||
progressChart = echarts.init(progressChartRef.value)
|
||
|
||
const data = [
|
||
{ value: netPct, name: '有效产出', itemStyle: { color: '#3370FF' } },
|
||
]
|
||
if (testPct > 0) data.push({ value: testPct, name: '测试损耗', itemStyle: { color: '#FF9500' } })
|
||
if (overPct > 0) data.push({ value: overPct, name: '超产损耗', itemStyle: { color: '#FF3B30' } })
|
||
if (remaining > 0) data.push({ value: remaining, name: '剩余', itemStyle: { color: '#E5E6EB' } })
|
||
|
||
const centerColor = pct > 100 ? '#FF9500' : '#3370FF'
|
||
|
||
progressChart.setOption({
|
||
series: [{
|
||
type: 'pie',
|
||
radius: ['55%', '78%'],
|
||
center: ['50%', '50%'],
|
||
silent: true,
|
||
label: { show: false },
|
||
data,
|
||
}],
|
||
graphic: [{
|
||
type: 'text',
|
||
left: 'center', top: 'center',
|
||
style: {
|
||
text: pct + '%',
|
||
fontSize: 22, fontWeight: 700,
|
||
fill: centerColor,
|
||
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 preWasteHours = computed(() => project.value.phase_summary?.pre?.waste_hours || 0)
|
||
const postWasteHours = computed(() => project.value.phase_summary?.post?.waste?.days_waste_hours || 0)
|
||
const prodWaste = computed(() => project.value.phase_summary?.production?.waste || {})
|
||
const shotRepairSeconds = computed(() => project.value.phase_summary?.production?.shot_repair_seconds || 0)
|
||
const newMilestone = reactive({ pre: '', post: '' })
|
||
|
||
// ── 里程碑编辑 ──
|
||
const showMilestoneEdit = ref(false)
|
||
const savingMilestone = ref(false)
|
||
const msEditForm = reactive({ id: null, name: '', estimated_days: null, start_date: null })
|
||
|
||
function openMilestoneEdit(m) {
|
||
msEditForm.id = m.id
|
||
msEditForm.name = m.name
|
||
msEditForm.estimated_days = m.estimated_days
|
||
msEditForm.start_date = m.start_date
|
||
showMilestoneEdit.value = true
|
||
}
|
||
|
||
async function handleSaveMilestone() {
|
||
savingMilestone.value = true
|
||
try {
|
||
await projectApi.updateMilestone(msEditForm.id, {
|
||
estimated_days: msEditForm.estimated_days,
|
||
start_date: msEditForm.start_date,
|
||
})
|
||
ElMessage.success('里程碑已更新')
|
||
showMilestoneEdit.value = false
|
||
load()
|
||
} finally { savingMilestone.value = false }
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
// ── EP 集数进度 ──
|
||
const epProgress = computed(() => project.value.episode_progress || [])
|
||
|
||
const EP_COLORS = ['#3370FF', '#34C759', '#FF9500', '#FF3B30', '#AF52DE', '#5AC8FA', '#FF2D55', '#FFCC00', '#8F959E', '#007AFF', '#30D158', '#FF6482', '#BF5AF2', '#64D2FF']
|
||
const memberColorMap = {}
|
||
let colorIdx = 0
|
||
function memberColor(name) {
|
||
if (!memberColorMap[name]) {
|
||
memberColorMap[name] = EP_COLORS[colorIdx % EP_COLORS.length]
|
||
colorIdx++
|
||
}
|
||
return memberColorMap[name]
|
||
}
|
||
|
||
// ── 工具函数 ──
|
||
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 (!users.value.length) {
|
||
try { users.value = await userApi.brief() } 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 (!users.value.length) {
|
||
try { users.value = await userApi.brief() } 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); }
|
||
|
||
/* 后期产出全宽卡片 */
|
||
.post-card {
|
||
display: flex; flex-direction: column; align-items: center;
|
||
background: var(--bg-card); border: 1px solid var(--border-color);
|
||
border-radius: var(--radius-md); padding: 16px 24px; margin-bottom: 16px;
|
||
}
|
||
.post-main { display: flex; align-items: baseline; gap: 12px; }
|
||
.post-label { font-size: 12px; color: var(--text-secondary); }
|
||
.post-value { font-size: 20px; font-weight: 700; color: var(--text-primary); }
|
||
.post-breakdown { display: flex; flex-wrap: wrap; gap: 4px 16px; justify-content: center; margin-top: 6px; }
|
||
.post-breakdown-item { font-size: 12px; color: var(--text-secondary); white-space: nowrap; }
|
||
|
||
.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; }
|
||
.rate-badge.success { background: #E8F8EE; color: #34C759; }
|
||
.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);
|
||
display: flex; justify-content: space-between; align-items: center;
|
||
}
|
||
.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, .milestone-edit { opacity: 0; transition: opacity 0.15s; padding: 2px !important; }
|
||
.milestone-item:hover .milestone-del, .milestone-item:hover .milestone-edit { opacity: 1; }
|
||
.milestone-add { margin-top: 8px; }
|
||
|
||
/* 里程碑调度标签 */
|
||
.ms-badge { font-size: 11px; padding: 1px 6px; border-radius: 3px; white-space: nowrap; }
|
||
.ms-badge.est { background: #F2F3F5; color: #8F959E; }
|
||
.ms-badge.actual { background: #E8F5E9; color: #34C759; }
|
||
.ms-badge.overdue { background: #FFE8E7; color: #FF3B30; font-weight: 600; }
|
||
.ms-waste-tag { font-size: 11px; color: #FF3B30; font-weight: 500; }
|
||
.stat-sub { font-size: 12px; color: #FF9500; margin-top: 4px; font-weight: 500; }
|
||
|
||
/* 制作阶段 — 圆环 + 数据 */
|
||
.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: #FF3B30; }
|
||
.heatmap-cell.type-测试 { background: #FF9500; }
|
||
.heatmap-cell.type-方案 { background: #8F959E; }
|
||
.heatmap-cell.type-QC { background: #34C759; }
|
||
.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: #FF3B30; }
|
||
.legend-dot.type-测试 { background: #FF9500; }
|
||
.legend-dot.type-方案 { background: #8F959E; }
|
||
.legend-dot.type-QC { background: #34C759; }
|
||
|
||
/* EP 集数进度 */
|
||
.ep-progress-body { padding: 16px 20px !important; }
|
||
.ep-row { margin-bottom: 16px; }
|
||
.ep-row:last-child { margin-bottom: 0; }
|
||
.ep-label {
|
||
font-size: 13px; font-weight: 600; color: var(--text-primary);
|
||
margin-bottom: 6px;
|
||
}
|
||
.ep-bar-wrapper { display: flex; align-items: center; gap: 12px; }
|
||
.ep-bar-track {
|
||
flex: 1; height: 16px; background: #E5E6EB; border-radius: 8px;
|
||
overflow: hidden; display: flex;
|
||
}
|
||
.ep-bar-segment {
|
||
height: 100%; min-width: 2px; transition: width 0.3s;
|
||
}
|
||
.ep-bar-segment:first-child { border-radius: 8px 0 0 8px; }
|
||
.ep-bar-segment:last-child { border-radius: 0 8px 8px 0; }
|
||
.ep-bar-segment:only-child { border-radius: 8px; }
|
||
.ep-bar-info { display: flex; gap: 8px; align-items: baseline; min-width: 120px; }
|
||
.ep-percent { font-size: 13px; font-weight: 600; color: var(--text-secondary); }
|
||
.ep-percent.done { color: #34C759; }
|
||
.ep-total { font-size: 12px; color: var(--text-placeholder, #C0C4CC); }
|
||
.ep-contributors {
|
||
display: flex; flex-wrap: wrap; gap: 8px; margin-top: 4px; padding-left: 2px;
|
||
}
|
||
.ep-contributor { display: flex; align-items: center; gap: 4px; font-size: 12px; color: var(--text-secondary); }
|
||
.ep-contributor-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||
|
||
/* 悬浮提示 */
|
||
.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; }
|
||
|
||
/* ── 手机端适配 ── */
|
||
@media (max-width: 768px) {
|
||
.page-header { flex-wrap: wrap; gap: 8px; }
|
||
.page-header-left h2 { font-size: 15px; }
|
||
.page-header .el-space { flex-wrap: wrap; }
|
||
|
||
.info-grid { grid-template-columns: 1fr 1fr; }
|
||
.info-item:nth-last-child(-n+3) { border-bottom: 1px solid var(--border-light, #f0f1f2); }
|
||
.info-item:last-child { border-bottom: none; }
|
||
|
||
.stat-grid { grid-template-columns: repeat(2, 1fr); gap: 10px; }
|
||
.stat-card { padding: 14px; }
|
||
.stat-value { font-size: 18px; }
|
||
|
||
.post-card { padding: 12px 16px; }
|
||
.post-value { font-size: 16px; }
|
||
|
||
.segmented-bar { flex-direction: column; gap: 8px; }
|
||
.milestone-columns { grid-template-columns: 1fr; }
|
||
.production-ring-layout { flex-direction: column; align-items: center; }
|
||
.production-info { max-width: none; }
|
||
|
||
.ep-bar-wrapper { flex-wrap: wrap; gap: 4px 12px; }
|
||
.ep-bar-track { flex: 1 1 100%; }
|
||
.ep-bar-info { min-width: auto; }
|
||
|
||
.heatmap-body { padding: 10px !important; }
|
||
|
||
.card-body { padding: 12px; }
|
||
}
|
||
</style>
|