Files
platforms/frontend/platform_admin/src/views/dashboard/DashboardPage.vue

157 lines
8.0 KiB
Vue
Raw Normal View History

<template>
<div class="dashboard">
<a-spin :loading="loading" class="dashboard-spin">
<a-alert v-if="errorMessage" type="error" show-icon class="dashboard-alert">
{{ errorMessage }}
<template #action><a-button size="small" @click="loadOverview">重新加载</a-button></template>
</a-alert>
<div class="dashboard-meta">统计口径以服务端为准 · 更新时间{{ updatedAt || '尚未加载' }}</div>
<a-grid :cols="{ xs: 1, sm: 2, lg: 4 }" :col-gap="16" :row-gap="16">
<a-grid-item v-for="item in cards" :key="item.key">
<a-card :bordered="false" class="metric-card">
<a-statistic
:title="item.label"
:value="overview[item.key]"
:precision="item.money ? 2 : 0"
show-group-separator
>
<template v-if="item.money" #prefix>¥</template>
</a-statistic>
<div class="metric-hint">{{ item.hint }}</div>
</a-card>
</a-grid-item>
</a-grid>
<a-card title="快捷操作" :bordered="false" class="section-card">
<a-grid :cols="{ xs: 2, sm: 3, md: 4, lg: 6 }" :col-gap="12" :row-gap="12">
<a-grid-item v-for="action in availableActions" :key="action.route">
<a-button long class="quick-action" @click="router.push({ name: action.route })">
<template #icon><component :is="action.icon" /></template>
{{ action.label }}
</a-button>
</a-grid-item>
</a-grid>
</a-card>
<a-grid :cols="{ xs: 1, lg: 2 }" :col-gap="16" :row-gap="16" class="section-card">
<a-grid-item><a-card title="近 7 日配送订单趋势" :bordered="false"><Chart :option="orderTrendOption" height="320px" /></a-card></a-grid-item>
<a-grid-item><a-card title="订单状态分布" :bordered="false"><Chart :option="orderStatusOption" height="320px" /></a-card></a-grid-item>
<a-grid-item><a-card title="气瓶状态分布" :bordered="false"><Chart :option="productStatusOption" height="320px" /></a-card></a-grid-item>
<a-grid-item><a-card title="支付渠道实收金额" :bordered="false"><Chart :option="paymentChannelOption" height="320px" /></a-card></a-grid-item>
</a-grid>
</a-spin>
</div>
</template>
<script setup lang="ts">
import { Message } from '@arco-design/web-vue';
import { IconApps, IconFile, IconPlus, IconSettings, IconStorage, IconUser } from '@arco-design/web-vue/es/icon';
import type { EChartsOption } from 'echarts';
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { platformApi, type DashboardOverview } from '@/api/platform';
import { useUserStore } from '@/store';
type CountKey = 'gas_basic_count' | 'delivery_basic_count' | 'staff_count' | 'user_count' |
'product_count' | 'active_contract_count' | 'today_order_count' | 'today_order_amount' |
'pending_ticket_count' | 'paid_amount';
const emptyOverview = (): DashboardOverview => ({
gas_basic_count: 0, delivery_basic_count: 0, staff_count: 0, user_count: 0,
product_count: 0, active_contract_count: 0, today_order_count: 0,
today_order_amount: 0, pending_ticket_count: 0, paid_amount: 0,
order_statuses: [], product_statuses: [], payment_channels: [], recent_orders: [],
});
const loading = ref(false);
const errorMessage = ref('');
const updatedAt = ref('');
const overview = ref<DashboardOverview>(emptyOverview());
const router = useRouter();
const userStore = useUserStore();
const cards: { key: CountKey; label: string; hint: string; money?: boolean }[] = [
{ key: 'gas_basic_count', label: '启用气站', hint: '当前正常运营' },
{ key: 'delivery_basic_count', label: '启用配送点', hint: '跨组织汇总' },
{ key: 'staff_count', label: '在岗人员', hint: '当前可接单' },
{ key: 'user_count', label: '启用客户', hint: '有效业主账户' },
{ key: 'product_count', label: '智能气阀', hint: '已启用资产' },
{ key: 'active_contract_count', label: '生效合同', hint: '当前履约中' },
{ key: 'today_order_count', label: '今日订单', hint: '自然日新增' },
{ key: 'today_order_amount', label: '今日应付金额', hint: '订单口径', money: true },
{ key: 'pending_ticket_count', label: '待受理工单', hint: '客服待办' },
{ key: 'paid_amount', label: '累计实收金额', hint: '支付成功口径', money: true },
];
const actions = [
{ label: '新建气站', route: 'organization-gas-basic', menu: 'gas_basic', icon: IconPlus },
{ label: '配送订单', route: 'gasorder-orders', menu: 'gasorder_basic', icon: IconFile },
{ label: '智能气阀', route: 'product-info', menu: 'product_info', icon: IconStorage },
{ label: '用户管理', route: 'user-account', menu: 'user_account', icon: IconUser },
{ label: '商城订单', route: 'ec-orders', menu: 'ec_order', icon: IconApps },
{ label: '角色权限', route: 'platform-roles', menu: 'platform_role', icon: IconSettings },
];
const availableActions = computed(() => actions.filter(
(action) => userStore.role === 'root' || userStore.menuCodes.includes(action.menu),
));
const centsToYuan = (value: number) => value / 100;
const safeArray = <T,>(value: T[] | null | undefined): T[] =>
Array.isArray(value) ? value : [];
const pieOption = (data: { name: string; value: number }[]): EChartsOption => ({
tooltip: { trigger: 'item' }, legend: { bottom: 0 },
series: [{ type: 'pie', radius: ['42%', '68%'], center: ['50%', '44%'], data, label: { formatter: '{b}\\n{c}' } }],
});
const orderStatusOption = computed(() => pieOption(safeArray(overview.value.order_statuses)));
const productStatusOption = computed(() => pieOption(safeArray(overview.value.product_statuses)));
const orderTrendOption = computed<EChartsOption>(() => ({
tooltip: { trigger: 'axis' }, legend: { data: ['订单数', '订单金额'] },
grid: { left: 48, right: 58, bottom: 34 },
xAxis: { type: 'category', data: safeArray(overview.value.recent_orders).map((item) => item.date.slice(5)) },
yAxis: [{ type: 'value', name: '单' }, { type: 'value', name: '元' }],
series: [
{ name: '订单数', type: 'bar', data: safeArray(overview.value.recent_orders).map((item) => item.order_count) },
{ name: '订单金额', type: 'line', yAxisIndex: 1, smooth: true, data: safeArray(overview.value.recent_orders).map((item) => centsToYuan(item.order_amount)) },
],
}));
const paymentChannelOption = computed<EChartsOption>(() => ({
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } }, grid: { left: 70, right: 24, bottom: 28 },
xAxis: { type: 'value', name: '元' },
yAxis: { type: 'category', data: safeArray(overview.value.payment_channels).map((item) => item.name || '未知渠道') },
series: [{ type: 'bar', data: safeArray(overview.value.payment_channels).map((item) => centsToYuan(item.value)) }],
}));
async function loadOverview() {
loading.value = true;
errorMessage.value = '';
try {
const data = await platformApi.overview();
overview.value = {
...emptyOverview(),
...data,
today_order_amount: centsToYuan(data.today_order_amount ?? 0),
paid_amount: centsToYuan(data.paid_amount ?? 0),
order_statuses: safeArray(data.order_statuses),
product_statuses: safeArray(data.product_statuses),
payment_channels: safeArray(data.payment_channels),
recent_orders: safeArray(data.recent_orders),
};
updatedAt.value = new Date().toLocaleString('zh-CN', { hour12: false });
} catch (error) {
errorMessage.value = (error as Error).message;
Message.error(errorMessage.value);
} finally {
loading.value = false;
}
}
onMounted(loadOverview);
</script>
<style scoped lang="less">
.dashboard { padding: 20px; }
.dashboard-spin { width: 100%; }
.dashboard-alert { margin-bottom: 16px; }
.dashboard-meta { margin-bottom: 12px; color: var(--color-text-3); font-size: 12px; text-align: right; }
.metric-card { min-height: 120px; }
.metric-hint { margin-top: 8px; color: var(--color-text-3); font-size: 12px; }
.section-card { margin-top: 16px; }
.quick-action { height: 52px; justify-content: flex-start; padding: 0 18px; }
</style>