From aaf8a50511d77dbc209ca93bbba308c21179a8bc Mon Sep 17 00:00:00 2001
From: zhou zhou <3272660260@qq.com>
Date: 星期二, 31 三月 2026 15:38:47 +0800
Subject: [PATCH] #前端

---
 rsf-design/src/views/dashboard/console/index.vue |  542 +++++++++++++++++++++++++++++++++--------------------
 1 files changed, 338 insertions(+), 204 deletions(-)

diff --git a/rsf-design/src/views/dashboard/console/index.vue b/rsf-design/src/views/dashboard/console/index.vue
index 90691e1..1a2f229 100644
--- a/rsf-design/src/views/dashboard/console/index.vue
+++ b/rsf-design/src/views/dashboard/console/index.vue
@@ -1,145 +1,145 @@
 <template>
-  <div class="art-full-height flex flex-col gap-5">
-    <section
-      class="overflow-hidden rounded-3xl border border-white/10 bg-[linear-gradient(135deg,var(--art-main-bg-color),var(--art-card-bg-color))] p-6 shadow-[0_24px_80px_rgba(15,23,42,0.08)]"
-    >
-      <div class="flex flex-col gap-6 lg:flex-row lg:items-end lg:justify-between">
-        <div class="max-w-3xl">
-          <div
-            class="mb-3 inline-flex items-center gap-2 rounded-full border border-emerald-500/20 bg-emerald-500/10 px-3 py-1 text-xs font-medium text-emerald-600"
-          >
-            <span class="size-2 rounded-full bg-emerald-500"></span>
-            RSF Phase 1 Landing
+  <div class="art-full-height flex flex-col gap-6">
+    <section class="grid gap-6 md:grid-cols-2 xl:grid-cols-4" v-loading="sectionLoading.summary">
+      <div
+        v-for="item in summaryCardItems"
+        :key="item.title"
+        class="art-card flex items-start justify-between rounded-3xl px-7 py-6"
+      >
+        <div class="min-w-0 pr-6">
+          <p class="text-sm font-medium text-g-700">{{ item.title }}</p>
+          <ArtCountTo class="mt-3 block text-[2.3rem] font-semibold leading-none text-g-900" :target="item.count" :duration="1400" />
+          <div class="mt-4 flex items-center gap-2 text-sm">
+            <span :class="item.metaTone">{{ item.metaLabel }}</span>
+            <span class="text-g-500">{{ item.metaValue }}</span>
           </div>
-          <h1
-            class="m-0 text-3xl font-semibold tracking-tight text-[var(--art-gray-900)] md:text-4xl"
-          >
-            杩愯楠ㄦ灦宸茬粡鍒囧埌 `rsf-design`
-          </h1>
-          <p class="mt-4 max-w-2xl text-sm leading-7 text-[var(--art-gray-600)] md:text-base">
-            褰撳墠鍏ュ彛宸茬粡鎺ュ叆鐪熷疄鍚庣鐧诲綍銆佸姩鎬佽彍鍗曞拰鏉冮檺閾捐矾銆傝繖涓椤靛彧灞曠ず宸茬粡鍙敤鐨� phase-1
-            鑳藉姏锛屼笉鍐嶄繚鐣欐ā鏉块噷鐨勭ず渚嬪浘琛ㄥ拰婕旂ず鏁版嵁銆�
-          </p>
         </div>
-
-        <div class="rounded-2xl border border-white/10 bg-white/70 px-4 py-3 backdrop-blur-sm">
-          <p class="text-xs uppercase tracking-[0.24em] text-[var(--art-gray-500)]"
-            >Current Entry</p
-          >
-          <p class="mt-2 text-lg font-semibold text-[var(--art-gray-900)]">
-            {{ currentUserName }}
-          </p>
-          <p class="mt-1 text-sm text-[var(--art-gray-600)]">
-            {{ currentUserRoleText }} 路 {{ currentMenuLabel }}
-          </p>
+        <div class="flex size-13 shrink-0 items-center justify-center rounded-2xl" :class="item.iconBoxClass">
+          <ArtSvgIcon :icon="item.icon" class="text-2xl" :class="item.iconClass" />
         </div>
       </div>
     </section>
 
