airlabs-manage/frontend/src/views/ProjectDetail.vue
seaislee1209 f9016ab2af
All checks were successful
Build and Deploy Backend / build-and-deploy (push) Successful in 3m4s
Build and Deploy Web / build-and-deploy (push) Successful in 17m17s
feat: 项目详情显示修补镜头 + 产出时长必填验证
- 项目详情中期模块显示修补镜头(后期秒数,归入制作产出计算)
- 产出时长改为必填项(非前期内容必须 > 0)
- 前端+后端双重验证,防止提交0秒产出
- 删除"无产出秒数的工作可填 0"过时提示

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-02 22:41:20 +08:00

1218 lines
53 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.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>