update dashboard page

This commit is contained in:
2026-03-20 14:25:49 +08:00
parent d07674ea04
commit c34f37e88f
4 changed files with 348 additions and 0 deletions
@@ -0,0 +1,121 @@
<script setup lang="ts">
import type { DashboardBreakdownItem } from '@/types/api'
defineProps<{
data: DashboardBreakdownItem[]
loading: boolean
dimension: 'company' | 'platform' | 'store'
dataType: string | undefined
}>()
const emit = defineEmits<{
'update:dimension': ['company' | 'platform' | 'store']
'update:dataType': [string | undefined]
'update:dateRange': [[string, string] | null]
}>()
const dimensionOptions = [
{ label: '公司', value: 'company' },
{ label: '平台', value: 'platform' },
{ label: '店铺', value: 'store' },
]
const dataTypeOptions = [
{ label: '全部', value: '' },
{ label: '订单', value: 'order' },
{ label: '产品', value: 'product' },
{ label: '退款', value: 'refund' },
{ label: '库存', value: 'inventory' },
]
const columns = computed(() => [
{ title: '名称', dataIndex: 'name', key: 'name' },
{ title: '成功数', dataIndex: 'success', key: 'success', sorter: (a: DashboardBreakdownItem, b: DashboardBreakdownItem) => a.success - b.success },
{ title: '失败数', dataIndex: 'failed', key: 'failed', sorter: (a: DashboardBreakdownItem, b: DashboardBreakdownItem) => a.failed - b.failed },
{ title: '合计', key: 'total', sorter: (a: DashboardBreakdownItem, b: DashboardBreakdownItem) => (a.success + a.failed) - (b.success + b.failed) },
{ title: '失败率', key: 'failRate', sorter: (a: DashboardBreakdownItem, b: DashboardBreakdownItem) => failRate(a) - failRate(b) },
])
function failRate(row: DashboardBreakdownItem): number {
const total = row.success + row.failed
return total === 0 ? 0 : row.failed / total
}
function formatPercent(row: DashboardBreakdownItem): string {
return (failRate(row) * 100).toFixed(1) + '%'
}
function handleDimensionChange(e: unknown) {
const val = (e as { target: { value?: string } }).target.value as 'company' | 'platform' | 'store'
emit('update:dimension', val)
}
function handleDataTypeChange(val: unknown) {
emit('update:dataType', (val as string) || undefined)
}
function handleDateRangeChange(_dates: unknown, dateStrings: [string, string]) {
if (dateStrings[0] && dateStrings[1]) {
emit('update:dateRange', dateStrings)
} else {
emit('update:dateRange', null)
}
}
</script>
<template>
<a-card title="分维度统计" class="mb-4">
<template #extra>
<a-space>
<a-radio-group
:value="dimension"
size="small"
@change="handleDimensionChange"
>
<a-radio-button v-for="opt in dimensionOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</a-radio-button>
</a-radio-group>
<a-range-picker
size="small"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
@change="handleDateRangeChange"
/>
<a-select
:value="dataType ?? ''"
:options="dataTypeOptions"
style="width: 100px"
size="small"
@change="handleDataTypeChange"
/>
</a-space>
</template>
<a-table
:columns="columns"
:data-source="data"
:loading="loading"
:pagination="false"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'failed'">
<span :class="{ 'text-red-500 font-bold': (record as DashboardBreakdownItem).failed > 0 }">
{{ (record as DashboardBreakdownItem).failed }}
</span>
</template>
<template v-else-if="column.key === 'total'">
{{ (record as DashboardBreakdownItem).success + (record as DashboardBreakdownItem).failed }}
</template>
<template v-else-if="column.key === 'failRate'">
<span :class="{ 'text-red-500': failRate(record as DashboardBreakdownItem) > 0 }">
{{ formatPercent(record as DashboardBreakdownItem) }}
</span>
</template>
</template>
<template #emptyText>
<a-empty description="暂无统计数据" />
</template>
</a-table>
</a-card>
</template>
@@ -0,0 +1,55 @@
<script setup lang="ts">
import {
ShoppingCartOutlined,
ShoppingOutlined,
DollarOutlined,
DatabaseOutlined,
} from '@ant-design/icons-vue'
import type { DataTypeCount } from '@/types/api'
defineProps<{
byType: DataTypeCount[]
loading: boolean
}>()
const iconMap: Record<string, ReturnType<typeof ShoppingCartOutlined>> = {
order: ShoppingCartOutlined,
product: ShoppingOutlined,
refund: DollarOutlined,
inventory: DatabaseOutlined,
}
const labelMap: Record<string, string> = {
order: '订单',
product: '产品',
refund: '退款',
inventory: '库存',
}
</script>
<template>
<a-row :gutter="16" class="mb-4">
<a-col :span="6" v-for="item in byType" :key="item.data_type">
<a-card :bordered="false">
<a-skeleton :loading="loading" active :paragraph="{ rows: 2 }">
<div class="flex items-center gap-2 mb-3">
<component :is="iconMap[item.data_type] ?? DatabaseOutlined" class="text-lg" />
<span class="text-base font-medium">{{ labelMap[item.data_type] ?? item.data_type }}</span>
</div>
<div class="flex gap-6">
<a-statistic
title="成功"
:value="item.success"
:value-style="{ color: '#3f8600' }"
/>
<a-statistic
title="失败"
:value="item.failed"
:value-style="item.failed > 0 ? { color: '#cf1322' } : {}"
/>
</div>
</a-skeleton>
</a-card>
</a-col>
</a-row>
</template>
@@ -0,0 +1,47 @@
<script setup lang="ts">
import { CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons-vue'
import type { DashboardOverview } from '@/types/api'
defineProps<{
overview: DashboardOverview | null
loading: boolean
}>()
const periods = [
{ key: 'today' as const, label: '今日' },
{ key: 'this_week' as const, label: '本周' },
{ key: 'this_month' as const, label: '本月' },
]
</script>
<template>
<a-row :gutter="16" class="mb-4">
<a-col :span="8" v-for="p in periods" :key="p.key">
<a-card :bordered="false">
<a-skeleton :loading="loading" active :paragraph="{ rows: 2 }">
<h3 class="text-base font-medium mb-3">{{ p.label }}</h3>
<div class="flex gap-6">
<a-statistic
title="成功"
:value="overview?.[p.key]?.success ?? 0"
:value-style="{ color: '#3f8600' }"
>
<template #prefix>
<CheckCircleOutlined />
</template>
</a-statistic>
<a-statistic
title="失败"
:value="overview?.[p.key]?.failed ?? 0"
:value-style="(overview?.[p.key]?.failed ?? 0) > 0 ? { color: '#cf1322' } : {}"
>
<template #prefix>
<CloseCircleOutlined />
</template>
</a-statistic>
</div>
</a-skeleton>
</a-card>
</a-col>
</a-row>
</template>
@@ -0,0 +1,125 @@
<script setup lang="ts">
import { Line } from '@antv/g2plot'
import type { DashboardTrendPoint } from '@/types/api'
const props = defineProps<{
data: DashboardTrendPoint[]
loading: boolean
groupBy: 'day' | 'week' | 'month'
dataType: string | undefined
}>()
const emit = defineEmits<{
'update:groupBy': ['day' | 'week' | 'month']
'update:dataType': [string | undefined]
}>()
const chartRef = ref<HTMLDivElement>()
let chart: Line | null = null
const groupByOptions = [
{ label: '日', value: 'day' },
{ label: '周', value: 'week' },
{ label: '月', value: 'month' },
]
const dataTypeOptions = [
{ label: '全部', value: '' },
{ label: '订单', value: 'order' },
{ label: '产品', value: 'product' },
{ label: '退款', value: 'refund' },
{ label: '库存', value: 'inventory' },
]
const flatData = computed(() => {
const result: { date: string; value: number; category: string }[] = []
for (const point of props.data) {
result.push({ date: point.date, value: point.success, category: '成功' })
result.push({ date: point.date, value: point.failed, category: '失败' })
}
return result
})
function createChart() {
if (!chartRef.value) return
chart = new Line(chartRef.value, {
data: flatData.value,
xField: 'date',
yField: 'value',
seriesField: 'category',
color: ['#52c41a', '#ff4d4f'],
smooth: true,
legend: { position: 'top' },
xAxis: { label: { autoRotate: true } },
yAxis: { label: { formatter: (v: string) => v } },
animation: false,
})
chart.render()
}
function handleGroupByChange(e: unknown) {
const val = (e as { target: { value?: string } }).target.value as 'day' | 'week' | 'month'
emit('update:groupBy', val)
}
function handleDataTypeChange(val: unknown) {
emit('update:dataType', (val as string) || undefined)
}
watch(flatData, () => {
if (chart) {
chart.changeData(flatData.value)
}
})
onMounted(() => {
if (props.data.length > 0) {
createChart()
}
})
watch(
() => props.data,
(newVal) => {
if (newVal.length > 0 && !chart) {
nextTick(() => createChart())
}
},
)
onBeforeUnmount(() => {
if (chart) {
chart.destroy()
chart = null
}
})
</script>
<template>
<a-card title="数据趋势" class="mb-4">
<template #extra>
<a-space>
<a-radio-group
:value="groupBy"
size="small"
@change="handleGroupByChange"
>
<a-radio-button v-for="opt in groupByOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</a-radio-button>
</a-radio-group>
<a-select
:value="dataType ?? ''"
:options="dataTypeOptions"
style="width: 100px"
size="small"
@change="handleDataTypeChange"
/>
</a-space>
</template>
<a-spin :spinning="loading">
<div ref="chartRef" style="height: 350px" />
<a-empty v-if="!loading && data.length === 0" description="暂无趋势数据" />
</a-spin>
</a-card>
</template>