-    <section class="grid gap-4 md:grid-cols-3">
-      <ArtStatsCard
-        title="宸叉帴鍏ュ悗绔�"
-        :count="backendSwitchCount"
-        description="鐧诲綍銆佺敤鎴蜂俊鎭�佽彍鍗曞叏閮ㄦ潵鑷� rsf-server"
-        icon="ri:server-line"
-      />
-      <ArtStatsCard
-        title="鍔ㄦ�佽彍鍗�"
-        :count="visibleMenuCount"
-        description="浠呭彂甯� phase-1 鍏佽杩涘叆鐨勬柊鍏ュ彛"
-        icon="ri:route-line"
-      />
-      <ArtStatsCard
-        title="鏉冮檺閾捐矾"
-        :count="permissionSignalCount"
-        description="瑙掕壊涓庢潈闄愯妭鐐瑰凡浠庣湡瀹炵敤鎴锋暟鎹仮澶�"
-        icon="ri:shield-check-line"
-      />
+    <section class="grid gap-6 xl:grid-cols-[1.35fr_1fr]">
+      <div class="art-card h-115 overflow-hidden p-6 box-border">
+        <div class="art-card-header">
+          <div class="title">
+            <h4>杩� 30 澶╁嚭鍏ュ簱瓒嬪娍</h4>
+            <p>鐪熷疄閾捐矾 <span class="text-success">宸叉帴閫�</span></p>
+          </div>
+        </div>
+        <div class="h-[calc(100%-4.5rem)]">
+          <ArtBarChart
+            height="22rem"
+            :loading="sectionLoading.trend"
+            :data="trendChartSeries"
+            :x-axis-data="trendChartXAxisData"
+            :show-axis-line="false"
+            :show-legend="true"
+            :show-split-line="true"
+            legend-position="top"
+            bar-width="38%"
+          />
+        </div>
+      </div>
+
+      <div class="art-card h-115 overflow-hidden p-6 box-border" v-loading="sectionLoading.locUsage">
+        <div class="art-card-header">
+          <div class="title">
+            <h4>搴撲綅浣跨敤鍒嗗竷</h4>
+            <p>{{ usageLegendCount }} 涓淮搴�</p>
+          </div>
+        </div>
+        <div class="grid h-[calc(100%-4.5rem)] gap-6 lg:grid-cols-[1fr_0.95fr] lg:items-center">
+          <ArtRingChart
+            height="21rem"
+            :data="locUsageList"
+            center-text="搴撲綅鍗犳瘮"
+            :show-legend="false"
+            :show-label="false"
+          />
+
+          <div class="space-y-1">
+            <div
+              v-for="item in usageLegend"
+              :key="item.name"
+              class="flex items-center justify-between border-b border-[var(--art-border-color)] py-4 last:border-b-0"
+            >
+              <div class="flex items-center gap-3">
+                <span class="size-2.5 rounded-full" :style="{ backgroundColor: item.color }"></span>
+                <span class="text-sm text-[var(--art-gray-900)]">{{ item.name }}</span>
+              </div>
+              <span class="text-sm font-medium text-[var(--art-gray-700)]">{{ item.value }}%</span>
+            </div>
+
+            <ElEmpty v-if="!locUsageList.length" description="鏆傛棤搴撲綅浣跨敤鏁版嵁" :image-size="88" />
+          </div>
+        </div>
+      </div>
     </section>
 
