| | |
| | | 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 type { DashboardTest } from '@/api/dashboard' |
| | | import { useDashboard } 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 activeTab = ref('running') |
| | | const chartPeriod = ref('week') |
| | | const router = useRouter() |
| | | const { records } = useTests() |
| | | const activeTab = ref('running'); const chartPeriod = ref<'week' | 'month' | 'year'>('week') |
| | | const { snapshot, load: loadDashboard } = useDashboard() |
| | | const dashboardError = ref(''); const todoError = ref(''); const loading = ref(false) |
| | | const { records: todoRecords, load: loadTodos } = 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 () => { |
| | | 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() { |
| | | loading.value = true; dashboardError.value = ''; todoError.value = '' |
| | | try { |
| | | await loadTodos() |
| | | 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') |
| | | 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(); dashboardError.value = error instanceof Error ? error.message : '首页加载失败' } |
| | | finally { loading.value = false } |
| | | } |
| | | |
| | | 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: TestRecord & { warning: number }) { router.push(item.warning ? '/exception' : `/test-list/${item.id}/console`) } |
| | | 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 /> |
| | | <el-button :loading="loading" @click="loadPage">刷新</el-button> |
| | | <section class="welcome-row"> |
| | | <div> |
| | | <h2>欢迎回来,Admin</h2> |
| | | <h2>欢迎回来,{{ displayName }}</h2> |
| | | <p>当前 {{ runningTestCount }} 项测试进行中,{{ pendingTestCount }} 项等待执行</p> |
| | | </div> |
| | | <button class="primary-action" type="button" @click="createTest"><el-icon><Plus /></el-icon>新建测试</button> |
| | | <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> |
| | | <div class="table-scroll"> |
| | | <table> |
| | | <thead><tr><th>测试名称</th><th>状态</th><th>工步 / 循环</th><th>警告</th><th>操作</th></tr></thead> |
| | | <thead><tr><th>测试名称</th><th>状态</th><th>最近记录工步 / 循环</th><th>警告</th><th>操作</th></tr></thead> |
| | | <tbody> |
| | | <tr v-for="item in tests" :key="item.name"> |
| | | <tr v-for="item in tests" :key="item.id"> |
| | | <td>{{ item.name }}</td> |
| | | <td><span class="state" :class="item.status === '暂停' ? 'paused' : ''"><i></i>{{ item.displayStatus }}</span></td> |
| | | <td>{{ item.step }}</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> |
| | |
| | | </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> |
| | | <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>{{ 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> |
| | | <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"> |
| | |
| | | |
| | | <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> |
| | | <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">36 UNITS</span></header> |
| | | <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>待办 ({{ 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> |
| | | <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> |
| | |
| | | .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; } |