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

157 lines
8.0 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>