-    <section class="grid gap-4 xl:grid-cols-[1.15fr_0.85fr]">
-      <ElCard
-        class="rounded-3xl border border-white/10 shadow-[0_18px_50px_rgba(15,23,42,0.06)]"
-        shadow="never"
-      >
-        <template #header>
-          <div class="flex items-center justify-between">
-            <div>
-              <h2 class="m-0 text-base font-semibold text-[var(--art-gray-900)]">鐪熷疄杩愯鐘舵��</h2>
-              <p class="mt-1 text-xs text-[var(--art-gray-500)]">
-                杩欎簺淇℃伅鏉ヨ嚜褰撳墠鐧诲綍鐢ㄦ埛鍜岃彍鍗� store
-              </p>
-            </div>
-            <ElTag type="success" effect="light">Backend mode</ElTag>
-          </div>
-        </template>
-
-        <div class="grid gap-4 md:grid-cols-2">
-          <div class="rounded-2xl bg-[var(--art-gray-50)] p-4">
-            <p class="text-xs uppercase tracking-[0.2em] text-[var(--art-gray-500)]">User</p>
-            <p class="mt-3 text-xl font-semibold text-[var(--art-gray-900)]">
-              {{ currentUserName }}
-            </p>
-            <p class="mt-2 text-sm text-[var(--art-gray-600)]">
-              {{ currentUserRoleText }}
-            </p>
-            <div class="mt-4 flex flex-wrap gap-2">
-              <ElTag v-for="role in currentRoles" :key="role" type="info" effect="plain">
-                {{ role }}
-              </ElTag>
-              <ElTag v-if="!currentRoles.length" type="info" effect="plain">No roles</ElTag>
-            </div>
-          </div>
-
-          <div class="rounded-2xl bg-[var(--art-gray-50)] p-4">
-            <p class="text-xs uppercase tracking-[0.2em] text-[var(--art-gray-500)]">Permissions</p>
-            <p class="mt-3 text-xl font-semibold text-[var(--art-gray-900)]">
-              {{ currentAuthorities.length }} auth nodes
-            </p>
-            <p class="mt-2 text-sm text-[var(--art-gray-600)]">
-              鏉冮檺鑺傜偣鐩存帴鏉ヨ嚜褰撳墠鐢ㄦ埛鐨勭湡瀹� `authorities` 杞借嵎锛屼笉鍐嶄緷璧栨ā鏉挎紨绀烘�併��
-            </p>
-            <div class="mt-4 flex flex-wrap gap-2">
-              <ElTag v-for="item in previewAuthorities" :key="item" type="warning" effect="plain">
-                {{ item }}
-              </ElTag>
-              <ElTag v-if="!previewAuthorities.length" type="warning" effect="plain">
-                No authorities
-              </ElTag>
-            </div>
+    <section class="grid gap-6 xl:grid-cols-[0.95fr_1.05fr]">
+      <div class="art-card h-98 p-5 box-border" v-loading="sectionLoading.tasks">
+        <div class="art-card-header">
+          <div class="title">
+            <h4>鎵ц涓换鍔�</h4>
+            <p>{{ taskSubtitle }}</p>
           </div>
         </div>
-      </ElCard>
 
-      <ElCard
-        class="rounded-3xl border border-white/10 shadow-[0_18px_50px_rgba(15,23,42,0.06)]"
-        shadow="never"
-      >
-        <template #header>
-          <div>
-            <h2 class="m-0 text-base font-semibold text-[var(--art-gray-900)]">鑿滃崟鎺ュ叆娓呭崟</h2>
-            <p class="mt-1 text-xs text-[var(--art-gray-500)]">
-              褰撳墠鑿滃崟鏍戠敤浜庨獙璇� `rsf-design` 鏄惁鐪熸鎺ヤ綇鍚庣鍙戝竷
-            </p>
-          </div>
-        </template>
-
-        <div class="space-y-3">
-          <div
-            v-for="item in menuPreview"
-            :key="item.key"
-            class="flex items-center justify-between rounded-2xl border border-[var(--art-gray-200)] px-4 py-3"
-          >
-            <div>
-              <p class="text-sm font-medium text-[var(--art-gray-900)]">{{ item.title }}</p>
-              <p class="mt-1 text-xs text-[var(--art-gray-500)]">{{ item.description }}</p>
+        <div class="mt-3 h-[calc(100%-3.75rem)] overflow-hidden">
+          <ElScrollbar>
+            <div
+              v-for="item in taskCardItems"
+              :key="`${item.time}-${item.title}`"
+              class="flex gap-4 border-b border-g-300 py-4 last:border-b-0"
+            >
+              <div class="flex flex-col items-center pt-1">
+                <span class="size-3 rounded-full bg-[var(--el-color-primary)]"></span>
+                <span class="mt-2 min-h-10 w-px bg-[var(--art-border-color)]"></span>
+              </div>
+              <div class="min-w-0 flex-1">
+                <p class="text-xs text-g-500">{{ item.time }}</p>
+                <div class="mt-2 flex items-center gap-2">
+                  <p class="truncate text-base font-medium text-[var(--art-gray-900)]">
+                    {{ item.title }}
+                  </p>
+                  <ElTag size="small" effect="light" :type="item.tagType">{{ item.tagText }}</ElTag>
+                </div>
+                <p class="mt-2 text-sm text-g-600">{{ item.subtitle }}</p>
+              </div>
             </div>
-            <ElTag :type="item.type" effect="light">
-              {{ item.value }}
-            </ElTag>
+          </ElScrollbar>
+        </div>
+      </div>
+
+      <div class="art-card h-98 p-5 box-border" v-loading="sectionLoading.deadStock">
+        <div class="art-card-header">
+          <div class="title">
+            <h4>搴撳瓨鏈�杩戝姩鎬�</h4>
+            <p>{{ deadStockSubtitle }}</p>
           </div>
         </div>
