add customer age chart

This commit is contained in:
Timi
2026-09-11 18:47:14 +08:00
parent 3544ac559b
commit d036d756f4
7 changed files with 198 additions and 15 deletions
+9 -1
View File
@@ -1,7 +1,7 @@
import { axios } from "timi-web-api"; import { axios } from "timi-web-api";
import EventAPI from "@/api/EventAPI"; import EventAPI from "@/api/EventAPI";
import StoreAPI from "@/api/StoreAPI"; import StoreAPI from "@/api/StoreAPI";
import type { CustomerDailyStatView, CustomerGenderStatView } from "@/types/Customer"; import type { CustomerAgeStatView, CustomerDailyStatView, CustomerGenderStatView } from "@/types/Customer";
import type { EventItem } from "@/types/EventItem"; import type { EventItem } from "@/types/EventItem";
import type { StoreBusinessDay, StoreChartReq, StoreEventRecordCalendarView, StoreEventRecordDailyStatView, StoreEventRecordNumberValueDailyStatView } from "@/types/Store"; import type { StoreBusinessDay, StoreChartReq, StoreEventRecordCalendarView, StoreEventRecordDailyStatView, StoreEventRecordNumberValueDailyStatView } from "@/types/Store";
import { getStoreChartCache } from "@/utils/storeChartCache"; import { getStoreChartCache } from "@/utils/storeChartCache";
@@ -21,6 +21,13 @@ async function customerGenderStat(): Promise<CustomerGenderStatView[]> {
})); }));
} }
async function customerAgeStat(): Promise<CustomerAgeStatView[]> {
const storeId = getCurrentStoreId();
return await getStoreChartCache(`customer-age:${storeId}`, async () => await axios.post(`${BASE_URI}/customer/age`, undefined, {
params: { storeId }
}));
}
async function eventRecordDailyStat(req: StoreChartReq): Promise<StoreEventRecordDailyStatView[]> { async function eventRecordDailyStat(req: StoreChartReq): Promise<StoreEventRecordDailyStatView[]> {
const request = withCurrentStoreId(req); const request = withCurrentStoreId(req);
return await getStoreChartCache(`event-record-daily:${JSON.stringify(request)}`, async () => await axios.post(`${BASE_URI}/event/record/daily`, request)); return await getStoreChartCache(`event-record-daily:${JSON.stringify(request)}`, async () => await axios.post(`${BASE_URI}/event/record/daily`, request));
@@ -48,6 +55,7 @@ async function businessDayCalendar(storeId: string, beginDateValue: number, endD
export default { export default {
customerDailyStat, customerDailyStat,
customerGenderStat, customerGenderStat,
customerAgeStat,
eventRecordDailyStat, eventRecordDailyStat,
eventRecordNumberValueDailyStat, eventRecordNumberValueDailyStat,
eventRecordCalendar, eventRecordCalendar,
+30 -3
View File
@@ -1,19 +1,32 @@
<template> <template>
<div v-can="permissionCodes.stat.read" class="data-charts-page"> <div v-can="permissionCodes.stat.read" class="data-charts-page">
<store-chart-summary v-entrance-transition :date-text-list="dateTextList" /> <store-chart-summary v-entrance-transition :date-text-list="dateTextList" />
<t-segmented
v-model="chartMode"
class="chart-mode"
block
:options="chartModeOptions"
v-entrance-transition="20"
/>
<template v-if="chartMode === 'event'">
<store-event-record-calendar-section v-entrance-transition="70" @list="toEventRecordList" /> <store-event-record-calendar-section v-entrance-transition="70" @list="toEventRecordList" />
<store-event-record-trend-chart v-entrance-transition="140" :date-text-list="dateTextList" /> <store-event-record-trend-chart v-entrance-transition="140" :date-text-list="dateTextList" />
<store-event-record-number-value-chart v-entrance-transition="210" :date-text-list="dateTextList" /> <store-event-record-number-value-chart v-entrance-transition="210" :date-text-list="dateTextList" />
<store-event-structure-chart v-entrance-transition="280" :date-text-list="dateTextList" /> <store-event-structure-chart v-entrance-transition="280" :date-text-list="dateTextList" />
<store-customer-disease-word-cloud-chart v-entrance-transition="350" /> </template>
<store-customer-growth-chart v-entrance-transition="420" :date-text-list="dateTextList" /> <template v-else>
<store-customer-gender-chart v-entrance-transition="490" /> <store-customer-growth-chart v-entrance-transition="70" :date-text-list="dateTextList" />
<store-customer-age-chart v-entrance-transition="140" />
<store-customer-disease-word-cloud-chart v-entrance-transition="210" />
<store-customer-gender-chart v-entrance-transition="280" />
</template>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Time } from "timi-web"; import { Time } from "timi-web";
import StoreChartSummary from "@/pages/store/component/StoreChartSummary.vue"; import StoreChartSummary from "@/pages/store/component/StoreChartSummary.vue";
import StoreCustomerAgeChart from "@/pages/store/component/StoreCustomerAgeChart.vue";
import StoreCustomerGenderChart from "@/pages/store/component/StoreCustomerGenderChart.vue"; import StoreCustomerGenderChart from "@/pages/store/component/StoreCustomerGenderChart.vue";
import StoreCustomerGrowthChart from "@/pages/store/component/StoreCustomerGrowthChart.vue"; import StoreCustomerGrowthChart from "@/pages/store/component/StoreCustomerGrowthChart.vue";
import StoreCustomerDiseaseWordCloudChart from "@/pages/store/component/StoreCustomerDiseaseWordCloudChart.vue"; import StoreCustomerDiseaseWordCloudChart from "@/pages/store/component/StoreCustomerDiseaseWordCloudChart.vue";
@@ -28,6 +41,16 @@ defineOptions({
name: "StoreDataCharts" name: "StoreDataCharts"
}); });
type ChartMode = "event" | "customer";
// ---------- 图表切换 ----------
const chartMode = ref<ChartMode>("event");
const chartModeOptions = [
{ value: "event" as ChartMode, label: "事件登记图表" },
{ value: "customer" as ChartMode, label: "客户分析图表" }
];
// ---------- 数据源 ---------- // ---------- 数据源 ----------
const beginDate = getDateStart(addMonths(new Date(), -1)); const beginDate = getDateStart(addMonths(new Date(), -1));
@@ -100,6 +123,10 @@ function buildDateTextList(begin: Date, end: Date): string[] {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
.chart-mode {
width: 100%;
}
.summary { .summary {
gap: .75rem; gap: .75rem;
display: grid; display: grid;
@@ -0,0 +1,143 @@
<template>
<section class="chart-section">
<h2>年龄分布</h2>
<div v-if="isLoading" class="center-tips" v-text="'正在分析客户年龄'" />
<div v-else-if="!ageDataList.length" class="center-tips" v-text="'暂无可分析的客户年龄'" />
<div v-else ref="chartElement" class="chart" />
</section>
</template>
<script setup lang="ts">
import type { EChartsOption } from "echarts";
import StoreChartAPI from "@/api/StoreChartAPI";
import type { CustomerAgeStatView } from "@/types/Customer";
import { getErrorMessage } from "@/utils/error";
import { Message } from "@/utils/message";
import { useStoreChart } from "@/pages/store/component/useStoreChart";
defineOptions({
name: "StoreCustomerAgeChart"
});
// ---------- 图表数据 ----------
const ageList = ref<CustomerAgeStatView[]>([]);
const isLoading = ref(false);
let requestId = 0;
const ageDataList = computed(() => ageList.value
.filter(item => item.age !== undefined && Number.isFinite(item.age) && 0 < (item.count || 0))
.map(item => [item.age as number, item.count || 0]));
const shouldRotateAgeLabel = computed(() => 12 < ageDataList.value.length);
const chartOption = computed<EChartsOption>(() => ({
grid: {
top: 24,
left: 12,
right: 12,
bottom: 0,
containLabel: true
},
tooltip: {
trigger: "axis",
axisPointer: {
type: "shadow"
},
formatter: "年龄:{b}<br/>客户数:{c} 人"
},
xAxis: {
type: "category",
data: ageDataList.value.map(item => item[0]),
axisTick: {
show: false
},
axisLabel: {
color: "#52607a",
hideOverlap: true,
interval: 0,
rotate: shouldRotateAgeLabel.value ? 45 : 0
}
},
yAxis: {
type: "value",
name: "客户数",
minInterval: 1,
axisLabel: {
color: "#52607a"
},
splitLine: {
lineStyle: {
color: "rgba(148, 163, 184, .2)"
}
}
},
series: [
{
type: "bar",
barMaxWidth: 24,
itemStyle: {
color: "#0f766e",
},
data: ageDataList.value.map(item => item[1])
}
]
}));
const { chartElement } = useStoreChart(chartOption);
// ---------- 图表生命周期 ----------
onMounted(async () => {
await loadAgeList();
});
async function loadAgeList(): Promise<void> {
const currentRequestId = ++requestId;
isLoading.value = true;
try {
const list = await StoreChartAPI.customerAgeStat();
if (currentRequestId === requestId) {
ageList.value = list || [];
}
} catch (error) {
if (currentRequestId === requestId) {
Message.error(getErrorMessage(error, "客户年龄图表加载失败"));
}
} finally {
if (currentRequestId === requestId) {
isLoading.value = false;
}
}
}
</script>
<style scoped lang="less">
.chart-section {
padding: 1rem;
background: rgba(255, 255, 255, .92);
box-shadow: var(--td-shadow-1);
border-radius: 1.25rem;
h2 {
color: #0f172a;
margin: 0 0 .875rem 0;
font-size: 1rem;
font-weight: 700;
}
.chart {
width: 100%;
height: 10rem;
}
}
:global(html.theme-dark) {
.chart-section {
background: rgba(15, 23, 42, .88);
box-shadow: 0 .75rem 2rem rgba(2, 6, 23, .32);
h2 {
color: #e2e8f0;
}
}
}
</style>
@@ -1,6 +1,6 @@
<template> <template>
<section class="chart-section"> <section class="chart-section">
<h2>客户病症词云</h2> <h2>病症词云</h2>
<div v-if="isLoading" class="center-tips" v-text="'正在分析客户病症'" /> <div v-if="isLoading" class="center-tips" v-text="'正在分析客户病症'" />
<div v-else-if="!wordList.length" class="center-tips" v-text="'暂无可分析的病症描述'" /> <div v-else-if="!wordList.length" class="center-tips" v-text="'暂无可分析的病症描述'" />
<div v-else ref="chartElement" class="chart" /> <div v-else ref="chartElement" class="chart" />
@@ -1,6 +1,6 @@
<template> <template>
<section class="chart-section"> <section class="chart-section">
<h2>性别统计</h2> <h2>性别</h2>
<div ref="chartElement" class="chart" /> <div ref="chartElement" class="chart" />
</section> </section>
</template> </template>
@@ -44,8 +44,8 @@ const chartOption = computed<EChartsOption>(() => ({
{ {
name: "客户数", name: "客户数",
type: "pie", type: "pie",
top: 4, top: 0,
bottom: 24, bottom: 0,
radius: ["0%", "58%"], radius: ["0%", "58%"],
center: ["50%", "46%"], center: ["50%", "46%"],
avoidLabelOverlap: true, avoidLabelOverlap: true,
@@ -103,7 +103,7 @@ function getGenderPieData(): Array<{ name: string; value: number; itemStyle: { c
.chart { .chart {
width: 100%; width: 100%;
height: 16rem; height: 12rem;
} }
} }
@@ -1,6 +1,6 @@
<template> <template>
<section class="chart-section"> <section class="chart-section">
<h2>客户增长趋势</h2> <h2>增长趋势</h2>
<div ref="chartElement" class="chart" /> <div ref="chartElement" class="chart" />
<chart-range-slider <chart-range-slider
v-if="showSlider" v-if="showSlider"
@@ -144,7 +144,7 @@ function getDateEnd(dateText: string): Date {
.chart { .chart {
width: 100%; width: 100%;
height: 16rem; height: 12rem;
} }
} }
+5
View File
@@ -64,6 +64,11 @@ export type CustomerGenderStatView = {
count?: number; count?: number;
}; };
export type CustomerAgeStatView = {
age?: number;
count?: number;
};
export type CustomerDiseaseWordView = { export type CustomerDiseaseWordView = {
word?: string; word?: string;
value?: number; value?: number;