<script setup lang="ts">
|
import { computed, onMounted, ref } from 'vue'
|
import { useRouter } from 'vue-router'
|
import { use } from 'echarts/core'
|
import { CanvasRenderer } from 'echarts/renderers'
|
import { BarChart } from 'echarts/charts'
|
import { GridComponent, TooltipComponent } from 'echarts/components'
|
import VChart from 'vue-echarts'
|
import { useTests, type TestRecord } from '@/stores/tests'
|
import { useTodos } from '@/stores/todos'
|
import { getTestList } from '@/api/tests'
|
import { getBatteryGroupList } from '@/api/batteryGroups'
|
import { getCurrentUser } from '@/utils/auth'
|
|
use([CanvasRenderer, BarChart, GridComponent, TooltipComponent])
|
|
const activeTab = ref('running')
|
const chartPeriod = ref('week')
|
const router = useRouter()
|
const { records } = useTests()
|
const { records: todoRecords } = useTodos()
|
const currentOwner = computed(() => getCurrentUser()?.displayName || getCurrentUser()?.username || 'Admin')
|
const dashboardError = ref('')
|
const hongKongKey = (date: Date) => {
|
const parts = new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Hong_Kong', year: 'numeric', month: '2-digit', day: '2-digit' }).formatToParts(date)
|
const value = (type: string) => parts.find(item => item.type === type)?.value || ''
|
return `${value('year')}-${value('month')}-${value('day')}`
|
}
|
onMounted(async () => {
|
try {
|
const [tests, groups] = await Promise.all([
|
getTestList({ pageNum: 1, pageSize: 1000 }),
|
getBatteryGroupList({ pageNum: 1, pageSize: 1000 }),
|
])
|
records.value = tests.rows
|
// 首页的电池组总数来自后端列表,避免用页面种子推断业务事实。
|
batteryGroupCount.value = groups.total
|
} catch {
|
dashboardError.value = '首页数据加载失败,请刷新后重试'
|
records.value = []
|
batteryGroupCount.value = 0
|
}
|
})
|
const batteryGroupCount = ref(0)
|
const testFilterVisible = ref(false)
|
const testType = ref('全部')
|
const testTypeDraft = ref('全部')
|
const cabinetFilterVisible = ref(false)
|
const cabinetKeyword = ref('')
|
const cabinetKeywordDraft = ref('')
|
|
const testTypeOptions = computed(() => [...new Set(records.value.map(item => item.type))].sort((a, b) => a.localeCompare(b, 'zh-CN')))
|
|
const testTabs = computed(() => [
|
{ k: 'running', l: `进行中 (${records.value.filter(item => item.status === '检测中').length})` },
|
{ k: 'pending', l: `待测试 (${records.value.filter(item => item.status === '待测试').length})` },
|
{ k: 'paused', l: `暂停 (${records.value.filter(item => item.status === '暂停').length})` },
|
{ k: 'done', l: `最近完成 (${records.value.filter(item => item.status === '检测完成').length})` },
|
])
|
|
const tests = computed(() => records.value.filter(item => {
|
const statusMatches = activeTab.value === 'running' ? item.status === '检测中'
|
: activeTab.value === 'pending' ? item.status === '待测试'
|
: activeTab.value === 'paused' ? item.status === '暂停' : item.status === '检测完成'
|
return statusMatches && (testType.value === '全部' || item.type === testType.value)
|
}).slice(0, 8).map((item, index) => ({ ...item, displayStatus: item.status, step: `工步 ${8 + index * 2} / 循环 ${3 + index}`, warning: 0 })))
|
|
const cabinets = [
|
{ id: 1, name: 'A-01 检测柜', capacity: '120 kWh', port: '24 / 32', test: 8, battery: 96, status: '在线' },
|
{ id: 2, name: 'A-02 检测柜', capacity: '96 kWh', port: '30 / 32', test: 12, battery: 120, status: '在线' },
|
{ id: 3, name: 'B-01 检测柜', capacity: '80 kWh', port: '18 / 24', test: 6, battery: 72, status: '离线' },
|
]
|
const visibleCabinets = computed(() => {
|
const keyword = cabinetKeyword.value.trim().toLocaleLowerCase()
|
return cabinets.filter(item => !keyword || item.name.toLocaleLowerCase().includes(keyword))
|
})
|
const runningTestCount = computed(() => records.value.filter(item => item.status === '检测中').length)
|
const pendingTestCount = computed(() => records.value.filter(item => item.status === '待测试').length)
|
|
const cabinetStatusSummary = computed(() => [
|
{ label: '在线', value: cabinets.filter(item => item.status === '在线').length, color: '#23865e' },
|
{ label: '离线', value: cabinets.filter(item => item.status === '离线').length, color: '#8795a3' },
|
{ label: '待测试', value: records.value.filter(item => item.status === '待测试').length, color: '#b77a24' },
|
{ label: '进行中', value: records.value.filter(item => item.status === '检测中').length, color: '#2188f5' },
|
{ label: '暂停', value: records.value.filter(item => item.status === '暂停').length, color: '#b34f4f' },
|
])
|
|
const todoList = computed(() => todoRecords.value.filter(item => item.owner === currentOwner.value && item.status === '待处理').slice(0, 6))
|
|
const periodData = computed(() => {
|
const now = new Date()
|
const dayKey = (date: Date) => hongKongKey(date)
|
const count = (key: string) => records.value.filter(item => item.createdAt.slice(0, key.length) === key).length
|
const weekLabels: string[] = []; const weekValues: number[] = []
|
for (let index = 6; index >= 0; index -= 1) { const date = new Date(now); date.setDate(now.getDate() - index); const key = dayKey(date); weekLabels.push(`${date.getMonth() + 1}/${date.getDate()}`); weekValues.push(count(key)) }
|
const monthKey = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
|
const monthDays = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate(); const monthLabels: string[] = []; const monthValues: number[] = []
|
for (let day = 1; day <= Math.min(now.getDate(), monthDays); day += 1) { const key = `${monthKey}-${String(day).padStart(2, '0')}`; monthLabels.push(`${day}日`); monthValues.push(count(key)) }
|
const yearLabels: string[] = []; const yearValues: number[] = []
|
for (let month = 1; month <= now.getMonth() + 1; month += 1) { const key = `${now.getFullYear()}-${String(month).padStart(2, '0')}`; yearLabels.push(`${month}月`); yearValues.push(count(key)) }
|
return { week: { labels: weekLabels, values: weekValues }, month: { labels: monthLabels, values: monthValues }, year: { labels: yearLabels, values: yearValues } }
|
})
|
|
const chartOption = computed(() => {
|
const data = periodData.value[chartPeriod.value as keyof typeof periodData.value]
|
const values = data.values
|
return {
|
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
|
grid: { left: 8, right: 10, bottom: 4, top: 22, containLabel: true },
|
xAxis: {
|
type: 'category', data: data.labels,
|
axisLine: { lineStyle: { color: '#d8e6f1' } }, axisTick: { show: false },
|
axisLabel: { color: '#7b8794', fontSize: 11 },
|
},
|
yAxis: {
|
type: 'value', axisLine: { show: false }, axisTick: { show: false },
|
splitLine: { lineStyle: { color: '#e6edf3' } }, axisLabel: { color: '#7b8794', fontSize: 11 },
|
},
|
series: [{
|
type: 'bar', barMaxWidth: 28,
|
data: values.map((value, index) => ({ value, itemStyle: { color: index === values.length - 1 ? '#2188f5' : '#b9d8f8' } })),
|
itemStyle: { borderRadius: 0 },
|
}],
|
}
|
})
|
|
function createTest() {
|
router.push('/test-list/new')
|
}
|
|
function applyTestFilter() { testType.value = testTypeDraft.value; testFilterVisible.value = false }
|
function applyCabinetFilter() { cabinetKeyword.value = cabinetKeywordDraft.value.trim(); cabinetFilterVisible.value = false }
|
function openTest(item: TestRecord & { warning: number }) { router.push(item.warning ? '/exception' : `/test-list/${item.id}/console`) }
|
function openCabinets() { router.push('/device-cabinet') }
|
</script>
|
|
<template>
|
<div class="dashboard">
|
<el-alert v-if="dashboardError" :title="dashboardError" type="error" :closable="false" show-icon />
|
<section class="welcome-row">
|
<div>
|
<h2>欢迎回来,Admin</h2>
|
<p>当前 {{ runningTestCount }} 项测试进行中,{{ pendingTestCount }} 项等待执行</p>
|
</div>
|
<button class="primary-action" type="button" @click="createTest"><el-icon><Plus /></el-icon>新建测试</button>
|
</section>
|
|
<div class="dashboard-grid">
|
<div class="primary-column">
|
<section class="panel test-panel">
|
<header class="panel-header">
|
<div class="panel-title"><i></i><h3>测试情况</h3></div>
|
<div class="panel-actions"><button type="button" @click="testTypeDraft=testType;testFilterVisible=true"><el-icon><Filter /></el-icon>筛选</button><button type="button" @click="router.push('/test-list')">查看全部</button></div>
|
</header>
|
<div class="tabs" role="tablist">
|
<button v-for="tab in testTabs" :key="tab.k" type="button" :class="{ active: activeTab === tab.k }" @click="activeTab = tab.k">{{ tab.l }}</button>
|
</div>
|
<div class="table-scroll">
|
<table>
|
<thead><tr><th>测试名称</th><th>状态</th><th>工步 / 循环</th><th>警告</th><th>操作</th></tr></thead>
|
<tbody>
|
<tr v-for="item in tests" :key="item.name">
|
<td>{{ item.name }}</td>
|
<td><span class="state" :class="item.status === '暂停' ? 'paused' : ''"><i></i>{{ item.displayStatus }}</span></td>
|
<td>{{ item.step }}</td>
|
<td :class="{ danger: item.warning }">{{ item.warning }}</td>
|
<td><button class="text-action" type="button" @click="openTest(item)">{{ item.warning ? '处理' : '查看' }}</button></td>
|
</tr>
|
</tbody>
|
</table>
|
</div>
|
</section>
|
|
<section class="panel usage-panel">
|
<header class="panel-header">
|
<div class="panel-title"><i></i><h3>测试柜使用情况</h3></div>
|
<div class="panel-actions"><button type="button" @click="cabinetKeywordDraft=cabinetKeyword;cabinetFilterVisible=true"><el-icon><Filter /></el-icon>筛选</button><button type="button" @click="openCabinets">设备详情</button></div>
|
</header>
|
<div class="table-scroll">
|
<table>
|
<thead><tr><th>测试柜</th><th>容量</th><th>端口</th><th>测试</th><th>电池</th><th>操作</th></tr></thead>
|
<tbody><tr v-for="item in visibleCabinets" :key="item.name"><td>{{ item.name }}</td><td>{{ item.capacity }}</td><td>{{ item.port }}</td><td>{{ item.test }}</td><td>{{ item.battery }}</td><td><button class="text-action" type="button" @click="openCabinets">查看</button></td></tr></tbody>
|
</table>
|
</div>
|
</section>
|
|
<div class="metrics">
|
<article><span>总测试数</span><strong>{{ records.length }}</strong></article>
|
<article><span>总电池组数</span><strong>{{ batteryGroupCount }}</strong></article>
|
<article><span>日新增测试</span><strong>{{ records.filter(item => item.createdAt.slice(0, 10) === hongKongKey(new Date())).length }}</strong></article>
|
<article><span>月新增测试</span><strong>{{ records.filter(item => item.createdAt.slice(0, 7) === hongKongKey(new Date()).slice(0, 7)).length }}</strong></article>
|
</div>
|
|
<section class="panel chart-panel">
|
<header class="panel-header">
|
<div class="panel-title"><i></i><h3>新测试数(次)<span>/ NEW TESTS</span></h3></div>
|
<div class="period-switch">
|
<button v-for="period in [{k:'week',l:'近7日'},{k:'month',l:'本月'},{k:'year',l:'今年'}]" :key="period.k" type="button" :class="{ active: chartPeriod === period.k }" @click="chartPeriod = period.k">{{ period.l }}</button>
|
</div>
|
</header>
|
<v-chart class="chart" :option="chartOption" autoresize />
|
</section>
|
</div>
|
|
<aside class="secondary-column">
|
<section class="panel alert-panel">
|
<header class="panel-header"><div class="panel-title"><i></i><h3>预警通知</h3></div><span class="open-count">0 OPEN</span></header>
|
<div class="alert-empty"><el-icon><CircleCheck /></el-icon><strong>暂无异常</strong><span>当前检测任务运行稳定</span></div>
|
</section>
|
|
<section class="panel cabinet-panel">
|
<header class="panel-header"><div class="panel-title"><i></i><h3>测试柜情况</h3></div><span class="panel-code">36 UNITS</span></header>
|
<div class="cabinet-list"><div v-for="item in cabinetStatusSummary" :key="item.label"><span><i :style="{ background: item.color }"></i>{{ item.label }}</span><strong :style="{ color: item.color }">{{ item.value }}</strong></div></div>
|
</section>
|
|
<section class="panel todo-panel">
|
<header class="panel-header"><div class="panel-title"><i></i><h3>待办 ({{ todoList.length }})</h3></div><button class="text-action" type="button" @click="router.push('/todo-list')">全部</button></header>
|
<div class="todo-list"><div v-for="item in todoList" :key="item.id"><span><strong>{{ item.content }}</strong><small>{{ item.operatedAt }} · 待处理</small></span><b class="pending">待处理</b></div></div>
|
</section>
|
</aside>
|
</div>
|
|
<el-dialog v-model="testFilterVisible" title="筛选测试" width="460px" align-center><div class="dashboard-dialog-form"><label><span>测试类型</span><el-select v-model="testTypeDraft"><el-option label="全部" value="全部" /><el-option v-for="item in testTypeOptions" :key="item" :label="item" :value="item" /></el-select></label></div><template #footer><button class="secondary-dialog-button" type="button" @click="testTypeDraft='全部';testType='全部';testFilterVisible=false">重置</button><button class="primary-dialog-button" type="button" @click="applyTestFilter"><el-icon><Search /></el-icon>查询</button></template></el-dialog>
|
<el-dialog v-model="cabinetFilterVisible" title="筛选测试柜" width="460px" align-center><div class="dashboard-dialog-form"><label><span>测试柜名称</span><el-input v-model="cabinetKeywordDraft" clearable placeholder="输入测试柜名称" @keyup.enter="applyCabinetFilter" /></label></div><template #footer><button class="secondary-dialog-button" type="button" @click="cabinetKeywordDraft='';cabinetKeyword='';cabinetFilterVisible=false">重置</button><button class="primary-dialog-button" type="button" @click="applyCabinetFilter"><el-icon><Search /></el-icon>查询</button></template></el-dialog>
|
</div>
|
</template>
|
|
<style scoped>
|
.dashboard { width: 100%; }
|
.welcome-row { height: 62px; display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
|
.welcome-row h2 { margin: 0 0 4px; color: var(--ink); font-size: 24px; font-weight: 600; }
|
.welcome-row p { margin: 0; color: var(--muted); font-size: 13px; }
|
.primary-action { height: 36px; display: inline-flex; align-items: center; gap: 7px; padding: 0 14px; border: 0; color: #fff; background: var(--blue); font-size: 13px; font-weight: 500; cursor: pointer; }
|
.primary-action:hover { background: #176fcb; }
|
.dashboard-grid { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 16px; }
|
.primary-column, .secondary-column { display: flex; min-width: 0; flex-direction: column; gap: 16px; }
|
.panel { overflow: hidden; background: #fff; border: 1px solid #d8e1e9; }
|
.panel-header { height: 50px; display: flex; align-items: center; justify-content: space-between; padding: 0 16px; background: var(--surface-soft); border-bottom: 1px solid #dde2e7; }
|
.panel-title { display: flex; align-items: center; gap: 9px; }
|
.panel-title > i { width: 3px; height: 16px; background: #2b78a3; }
|
.panel-title h3 { margin: 0; color: var(--ink); font-size: 16px; font-weight: 600; }
|
.panel-title h3 span { color: #7b8794; font-size: 10px; font-weight: 500; }
|
.panel-actions { display: flex; align-items: center; gap: 8px; }
|
.panel-actions button, .text-action { border: 0; color: var(--blue); background: transparent; font-size: 13px; font-weight: 500; cursor: pointer; }
|
.panel-actions button:first-child { height: 30px; display: flex; align-items: center; gap: 5px; padding: 0 10px; background: #eaf4ff; border: 1px solid #c7e0ff; border-radius: 4px; }
|
.tabs { height: 42px; display: flex; align-items: stretch; gap: 24px; padding: 0 16px; border-bottom: 1px solid #dde2e7; }
|
.tabs button { border: 0; border-bottom: 2px solid transparent; color: #697682; background: transparent; font-size: 13px; cursor: pointer; }
|
.tabs button.active { color: var(--blue); border-bottom-color: var(--blue); font-weight: 600; }
|
.table-scroll { overflow-x: auto; }
|
table { width: 100%; border-collapse: collapse; table-layout: fixed; color: #52606d; font-size: 13px; }
|
th, td { height: 40px; padding: 0 12px; text-align: left; border-right: 1px solid #dde2e7; border-bottom: 1px solid #dde2e7; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
th { color: #52606d; background: #eaf4ff; border-color: #c7e0ff; font-size: 12px; font-weight: 600; }
|
tbody td { height: 48px; }
|
tbody tr:last-child td { border-bottom: 0; }
|
th:last-child, td:last-child { width: 76px; border-right: 0; text-align: center; }
|
.test-panel th:first-child { width: 36%; }
|
.usage-panel th:first-child { width: 30%; }
|
.state { display: inline-flex; align-items: center; gap: 6px; color: var(--success); }
|
.state i { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
.state.paused { color: #b77a24; }
|
.danger { color: #b34f4f; font-weight: 600; }
|
.metrics { display: grid; grid-template-columns: repeat(4, 1fr); border: 1px solid #d8e1e9; background: #fff; }
|
.metrics article { position: relative; min-width: 0; height: 84px; display: flex; flex-direction: column; justify-content: center; padding: 0 16px; border-right: 1px solid #dde2e7; }
|
.metrics article:last-child { border-right: 0; }
|
.metrics span { color: var(--muted); font-size: 11px; }
|
.metrics small { position: absolute; top: 15px; right: 14px; color: var(--success); font-size: 10px; }
|
.metrics strong { margin-top: 6px; color: var(--ink); font-size: 24px; font-weight: 600; }
|
.chart { width: 100%; height: 240px; }
|
.period-switch { display: flex; border: 1px solid #cbd9e5; }
|
.period-switch button { height: 28px; padding: 0 11px; border: 0; border-right: 1px solid #cbd9e5; color: #697682; background: #fff; font-size: 11px; cursor: pointer; }
|
.period-switch button:last-child { border-right: 0; }
|
.period-switch button.active { color: #fff; background: var(--blue); }
|
.dashboard-dialog-form{display:grid;gap:14px}.dashboard-dialog-form label{display:grid;grid-template-columns:82px minmax(0,1fr);align-items:center;gap:10px;color:#52606d;font-size:12px}.dashboard-dialog-form :deep(.el-select){width:100%}.primary-dialog-button,.secondary-dialog-button{height:34px;min-width:82px;border:1px solid;border-radius:3px;font-size:13px;cursor:pointer}.primary-dialog-button{color:#fff;background:var(--blue);border-color:var(--blue)}.secondary-dialog-button{color:#52606d;background:#fff;border-color:var(--line-strong)}
|
.open-count, .panel-code { color: #7b8794; font-size: 10px; }
|
.open-count { padding: 3px 6px; border: 1px solid #cbd9e5; }
|
.secondary-column .panel-header { height: 48px; }
|
.alert-empty { height: 112px; display: flex; align-items: center; justify-content: center; flex-direction: column; color: #7b8794; }
|
.alert-empty .el-icon { color: #42aa7b; font-size: 32px; }
|
.alert-empty strong { margin-top: 8px; color: #52606d; font-size: 13px; font-weight: 500; }
|
.alert-empty span { margin-top: 4px; font-size: 10px; }
|
.cabinet-list { padding: 0 14px; }
|
.cabinet-list > div { height: 40px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid #edf1f4; font-size: 12px; }
|
.cabinet-list > div:last-child { border-bottom: 0; }
|
.cabinet-list span { display: flex; align-items: center; gap: 8px; color: #697682; }
|
.cabinet-list i { width: 3px; height: 14px; }
|
.cabinet-list strong { font-size: 13px; font-weight: 600; }
|
.todo-list { padding: 0 14px; }
|
.todo-list > div { height: 70px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid #edf1f4; }
|
.todo-list > div:last-child { border-bottom: 0; }
|
.todo-list strong, .todo-list small { display: block; }
|
.todo-list strong { color: #35414d; font-size: 13px; font-weight: 500; }
|
.todo-list small { margin-top: 4px; color: #8a96a1; font-size: 11px; }
|
.todo-list b { padding: 2px 5px; border: 1px solid; font-size: 10px; font-weight: 600; }
|
.todo-list b.p1 { color: #b34f4f; }.todo-list b.p2 { color: #b77a24; }
|
|
@media (max-width: 1100px) {
|
.dashboard-grid { grid-template-columns: 1fr; }
|
.secondary-column { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); align-items: start; }
|
}
|
|
@media (max-width: 720px) {
|
.welcome-row { height: auto; min-height: 68px; gap: 12px; }
|
.welcome-row h2 { font-size: 17px; }
|
.welcome-row p { max-width: 240px; line-height: 1.5; }
|
.primary-action { flex: 0 0 auto; }
|
.metrics { grid-template-columns: repeat(2, 1fr); }
|
.metrics article:nth-child(2) { border-right: 0; }
|
.metrics article:nth-child(-n+2) { border-bottom: 1px solid #dde2e7; }
|
.secondary-column { display: flex; }
|
.panel-header { padding: 0 12px; }
|
.panel-title h3 { font-size: 12px; }
|
table { min-width: 640px; }
|
}
|
|
@media (max-width: 420px) {
|
.welcome-row p { display: none; }
|
.tabs { gap: 14px; overflow-x: auto; }
|
}
|
</style>
|