feat(wms):增加首页的 review

pull/878/head
YunaiV 2026-05-14 18:53:56 +08:00
parent 804ad667e0
commit 58537a34c7
2 changed files with 767 additions and 0 deletions

77
src/api/wms/home/index.ts Normal file
View File

@ -0,0 +1,77 @@
import request from '@/config/axios'
// WMS 首页统计查询参数
export interface WmsHomeStatisticsReqVO {
warehouseId?: number
}
// WMS 首页单据状态统计 VO
export interface WmsHomeOrderStatusVO {
status: number
statusName: string
count: number
}
// WMS 首页单据汇总统计 VO
export interface WmsHomeOrderSummaryVO {
orderType: number
orderTypeName: string
total: number
statusList: WmsHomeOrderStatusVO[]
}
// WMS 首页单据趋势 VO
export interface WmsHomeOrderTrendVO {
date: string
receiptCount: number
shipmentCount: number
movementCount: number
checkCount: number
}
// WMS 首页商品库存排行 VO
export interface WmsHomeInventoryItemRankVO {
itemId: number
itemName: string
quantity: number
}
// WMS 首页仓库库存排行 VO
export interface WmsHomeInventoryWarehouseRankVO {
warehouseId: number
warehouseName: string
quantity: number
}
// WMS 首页库存汇总统计 VO
export interface WmsHomeInventorySummaryVO {
totalQuantity: number
goodsShareList: WmsHomeInventoryItemRankVO[]
warehouseDistributionList: WmsHomeInventoryWarehouseRankVO[]
}
// WMS 首页统计 API
export const WmsHomeStatisticsApi = {
// 获得首页单据汇总统计
getOrderSummary: async (params?: WmsHomeStatisticsReqVO): Promise<WmsHomeOrderSummaryVO[]> => {
return await request.get({ url: `/wms/home-statistics/order-summary`, params })
},
// 获得首页单据趋势
getOrderTrend: async (
days?: number,
params?: WmsHomeStatisticsReqVO
): Promise<WmsHomeOrderTrendVO[]> => {
return await request.get({
url: `/wms/home-statistics/order-trend`,
params: { ...params, days }
})
},
// 获得首页库存汇总统计
getInventorySummary: async (
params?: WmsHomeStatisticsReqVO
): Promise<WmsHomeInventorySummaryVO> => {
return await request.get({ url: `/wms/home-statistics/inventory-summary`, params })
}
}

View File

