1168 lines
31 KiB
Vue
1168 lines
31 KiB
Vue
<template>
|
||
<DefaultLayout>
|
||
<div class="task-progress-page">
|
||
<div class="dashboard-shell">
|
||
<div class="headline">
|
||
<div class="headline-text">
|
||
<p class="eyebrow">数据分析 / 任务进度</p>
|
||
<h1>全量任务进度指挥舱</h1>
|
||
<p class="subtitle">统一展示任务状态、风险和部门负载,保持与现有数据分析页面一致的视觉规范。</p>
|
||
</div>
|
||
<div class="headline-actions">
|
||
<div class="snapshot-chip">
|
||
<span>快照时间</span>
|
||
<strong>{{ snapshotTimeText }}</strong>
|
||
</div>
|
||
<a-button type="primary" @click="refreshData">
|
||
<template #icon><Icon name="refresh" size="sm" /></template>
|
||
刷新快照
|
||
</a-button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="filter-row">
|
||
<div class="filter-left">
|
||
<a-date-picker
|
||
v-model:value="selectedMonth"
|
||
picker="month"
|
||
:locale="locale"
|
||
format="YYYY年MM月"
|
||
valueFormat="YYYY-MM"
|
||
:allow-clear="true"
|
||
@change="handleMonthChange"
|
||
placeholder="选择月份"
|
||
/>
|
||
<a-select
|
||
v-model:value="dateRange"
|
||
class="filter-select"
|
||
@change="handleDateRangeChange"
|
||
:disabled="!!selectedMonth"
|
||
>
|
||
<a-select-option value="week">近一周</a-select-option>
|
||
<a-select-option value="month">近一月</a-select-option>
|
||
<a-select-option value="quarter">近三月</a-select-option>
|
||
<a-select-option value="year">近一年</a-select-option>
|
||
<a-select-option value="all">全部</a-select-option>
|
||
</a-select>
|
||
<a-select
|
||
v-model:value="selectedDeptId"
|
||
class="filter-select"
|
||
allow-clear
|
||
show-search
|
||
:filter-option="filterDept"
|
||
placeholder="全部部门"
|
||
@change="handleDeptChange"
|
||
>
|
||
<a-select-option v-for="dept in departmentOptions" :key="dept.id" :value="dept.id">
|
||
{{ dept.deptName }}
|
||
</a-select-option>
|
||
</a-select>
|
||
</div>
|
||
<div class="filter-right">
|
||
<a-button type="link" @click="resetFilters">重置筛选</a-button>
|
||
</div>
|
||
</div>
|
||
|
||
<a-spin :spinning="loading">
|
||
<div class="kpi-grid">
|
||
<div class="kpi-card" v-for="card in heroCards" :key="card.label">
|
||
<div class="kpi-label">{{ card.label }}</div>
|
||
<div class="kpi-value">{{ card.value }}</div>
|
||
<div class="kpi-meta">{{ card.meta }}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="hero-grid">
|
||
<div class="panel galaxy-panel">
|
||
<div class="panel-header">
|
||
<div>
|
||
<h3>任务银河图</h3>
|
||
<p>颜色 = 状态 · X = 创建时间 · Y = 进度百分比</p>
|
||
</div>
|
||
<a-tag color="green" v-if="galaxyCount">{{ galaxyCount }} 颗节点</a-tag>
|
||
</div>
|
||
<div class="chart-layer" ref="galaxyChartRef"></div>
|
||
<div v-if="!galaxyHasData" class="panel-empty">
|
||
<a-empty description="暂无任务样本" />
|
||
</div>
|
||
</div>
|
||
<div class="panel summary-panel">
|
||
<div class="status-block">
|
||
<div class="panel-header">
|
||
<div>
|
||
<h3>状态分布</h3>
|
||
<p>按快照聚合</p>
|
||
</div>
|
||
</div>
|
||
<div class="chart-layer" ref="statusChartRef"></div>
|
||
<div v-if="!statusHasData" class="panel-empty flat">
|
||
<a-empty description="暂无数据" />
|
||
</div>
|
||
</div>
|
||
<div class="trend-block">
|
||
<div class="panel-header">
|
||
<div>
|
||
<h3>完成趋势</h3>
|
||
<p>近 30 天创建 / 完成对比</p>
|
||
</div>
|
||
</div>
|
||
<div class="chart-layer" ref="trendChartRef"></div>
|
||
<div v-if="!trendHasData" class="panel-empty flat">
|
||
<a-empty description="暂无趋势" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="secondary-grid">
|
||
<div class="panel stage-panel">
|
||
<div class="panel-header">
|
||
<div>
|
||
<h3>流程耗时断面</h3>
|
||
<p>审批 - 制作 - 评审 分段均耗</p>
|
||
</div>
|
||
</div>
|
||
<div class="stage-list">
|
||
<div class="stage-item" v-for="segment in stageSegments" :key="segment.stage">
|
||
<div class="stage-label">
|
||
<span>{{ segment.stage }}</span>
|
||
<strong>{{ formatHours(segment.avgHours) }}</strong>
|
||
</div>
|
||
<div class="stage-bar">
|
||
<div class="stage-bar-fill" :style="{ width: segment.percentage + '%'}"></div>
|
||
</div>
|
||
<div class="stage-percentage">{{ segment.percentage.toFixed(1) }}%</div>
|
||
</div>
|
||
</div>
|
||
<ul class="insight-list">
|
||
<li v-for="insight in insightTips" :key="insight">{{ insight }}</li>
|
||
</ul>
|
||
</div>
|
||
<div class="panel risk-panel">
|
||
<div class="panel-header">
|
||
<div>
|
||
<h3>风险雷达墙</h3>
|
||
<p>优先展示即将超时与已超时任务</p>
|
||
</div>
|
||
<a-tag color="orange" v-if="riskCards.length">{{ riskCards.length }} 项</a-tag>
|
||
</div>
|
||
<div class="risk-grid" v-if="riskCards.length">
|
||
<div
|
||
v-for="risk in riskCards"
|
||
:key="risk.taskId"
|
||
class="risk-card"
|
||
:class="risk.severity === 'danger' ? 'danger' : 'warning'"
|
||
>
|
||
<div class="risk-meta">
|
||
<span class="risk-type">{{ riskBadge(risk) }}</span>
|
||
<button class="ghost-link" @click="copyTaskNo(risk.taskNo)">复制编号</button>
|
||
</div>
|
||
<h4>{{ risk.title }}</h4>
|
||
<p class="risk-dept">{{ risk.deptName }} · {{ risk.ownerName || '未分配' }}</p>
|
||
<p class="risk-reason">{{ risk.warningReason }}</p>
|
||
<p class="risk-deadline">截止:{{ formatDateTime(risk.deadline) }}</p>
|
||
</div>
|
||
</div>
|
||
<div v-else class="panel-empty flat">
|
||
<a-empty description="暂无风险任务" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="panel dept-panel">
|
||
<div class="panel-header">
|
||
<div>
|
||
<h3>部门热力矩阵</h3>
|
||
<p>按照 Workload Index 排序,气泡越亮代表负载越高</p>
|
||
</div>
|
||
</div>
|
||
<div class="dept-grid">
|
||
<div
|
||
v-for="dept in deptHeatmap"
|
||
:key="dept.deptId"
|
||
class="dept-card"
|
||
:style="{ background: workloadToBackground(dept.workloadIndex) }"
|
||
>
|
||
<div class="dept-title">
|
||
<h4>{{ dept.deptName }}</h4>
|
||
<span class="badge">WI {{ dept.workloadIndex.toFixed(1) }}</span>
|
||
</div>
|
||
<div class="dept-metrics">
|
||
<div>
|
||
<span>待处理</span>
|
||
<strong>{{ dept.pendingCount }}</strong>
|
||
</div>
|
||
<div>
|
||
<span>进行中</span>
|
||
<strong>{{ dept.inProgressCount }}</strong>
|
||
</div>
|
||
<div>
|
||
<span>超时</span>
|
||
<strong>{{ dept.overdueCount }}</strong>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="panel task-panel">
|
||
<div class="panel-header">
|
||
<div>
|
||
<h3>关键任务序列</h3>
|
||
<p>按最新创建时间取前 20 个任务,可直接复制编号</p>
|
||
</div>
|
||
<a-tag color="green" v-if="taskTableData.length">TOP {{ taskTableData.length }}</a-tag>
|
||
</div>
|
||
<a-table
|
||
:columns="taskColumns"
|
||
:data-source="taskTableData"
|
||
row-key="taskId"
|
||
:pagination="{ pageSize: 10 }"
|
||
size="middle"
|
||
class="dark-table"
|
||
>
|
||
<template #bodyCell="{ column, record }">
|
||
<template v-if="column.key === 'title'">
|
||
<div class="task-title">
|
||
<span class="task-no" @click="copyTaskNo(record.taskNo)">{{ record.taskNo }}</span>
|
||
<span>{{ record.title }}</span>
|
||
</div>
|
||
</template>
|
||
<template v-else-if="column.key === 'status'">
|
||
<span class="status-pill" :style="{ background: resolveStatusColor(record.status) }">
|
||
{{ statusLabel(record.status) }}
|
||
</span>
|
||
</template>
|
||
<template v-else-if="column.key === 'progress'">
|
||
<div class="progress-bar">
|
||
<div class="progress-bar-fill" :style="{ width: (record.progress || 0) + '%' }"></div>
|
||
<span>{{ record.progress || 0 }}%</span>
|
||
</div>
|
||
</template>
|
||
<template v-else-if="column.key === 'deadline'">
|
||
{{ formatDateTime(record.deadline) }}
|
||
</template>
|
||
<template v-else-if="column.key === 'priority'">
|
||
<span class="priority-pill" :class="priorityClass(record.priority)">
|
||
{{ priorityLabel(record.priority) }}
|
||
</span>
|
||
</template>
|
||
<template v-else-if="column.key === 'risk'">
|
||
<span :class="record.overdue ? 'risk-flag danger' : record.nearDeadline ? 'risk-flag warning' : 'risk-flag muted'">
|
||
{{ record.overdue ? '超时' : record.nearDeadline ? '临近' : '正常' }}
|
||
</span>
|
||
</template>
|
||
<template v-else>
|
||
{{ record[column.dataIndex] || '-' }}
|
||
</template>
|
||
</template>
|
||
</a-table>
|
||
</div>
|
||
</a-spin>
|
||
</div>
|
||
</div>
|
||
</DefaultLayout>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||
import DefaultLayout from '@/layouts/DefaultLayout.vue'
|
||
import Icon from '@/components/Icon.vue'
|
||
import { message } from 'ant-design-vue'
|
||
import * as echarts from 'echarts'
|
||
import dayjs from 'dayjs'
|
||
import 'dayjs/locale/zh-cn'
|
||
import zhCN from 'ant-design-vue/es/locale/zh_CN'
|
||
import type { DepartmentOverview } from '@/api/department'
|
||
import { getDepartmentList } from '@/api/department'
|
||
import {
|
||
getTaskProgressOverview,
|
||
type TaskProgressOverview,
|
||
type TaskProgressMetrics,
|
||
type TaskProgressStageSegment,
|
||
type TaskProgressDepartmentLoad,
|
||
type TaskProgressTaskPoint,
|
||
type TaskProgressRiskTask
|
||
} from '@/api/analytics'
|
||
|
||
const locale = zhCN
|
||
const selectedMonth = ref<string | null>(null)
|
||
const dateRange = ref('month')
|
||
const selectedDeptId = ref<number | undefined>(undefined)
|
||
const departmentOptions = ref<DepartmentOverview[]>([])
|
||
const loading = ref(false)
|
||
const overview = ref<TaskProgressOverview | null>(null)
|
||
|
||
const galaxyChartRef = ref<HTMLDivElement | null>(null)
|
||
const statusChartRef = ref<HTMLDivElement | null>(null)
|
||
const trendChartRef = ref<HTMLDivElement | null>(null)
|
||
let galaxyChart: echarts.ECharts | null = null
|
||
let statusChart: echarts.ECharts | null = null
|
||
let trendChart: echarts.ECharts | null = null
|
||
|
||
dayjs.locale('zh-cn')
|
||
|
||
const emptyMetrics: TaskProgressMetrics = {
|
||
totalTasks: 0,
|
||
pendingTasks: 0,
|
||
inProgressTasks: 0,
|
||
reviewTasks: 0,
|
||
completedTasks: 0,
|
||
overdueTasks: 0,
|
||
completionRate: 0,
|
||
onTimeRate: 0,
|
||
avgCycleHours: 0,
|
||
riskTaskCount: 0
|
||
}
|
||
|
||
const metrics = computed(() => overview.value?.metrics || emptyMetrics)
|
||
const heroCards = computed(() => [
|
||
{ label: '完成率', value: formatPercent(metrics.value.completionRate), meta: `${metrics.value.completedTasks} 已完结` },
|
||
{ label: '准时率', value: formatPercent(metrics.value.onTimeRate), meta: '基于近 30 天' },
|
||
{ label: '风险任务', value: metrics.value.riskTaskCount, meta: `${metrics.value.overdueTasks} 已超时` },
|
||
{ label: '平均周期', value: `${metrics.value.avgCycleHours.toFixed(1)}h`, meta: '发起至完结' },
|
||
{ label: '待处理', value: metrics.value.pendingTasks, meta: `${metrics.value.inProgressTasks} 进行中` },
|
||
{ label: '总任务', value: metrics.value.totalTasks, meta: `${metrics.value.reviewTasks} 待审核` }
|
||
])
|
||
|
||
const snapshotTimeText = computed(() => {
|
||
if (!overview.value?.snapshotTime) {
|
||
return '待生成'
|
||
}
|
||
return dayjs(overview.value.snapshotTime).format('YYYY-MM-DD HH:mm')
|
||
})
|
||
|
||
const stageSegments = computed<TaskProgressStageSegment[]>(() => overview.value?.stageSegments || [])
|
||
const riskCards = computed<TaskProgressRiskTask[]>(() => (overview.value?.riskTasks || []).slice(0, 6))
|
||
const deptHeatmap = computed<TaskProgressDepartmentLoad[]>(() => (overview.value?.departmentLoads || []).slice(0, 6))
|
||
const deptMaxWorkload = computed(() => {
|
||
const loads = overview.value?.departmentLoads || []
|
||
if (!loads.length) return 1
|
||
return Math.max(...loads.map(load => load.workloadIndex || 0), 1)
|
||
})
|
||
const taskTableData = computed<TaskProgressTaskPoint[]>(() => (overview.value?.taskPoints || []).slice(0, 20))
|
||
const galaxyCount = computed(() => overview.value?.taskPoints?.length || 0)
|
||
const galaxyHasData = computed(() => galaxyCount.value > 0)
|
||
const statusHasData = computed(() => Object.keys(overview.value?.statusDistribution || {}).length > 0)
|
||
const trendHasData = computed(() => (overview.value?.trend || []).length > 0)
|
||
|
||
const insightTips = computed(() => {
|
||
const tips: string[] = []
|
||
if (metrics.value.overdueTasks > 0) {
|
||
tips.push(`存在 ${metrics.value.overdueTasks} 个已超时任务,建议优先协调资源。`)
|
||
}
|
||
if (metrics.value.inProgressTasks > metrics.value.completedTasks) {
|
||
tips.push('进行中任务量高于已完结,关注流转效率。')
|
||
}
|
||
if ((stageSegments.value[1]?.percentage || 0) > 50) {
|
||
tips.push('制作环节占比超过 50%,可量化拆分子阶段。')
|
||
}
|
||
if (!tips.length) {
|
||
tips.push('整体运行平稳,保持当前节奏。')
|
||
}
|
||
return tips
|
||
})
|
||
|
||
const taskColumns = [
|
||
{ title: '任务', dataIndex: 'title', key: 'title' },
|
||
{ title: '部门', dataIndex: 'deptName', key: 'deptName' },
|
||
{ title: '负责人', dataIndex: 'receiverName', key: 'receiverName' },
|
||
{ title: '状态', dataIndex: 'status', key: 'status', width: 120 },
|
||
{ title: '进度', dataIndex: 'progress', key: 'progress', width: 180 },
|
||
{ title: '截止', dataIndex: 'deadline', key: 'deadline', width: 180 },
|
||
{ title: '优先级', dataIndex: 'priority', key: 'priority', width: 120 },
|
||
{ title: '风险', dataIndex: 'risk', key: 'risk', width: 120 }
|
||
]
|
||
|
||
const refreshData = async () => {
|
||
loading.value = true
|
||
try {
|
||
const data = await getTaskProgressOverview({
|
||
dateRange: dateRange.value,
|
||
month: selectedMonth.value || undefined,
|
||
deptId: selectedDeptId.value
|
||
})
|
||
overview.value = data
|
||
await nextTick()
|
||
renderCharts()
|
||
} catch (error) {
|
||
console.error('加载任务进度总览失败', error)
|
||
message.error('加载任务进度总览失败')
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
const fetchDepartments = async () => {
|
||
try {
|
||
const list = await getDepartmentList({ status: 1 })
|
||
if (Array.isArray(list)) {
|
||
departmentOptions.value = list
|
||
} else if (Array.isArray(list?.data)) {
|
||
departmentOptions.value = list.data
|
||
}
|
||
} catch (error) {
|
||
console.warn('获取部门列表失败', error)
|
||
}
|
||
}
|
||
|
||
const handleMonthChange = (value: string | null) => {
|
||
selectedMonth.value = value
|
||
refreshData()
|
||
}
|
||
|
||
const handleDeptChange = (value: number | undefined) => {
|
||
selectedDeptId.value = value
|
||
refreshData()
|
||
}
|
||
|
||
const handleDateRangeChange = (value: string) => {
|
||
dateRange.value = value
|
||
if (!selectedMonth.value) {
|
||
refreshData()
|
||
}
|
||
}
|
||
|
||
const resetFilters = () => {
|
||
selectedMonth.value = null
|
||
dateRange.value = 'month'
|
||
selectedDeptId.value = undefined
|
||
refreshData()
|
||
}
|
||
|
||
const filterDept = (input: string, option: any) => {
|
||
return option?.children?.toLowerCase().includes(input.toLowerCase())
|
||
}
|
||
|
||
const copyTaskNo = async (taskNo?: string) => {
|
||
if (!taskNo) return
|
||
try {
|
||
await navigator.clipboard.writeText(taskNo)
|
||
message.success('任务编号已复制')
|
||
} catch (error) {
|
||
console.error('复制失败', error)
|
||
}
|
||
}
|
||
|
||
const workloadToBackground = (value: number) => {
|
||
const ratio = Math.min(1, value / (deptMaxWorkload.value || 1))
|
||
const alpha = 0.08 + ratio * 0.24
|
||
return `linear-gradient(135deg, rgba(49,240,139,${alpha}), rgba(255,255,255,0.02))`
|
||
}
|
||
|
||
const priorityLabel = (priority?: number) => {
|
||
switch (priority) {
|
||
case 3:
|
||
return '特急'
|
||
case 2:
|
||
return '高'
|
||
case 1:
|
||
return '中'
|
||
default:
|
||
return '普通'
|
||
}
|
||
}
|
||
|
||
const priorityClass = (priority?: number) => {
|
||
if (priority === 3) return 'danger'
|
||
if (priority === 2) return 'warning'
|
||
if (priority === 1) return 'info'
|
||
return 'muted'
|
||
}
|
||
|
||
const statusMap: Record<string, { label: string; color: string }> = {
|
||
pending: { label: '待处理', color: '#ffa940' },
|
||
pending_audit: { label: '待审核', color: '#ffa940' },
|
||
pending_demand_approval: { label: '需求审核', color: '#ffa940' },
|
||
pending_receiving_approval: { label: '接单审核', color: '#ffa940' },
|
||
in_progress: { label: '进行中', color: '#3c7eff' },
|
||
review: { label: '评审中', color: '#a855f7' },
|
||
submitted: { label: '待确认', color: '#a855f7' },
|
||
completed: { label: '已完结', color: '#31f08b' }
|
||
}
|
||
|
||
const resolveStatusColor = (status?: string) => statusMap[status || '']?.color || 'rgba(255,255,255,0.2)'
|
||
const statusLabel = (status?: string) => statusMap[status || '']?.label || '其他'
|
||
|
||
const formatPercent = (value: number) => `${value.toFixed(1)}%`
|
||
const formatHours = (value: number) => `${value.toFixed(1)}h`
|
||
const formatDateTime = (value?: string) => (value ? dayjs(value).format('MM-DD HH:mm') : '--')
|
||
|
||
const riskBadge = (risk: TaskProgressRiskTask) => (risk.severity === 'danger' ? '⚠ 超时' : '△ 预警')
|
||
|
||
const disposeCharts = () => {
|
||
galaxyChart?.dispose()
|
||
statusChart?.dispose()
|
||
trendChart?.dispose()
|
||
galaxyChart = null
|
||
statusChart = null
|
||
trendChart = null
|
||
}
|
||
|
||
const renderCharts = () => {
|
||
renderGalaxyChart()
|
||
renderStatusChart()
|
||
renderTrendChart()
|
||
}
|
||
|
||
const renderGalaxyChart = () => {
|
||
if (!galaxyChartRef.value) return
|
||
if (!galaxyChart) {
|
||
galaxyChart = echarts.init(galaxyChartRef.value)
|
||
}
|
||
const points = overview.value?.taskPoints || []
|
||
if (!points.length) {
|
||
galaxyChart.clear()
|
||
return
|
||
}
|
||
const scatterData = points.map(point => ({
|
||
value: [
|
||
point.createTime ? dayjs(point.createTime).valueOf() : Date.now(),
|
||
point.progress || 0,
|
||
Number(point.score || 1)
|
||
],
|
||
symbolSize: 6 + Math.min(12, Number(point.score || 1)),
|
||
itemStyle: {
|
||
color: resolveStatusColor(point.status),
|
||
shadowBlur: 18,
|
||
shadowColor: resolveStatusColor(point.status)
|
||
},
|
||
task: point
|
||
}))
|
||
galaxyChart.setOption({
|
||
backgroundColor: 'transparent',
|
||
tooltip: {
|
||
trigger: 'item',
|
||
backgroundColor: '#1e1e1e',
|
||
borderColor: 'rgba(255,255,255,0.16)',
|
||
textStyle: { color: '#e5e5e5' },
|
||
formatter: (params: any) => {
|
||
const task = params.data.task as TaskProgressTaskPoint
|
||
return `任务:${task.title}<br/>状态:${statusLabel(task.status)}<br/>积分:${task.score || '-'}<br/>进度:${task.progress || 0}%`
|
||
}
|
||
},
|
||
xAxis: {
|
||
type: 'time',
|
||
axisLine: { lineStyle: { color: 'rgba(255,255,255,0.16)' } },
|
||
axisLabel: { color: 'rgba(255,255,255,0.6)' },
|
||
splitLine: { lineStyle: { color: 'rgba(255,255,255,0.06)' } }
|
||
},
|
||
yAxis: {
|
||
type: 'value',
|
||
min: 0,
|
||
max: 100,
|
||
axisLabel: { color: 'rgba(255,255,255,0.6)', formatter: '{value}%' },
|
||
axisLine: { lineStyle: { color: 'rgba(255,255,255,0.16)' } },
|
||
splitLine: { lineStyle: { color: 'rgba(255,255,255,0.06)' } }
|
||
},
|
||
series: [
|
||
{
|
||
type: 'scatter',
|
||
name: '任务',
|
||
symbol: 'circle',
|
||
data: scatterData,
|
||
emphasis: {
|
||
scale: 1.1
|
||
}
|
||
}
|
||
]
|
||
})
|
||
}
|
||
|
||
const renderStatusChart = () => {
|
||
if (!statusChartRef.value) return
|
||
if (!statusChart) {
|
||
statusChart = echarts.init(statusChartRef.value)
|
||
}
|
||
const distribution = overview.value?.statusDistribution || {}
|
||
const entries = Object.entries(distribution)
|
||
if (!entries.length) {
|
||
statusChart.clear()
|
||
return
|
||
}
|
||
statusChart.setOption({
|
||
backgroundColor: 'transparent',
|
||
tooltip: { trigger: 'item' },
|
||
legend: { show: false },
|
||
series: [
|
||
{
|
||
type: 'pie',
|
||
radius: ['45%', '80%'],
|
||
startAngle: 220,
|
||
itemStyle: { borderColor: '#1e1e1e', borderWidth: 2 },
|
||
label: {
|
||
color: '#e5e5e5',
|
||
formatter: '{b}\n{d}%'
|
||
},
|
||
data: entries.map(([status, value]) => ({
|
||
name: statusLabel(status),
|
||
value,
|
||
itemStyle: { color: resolveStatusColor(status) }
|
||
}))
|
||
}
|
||
]
|
||
})
|
||
}
|
||
|
||
const renderTrendChart = () => {
|
||
if (!trendChartRef.value) return
|
||
if (!trendChart) {
|
||
trendChart = echarts.init(trendChartRef.value)
|
||
}
|
||
const trend = overview.value?.trend || []
|
||
if (!trend.length) {
|
||
trendChart.clear()
|
||
return
|
||
}
|
||
trendChart.setOption({
|
||
backgroundColor: 'transparent',
|
||
tooltip: { trigger: 'axis' },
|
||
legend: {
|
||
data: ['创建', '完结'],
|
||
textStyle: { color: '#e5e5e5' }
|
||
},
|
||
grid: { left: 30, right: 20, top: 30, bottom: 20 },
|
||
xAxis: {
|
||
type: 'category',
|
||
data: trend.map(item => item.date),
|
||
axisLabel: { color: 'rgba(255,255,255,0.6)' },
|
||
axisLine: { lineStyle: { color: 'rgba(255,255,255,0.16)' } }
|
||
},
|
||
yAxis: {
|
||
type: 'value',
|
||
axisLabel: { color: 'rgba(255,255,255,0.6)' },
|
||
splitLine: { lineStyle: { color: 'rgba(255,255,255,0.06)' } }
|
||
},
|
||
series: [
|
||
{
|
||
name: '创建',
|
||
type: 'line',
|
||
smooth: true,
|
||
showSymbol: false,
|
||
areaStyle: { color: 'rgba(49,240,139,0.12)' },
|
||
lineStyle: { color: '#31f08b' },
|
||
data: trend.map(item => item.createdCount)
|
||
},
|
||
{
|
||
name: '完结',
|
||
type: 'line',
|
||
smooth: true,
|
||
showSymbol: false,
|
||
areaStyle: { color: 'rgba(96,165,250,0.12)' },
|
||
lineStyle: { color: '#60a5fa' },
|
||
data: trend.map(item => item.completedCount)
|
||
}
|
||
]
|
||
})
|
||
}
|
||
|
||
const handleResize = () => {
|
||
galaxyChart?.resize()
|
||
statusChart?.resize()
|
||
trendChart?.resize()
|
||
}
|
||
|
||
onMounted(() => {
|
||
refreshData()
|
||
fetchDepartments()
|
||
window.addEventListener('resize', handleResize)
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
window.removeEventListener('resize', handleResize)
|
||
disposeCharts()
|
||
})
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
@import "@/styles/variables.scss";
|
||
|
||
.task-progress-page {
|
||
min-height: 100vh;
|
||
padding: $space-xl 0;
|
||
background: $bg-primary;
|
||
}
|
||
|
||
.dashboard-shell {
|
||
max-width: $container-max-width;
|
||
margin: 0 auto;
|
||
padding: 0 $space-xl $space-xxl;
|
||
}
|
||
|
||
.headline {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
gap: $space-lg;
|
||
margin-bottom: $space-xl;
|
||
}
|
||
|
||
.eyebrow {
|
||
color: $text-secondary;
|
||
font-size: $font-size-sm;
|
||
margin-bottom: $space-xs;
|
||
}
|
||
|
||
.headline h1 {
|
||
margin: 0;
|
||
font-size: $font-size-2xl;
|
||
color: $text-white;
|
||
}
|
||
|
||
.subtitle {
|
||
margin-top: $space-xs;
|
||
font-size: $font-size-base;
|
||
color: $text-secondary;
|
||
max-width: 700px;
|
||
}
|
||
|
||
.headline-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: $space-sm;
|
||
}
|
||
|
||
.snapshot-chip {
|
||
padding: 10px 14px;
|
||
border-radius: $radius-md;
|
||
border: 1px solid $border-primary;
|
||
background: $bg-card;
|
||
min-width: 180px;
|
||
}
|
||
|
||
.snapshot-chip span {
|
||
color: $text-secondary;
|
||
font-size: $font-size-sm;
|
||
}
|
||
|
||
.snapshot-chip strong {
|
||
display: block;
|
||
color: $text-primary;
|
||
font-size: $font-size-lg;
|
||
}
|
||
|
||
.filter-row {
|
||
background: $bg-card;
|
||
border: 1px solid $border-primary;
|
||
border-radius: $radius-lg;
|
||
padding: $space-md;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: $space-md;
|
||
margin-bottom: $space-lg;
|
||
}
|
||
|
||
.filter-left {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: $space-sm;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.filter-select {
|
||
width: 160px;
|
||
}
|
||
|
||
.kpi-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||
gap: $space-sm;
|
||
margin-bottom: $space-lg;
|
||
}
|
||
|
||
.kpi-card {
|
||
background: $bg-card;
|
||
border: 1px solid $border-primary;
|
||
border-radius: $radius-lg;
|
||
padding: $space-md;
|
||
}
|
||
|
||
.kpi-label {
|
||
color: $text-secondary;
|
||
font-size: $font-size-sm;
|
||
}
|
||
|
||
.kpi-value {
|
||
margin-top: 2px;
|
||
font-size: $font-size-xl;
|
||
font-weight: $font-weight-semibold;
|
||
color: $text-primary;
|
||
}
|
||
|
||
.kpi-meta {
|
||
margin-top: $space-xs;
|
||
color: $text-tertiary;
|
||
font-size: $font-size-xs;
|
||
}
|
||
|
||
.hero-grid,
|
||
.secondary-grid {
|
||
display: grid;
|
||
grid-template-columns: 2fr 1fr;
|
||
gap: $space-md;
|
||
margin-bottom: $space-md;
|
||
}
|
||
|
||
.secondary-grid {
|
||
grid-template-columns: 1fr 1fr;
|
||
}
|
||
|
||
.panel {
|
||
background: $bg-card;
|
||
border: 1px solid $border-primary;
|
||
border-radius: $radius-lg;
|
||
padding: $space-md;
|
||
}
|
||
|
||
.task-panel {
|
||
margin-top: $space-md;
|
||
}
|
||
|
||
.panel-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
margin-bottom: $space-sm;
|
||
}
|
||
|
||
.panel-header h3 {
|
||
margin: 0;
|
||
font-size: $font-size-lg;
|
||
color: $text-primary;
|
||
}
|
||
|
||
.panel-header p {
|
||
margin: 2px 0 0;
|
||
font-size: $font-size-sm;
|
||
color: $text-secondary;
|
||
}
|
||
|
||
.chart-layer {
|
||
position: relative;
|
||
height: 280px;
|
||
}
|
||
|
||
.galaxy-panel .chart-layer {
|
||
height: 360px;
|
||
}
|
||
|
||
.panel-empty {
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
min-height: 220px;
|
||
}
|
||
|
||
.panel-empty.flat {
|
||
min-height: 120px;
|
||
}
|
||
|
||
.summary-panel {
|
||
display: grid;
|
||
gap: $space-sm;
|
||
}
|
||
|
||
.stage-list {
|
||
display: grid;
|
||
gap: $space-sm;
|
||
}
|
||
|
||
.stage-item {
|
||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||
background: $bg-secondary;
|
||
border-radius: $radius-md;
|
||
padding: $space-sm $space-md;
|
||
}
|
||
|
||
.stage-label {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
font-size: $font-size-sm;
|
||
}
|
||
|
||
.stage-bar {
|
||
height: 6px;
|
||
border-radius: 999px;
|
||
background: rgba(255, 255, 255, 0.08);
|
||
margin: 6px 0;
|
||
}
|
||
|
||
.stage-bar-fill {
|
||
height: 100%;
|
||
border-radius: inherit;
|
||
background: linear-gradient(90deg, $brand-primary, #60a5fa);
|
||
}
|
||
|
||
.stage-percentage {
|
||
font-size: $font-size-xs;
|
||
color: $text-secondary;
|
||
}
|
||
|
||
.insight-list {
|
||
margin: $space-md 0 0;
|
||
padding-left: 16px;
|
||
color: $text-secondary;
|
||
font-size: $font-size-sm;
|
||
}
|
||
|
||
.risk-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: $space-sm;
|
||
}
|
||
|
||
.risk-card {
|
||
border: 1px solid $border-primary;
|
||
background: $bg-secondary;
|
||
border-radius: $radius-md;
|
||
padding: $space-sm $space-md;
|
||
}
|
||
|
||
.risk-card h4 {
|
||
margin: $space-xs 0;
|
||
font-size: $font-size-base;
|
||
color: $text-primary;
|
||
}
|
||
|
||
.risk-card p {
|
||
margin: 0;
|
||
font-size: $font-size-sm;
|
||
color: $text-secondary;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.risk-card .risk-reason {
|
||
margin-top: $space-xs;
|
||
}
|
||
|
||
.risk-card.danger {
|
||
border-color: rgba(239, 68, 68, 0.45);
|
||
}
|
||
|
||
.risk-card.warning {
|
||
border-color: rgba(245, 158, 11, 0.4);
|
||
}
|
||
|
||
.risk-meta {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
font-size: $font-size-xs;
|
||
color: $text-secondary;
|
||
}
|
||
|
||
.ghost-link {
|
||
background: none;
|
||
border: none;
|
||
color: $brand-primary;
|
||
cursor: pointer;
|
||
font-size: $font-size-xs;
|
||
}
|
||
|
||
.dept-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||
gap: $space-sm;
|
||
}
|
||
|
||
.dept-card {
|
||
border-radius: $radius-md;
|
||
border: 1px solid $border-primary;
|
||
padding: $space-sm $space-md;
|
||
}
|
||
|
||
.dept-title {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
|
||
.dept-title h4 {
|
||
margin: 0;
|
||
font-size: $font-size-base;
|
||
color: $text-primary;
|
||
}
|
||
|
||
.badge {
|
||
background: rgba(49, 240, 139, 0.14);
|
||
color: $brand-primary;
|
||
border-radius: 999px;
|
||
padding: 2px 8px;
|
||
font-size: $font-size-xs;
|
||
}
|
||
|
||
.dept-metrics {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||
gap: $space-sm;
|
||
margin-top: $space-sm;
|
||
}
|
||
|
||
.dept-metrics span {
|
||
display: block;
|
||
color: $text-secondary;
|
||
font-size: $font-size-xs;
|
||
}
|
||
|
||
.dept-metrics strong {
|
||
display: block;
|
||
color: $text-primary;
|
||
font-size: $font-size-lg;
|
||
}
|
||
|
||
.task-title {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
}
|
||
|
||
.task-no {
|
||
color: $text-secondary;
|
||
font-size: $font-size-xs;
|
||
cursor: pointer;
|
||
|
||
&:hover {
|
||
color: $brand-primary;
|
||
}
|
||
}
|
||
|
||
.status-pill {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
padding: 2px 10px;
|
||
border-radius: 999px;
|
||
color: $text-white;
|
||
font-size: $font-size-xs;
|
||
}
|
||
|
||
.progress-bar {
|
||
position: relative;
|
||
height: 6px;
|
||
border-radius: 999px;
|
||
background: rgba(255, 255, 255, 0.08);
|
||
}
|
||
|
||
.progress-bar-fill {
|
||
position: absolute;
|
||
inset: 0 auto 0 0;
|
||
border-radius: inherit;
|
||
background: $brand-primary;
|
||
}
|
||
|
||
.progress-bar span {
|
||
position: absolute;
|
||
right: 0;
|
||
top: -18px;
|
||
font-size: $font-size-xs;
|
||
color: $text-secondary;
|
||
}
|
||
|
||
.priority-pill,
|
||
.risk-flag {
|
||
font-size: $font-size-xs;
|
||
padding: 2px 8px;
|
||
border-radius: $radius-sm;
|
||
}
|
||
|
||
.priority-pill.danger,
|
||
.risk-flag.danger {
|
||
color: #ff6b6b;
|
||
background: rgba(255, 107, 107, 0.14);
|
||
}
|
||
|
||
.priority-pill.warning,
|
||
.risk-flag.warning {
|
||
color: #f6b454;
|
||
background: rgba(245, 158, 11, 0.14);
|
||
}
|
||
|
||
.priority-pill.info {
|
||
color: #60a5fa;
|
||
background: rgba(96, 165, 250, 0.14);
|
||
}
|
||
|
||
.priority-pill.muted,
|
||
.risk-flag.muted {
|
||
color: $text-secondary;
|
||
background: rgba(255, 255, 255, 0.08);
|
||
}
|
||
|
||
.dark-table {
|
||
:deep(.ant-table) {
|
||
background: transparent !important;
|
||
}
|
||
|
||
:deep(.ant-table-thead > tr > th) {
|
||
background: rgba(0, 0, 0, 0.32) !important;
|
||
border: none !important;
|
||
color: rgba(255, 255, 255, 0.66) !important;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
:deep(.ant-table-tbody > tr > td) {
|
||
border: none !important;
|
||
color: rgba(255, 255, 255, 0.86) !important;
|
||
font-size: 13px;
|
||
background: transparent !important;
|
||
}
|
||
|
||
:deep(.ant-table-tbody > tr:nth-child(even) > td) {
|
||
background: rgba(255, 255, 255, 0.015) !important;
|
||
}
|
||
|
||
:deep(.ant-table-tbody > tr:hover > td) {
|
||
background: rgba(49, 240, 139, 0.06) !important;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 1200px) {
|
||
.kpi-grid {
|
||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||
}
|
||
|
||
.hero-grid,
|
||
.secondary-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.dept-grid {
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
}
|
||
}
|
||
|
||
@media (max-width: $breakpoint-mobile) {
|
||
.dashboard-shell {
|
||
padding: 0 $space-md $space-xxl;
|
||
}
|
||
|
||
.headline {
|
||
flex-direction: column;
|
||
margin-bottom: $space-lg;
|
||
}
|
||
|
||
.headline h1 {
|
||
font-size: $font-size-xl;
|
||
}
|
||
|
||
.headline-actions {
|
||
width: 100%;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.filter-row {
|
||
padding: $space-sm;
|
||
}
|
||
|
||
.kpi-grid {
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
}
|
||
|
||
.risk-grid,
|
||
.dept-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
</style>
|