feat:【mall 商城】商城首页的迁移【antd】10%:初始化
This commit is contained in:
@@ -1,159 +1,45 @@
|
||||
<script lang="ts" setup>
|
||||
import type {
|
||||
AnalysisOverviewItem,
|
||||
WorkbenchProjectItem,
|
||||
WorkbenchQuickNavItem,
|
||||
} from '@vben/common-ui';
|
||||
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import {
|
||||
AnalysisOverview,
|
||||
DocAlert,
|
||||
Page,
|
||||
WorkbenchQuickNav,
|
||||
} from '@vben/common-ui';
|
||||
import {
|
||||
SvgBellIcon,
|
||||
SvgCakeIcon,
|
||||
SvgCardIcon,
|
||||
SvgDownloadIcon,
|
||||
} from '@vben/icons';
|
||||
import { isString, openWindow } from '@vben/utils';
|
||||
import { Col, Row } from 'ant-design-vue';
|
||||
|
||||
import { getUserCountComparison } from '#/api/mall/statistics/member';
|
||||
import { getOrderComparison } from '#/api/mall/statistics/trade';
|
||||
import { DocAlert, Page } from '@vben/common-ui';
|
||||
import { fenToYuan } from '@vben/utils';
|
||||
|
||||
import * as MemberStatisticsApi from '#/api/mall/statistics/member';
|
||||
import * as TradeStatisticsApi from '#/api/mall/statistics/trade';
|
||||
|
||||
import ComparisonCard from './modules/comparison-card.vue';
|
||||
import MemberFunnelCard from './modules/member-funnel-card.vue';
|
||||
import MemberStatisticsCard from './modules/member-statistics-card.vue';
|
||||
import MemberTerminalCard from './modules/member-terminal-card.vue';
|
||||
import OperationDataCard from './modules/operation-data-card.vue';
|
||||
import ShortcutCard from './modules/shortcut-card.vue';
|
||||
import TradeTrendCard from './modules/trade-trend-card.vue';
|
||||
|
||||
/** 商城首页 */
|
||||
defineOptions({ name: 'MallHome' });
|
||||
|
||||
const loading = ref(true); // 加载中
|
||||
const orderComparison = ref(); // 交易对照数据
|
||||
const userComparison = ref(); // 用户对照数据
|
||||
const orderComparison = ref<any>(); // 交易对照数据
|
||||
const userComparison = ref<any>(); // 用户对照数据
|
||||
|
||||
/** 查询交易对照卡片数据 */
|
||||
const getOrder = async () => {
|
||||
orderComparison.value = await getOrderComparison();
|
||||
const getOrderComparison = async () => {
|
||||
orderComparison.value = await TradeStatisticsApi.getOrderComparison();
|
||||
};
|
||||
|
||||
/** 查询会员用户数量对照卡片数据 */
|
||||
const getUserCount = async () => {
|
||||
userComparison.value = await getUserCountComparison();
|
||||
const getUserCountComparison = async () => {
|
||||
userComparison.value = await MemberStatisticsApi.getUserCountComparison();
|
||||
};
|
||||
|
||||
/** 初始化 */
|
||||
onMounted(async () => {
|
||||
loading.value = true;
|
||||
await Promise.all([getOrder(), getUserCount()]);
|
||||
await Promise.all([getOrderComparison(), getUserCountComparison()]);
|
||||
loading.value = false;
|
||||
});
|
||||
|
||||
const overviewItems: AnalysisOverviewItem[] = [
|
||||
{
|
||||
icon: SvgCardIcon,
|
||||
title: '今日销售额',
|
||||
totalTitle: '昨日数据',
|
||||
totalValue: orderComparison.value?.reference?.orderPayPrice || 0,
|
||||
value: orderComparison.value?.orderPayPrice || 0,
|
||||
},
|
||||
{
|
||||
icon: SvgCakeIcon,
|
||||
title: '今日用户访问量',
|
||||
totalTitle: '总访问量',
|
||||
totalValue: userComparison.value?.reference?.visitUserCount || 0,
|
||||
value: userComparison.value?.visitUserCount || 0,
|
||||
},
|
||||
{
|
||||
icon: SvgDownloadIcon,
|
||||
title: '今日订单量',
|
||||
totalTitle: '总订单量',
|
||||
totalValue: orderComparison.value?.orderPayCount || 0,
|
||||
value: orderComparison.value?.reference?.orderPayCount || 0,
|
||||
},
|
||||
{
|
||||
icon: SvgBellIcon,
|
||||
title: '今日会员注册量',
|
||||
totalTitle: '总会员注册量',
|
||||
totalValue: userComparison.value?.registerUserCount || 0,
|
||||
value: userComparison.value?.reference?.registerUserCount || 0,
|
||||
},
|
||||
];
|
||||
|
||||
// 同样,这里的 url 也可以使用以 http 开头的外部链接
|
||||
const quickNavItems: WorkbenchQuickNavItem[] = [
|
||||
{
|
||||
color: '#1fdaca',
|
||||
icon: 'ep:user-filled',
|
||||
title: '用户管理',
|
||||
url: 'MemberUser',
|
||||
},
|
||||
{
|
||||
color: '#ff6b6b',
|
||||
icon: 'fluent-mdl2:product',
|
||||
title: '商品管理',
|
||||
url: 'ProductSpu',
|
||||
},
|
||||
{
|
||||
color: '#7c3aed',
|
||||
icon: 'ep:list',
|
||||
title: '订单管理',
|
||||
url: 'TradeOrder',
|
||||
},
|
||||
{
|
||||
color: '#3fb27f',
|
||||
icon: 'ri:refund-2-line',
|
||||
title: '售后管理',
|
||||
url: 'TradeAfterSale',
|
||||
},
|
||||
{
|
||||
color: '#4daf1bc9',
|
||||
icon: 'fa-solid:project-diagram',
|
||||
title: '分销管理',
|
||||
url: 'TradeBrokerageUser',
|
||||
},
|
||||
{
|
||||
color: '#1a73e8',
|
||||
icon: 'ep:ticket',
|
||||
title: '优惠券',
|
||||
url: 'PromotionCoupon',
|
||||
},
|
||||
{
|
||||
color: '#4daf1bc9',
|
||||
icon: 'fa:group',
|
||||
title: '拼团活动',
|
||||
url: 'PromotionBargainActivity',
|
||||
},
|
||||
{
|
||||
color: '#1a73e8',
|
||||
icon: 'vaadin:money-withdraw',
|
||||
title: '佣金提现',
|
||||
url: 'TradeBrokerageWithdraw',
|
||||
},
|
||||
{
|
||||
color: '#1a73e8',
|
||||
icon: 'vaadin:money-withdraw',
|
||||
title: '数据统计',
|
||||
url: 'TradeBrokerageWithdraw',
|
||||
},
|
||||
];
|
||||
|
||||
const router = useRouter();
|
||||
function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
|
||||
if (nav.url?.startsWith('http')) {
|
||||
openWindow(nav.url);
|
||||
return;
|
||||
}
|
||||
if (nav.url?.startsWith('/')) {
|
||||
router.push(nav.url).catch((error) => {
|
||||
console.error('Navigation failed:', error);
|
||||
});
|
||||
} else if (isString(nav.url)) {
|
||||
router.push({ name: nav.url });
|
||||
} else {
|
||||
console.warn(`Unknown URL for navigation item: ${nav.title} -> ${nav.url}`);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -164,14 +50,71 @@ function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
|
||||
url="https://doc.iocoder.cn/mall/build/"
|
||||
/>
|
||||
</template>
|
||||
<AnalysisOverview :items="overviewItems" />
|
||||
<div class="mt-5 w-full lg:w-2/5">
|
||||
<WorkbenchQuickNav
|
||||
:items="quickNavItems"
|
||||
class="mt-5 lg:mt-0"
|
||||
title="快捷导航"
|
||||
@click="navTo"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- 数据对照 -->
|
||||
<Row :gutter="16">
|
||||
<Col :md="6" :sm="12" :xs="24">
|
||||
<ComparisonCard
|
||||
tag="今日"
|
||||
title="销售额"
|
||||
prefix="¥"
|
||||
:decimals="2"
|
||||
:value="fenToYuan(orderComparison?.value?.orderPayPrice || 0)"
|
||||
:reference="fenToYuan(orderComparison?.reference?.orderPayPrice || 0)"
|
||||
/>
|
||||
</Col>
|
||||
<Col :md="6" :sm="12" :xs="24">
|
||||
<ComparisonCard
|
||||
tag="今日"
|
||||
title="用户访问量"
|
||||
:value="userComparison?.value?.visitUserCount || 0"
|
||||
:reference="userComparison?.reference?.visitUserCount || 0"
|
||||
/>
|
||||
</Col>
|
||||
<Col :md="6" :sm="12" :xs="24">
|
||||
<ComparisonCard
|
||||
tag="今日"
|
||||
title="订单量"
|
||||
:value="orderComparison?.value?.orderPayCount || 0"
|
||||
:reference="orderComparison?.reference?.orderPayCount || 0"
|
||||
/>
|
||||
</Col>
|
||||
<Col :md="6" :sm="12" :xs="24">
|
||||
<ComparisonCard
|
||||
tag="今日"
|
||||
title="新增用户"
|
||||
:value="userComparison?.value?.registerUserCount || 0"
|
||||
:reference="userComparison?.reference?.registerUserCount || 0"
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<!-- 快捷入口和运营数据 -->
|
||||
<Row :gutter="16">
|
||||
<Col :md="12" :xs="24">
|
||||
<ShortcutCard />
|
||||
</Col>
|
||||
<Col :md="12" :xs="24">
|
||||
<OperationDataCard />
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<!-- 会员概览和会员终端 -->
|
||||
<Row :gutter="16">
|
||||
<Col :md="18" :sm="24" :xs="24">
|
||||
<MemberFunnelCard />
|
||||
</Col>
|
||||
<Col :md="6" :sm="24" :xs="24">
|
||||
<MemberTerminalCard />
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<!-- 交易量趋势 -->
|
||||
<TradeTrendCard />
|
||||
|
||||
<!-- 会员统计 -->
|
||||
<MemberStatisticsCard />
|
||||
</div>
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { Card, Tag } from 'ant-design-vue';
|
||||
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
|
||||
import { erpCalculatePercentage } from '@vben/utils';
|
||||
|
||||
/** 交易对照卡片 */
|
||||
defineOptions({ name: 'ComparisonCard' });
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
tag?: string;
|
||||
prefix?: string;
|
||||
value: number | string;
|
||||
reference: number | string;
|
||||
decimals?: number;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
tag: '',
|
||||
prefix: '',
|
||||
decimals: 0,
|
||||
});
|
||||
|
||||
// 计算环比
|
||||
const percent = computed(() => {
|
||||
const refValue = Number(props.reference);
|
||||
const curValue = Number(props.value);
|
||||
if (!refValue || refValue === 0) return 0;
|
||||
return ((curValue - refValue) / refValue) * 100;
|
||||
});
|
||||
|
||||
// 格式化数值
|
||||
const formattedValue = computed(() => {
|
||||
const numValue = Number(props.value);
|
||||
return numValue.toFixed(props.decimals);
|
||||
});
|
||||
|
||||
const formattedReference = computed(() => {
|
||||
const numValue = Number(props.reference);
|
||||
return numValue.toFixed(props.decimals);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card :bordered="false" class="comparison-card">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between text-gray-500">
|
||||
<span>{{ title }}</span>
|
||||
<Tag v-if="tag">{{ tag }}</Tag>
|
||||
</div>
|
||||
<div class="flex items-baseline justify-between">
|
||||
<div class="text-3xl font-semibold">
|
||||
{{ prefix }}{{ formattedValue }}
|
||||
</div>
|
||||
<span :class="percent > 0 ? 'text-red-500' : 'text-green-500'">
|
||||
{{ Math.abs(percent).toFixed(2) }}%
|
||||
<IconifyIcon
|
||||
:icon="percent > 0 ? 'ep:caret-top' : 'ep:caret-bottom'"
|
||||
class="text-sm"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-2 border-t border-gray-200 pt-2">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-gray-500">昨日数据</span>
|
||||
<span>{{ prefix }}{{ formattedReference }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.comparison-card {
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
151
apps/web-antd/src/views/mall/home/modules/member-funnel-card.vue
Normal file
151
apps/web-antd/src/views/mall/home/modules/member-funnel-card.vue
Normal file
@@ -0,0 +1,151 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Dayjs } from 'dayjs';
|
||||
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { Card, DatePicker } from 'ant-design-vue';
|
||||
|
||||
import { erpCalculatePercentage, fenToYuan } from '@vben/utils';
|
||||
|
||||
import * as MemberStatisticsApi from '#/api/mall/statistics/member';
|
||||
|
||||
/** 会员概览卡片 */
|
||||
defineOptions({ name: 'MemberFunnelCard' });
|
||||
|
||||
const loading = ref(false);
|
||||
const analyseData = ref<any>();
|
||||
|
||||
/** 查询会员概览数据列表 */
|
||||
const handleTimeRangeChange = async (times: [Dayjs, Dayjs]) => {
|
||||
if (!times || times.length !== 2) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
// 查询数据
|
||||
analyseData.value = await MemberStatisticsApi.getMemberAnalyse({ times });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
/** 计算环比增长率 */
|
||||
const calculateRelativeRate = (value?: number, reference?: number) => {
|
||||
if (!reference || reference === 0) return 0;
|
||||
return (((value || 0) - reference) / reference) * 100;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card :bordered="false" :loading="loading">
|
||||
<template #title>
|
||||
<div class="flex items-center justify-between">
|
||||
<span>会员概览</span>
|
||||
<DatePicker.RangePicker @change="handleTimeRangeChange" />
|
||||
</div>
|
||||
</template>
|
||||
<div class="min-w-[900px] py-7">
|
||||
<div class="relative flex h-24">
|
||||
<div class="flex h-full w-[75%] bg-blue-50">
|
||||
<div class="ml-15 flex h-full flex-col justify-center">
|
||||
<div class="font-bold">
|
||||
注册用户数量:{{
|
||||
analyseData?.comparison?.value?.registerUserCount || 0
|
||||
}}
|
||||
</div>
|
||||
<div class="mt-2 text-sm">
|
||||
环比增长率:{{
|
||||
calculateRelativeRate(
|
||||
analyseData?.comparison?.value?.registerUserCount,
|
||||
analyseData?.comparison?.reference?.registerUserCount,
|
||||
).toFixed(2)
|
||||
}}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="trapezoid1 -ml-[154px] mt-1.5 flex h-full w-[308px] flex-col items-center justify-center bg-blue-500 text-sm text-white"
|
||||
>
|
||||
<span class="text-2xl font-bold">{{
|
||||
analyseData?.visitUserCount || 0
|
||||
}}</span>
|
||||
<span>访客</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative flex h-24">
|
||||
<div class="flex h-full w-[75%] bg-cyan-50">
|
||||
<div class="ml-15 flex h-full flex-col justify-center">
|
||||
<div class="font-bold">
|
||||
活跃用户数量:{{
|
||||
analyseData?.comparison?.value?.visitUserCount || 0
|
||||
}}
|
||||
</div>
|
||||
<div class="mt-2 text-sm">
|
||||
环比增长率:{{
|
||||
calculateRelativeRate(
|
||||
analyseData?.comparison?.value?.visitUserCount,
|
||||
analyseData?.comparison?.reference?.visitUserCount,
|
||||
).toFixed(2)
|
||||
}}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="trapezoid2 -ml-[112px] mt-[6.8px] flex h-[100px] w-[224px] flex-col items-center justify-center bg-cyan-500 text-sm text-white"
|
||||
>
|
||||
<span class="text-2xl font-bold">{{
|
||||
analyseData?.orderUserCount || 0
|
||||
}}</span>
|
||||
<span>下单</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative flex h-24">
|
||||
<div class="flex w-[75%] bg-slate-50">
|
||||
<div class="ml-15 flex h-full flex-row gap-x-16">
|
||||
<div class="flex flex-col justify-center">
|
||||
<div class="font-bold">
|
||||
充值用户数量:{{
|
||||
analyseData?.comparison?.value?.rechargeUserCount || 0
|
||||
}}
|
||||
</div>
|
||||
<div class="mt-2 text-sm">
|
||||
环比增长率:{{
|
||||
calculateRelativeRate(
|
||||
analyseData?.comparison?.value?.rechargeUserCount,
|
||||
analyseData?.comparison?.reference?.rechargeUserCount,
|
||||
).toFixed(2)
|
||||
}}%
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col justify-center">
|
||||
<div class="font-bold">
|
||||
客单价:{{ fenToYuan(analyseData?.atv || 0) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="trapezoid3 -ml-[72px] mt-[13px] flex h-[92px] w-[144px] flex-col items-center justify-center bg-slate-500 text-sm text-white"
|
||||
>
|
||||
<span class="text-2xl font-bold">{{
|
||||
analyseData?.payUserCount || 0
|
||||
}}</span>
|
||||
<span>成交用户</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.trapezoid1 {
|
||||
transform: perspective(5em) rotateX(-11deg);
|
||||
}
|
||||
|
||||
.trapezoid2 {
|
||||
transform: perspective(7em) rotateX(-20deg);
|
||||
}
|
||||
|
||||
.trapezoid3 {
|
||||
transform: perspective(3em) rotateX(-13deg);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<script lang="ts" setup>
|
||||
import type { EchartsUIType } from '@vben/plugins/echarts';
|
||||
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
import { Card, Spin } from 'ant-design-vue';
|
||||
|
||||
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
|
||||
|
||||
import * as MemberStatisticsApi from '#/api/mall/statistics/member';
|
||||
|
||||
import { getMemberStatisticsChartOptions } from './member-statistics-chart-options';
|
||||
|
||||
/** 会员用户统计卡片 */
|
||||
defineOptions({ name: 'MemberStatisticsCard' });
|
||||
|
||||
const loading = ref(false);
|
||||
const chartRef = ref<EchartsUIType>();
|
||||
const { renderEcharts } = useEcharts(chartRef);
|
||||
|
||||
const getMemberRegisterCountList = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
// 查询最近一月数据
|
||||
const beginTime = dayjs().subtract(30, 'd').startOf('d');
|
||||
const endTime = dayjs().endOf('d');
|
||||
const list = await MemberStatisticsApi.getMemberRegisterCountList(
|
||||
beginTime,
|
||||
endTime,
|
||||
);
|
||||
// 更新 Echarts 数据
|
||||
await renderEcharts(getMemberStatisticsChartOptions(list));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
/** 初始化 */
|
||||
onMounted(() => {
|
||||
getMemberRegisterCountList();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card :bordered="false" title="用户统计">
|
||||
<Spin :spinning="loading">
|
||||
<EchartsUI ref="chartRef" class="h-[300px] w-full" />
|
||||
</Spin>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
/**
|
||||
* 会员统计图表配置
|
||||
*/
|
||||
export function getMemberStatisticsChartOptions(list: any[]): any {
|
||||
return {
|
||||
dataset: {
|
||||
dimensions: ['date', 'count'],
|
||||
source: list,
|
||||
},
|
||||
grid: {
|
||||
left: 20,
|
||||
right: 20,
|
||||
bottom: 20,
|
||||
top: 80,
|
||||
containLabel: true,
|
||||
},
|
||||
legend: {
|
||||
top: 50,
|
||||
},
|
||||
series: [{ name: '注册量', type: 'line', smooth: true, areaStyle: {} }],
|
||||
toolbox: {
|
||||
feature: {
|
||||
// 数据区域缩放
|
||||
dataZoom: {
|
||||
yAxisIndex: false, // Y轴不缩放
|
||||
},
|
||||
brush: {
|
||||
type: ['lineX', 'clear'], // 区域缩放按钮、还原按钮
|
||||
},
|
||||
saveAsImage: { show: true, name: '会员统计' }, // 保存为图片
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'cross',
|
||||
},
|
||||
padding: [5, 10],
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
axisTick: {
|
||||
show: false,
|
||||
},
|
||||
axisLabel: {
|
||||
formatter: (date: string) => dayjs(date).format('MM-DD'),
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
axisTick: {
|
||||
show: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<script lang="ts" setup>
|
||||
import type { EchartsUIType } from '@vben/plugins/echarts';
|
||||
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { Card, Spin } from 'ant-design-vue';
|
||||
|
||||
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
|
||||
import { getDictOptions } from '@vben/hooks';
|
||||
|
||||
import * as MemberStatisticsApi from '#/api/mall/statistics/member';
|
||||
|
||||
import { getTerminalChartOptions } from './member-terminal-chart-options';
|
||||
|
||||
/** 会员终端卡片 */
|
||||
defineOptions({ name: 'MemberTerminalCard' });
|
||||
|
||||
const loading = ref(true);
|
||||
const chartRef = ref<EchartsUIType>();
|
||||
const { renderEcharts } = useEcharts(chartRef);
|
||||
|
||||
/** 按照终端,查询会员统计列表 */
|
||||
const getMemberTerminalStatisticsList = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const list = await MemberStatisticsApi.getMemberTerminalStatisticsList();
|
||||
const dictDataList = getDictOptions('terminal');
|
||||
const chartData = dictDataList.map((dictData: any) => {
|
||||
const userCount = list.find(
|
||||
(item: any) => item.terminal === dictData.value,
|
||||
)?.userCount;
|
||||
return {
|
||||
name: dictData.label,
|
||||
value: userCount || 0,
|
||||
};
|
||||
});
|
||||
await renderEcharts(getTerminalChartOptions(chartData));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
/** 初始化 */
|
||||
onMounted(() => {
|
||||
getMemberTerminalStatisticsList();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card :bordered="false" title="会员终端">
|
||||
<Spin :spinning="loading">
|
||||
<EchartsUI ref="chartRef" class="h-[300px] w-full" />
|
||||
</Spin>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 会员终端统计图配置
|
||||
*/
|
||||
export function getTerminalChartOptions(data: any[]): any {
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
confine: true,
|
||||
formatter: '{a} <br/>{b} : {c} ({d}%)',
|
||||
},
|
||||
legend: {
|
||||
orient: 'vertical',
|
||||
left: 'right',
|
||||
},
|
||||
roseType: 'area',
|
||||
series: [
|
||||
{
|
||||
name: '会员终端',
|
||||
type: 'pie',
|
||||
label: {
|
||||
show: false,
|
||||
},
|
||||
labelLine: {
|
||||
show: false,
|
||||
},
|
||||
data,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
<script lang="ts" setup>
|
||||
import { onActivated, onMounted, reactive } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { Card } from 'ant-design-vue';
|
||||
|
||||
import { CountTo } from '@vben/common-ui';
|
||||
|
||||
import * as ProductSpuApi from '#/api/mall/product/spu';
|
||||
import * as PayStatisticsApi from '#/api/mall/statistics/pay';
|
||||
import * as TradeStatisticsApi from '#/api/mall/statistics/trade';
|
||||
|
||||
/** 运营数据卡片 */
|
||||
defineOptions({ name: 'OperationDataCard' });
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
/** 数据 */
|
||||
const data = reactive({
|
||||
orderUndelivered: { name: '待发货订单', value: 0, routerName: 'TradeOrder' },
|
||||
orderAfterSaleApply: {
|
||||
name: '退款中订单',
|
||||
value: 0,
|
||||
routerName: 'TradeAfterSale',
|
||||
},
|
||||
orderWaitePickUp: { name: '待核销订单', value: 0, routerName: 'TradeOrder' },
|
||||
productAlertStock: { name: '库存预警', value: 0, routerName: 'ProductSpu' },
|
||||
productForSale: { name: '上架商品', value: 0, routerName: 'ProductSpu' },
|
||||
productInWarehouse: { name: '仓库商品', value: 0, routerName: 'ProductSpu' },
|
||||
withdrawAuditing: {
|
||||
name: '提现待审核',
|
||||
value: 0,
|
||||
routerName: 'TradeBrokerageWithdraw',
|
||||
},
|
||||
rechargePrice: {
|
||||
name: '账户充值',
|
||||
value: 0.0,
|
||||
prefix: '¥',
|
||||
decimals: 2,
|
||||
routerName: 'PayWalletRecharge',
|
||||
},
|
||||
});
|
||||
|
||||
/** 查询订单数据 */
|
||||
const getOrderData = async () => {
|
||||
const orderCount = await TradeStatisticsApi.getOrderCount();
|
||||
if (orderCount.undelivered != null) {
|
||||
data.orderUndelivered.value = orderCount.undelivered;
|
||||
}
|
||||
if (orderCount.afterSaleApply != null) {
|
||||
data.orderAfterSaleApply.value = orderCount.afterSaleApply;
|
||||
}
|
||||
if (orderCount.pickUp != null) {
|
||||
data.orderWaitePickUp.value = orderCount.pickUp;
|
||||
}
|
||||
if (orderCount.auditingWithdraw != null) {
|
||||
data.withdrawAuditing.value = orderCount.auditingWithdraw;
|
||||
}
|
||||
};
|
||||
|
||||
/** 查询商品数据 */
|
||||
const getProductData = async () => {
|
||||
const productCount = await ProductSpuApi.getTabsCount();
|
||||
data.productForSale.value = productCount['0'];
|
||||
data.productInWarehouse.value = productCount['1'];
|
||||
data.productAlertStock.value = productCount['3'];
|
||||
};
|
||||
|
||||
/** 查询钱包充值数据 */
|
||||
const getWalletRechargeData = async () => {
|
||||
const paySummary = await PayStatisticsApi.getWalletRechargePrice();
|
||||
data.rechargePrice.value = paySummary.rechargePrice;
|
||||
};
|
||||
|
||||
/**
|
||||
* 跳转到对应页面
|
||||
*
|
||||
* @param routerName 路由页面组件的名称
|
||||
*/
|
||||
const handleClick = (routerName: string) => {
|
||||
router.push({ name: routerName });
|
||||
};
|
||||
|
||||
/** 激活时 */
|
||||
onActivated(() => {
|
||||
getOrderData();
|
||||
getProductData();
|
||||
getWalletRechargeData();
|
||||
});
|
||||
|
||||
/** 初始化 */
|
||||
onMounted(() => {
|
||||
getOrderData();
|
||||
getProductData();
|
||||
getWalletRechargeData();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card :bordered="false" title="运营数据">
|
||||
<div class="flex flex-row flex-wrap items-center gap-8 p-4">
|
||||
<div
|
||||
v-for="item in data"
|
||||
:key="item.name"
|
||||
class="flex h-20 w-[20%] cursor-pointer flex-col items-center justify-center gap-2"
|
||||
@click="handleClick(item.routerName)"
|
||||
>
|
||||
<CountTo
|
||||
:decimals="item.decimals || 0"
|
||||
:end-val="item.value"
|
||||
:prefix="item.prefix || ''"
|
||||
class="text-3xl"
|
||||
/>
|
||||
<span class="text-center">{{ item.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
95
apps/web-antd/src/views/mall/home/modules/shortcut-card.vue
Normal file
95
apps/web-antd/src/views/mall/home/modules/shortcut-card.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<script lang="ts" setup>
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { Card } from 'ant-design-vue';
|
||||
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
|
||||
/** 快捷入口卡片 */
|
||||
defineOptions({ name: 'ShortcutCard' });
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
/** 菜单列表 */
|
||||
const menuList = [
|
||||
{
|
||||
name: '用户管理',
|
||||
icon: 'ep:user-filled',
|
||||
bgColor: 'bg-red-400',
|
||||
routerName: 'MemberUser',
|
||||
},
|
||||
{
|
||||
name: '商品管理',
|
||||
icon: 'fluent-mdl2:product',
|
||||
bgColor: 'bg-orange-400',
|
||||
routerName: 'ProductSpu',
|
||||
},
|
||||
{
|
||||
name: '订单管理',
|
||||
icon: 'ep:list',
|
||||
bgColor: 'bg-yellow-500',
|
||||
routerName: 'TradeOrder',
|
||||
},
|
||||
{
|
||||
name: '售后管理',
|
||||
icon: 'ri:refund-2-line',
|
||||
bgColor: 'bg-green-600',
|
||||
routerName: 'TradeAfterSale',
|
||||
},
|
||||
{
|
||||
name: '分销管理',
|
||||
icon: 'fa-solid:project-diagram',
|
||||
bgColor: 'bg-cyan-500',
|
||||
routerName: 'TradeBrokerageUser',
|
||||
},
|
||||
{
|
||||
name: '优惠券',
|
||||
icon: 'ep:ticket',
|
||||
bgColor: 'bg-blue-500',
|
||||
routerName: 'PromotionCoupon',
|
||||
},
|
||||
{
|
||||
name: '拼团活动',
|
||||
icon: 'fa:group',
|
||||
bgColor: 'bg-purple-500',
|
||||
routerName: 'PromotionBargainActivity',
|
||||
},
|
||||
{
|
||||
name: '佣金提现',
|
||||
icon: 'vaadin:money-withdraw',
|
||||
bgColor: 'bg-rose-500',
|
||||
routerName: 'TradeBrokerageWithdraw',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 跳转到菜单对应页面
|
||||
*
|
||||
* @param routerName 路由页面组件的名称
|
||||
*/
|
||||
const handleMenuClick = (routerName: string) => {
|
||||
router.push({ name: routerName });
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card :bordered="false" title="快捷入口">
|
||||
<div class="flex flex-row flex-wrap gap-8 p-4">
|
||||
<div
|
||||
v-for="menu in menuList"
|
||||
:key="menu.name"
|
||||
class="flex h-20 w-[20%] cursor-pointer flex-col items-center justify-center gap-2"
|
||||
@click="handleMenuClick(menu.routerName)"
|
||||
>
|
||||
<div
|
||||
:class="menu.bgColor"
|
||||
class="flex h-12 w-12 items-center justify-center rounded text-white"
|
||||
>
|
||||
<IconifyIcon :icon="menu.icon" class="text-2xl" />
|
||||
</div>
|
||||
<span>{{ menu.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
203
apps/web-antd/src/views/mall/home/modules/trade-trend-card.vue
Normal file
203
apps/web-antd/src/views/mall/home/modules/trade-trend-card.vue
Normal file
@@ -0,0 +1,203 @@
|
||||
<script lang="ts" setup>
|
||||
import type { EchartsUIType } from '@vben/plugins/echarts';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
import { Card, Radio, RadioGroup, Spin } from 'ant-design-vue';
|
||||
|
||||
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
|
||||
import { fenToYuan } from '@vben/utils';
|
||||
|
||||
import * as TradeStatisticsApi from '#/api/mall/statistics/trade';
|
||||
|
||||
import { getTradeTrendChartOptions } from './trade-trend-chart-options';
|
||||
|
||||
/** 交易量趋势 */
|
||||
defineOptions({ name: 'TradeTrendCard' });
|
||||
|
||||
enum TimeRangeTypeEnum {
|
||||
DAY30 = 1,
|
||||
WEEK = 7,
|
||||
MONTH = 30,
|
||||
YEAR = 365,
|
||||
}
|
||||
|
||||
const timeRangeType = ref(TimeRangeTypeEnum.DAY30); // 日期快捷选择按钮, 默认30天
|
||||
const loading = ref(false);
|
||||
const chartRef = ref<EchartsUIType>();
|
||||
const { renderEcharts } = useEcharts(chartRef);
|
||||
|
||||
// 时间范围 Map
|
||||
const timeRangeConfig = {
|
||||
[TimeRangeTypeEnum.DAY30]: {
|
||||
name: '30天',
|
||||
seriesCount: 2,
|
||||
},
|
||||
[TimeRangeTypeEnum.WEEK]: {
|
||||
name: '周',
|
||||
seriesCount: 4,
|
||||
},
|
||||
[TimeRangeTypeEnum.MONTH]: {
|
||||
name: '月',
|
||||
seriesCount: 4,
|
||||
},
|
||||
[TimeRangeTypeEnum.YEAR]: {
|
||||
name: '年',
|
||||
seriesCount: 4,
|
||||
},
|
||||
};
|
||||
|
||||
/** 时间范围类型单选按钮选中 */
|
||||
const handleTimeRangeTypeChange = async () => {
|
||||
// 设置时间范围
|
||||
let beginTime: Dayjs;
|
||||
let endTime: Dayjs;
|
||||
switch (timeRangeType.value) {
|
||||
case TimeRangeTypeEnum.WEEK: {
|
||||
beginTime = dayjs().startOf('week');
|
||||
endTime = dayjs().endOf('week');
|
||||
break;
|
||||
}
|
||||
case TimeRangeTypeEnum.MONTH: {
|
||||
beginTime = dayjs().startOf('month');
|
||||
endTime = dayjs().endOf('month');
|
||||
break;
|
||||
}
|
||||
case TimeRangeTypeEnum.YEAR: {
|
||||
beginTime = dayjs().startOf('year');
|
||||
endTime = dayjs().endOf('year');
|
||||
break;
|
||||
}
|
||||
case TimeRangeTypeEnum.DAY30:
|
||||
default: {
|
||||
beginTime = dayjs().subtract(30, 'day').startOf('d');
|
||||
endTime = dayjs().endOf('d');
|
||||
break;
|
||||
}
|
||||
}
|
||||
// 发送时间范围选中事件
|
||||
await getOrderCountTrendComparison(beginTime, endTime);
|
||||
};
|
||||
|
||||
/** 查询订单数量趋势对照数据 */
|
||||
const getOrderCountTrendComparison = async (
|
||||
beginTime: dayjs.ConfigType,
|
||||
endTime: dayjs.ConfigType,
|
||||
) => {
|
||||
loading.value = true;
|
||||
try {
|
||||
// 查询数据
|
||||
const list = await TradeStatisticsApi.getOrderCountTrendComparison(
|
||||
timeRangeType.value,
|
||||
beginTime,
|
||||
endTime,
|
||||
);
|
||||
// 处理数据
|
||||
const dates: string[] = [];
|
||||
const series: any[] = [];
|
||||
const config = timeRangeConfig[timeRangeType.value];
|
||||
|
||||
if (config.seriesCount === 2) {
|
||||
const orderPayPriceData: number[] = [];
|
||||
const orderPayCountData: number[] = [];
|
||||
for (const item of list) {
|
||||
dates.push(item.value.date);
|
||||
orderPayPriceData.push(fenToYuan(item?.value?.orderPayPrice || 0));
|
||||
orderPayCountData.push(item?.value?.orderPayCount || 0);
|
||||
}
|
||||
series.push(
|
||||
{ name: '订单金额', type: 'bar', smooth: true, data: orderPayPriceData },
|
||||
{
|
||||
name: '订单数量',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
data: orderPayCountData,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
const refPriceData: number[] = [];
|
||||
const curPriceData: number[] = [];
|
||||
const refCountData: number[] = [];
|
||||
const curCountData: number[] = [];
|
||||
|
||||
for (const item of list) {
|
||||
dates.push(item.value.date);
|
||||
refPriceData.push(fenToYuan(item?.reference?.orderPayPrice || 0));
|
||||
curPriceData.push(fenToYuan(item?.value?.orderPayPrice || 0));
|
||||
refCountData.push(item?.reference?.orderPayCount || 0);
|
||||
curCountData.push(item?.value?.orderPayCount || 0);
|
||||
}
|
||||
|
||||
const timeLabel =
|
||||
timeRangeType.value === TimeRangeTypeEnum.WEEK
|
||||
? ['上周', '本周']
|
||||
: timeRangeType.value === TimeRangeTypeEnum.MONTH
|
||||
? ['上月', '本月']
|
||||
: ['去年', '今年'];
|
||||
|
||||
series.push(
|
||||
{
|
||||
name: `${timeLabel[0]}金额`,
|
||||
type: 'bar',
|
||||
smooth: true,
|
||||
data: refPriceData,
|
||||
},
|
||||
{
|
||||
name: `${timeLabel[1]}金额`,
|
||||
type: 'bar',
|
||||
smooth: true,
|
||||
data: curPriceData,
|
||||
},
|
||||
{
|
||||
name: `${timeLabel[0]}数量`,
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
data: refCountData,
|
||||
},
|
||||
{
|
||||
name: `${timeLabel[1]}数量`,
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
data: curCountData,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
await renderEcharts(
|
||||
getTradeTrendChartOptions(dates, series, timeRangeType.value),
|
||||
);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
/** 初始化 */
|
||||
onMounted(() => {
|
||||
handleTimeRangeTypeChange();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card :bordered="false">
|
||||
<template #title>
|
||||
<div class="flex items-center justify-between">
|
||||
<span>交易量趋势</span>
|
||||
<RadioGroup v-model:value="timeRangeType" @change="handleTimeRangeTypeChange">
|
||||
<Radio
|
||||
v-for="[key, value] in Object.entries(timeRangeConfig)"
|
||||
:key="key"
|
||||
:value="Number(key)"
|
||||
>
|
||||
{{ value.name }}
|
||||
</Radio>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</template>
|
||||
<Spin :spinning="loading">
|
||||
<EchartsUI ref="chartRef" class="h-[300px] w-full" />
|
||||
</Spin>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
/**
|
||||
* 交易量趋势图表配置
|
||||
*/
|
||||
export function getTradeTrendChartOptions(
|
||||
dates: string[],
|
||||
series: any[],
|
||||
timeRangeType: number,
|
||||
): any {
|
||||
return {
|
||||
grid: {
|
||||
left: 20,
|
||||
right: 20,
|
||||
bottom: 20,
|
||||
top: 80,
|
||||
containLabel: true,
|
||||
},
|
||||
legend: {
|
||||
top: 50,
|
||||
data: series.map((item) => item.name),
|
||||
},
|
||||
series,
|
||||
toolbox: {
|
||||
feature: {
|
||||
// 数据区域缩放
|
||||
dataZoom: {
|
||||
yAxisIndex: false, // Y轴不缩放
|
||||
},
|
||||
brush: {
|
||||
type: ['lineX', 'clear'], // 区域缩放按钮、还原按钮
|
||||
},
|
||||
saveAsImage: { show: true, name: '订单量趋势' }, // 保存为图片
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'cross',
|
||||
},
|
||||
padding: [5, 10],
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
inverse: true,
|
||||
boundaryGap: false,
|
||||
axisTick: {
|
||||
show: false,
|
||||
},
|
||||
data: dates,
|
||||
axisLabel: {
|
||||
formatter: (date: string) => {
|
||||
switch (timeRangeType) {
|
||||
case 1: // DAY30
|
||||
return dayjs(date).format('MM-DD');
|
||||
case 7: // WEEK
|
||||
{
|
||||
const weekDay = dayjs(date).day();
|
||||
return weekDay === 0 ? '周日' : `周${weekDay}`;
|
||||
}
|
||||
case 30: // MONTH
|
||||
return dayjs(date).format('D');
|
||||
case 365: // YEAR
|
||||
return dayjs(date).format('M') + '月';
|
||||
default:
|
||||
return date;
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
axisTick: {
|
||||
show: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user