-      </ElCard>
+
+        <div class="mt-3 h-[calc(100%-3.75rem)] overflow-hidden">
+          <ElScrollbar>
+            <div
+              v-for="item in stockCardItems"
+              :key="`${item.title}-${item.time}`"
+              class="flex items-center gap-4 border-b border-g-300 py-4 last:border-b-0"
+            >
+              <div class="size-12 rounded-2xl bg-[var(--el-color-primary-light-9)] flex-cc">
+                <ArtSvgIcon :icon="item.icon" class="text-xl text-[var(--el-color-primary)]" />
+              </div>
+              <div class="min-w-0 flex-1">
+                <p class="truncate text-base font-medium text-[var(--art-gray-900)]">{{ item.title }}</p>
+                <p class="mt-1 text-sm text-g-500">{{ item.status }}</p>
+              </div>
+              <div class="max-w-40 text-right text-sm text-g-500">{{ item.time }}</div>
+            </div>
+          </ElScrollbar>
+        </div>
+      </div>
     </section>
   </div>
 </template>
@@ -147,15 +147,42 @@
 <script setup>
   import { storeToRefs } from 'pinia'
   import { useUserStore } from '@/store/modules/user'
-  import { useMenuStore } from '@/store/modules/menu'
-  import { formatMenuTitle } from '@/utils/router'
+  import {
+    fetchDashboardHeader,
+    fetchDashboardTrend,
+    fetchDashboardDeadStock,
+    fetchDashboardLocUsage,
+    fetchDashboardTasks
+  } from '@/api/dashboard'
+  import {
+    EMPTY_SUMMARY,
+    buildDashboardDeadStockQuery,
+    buildDashboardTaskQuery,
+    normalizeDashboardSummary,
+    normalizeDashboardTrend,
+    normalizeDashboardTaskList,
+    normalizeDashboardLocUsage,
+    normalizeDashboardDeadStockList,
+    withDashboardRequestGuard
+  } from './consolePage.helpers'
 
   defineOptions({ name: 'Console' })
 
+  const summary = ref({ ...EMPTY_SUMMARY })
+  const trendModel = ref(normalizeDashboardTrend())
+  const taskList = ref([])
+  const deadStockList = ref([])
+  const locUsageList = ref([])
+  const sectionLoading = reactive({
+    summary: false,
+    trend: false,
+    deadStock: false,
+    locUsage: false,
+    tasks: false
+  })
+
   const userStore = useUserStore()
-  const menuStore = useMenuStore()
   const { getUserInfo } = storeToRefs(userStore)
-  const { menuList } = storeToRefs(menuStore)
 
   const currentUser = computed(() => getUserInfo.value || {})
   const currentUserName = computed(() => {
@@ -166,84 +193,191 @@
       'RSF User'
     )
   })
