<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 type { DashboardTest } from '@/api/dashboard'
|
import { useDashboard, clearDashboard } from '@/stores/dashboard'
|
import { getUserInfo } from '@/api/auth'
|
import { getCurrentUser } from '@/utils/auth'
|
import { useTodos, clearTodos } from '@/stores/todos'
|
import { formatBusinessTime } from '@/utils/businessTime'
|
use([CanvasRenderer, BarChart, GridComponent, TooltipComponent])
|
const router = useRouter()
|
const activeTab = ref('running'); const chartPeriod = ref<'week' | 'month' | 'year'>('week')
|
const { snapshot, load: loadDashboard } = useDashboard()
|
const dashboardError = ref(''); const todoError = ref('')
|
const { records: todoRecords, load: loadTodos } = useTodos()
|
const permissions = ref<string[]>([])
|
const has = (permission: string) => permissions.value.includes('*:*:*') || permissions.value.includes(permission)
|
const displayName = computed(() => getCurrentUser()?.displayName || getCurrentUser()?.username || '')
|
async function loadPage() {
|
dashboardError.value = ''; todoError.value = ''
|
try {
|
permissions.value = (await getUserInfo()).permissions || []
|
const results = await Promise.allSettled([loadDashboard(), has('todo:list:query') || has('todo:list:list') ? loadTodos() : Promise.resolve()])
|
const main = results[0]; const todos = results[1]
|
if (main.status === 'rejected') dashboardError.value = main.reason instanceof Error ? main.reason.message : '首页数据加载失败'
|
if (todos.status === 'rejected') todoError.value = todos.reason instanceof Error ? todos.reason.message : '待办加载失败'
|
} catch (error) { clearTodos(); clearDashboard(); dashboardError.value = error instanceof Error ? error.message : '首页加载失败' }
|
}
|
onMounted(loadPage)
|
const testFilterVisible = ref(false); const testType = ref('全部'); const testTypeDraft = ref('全部')
|
const cabinetFilterVisible = ref(false); const cabinetKeyword = ref(''); const cabinetKeywordDraft = ref('')
|
const records = computed(() => snapshot.value?.tests || [])
|
const testTypeOptions = computed(() => [...new Set(records.value.map(item => item.type))].filter(Boolean).sort((a,b) => a.localeCompare(b,'zh-CN')))
|
const statusLabels: Record<string,string> = { WAITING:'待测试', RUNNING:'检测中', PAUSED:'暂停', COMPLETED:'检测完成' }
|
const tabs = [{ k:'running', code:'RUNNING', name:'进行中' }, { k:'pending', code:'WAITING', name:'待测试' }, { k:'paused', code:'PAUSED', name:'暂停' }, { k:'done', code:'COMPLETED', name:'最近完成' }]
|
const testTabs = computed(() => tabs.map(tab => ({ k:tab.k, l:tab.name + ' (' + records.value.filter(item => item.status === tab.code).length + ')' })))
|
const tests = computed(() => records.value.filter(item => item.status === tabs.find(tab => tab.k === activeTab.value)?.code && (testType.value === '全部' || item.type === testType.value)).slice(0,8).map(item => ({ ...item, displayStatus: statusLabels[item.status] || '未知' })))
|
const cabinets = computed(() => snapshot.value?.cabinets || [])
|
const visibleCabinets = computed(() => cabinets.value.filter(item => item.name.toLocaleLowerCase().includes(cabinetKeyword.value.trim().toLocaleLowerCase())))
|
const runningTestCount = computed(() => records.value.filter(item => item.status === 'RUNNING').length)
|
const pendingTestCount = computed(() => records.value.filter(item => item.status === 'WAITING').length)
|
const cabinetStatusSummary = computed(() => [
|
{label:'在线',value:cabinets.value.filter(item => item.onlineStatus === '在线').length,color:'#23865e'},
|
{label:'离线',value:cabinets.value.filter(item => item.onlineStatus === '离线').length,color:'#8795a3'},
|
])
|
const pendingTodos = computed(() => (has('todo:list:query') || has('todo:list:list') ? todoRecords.value : []).filter(item => item.status === '待处理'))
|
const todoList = computed(() => pendingTodos.value.slice(0,6))
|
const warnings = computed(() => snapshot.value?.warnings || [])
|
const chartOption = computed(() => {
|
const data = snapshot.value?.trends[chartPeriod.value] || { labels:[],values:[] }
|
return { animation:false, tooltip:{trigger:'axis'}, 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',minInterval:1,axisLine:{show:false},axisTick:{show:false},splitLine:{lineStyle:{color:'#e6edf3'}},axisLabel:{color:'#7b8794',fontSize:11}},
|
series:[{type:'bar',barMaxWidth:28,data:data.values.map((value,index) => ({value,itemStyle:{color:index === data.values.length-1 ? '#2188f5' : '#b9d8f8'}}))}] }
|
})
|
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: DashboardTest) { 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>欢迎回来,{{ displayName }}</h2>
|
<p>当前 {{ runningTestCount }} 项测试进行中,{{ pendingTestCount }} 项等待执行</p>
|
</div>
|
<button v-if="has('system:test:add')" 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.id">
|
<td>{{ item.name }}</td>
|
<td><span class="state" :class="item.status === 'PAUSED' ? '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.usedChannels }} / {{ item.channelCount }}</td><td>{{ item.testCount }}</td><td>{{ item.batteryCount }}</td><td><button class="text-action" type="button" @click="openCabinets">查看</button></td></tr></tbody>
|
</table>
|
</div>
|
</section>
|
|
<div class="metrics">
|
<article><span>总测试数</span><strong>{{ snapshot?.summary.testCount ?? '—' }}</strong></article>
|
<article><span>总电池组数</span><strong>{{ snapshot?.summary.batteryGroupCount ?? '—' }}</strong></article>
|
<article><span>日新增测试</span><strong>{{ snapshot?.summary.todayCount ?? '—' }}</strong></article>
|
<article><span>月新增测试</span><strong>{{ snapshot?.summary.monthCount ?? '—' }}</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">{{ warnings.length }} OPEN</span></header>
|
<div v-if="warnings.length" class="todo-list"><div v-for="item in warnings.slice(0,6)" :key="item.id"><span><strong>{{ item.testName }}</strong><small>{{ item.item }} · {{ formatBusinessTime(item.occurredAt) }}</small></span><button class="text-action" @click="router.push('/exception')">查看</button></div></div>
|
<div v-else class="alert-empty"><el-icon><CircleCheck /></el-icon><strong>{{ snapshot ? '暂无待处理异常' : '暂无已加载数据' }}</strong></div>
|
</section>
|
|
<section class="panel cabinet-panel">
|
<header class="panel-header"><div class="panel-title"><i></i><h3>测试柜情况</h3></div><span class="panel-code">{{ cabinets.length }} 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>待办 ({{ pendingTodos.length }})</h3></div><button class="text-action" type="button" @click="router.push('/todo-list')">全部</button></header>
|
<p v-if="todoError" role="alert">{{ todoError }}</p><p v-else-if="!has('todo:list:query') && !has('todo:list:list')">无待办查看权限</p><div v-else class="todo-list"><div v-for="item in todoList" :key="item.id"><span><strong>{{ item.content }}</strong><small>{{ item.operatedAt }} · 待处理</small></span></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; }
|
|
|
@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>
|