@ -0,0 +1,690 @@
<!-- TODO @AI组件拆分下 -->
<template>
<div class="wms-home">
<div class="wms-home__toolbar">
<div class="wms-home__toolbar-main">
<div>
<div class="wms-home__title">WMS 首页</div>
<div class="wms-home__subtitle">单据工作台 / 库存概览</div>
</div>
<div class="wms-home__filters">
<el-select
v-model="warehouseId"
class="!w-220px"
clearable
filterable
placeholder="全部仓库"
@change="refresh"
>
<el-option
v-for="warehouse in warehouseList"
:key="warehouse.id!"
:label="warehouse.name"
:value="warehouse.id!"
/>
</el-select>
<el-button :loading="loading || trendLoading || inventoryLoading" @click="refresh">
<Icon class="mr-5px" icon="ep:refresh" />
刷新
</el-button>
</div>
</div>
</div>
<el-skeleton :loading="loading" animated>
<template #template>
<div class="wms-home__summary-grid">
<el-skeleton-item v-for="item in 4" :key="item" class="wms-home__summary-skeleton" />
</div>
</template>
<div class="wms-home__summary-grid">
<div
v-for="item in orderSummaries"
:key="item.key"
class="wms-home__summary-card"
:style="{ '--theme-color': item.color }"
>
<div class="wms-home__summary-head">
<div class="wms-home__summary-title">
<span :style="{ backgroundColor: item.color }"></span>
{{ item.title }}
</div>
<el-button link type="primary" @click="handleNavigate(item.routeName)"></el-button>
</div>
<div class="wms-home__summary-total">
<em>合计</em>
<span>{{ formatCount(item.total) }}</span>
<em></em>
</div>
<div class="wms-home__status-bar">
<span
v-for="status in statusList"
:key="status.key"
:style="{
width: getStatusPercent(item, status.key),
backgroundColor: status.color
}"
></span>
</div>
<div class="wms-home__status-list">
<div v-for="status in statusList" :key="status.key">
<span>{{ status.label }}</span>
<strong :style="{ color: status.color }">{{ item.statusCounts[status.key] }}</strong>
</div>
</div>
</div>
</div>
</el-skeleton>
<div class="wms-home__chart-card">
<div class="wms-home__card-head">
<div>
<div class="wms-home__card-title">单据趋势</div>
<div class="wms-home__card-subtitle">入库出库移库盘库单据数量</div>
</div>
<el-segmented v-model="trendDays" :options="trendDayOptions" @change="loadTrendData" />
</div>
<el-skeleton :loading="trendLoading" animated>
<Echart :height="330" :options="trendChartOptions" />
</el-skeleton>
</div>
<el-row :gutter="16">
<el-col :lg="12" :md="24" :sm="24" :xl="12" :xs="24">
<div class="wms-home__chart-card">
<div class="wms-home__card-head">
<div>
<div class="wms-home__card-title">货物占比</div>
<div class="wms-home__card-subtitle">按商品库存数量汇总 Top 5</div>
</div>
</div>
<el-skeleton :loading="inventoryLoading" animated>
<Echart :height="300" :options="goodsShareChartOptions" />
</el-skeleton>
</div>
</el-col>
<el-col :lg="12" :md="24" :sm="24" :xl="12" :xs="24">
<div class="wms-home__chart-card">
<div class="wms-home__card-head">
<div>
<div class="wms-home__card-title">库存分布</div>
<div class="wms-home__card-subtitle">按仓库库存数量汇总</div>
</div>
<div class="wms-home__total-quantity"
>总库存 {{ formatQuantityText(totalQuantity) }}</div
>
</div>
<el-skeleton :loading="inventoryLoading" animated>
<Echart :height="300" :options="inventoryDistributionChartOptions" />
</el-skeleton>
</div>
</el-col>
</el-row>
<div class="wms-home__stat-time">
<Icon class="mr-5px" icon="ep:clock" />
统计时间{{ statTime }}
</div>
</div>
</template>
<script lang="ts" setup>
import { EChartsOption } from 'echarts'
import { useRouter } from 'vue-router'
import { WmsHomeOrderTrendVO, WmsHomeStatisticsApi, WmsHomeStatisticsReqVO } from '@/api/wms/home'
import { WarehouseApi, WarehouseVO } from '@/api/wms/md/warehouse'
import { OrderStatusEnum } from '@/views/wms/utils/constants'
import { formatQuantity } from '@/views/wms/utils/format'
import { formatDate } from '@/utils/formatTime'
/** WMS 首页 */
defineOptions({ name: 'WmsHome' })
type StatusKey = 'prepare' | 'finished' | 'canceled'
type OrderKey = 'receipt' | 'shipment' | 'movement' | 'check'
interface OrderDefinition {
key: OrderKey
orderType: number
title: string
color: string
routeName: string
trendField: keyof Pick<
WmsHomeOrderTrendVO,
'receiptCount' | 'shipmentCount' | 'movementCount' | 'checkCount'
>
}
interface OrderSummary extends OrderDefinition {
total: number
statusCounts: Record<StatusKey, number>
}
interface ChartItem {
name: string
value: number
}
const router = useRouter()
const message = useMessage()
const statusList: Array<{ key: StatusKey; label: string; value: number; color: string }> = [
{ key: 'prepare', label: '草稿', value: OrderStatusEnum.PREPARE, color: '#409eff' },
{ key: 'finished', label: '已完成', value: OrderStatusEnum.FINISHED, color: '#67c23a' },
{ key: 'canceled', label: '已作废', value: OrderStatusEnum.CANCELED, color: '#909399' }
]
const OrderTypeEnum = {
RECEIPT: 1,
SHIPMENT: 2,
MOVEMENT: 3,
CHECK: 4
} as const
const orderDefinitions: OrderDefinition[] = [
{
key: 'receipt',
orderType: OrderTypeEnum.RECEIPT,
title: '入库',
color: '#2f7df6',
routeName: 'WmsReceiptOrder',
trendField: 'receiptCount'
},
{
key: 'shipment',
orderType: OrderTypeEnum.SHIPMENT,
title: '出库',
color: '#18a058',
routeName: 'WmsShipmentOrder',
trendField: 'shipmentCount'
},
{
key: 'movement',
orderType: OrderTypeEnum.MOVEMENT,
title: '移库',
color: '#f59e0b',
routeName: 'WmsMovementOrder',
trendField: 'movementCount'
},
{
key: 'check',
orderType: OrderTypeEnum.CHECK,
title: '盘库',
color: '#7c3aed',
routeName: 'WmsCheckOrder',
trendField: 'checkCount'
}
]
const trendDayOptions = [
{ label: '近7天', value: 7 },
{ label: '近30天', value: 30 }
]
const loading = ref(false)
const trendLoading = ref(false)
const inventoryLoading = ref(false)
const warehouseId = ref<number>()
const warehouseList = ref<WarehouseVO[]>([])
const statTime = ref(formatDate(new Date()))
const orderSummaries = ref<OrderSummary[]>(
orderDefinitions.map((item) => ({
...item,
total: 0,
statusCounts: { prepare: 0, finished: 0, canceled: 0 }
}))
)
const trendDays = ref(7)
const trendLabels = ref<string[]>([])
const trendSeriesMap = reactive<Record<OrderKey, number[]>>({
receipt: [],
shipment: [],
movement: [],
check: []
})
const totalQuantity = ref(0)
const goodsShareList = ref<ChartItem[]>([])
const inventoryDistributionList = ref<ChartItem[]>([])
const getStatisticsParams = (): WmsHomeStatisticsReqVO => {
return warehouseId.value ? { warehouseId: warehouseId.value } : {}
}
const loadOrderSummaries = async () => {
const data = await WmsHomeStatisticsApi.getOrderSummary(getStatisticsParams())
orderSummaries.value = orderDefinitions.map((definition) => {
const summary = data.find((item) => item.orderType === definition.orderType)
const statusCounts = statusList.reduce(
(result, status) => {
const statusItem = summary?.statusList?.find((item) => item.status === status.value)
result[status.key] = statusItem?.count || 0
return result
},
{ prepare: 0, finished: 0, canceled: 0 } as Record<StatusKey, number>
)
return {
...definition,
total: summary?.total || 0,
statusCounts
}
})
}
const buildChartItemList = <T,>(
list: T[] | undefined,
nameGetter: (item: T) => string,
valueGetter: (item: T) => number | undefined
) => {
return (list || [])
.map((item) => ({
name: nameGetter(item),
value: Number(valueGetter(item) || 0)
}))
.filter((item) => item.value > 0)
}
const loadTrendData = async () => {
trendLoading.value = true
try {
const data = await WmsHomeStatisticsApi.getOrderTrend(trendDays.value, getStatisticsParams())
trendLabels.value = data.map((item) => item.date.substring(5))
orderDefinitions.forEach((definition) => {
trendSeriesMap[definition.key] = data.map((item) => Number(item[definition.trendField] || 0))
})
} finally {
trendLoading.value = false
}
}
const loadInventoryData = async () => {
inventoryLoading.value = true
try {
const data = await WmsHomeStatisticsApi.getInventorySummary(getStatisticsParams())
totalQuantity.value = Number(data.totalQuantity || 0)
goodsShareList.value = buildChartItemList(
data.goodsShareList,
(item) => item.itemName || '未命名商品',
(item) => item.quantity
)
inventoryDistributionList.value = buildChartItemList(
data.warehouseDistributionList,
(item) => item.warehouseName || '未指定仓库',
(item) => item.quantity
)
} finally {
inventoryLoading.value = false
}
}
const refresh = async () => {
loading.value = true
try {
await Promise.all([loadOrderSummaries(), loadTrendData(), loadInventoryData()])
statTime.value = formatDate(new Date())
} finally {
loading.value = false
}
}
const loadWarehouseList = async () => {
warehouseList.value = await WarehouseApi.getWarehouseSimpleList()
}
const handleNavigate = async (name: string) => {
try {
await router.push({ name })
} catch {
message.warning('当前菜单尚未加载,请从左侧菜单进入对应页面')
}
}
const getStatusPercent = (item: OrderSummary, key: StatusKey) => {
if (!item.total) {
return `${100 / statusList.length}%`
}
return `${Math.max((item.statusCounts[key] / item.total) * 100, 4)}%`
}
const formatCount = (value: number) => value.toLocaleString()
const formatQuantityText = (value: number) => formatQuantity(value) || '0.00'
const chartFontFamily =
"Inter, 'Helvetica Neue', Arial, 'PingFang SC', 'Microsoft YaHei', sans-serif"
const chartTextStyle = {
color: '#303133',
fontFamily: chartFontFamily,
fontSize: 12,
fontWeight: 400,
textBorderWidth: 0,
textShadowBlur: 0
}
const chartAxisLabelStyle = {
color: '#8a9099',
fontFamily: chartFontFamily,
fontSize: 12,
textBorderWidth: 0,
textShadowBlur: 0
}
const formatGoodsLegend = (name: string) => {
const total = goodsShareList.value.reduce((sum, item) => sum + item.value, 0)
const item = goodsShareList.value.find((goods) => goods.name === name)
if (!total || !item) {
return name
}
return `${name} ${((item.value / total) * 100).toFixed(1)}%`
}
const trendChartOptions = computed<EChartsOption>(() => ({
color: orderDefinitions.map((item) => item.color),
grid: { top: 48, left: 28, right: 24, bottom: 24, containLabel: true },
textStyle: chartTextStyle,
legend: { top: 6, itemWidth: 10, itemHeight: 10, textStyle: chartTextStyle },
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
xAxis: {
type: 'category',
data: trendLabels.value,
axisLabel: chartAxisLabelStyle,
axisTick: { show: false },
axisLine: { lineStyle: { color: '#dcdfe6' } }
},
yAxis: {
type: 'value',
name: '单据数',
nameTextStyle: chartAxisLabelStyle,
axisLabel: chartAxisLabelStyle,
minInterval: 1,
splitLine: { lineStyle: { color: '#eef2f7' } }
},
series: orderDefinitions.map((item) => ({
name: item.title,
type: 'bar',
barMaxWidth: 18,
data: trendSeriesMap[item.key],
emphasis: { focus: 'series' }
}))
}))
const goodsShareChartOptions = computed<EChartsOption>(() => ({
color: ['#2f7df6', '#18a058', '#f59e0b', '#7c3aed', '#14b8a6'],
textStyle: chartTextStyle,
tooltip: {
trigger: 'item',
formatter: '{b}<br/>库存:{c} ({d}%)'
},
legend: {
type: 'scroll',
orient: 'vertical',
right: 10,
top: 'middle',
itemWidth: 10,
itemHeight: 10,
formatter: formatGoodsLegend,
textStyle: chartTextStyle
},
series: [
{
name: '货物占比',
type: 'pie',
radius: ['48%', '70%'],
center: ['34%', '52%'],
avoidLabelOverlap: true,
label: { show: false },
labelLine: { show: false },
data: goodsShareList.value
}
]
}))
const inventoryDistributionChartOptions = computed<EChartsOption>(() => ({
color: ['#2f7df6'],
grid: { top: 12, left: 24, right: 40, bottom: 16, containLabel: true },
textStyle: chartTextStyle,
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
formatter: (params: any) => {
const item = Array.isArray(params) ? params[0] : params
return `${item.name}<br/>库存:${formatQuantityText(item.value)}`
}
},
xAxis: {
type: 'value',
axisLabel: chartAxisLabelStyle,
splitLine: { lineStyle: { color: '#eef2f7' } }
},
yAxis: {
type: 'category',
data: inventoryDistributionList.value.map((item) => item.name).reverse(),
axisLabel: chartAxisLabelStyle,
axisTick: { show: false },
axisLine: { show: false }
},
series: [
{
name: '库存',
type: 'bar',
barMaxWidth: 16,
data: inventoryDistributionList.value.map((item) => item.value).reverse(),
label: {
show: true,
position: 'right',
...chartTextStyle,
formatter: ({ value }) => formatQuantityText(Number(value))
}
}
]
}))
onMounted(async () => {
loading.value = true
try {
await loadWarehouseList()
await refresh()
} finally {
loading.value = false
}
})
</script>
<style lang="scss" scoped>
.wms-home {
--wms-card-radius: 8px;
}
.wms-home__toolbar {
display: flex;
flex-wrap: wrap;
gap: 16px;
justify-content: space-between;
margin-bottom: 16px;
padding: 16px;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-light);
border-radius: var(--wms-card-radius);
}
.wms-home__toolbar-main {
display: flex;
flex: 1;
flex-wrap: wrap;
gap: 16px;
align-items: center;
justify-content: space-between;
min-width: 320px;
}
.wms-home__title {
font-size: 20px;
font-weight: 600;
line-height: 28px;
color: var(--el-text-color-primary);
}
.wms-home__subtitle,
.wms-home__card-subtitle,
.wms-home__stat-time {
font-size: 13px;
color: var(--el-text-color-secondary);
}
.wms-home__filters {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.wms-home__summary-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
margin-bottom: 16px;
}
.wms-home__summary-skeleton {
width: 100%;
height: 174px;
border-radius: var(--wms-card-radius);
}
.wms-home__summary-card,
.wms-home__chart-card {
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-light);
border-radius: var(--wms-card-radius);
box-shadow: 0 8px 24px rgb(15 23 42 / 4%);
}
.wms-home__summary-card {
min-height: 154px;
padding: 16px 18px;
border-top: 3px solid var(--theme-color);
}
.wms-home__summary-head,
.wms-home__card-head {
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
}
.wms-home__summary-title {
display: flex;
gap: 8px;
align-items: center;
font-size: 15px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.wms-home__summary-title span {
width: 8px;
height: 8px;
border-radius: 50%;
}
.wms-home__summary-total {
display: flex;
gap: 8px;
align-items: baseline;
margin-top: 18px;
}
.wms-home__summary-total span {
font-size: 32px;
font-weight: 700;
line-height: 38px;
color: var(--el-text-color-primary);
}
.wms-home__summary-total em {
font-style: normal;
color: var(--el-text-color-secondary);
}
.wms-home__status-bar {
display: flex;
height: 8px;
margin-top: 14px;
overflow: hidden;
background: var(--el-fill-color-light);
border-radius: 999px;
}
.wms-home__status-list {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
margin-top: 14px;
}
.wms-home__status-list div {
min-width: 0;
}
.wms-home__status-list span {
display: block;
overflow: hidden;
font-size: 12px;
color: var(--el-text-color-secondary);
text-overflow: ellipsis;
white-space: nowrap;
}
.wms-home__status-list strong {
display: block;
margin-top: 2px;
font-size: 16px;
}
.wms-home__chart-card {
margin-bottom: 16px;
padding: 18px;
}
.wms-home__card-head {
margin-bottom: 12px;
}
.wms-home__card-title {
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.wms-home__total-quantity {
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.wms-home__stat-time {
display: flex;
align-items: center;
justify-content: center;
margin-top: 2px;
}
@media (max-width: 1200px) {
.wms-home__summary-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.wms-home__toolbar-main,
.wms-home__filters {
width: 100%;
}
.wms-home__summary-grid {
grid-template-columns: 1fr;
}
.wms-home__card-head {
align-items: flex-start;
flex-direction: column;
}
}
</style>