<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
|
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>
|
</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>
|
</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>
|
</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-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>
|
<p class="mt-2 text-sm text-g-600">{{ item.subtitle }}</p>
|
</div>
|
</div>
|
</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>
|
|
<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>
|
|
<script setup>
|
import { storeToRefs } from 'pinia'
|
import { useUserStore } from '@/store/modules/user'
|
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 { getUserInfo } = storeToRefs(userStore)
|
|
const currentUser = computed(() => getUserInfo.value || {})
|
const currentUserName = computed(() => {
|
return (
|
currentUser.value.userName ||
|
currentUser.value.username ||
|
currentUser.value.nickname ||
|
'RSF User'
|
)
|
})
|
const currentDateText = computed(() => {
|
return new Date().toLocaleDateString('zh-CN', {
|
year: 'numeric',
|
month: '2-digit',
|
day: '2-digit'
|
})
|
})
|
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]'
|
}
|
])
|
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: [] }
|
}
|
|
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 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>
|