-  const currentRoles = computed(() => {
-    const roles = currentUser.value.roles
-    if (!Array.isArray(roles)) return []
-    return roles
-      .map((role) => {
-        if (typeof role === 'string') {
-          return role
-        }
-        return role?.code || role?.name || role?.title || ''
-      })
-      .filter(Boolean)
+  const currentDateText = computed(() => {
+    return new Date().toLocaleDateString('zh-CN', {
+      year: 'numeric',
+      month: '2-digit',
+      day: '2-digit'
+    })
   })
-  const currentAuthorities = computed(() => {
-    const authorities = currentUser.value.authorities
-    if (!Array.isArray(authorities)) return []
-    return authorities.map((item) => item?.authority || '').filter(Boolean)
-  })
-  const currentMenuLabel = computed(() => {
-    const firstMenu = menuList.value?.[0]
-    return formatMenuTitle(firstMenu?.meta?.title || 'menus.dashboard.console')
-  })
-  const currentUserRoleText = computed(() => {
-    if (!currentRoles.value.length) {
-      return 'No role information yet'
+  const summaryCardItems = computed(() => [
+    {
+      title: '寰呭叆搴撴暟閲�',
+      count: summary.value.pendingIn,
+      metaLabel: '鎴嚦',
+      metaValue: currentDateText.value,
+      metaTone: 'text-g-500',
+      icon: 'ri:pie-chart-line',
+      iconBoxClass: 'bg-[var(--el-color-primary-light-9)]',
+      iconClass: 'text-[var(--el-color-primary)]'
+    },
+    {
+      title: '寰呭嚭搴撴暟閲�',
+      count: summary.value.pendingOut,
+      metaLabel: '鐘舵��',
+      metaValue: '褰撳墠寰呮墽琛屽嚭搴撻噺',
+      metaTone: 'text-success',
+      icon: 'ri:fire-line',
+      iconBoxClass: 'bg-[rgba(255,175,32,0.14)]',
+      iconClass: 'text-[#FFAF20]'
+    },
+    {
+      title: '宸插叆搴撴暟閲�',
+      count: summary.value.completedIn,
+      metaLabel: '缁撴灉',
+      metaValue: '绱瀹屾垚鍏ュ簱缁撴灉',
+      metaTone: 'text-g-500',
+      icon: 'ri:archive-line',
+      iconBoxClass: 'bg-[rgba(20,222,186,0.14)]',
+      iconClass: 'text-[#14DEBA]'
+    },
+    {
+      title: '鎵ц涓换鍔�',
+      count: summary.value.taskQty,
+      metaLabel: '褰撳墠鐢ㄦ埛',
+      metaValue: currentUserName.value,
+      metaTone: 'text-g-500',
+      icon: 'ri:progress-2-line',
+      iconBoxClass: 'bg-[rgba(139,92,246,0.16)]',
+      iconClass: 'text-[#8B5CF6]'
     }
-    return `Roles: ${currentRoles.value.join(' / ')}`
-  })
-  const backendSwitchCount = computed(() => {
-    return currentUserName.value !== 'RSF User' || menuList.value.length > 0 ? 1 : 0
-  })
-  const visibleMenuCount = computed(() => countVisibleMenus(menuList.value))
-  const permissionSignalCount = computed(() => {
-    return currentRoles.value.length + currentAuthorities.value.length
-  })
-  const previewAuthorities = computed(() => currentAuthorities.value.slice(0, 4))
-  const menuPreview = computed(() => {
-    const total = visibleMenuCount.value
-    const rootCount = Array.isArray(menuList.value) ? menuList.value.length : 0
-    const firstMenu = menuList.value?.[0]
-    const firstChildren = Array.isArray(firstMenu?.children) ? firstMenu.children.length : 0
+  ])
+  const trendDisplayModel = computed(() => {
+    const xAxisData = Array.isArray(trendModel.value.xAxisData) ? trendModel.value.xAxisData : []
+    const series = Array.isArray(trendModel.value.series) ? trendModel.value.series : []
+    if (!xAxisData.length || !series.length) {
+      return { xAxisData: [], series: [] }
+    }
 
-    return [
-      {
-        key: 'entry',
-        title: '鍏ュ彛妯″紡',
-        description: '褰撳墠椤甸潰浠� backend mode 杩愯锛岃彍鍗曠敱鏈嶅姟绔┍鍔�',
-        value: 'backend',
-        type: 'success'
-      },
-      {
-        key: 'menus',
-        title: '鍙鑿滃崟',
-        description: '宸查�氳繃鍔ㄦ�佽矾鐢遍�傞厤鍚庤繘鍏ュ墠绔彍鍗曟爲',
-        value: `${total}`,
-        type: 'primary'
-      },
-      {
-        key: 'root',
-        title: '涓�绾х洰褰�',
-        description: '鏍圭骇鑿滃崟鑺傜偣鏁伴噺',
-        value: `${rootCount}`,
-        type: 'info'
-      },
-      {
-        key: 'children',
-        title: '棣栦釜鐩綍瀛愰」',
-        description: '鐢ㄤ簬蹇�熺‘璁よ彍鍗曟爲宸茶姝g‘灞曞紑',
-        value: `${firstChildren}`,
-        type: 'warning'
+    const bucketSize = Math.max(1, Math.ceil(xAxisData.length / 8))
+    const bucketLabels = []
+    const bucketSeries = series.map((item) => ({
+      ...item,
+      data: []
+    }))
+
+    for (let index = 0; index < xAxisData.length; index += bucketSize) {
+      const labelBucket = xAxisData.slice(index, index + bucketSize)
+      bucketLabels.push(labelBucket[labelBucket.length - 1] || '--')
+      bucketSeries.forEach((seriesItem, seriesIndex) => {
+        const sourceBucket = Array.isArray(series[seriesIndex]?.data)
+          ? series[seriesIndex].data.slice(index, index + bucketSize)
+          : []
+        const bucketTotal = sourceBucket.reduce((total, value) => total + Number(value || 0), 0)
+        seriesItem.data.push(bucketTotal)
+      })
+    }
+
+    const visibleIndexes = bucketLabels.reduce((indexes, _, index) => {
+      const hasData = bucketSeries.some((item) => Number(item.data[index] || 0) > 0)
+      if (hasData) {
+        indexes.push(index)
       }
-    ]
+      return indexes
+    }, [])
+
+    const effectiveIndexes =
+      visibleIndexes.length > 0
+        ? visibleIndexes
+        : bucketLabels.map((_, index) => index).slice(-8)
+
+    return {
+      xAxisData: effectiveIndexes.map((index) => bucketLabels[index]),
+      series: bucketSeries.map((item) => ({
+        ...item,
+        data: effectiveIndexes.map((index) => item.data[index])
+      }))
+    }
+  })
+  const trendChartXAxisData = computed(() => trendDisplayModel.value.xAxisData)
+  const trendChartSeries = computed(() => trendDisplayModel.value.series)
+  const taskSubtitle = computed(() => `鏈�杩� ${taskList.value.length} 鏉′换鍔″姩鎬乣)
+  const deadStockSubtitle = computed(() => `鏈�杩� ${deadStockList.value.length} 鏉″簱瀛樿褰昤)
+  const usageLegendCount = computed(() => locUsageList.value.length)
+  const usageLegend = computed(() => {
+    const palette = ['#5B8FF9', '#5AD8A6', '#5D7092', '#F6BD16', '#E8684A', '#6DC8EC']
+    return locUsageList.value.map((item, index) => ({
+      ...item,
+      color: palette[index % palette.length]
+    }))
+  })
+  const taskCardItems = computed(() =>
+    taskList.value.slice(0, 6).map((item) => ({
+      title: item.title,
+      time: item.time,
+      subtitle: item.status,
+      tagText: resolveTaskTagText(item.status),
+      tagType: resolveTaskTagType(item.class)
+    }))
+  )
+  const stockCardItems = computed(() => deadStockList.value.slice(0, 6))
+
+  onMounted(() => {
+    loadDashboard()
   })
 
-  function countVisibleMenus(items) {
-    if (!Array.isArray(items)) return 0
-    return items.reduce((total, item) => {
-      const current = item?.meta?.isHide ? 0 : 1
-      return total + current + countVisibleMenus(item?.children)
-    }, 0)
+  function loadDashboard() {
+    void loadSummarySection()
+    void loadTrendSection()
+    void loadDeadStockSection()
+    void loadLocUsageSection()
+    void loadTaskSection()
+  }
+
+  async function loadSummarySection() {
+    sectionLoading.summary = true
+    const payload = await withDashboardRequestGuard(fetchDashboardHeader(), null)
+    summary.value = normalizeDashboardSummary(payload)
+    sectionLoading.summary = false
+  }
+
+  async function loadTrendSection() {
+    sectionLoading.trend = true
+    const payload = await withDashboardRequestGuard(fetchDashboardTrend(), null)
+    trendModel.value = normalizeDashboardTrend(payload)
+    sectionLoading.trend = false
+  }
+
+  async function loadDeadStockSection() {
+    sectionLoading.deadStock = true
+    const payload = await withDashboardRequestGuard(
+      fetchDashboardDeadStock(buildDashboardDeadStockQuery()),
+      {}
+    )
+    deadStockList.value = normalizeDashboardDeadStockList(payload || {})
+    sectionLoading.deadStock = false
+  }
+
+  async function loadLocUsageSection() {
+    sectionLoading.locUsage = true
+    const payload = await withDashboardRequestGuard(fetchDashboardLocUsage(), null)
+    locUsageList.value = normalizeDashboardLocUsage(payload)
+    sectionLoading.locUsage = false
+  }
+
+  async function loadTaskSection() {
+    sectionLoading.tasks = true
+    const payload = await withDashboardRequestGuard(
+      fetchDashboardTasks(buildDashboardTaskQuery()),
+      {}
+    )
+    taskList.value = normalizeDashboardTaskList(payload || {})
+    sectionLoading.tasks = false
+  }
+
+  function resolveTaskTagType(statusClass) {
+    const text = String(statusClass || '')
+    if (text.includes('emerald')) return 'success'
+    if (text.includes('rose')) return 'danger'
+    return 'primary'
+  }
+
+  function resolveTaskTagText(statusText) {
+    const text = String(statusText || '')
+      .replace(/\b\d+\./g, '')
+      .replace(/\s+/g, ' ')
+      .trim()
+    const parts = text.split('路').map((item) => item.trim()).filter(Boolean)
+    return parts[parts.length - 1] || '澶勭悊涓�'
   }
 </script>

--
Gitblit v1.9.1