| | |
| | | <template> |
| | | <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 class="art-full-height flex flex-col gap-6 lg:min-h-0 lg:overflow-hidden lg:gap-2"> |
| | | <section class="grid gap-3 md:grid-cols-2 lg:grid-cols-4 lg:shrink-0" 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" |
| | | class="art-card flex items-start justify-between rounded-3xl px-4 py-4 lg:px-4 lg:py-3 xl:px-5" |
| | | > |
| | | <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 class="min-w-0 flex-1 pr-3"> |
| | | <p class="truncate text-sm font-medium text-g-700 lg:text-[13px]">{{ item.title }}</p> |
| | | <ArtCountTo |
| | | class="mt-1 block truncate text-[1.95rem] font-semibold leading-none text-g-900 lg:text-[1.72rem] xl:text-[1.92rem]" |
| | | :target="item.count" |
| | | :duration="1400" |
| | | /> |
| | | <div class="mt-1.5 flex items-center gap-1 text-xs lg:text-[12px] xl:text-sm"> |
| | | <span :class="item.metaTone" class="shrink-0">{{ item.metaLabel }}</span> |
| | | <span class="truncate text-g-500">{{ item.metaValue }}</span> |
| | | </div> |
| | | </div> |
| | | <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 class="flex size-10 shrink-0 items-center justify-center rounded-2xl lg:size-9 xl:size-11" :class="item.iconBoxClass"> |
| | | <ArtSvgIcon :icon="item.icon" class="text-xl xl:text-2xl" :class="item.iconClass" /> |
| | | </div> |
| | | </div> |
| | | </section> |
| | | |
| | | <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> |
| | | <section class="flex min-h-0 flex-1 flex-col gap-2"> |
| | | <div class="grid min-h-0 flex-1 gap-2 lg:grid-cols-[1.35fr_1fr]"> |
| | | <div class="art-card box-border flex h-full min-h-0 flex-col overflow-hidden p-4 lg:p-5"> |
| | | <div class="art-card-header"> |
| | | <div class="title"> |
| | | <h4>近 30 天出入库趋势</h4> |
| | | <p>真实链路 <span class="text-success">已接通</span></p> |
| | | </div> |
| | | </div> |
| | | <div class="mt-3 min-h-0 flex-1"> |
| | | <ArtBarChart |
| | | height="100%" |
| | | :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="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 class="art-card box-border flex h-full min-h-0 flex-col overflow-hidden p-4 lg:p-5" v-loading="sectionLoading.locUsage"> |
| | | <div class="art-card-header"> |
| | | <div class="title"> |
| | | <h4>库位使用分布</h4> |
| | | <p>{{ usageLegendCount }} 个维度</p> |
| | | </div> |
| | | </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 class="mt-3 grid min-h-0 flex-1 gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(220px,0.92fr)] lg:items-center"> |
| | | <div class="min-h-0 h-full"> |
| | | <ArtRingChart |
| | | height="100%" |
| | | :data="locUsageList" |
| | | center-text="库位占比" |
| | | :show-legend="false" |
| | | :show-label="false" |
| | | /> |
| | | </div> |
| | | |
| | | <ElEmpty v-if="!locUsageList.length" description="暂无库位使用数据" :image-size="88" /> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </section> |
| | | |
| | | <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> |
| | | |
| | | <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 class="min-h-0 overflow-hidden"> |
| | | <div |
| | | v-for="item in usageLegend" |
| | | :key="item.name" |
| | | class="flex items-center justify-between gap-3 border-b border-[var(--art-border-color)] py-3 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="truncate text-sm text-[var(--art-gray-900)]">{{ item.name }}</span> |
| | | </div> |
| | | <p class="mt-2 text-sm text-g-600">{{ item.subtitle }}</p> |
| | | <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> |
| | | </ElScrollbar> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | |
| | | <div class="art-card h-98 p-5 box-border" v-loading="sectionLoading.deadStock"> |
| | | <div class="art-card box-border flex shrink-0 flex-col p-4 lg:p-3.5"> |
| | | <div class="art-card-header"> |
| | | <div class="title"> |
| | | <h4>库存最近动态</h4> |
| | | <p>{{ deadStockSubtitle }}</p> |
| | | <h4>快捷跳转</h4> |
| | | <p>快速进入任务页和库存页</p> |
| | | </div> |
| | | </div> |
| | | |
| | | <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 class="mt-2.5 grid gap-2 md:grid-cols-2"> |
| | | <button |
| | | v-for="item in quickLinkCards" |
| | | :key="item.title" |
| | | type="button" |
| | | class="flex items-start justify-between rounded-3xl border border-[var(--art-border-color)] bg-[var(--art-main-bg-color)] px-4 py-3 text-left transition hover:border-[var(--el-color-primary-light-5)] hover:bg-[var(--el-color-primary-light-9)]" |
| | | @click="navigateTo(item.path)" |
| | | > |
| | | <div class="min-w-0 pr-4"> |
| | | <p class="text-base font-medium text-[var(--art-gray-900)]">{{ item.title }}</p> |
| | | <p class="mt-0.5 text-sm text-g-500">{{ item.description }}</p> |
| | | </div> |
| | | </ElScrollbar> |
| | | <div class="flex size-11 shrink-0 items-center justify-center rounded-2xl" :class="item.iconBoxClass"> |
| | | <ArtSvgIcon :icon="item.icon" class="text-xl" :class="item.iconClass" /> |
| | | </div> |
| | | </button> |
| | | </div> |
| | | </div> |
| | | </section> |
| | |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { useRouter } from 'vue-router' |
| | | import { storeToRefs } from 'pinia' |
| | | import { useUserStore } from '@/store/modules/user' |
| | | import { |
| | | fetchDashboardHeader, |
| | | fetchDashboardTrend, |
| | | fetchDashboardDeadStock, |
| | | fetchDashboardLocUsage, |
| | | fetchDashboardTasks |
| | | fetchDashboardLocUsage |
| | | } from '@/api/dashboard' |
| | | import { |
| | | EMPTY_SUMMARY, |
| | | buildDashboardDeadStockQuery, |
| | | buildDashboardTaskQuery, |
| | | normalizeDashboardSummary, |
| | | normalizeDashboardTrend, |
| | | normalizeDashboardTaskList, |
| | | normalizeDashboardLocUsage, |
| | | normalizeDashboardDeadStockList, |
| | | withDashboardRequestGuard |
| | | } from './consolePage.helpers' |
| | | |
| | | defineOptions({ name: 'Console' }) |
| | | |
| | | const router = useRouter() |
| | | 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 |
| | | locUsage: false |
| | | }) |
| | | |
| | | const userStore = useUserStore() |
| | | const { getUserInfo } = storeToRefs(userStore) |
| | | |
| | | const quickLinkCards = [ |
| | | { |
| | | title: '任务页', |
| | | description: '查看和处理任务管理数据', |
| | | path: '/manager/task', |
| | | icon: 'ri:task-line', |
| | | iconBoxClass: 'bg-[var(--el-color-primary-light-9)]', |
| | | iconClass: 'text-[var(--el-color-primary)]' |
| | | }, |
| | | { |
| | | title: '库存页', |
| | | description: '查看当前库存与库存明细', |
| | | path: '/manager/stock', |
| | | icon: 'ri:archive-stack-line', |
| | | iconBoxClass: 'bg-[rgba(20,222,186,0.14)]', |
| | | iconClass: 'text-[#14DEBA]' |
| | | } |
| | | ] |
| | | |
| | | const currentUser = computed(() => getUserInfo.value || {}) |
| | | const currentUserName = computed(() => { |
| | |
| | | }) |
| | | 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'] |
| | |
| | | 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 loadDashboard() { |
| | | void loadSummarySection() |
| | | void loadTrendSection() |
| | | void loadDeadStockSection() |
| | | void loadLocUsageSection() |
| | | void loadTaskSection() |
| | | } |
| | | |
| | | async function loadSummarySection() { |
| | |
| | | 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) |
| | |
| | | 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] || '处理中' |
| | | function navigateTo(path) { |
| | | if (!path) return |
| | | router.push(path) |
| | | } |
| | | </script> |