zhou zhou
4 天以前 aaf8a50511d77dbc209ca93bbba308c21179a8bc
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: '用于快速确认菜单树已被正确展开',
        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>