add customer age chart
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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" />
|
||||||
<store-event-record-calendar-section v-entrance-transition="70" @list="toEventRecordList" />
|
<t-segmented
|
||||||
<store-event-record-trend-chart v-entrance-transition="140" :date-text-list="dateTextList" />
|
v-model="chartMode"
|
||||||
<store-event-record-number-value-chart v-entrance-transition="210" :date-text-list="dateTextList" />
|
class="chart-mode"
|
||||||
<store-event-structure-chart v-entrance-transition="280" :date-text-list="dateTextList" />
|
block
|
||||||
<store-customer-disease-word-cloud-chart v-entrance-transition="350" />
|
:options="chartModeOptions"
|
||||||
<store-customer-growth-chart v-entrance-transition="420" :date-text-list="dateTextList" />
|
v-entrance-transition="20"
|
||||||
<store-customer-gender-chart v-entrance-transition="490" />
|
/>
|
||||||
|
<template v-if="chartMode === 'event'">
|
||||||
|
<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-number-value-chart v-entrance-transition="210" :date-text-list="dateTextList" />
|
||||||
|
<store-event-structure-chart v-entrance-transition="280" :date-text-list="dateTextList" />
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user