yeg
2026-09-09 528d52f2c7c6f911c54cae6e3f25500eab4384fe
src/views/Dashboard.vue
@@ -6,144 +6,76 @@
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">
@@ -158,12 +90,12 @@
          </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>
@@ -179,17 +111,17 @@
          </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">
@@ -205,18 +137,19 @@
      <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>
@@ -292,7 +225,7 @@
.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; }