feat(statistics): 添加统计模块及相关视图

This commit is contained in:
pikachu1995@126.com
2026-07-20 09:10:56 +08:00
parent 59c98d1289
commit 6c9a3820e8
8 changed files with 1778 additions and 187 deletions

View File

@@ -0,0 +1,116 @@
import { getRequest } from "@/libs/axios";
/** 营业概况 */
export const getBusinessOverview = (params) => {
return getRequest("/statistics/overview", params);
};
/** 收款构成 */
export const getBusinessSource = (params) => {
return getRequest("/statistics/overview/source", params);
};
/** 营业构成 */
export const getBusinessComposition = (params) => {
return getRequest("/statistics/overview/businessComposition", params);
};
/** 商品概况 */
export const getGoodsOverview = (params) => {
return getRequest("/statistics/goods/overview", params);
};
/** 退货排行榜 TOP10 */
export const getGoodsRefundRank = (params) => {
return getRequest("/statistics/goods/rank/refund", params);
};
/** 畅销排行榜 TOP10 */
export const getGoodsSalesRank = (params) => {
return getRequest("/statistics/goods/rank/sales", params);
};
/** 分类排行 */
export const getGoodsCategoryRank = (params) => {
return getRequest("/statistics/goods/getCategoryByPage", params);
};
/** 会员概况 */
export const getMemberOverview = (params) => {
return getRequest("/statistics/member/overview", params);
};
/** 会员新增人数趋势 */
export const getMemberNewTrend = (params) => {
return getRequest("/statistics/member/trend", params);
};
/** 客户分析 */
export const getMemberAnalysis = (params) => {
return getRequest("/statistics/member/analysis", params);
};
/** 性别分布 */
export const getMemberGenderDistribution = (params) => {
return getRequest("/statistics/member/distribution/gender", params);
};
/** 地域分布 */
export const getMemberRegionDistribution = (params) => {
return getRequest("/statistics/member/distribution/region", params);
};
/** 积分分析概览 */
export const getPointsOverview = () => {
return getRequest("/statistics/points");
};
/** 客户可用积分分布 */
export const getPointsDistribution = () => {
return getRequest("/statistics/points/distribution");
};
/** 积分累计分发分布 */
export const getPointsSourceDistribution = () => {
return getRequest("/statistics/points/sourceDistribution");
};
/** 客户身份积分累计统计 */
export const getPointsIdentityStat = () => {
return getRequest("/statistics/points/identityStat");
};
/** 储值余额分布 */
export const getDepositBalanceDistribution = () => {
return getRequest("/statistics/deposit/balanceDistribution");
};
/** 储值充值次数分布 */
export const getDepositRechargeTimesDistribution = () => {
return getRequest("/statistics/deposit/rechargeTimesDistribution");
};
/** 储值充值金额分布 */
export const getDepositRechargeAmountDistribution = () => {
return getRequest("/statistics/deposit/rechargeAmountDistribution");
};
/** 营销概况 */
export const getMarketingOverview = (params) => {
return getRequest("/statistics/marketing", params);
};
/** 分销概况 */
export const getDistributionOverview = (params) => {
return getRequest("/statistics/distribution", params);
};
/** TOP分销员 */
export const getDistributionRankDistributor = (params) => {
return getRequest("/statistics/distribution/rank/distributor", params);
};
/** TOP分销商品 */
export const getDistributionRankGoods = (params) => {
return getRequest("/statistics/distribution/rank/goods", params);
};

View File

@@ -0,0 +1,148 @@
<template>
<div class="wrapper">
<el-card class="card">
<h4>储值余额分布</h4>
<div id="depositBalanceChart"></div>
</el-card>
<el-card class="card">
<h4>充值次数分布</h4>
<div id="depositTimesChart"></div>
</el-card>
<el-card class="card">
<h4>充值金额分布</h4>
<div id="depositAmountChart"></div>
</el-card>
</div>
</template>
<script>
import * as API_Statistics from "@/api/statistics";
import { Chart } from "@antv/g2";
export default {
data() {
return {
balanceChart: null,
timesChart: null,
amountChart: null,
};
},
mounted() {
this.loadData();
},
beforeUnmount() {
this.destroyCharts();
},
methods: {
destroyCharts() {
["balanceChart", "timesChart", "amountChart"].forEach((key) => {
if (this[key]) {
this[key].destroy();
this[key] = null;
}
});
},
withProportion(list) {
const rows = list || [];
const total = rows.reduce((sum, item) => sum + (Number(item.memberNum) || 0), 0);
return rows.map((item) => {
const num = Number(item.memberNum) || 0;
const proportion = total > 0 ? Math.round((num / total) * 10000) / 100 : 0;
return {
label: item.label,
memberNum: num,
proportion,
};
});
},
renderBarChart(chartKey, containerId, data, xTitle) {
if (this[chartKey]) {
this[chartKey].destroy();
this[chartKey] = null;
}
this.$nextTick(() => {
const chart = new Chart({
container: containerId,
autoFit: true,
height: 320,
padding: [40, 40, 50, 60],
});
chart.data(data || []);
chart.scale("memberNum", {
nice: true,
min: 0,
alias: "客户数",
});
chart.axis("label", {
title: {
text: xTitle,
},
});
chart.tooltip({
showMarkers: false,
shared: true,
});
chart
.interval()
.position("label*memberNum")
.color("#409EFF")
.label("memberNum", {
offset: 8,
})
.tooltip("label*memberNum*proportion", (label, memberNum, proportion) => ({
name: label,
value: `${memberNum}人(${proportion}%`,
}));
chart.interaction("active-region");
chart.render();
this[chartKey] = chart;
});
},
async loadData() {
const [balanceRes, timesRes, amountRes] = await Promise.all([
API_Statistics.getDepositBalanceDistribution(),
API_Statistics.getDepositRechargeTimesDistribution(),
API_Statistics.getDepositRechargeAmountDistribution(),
]);
if (balanceRes && balanceRes.success) {
this.renderBarChart(
"balanceChart",
"depositBalanceChart",
this.withProportion(balanceRes.result || []),
"余额区间(元)"
);
}
if (timesRes && timesRes.success) {
this.renderBarChart(
"timesChart",
"depositTimesChart",
this.withProportion(timesRes.result || []),
"充值次数区间"
);
}
if (amountRes && amountRes.success) {
this.renderBarChart(
"amountChart",
"depositAmountChart",
this.withProportion(amountRes.result || []),
"累计充值金额区间(元)"
);
}
},
},
};
</script>
<style scoped lang="scss">
.wrapper {
padding-bottom: 40px;
}
.card {
margin-bottom: 10px;
}
h4 {
margin: 0 0 20px 0;
}
</style>

View File

@@ -0,0 +1,213 @@
<template>
<div class="wrapper">
<el-affix :offset="100">
<el-card class="card fixed-bottom">
<affixTime @selected="clickBreadcrumb" />
</el-card>
</el-affix>
<el-card class="card">
<h4>分销概况</h4>
<div class="metric-grid">
<div class="metric-item" v-for="item in metricDefs" :key="item.key">
<div class="metric-label">
<span>{{ item.label }}</span>
<el-tooltip v-if="item.tip" :content="item.tip" placement="top">
<el-icon class="metric-tip"><QuestionFilled /></el-icon>
</el-tooltip>
</div>
<div class="metric-value">
<template v-if="item.isMoney">
{{ $filters.unitPrice(overview[item.key] || 0, "") }}
</template>
<template v-else>
{{ formatNumber(overview[item.key]) }}
</template>
</div>
</div>
</div>
</el-card>
<el-card class="card">
<h4>分销排行</h4>
<el-row :gutter="16">
<el-col :xs="24" :md="12">
<div class="rank-title">TOP分销员</div>
<el-table :data="distributorRank" stripe style="width: 100%">
<el-table-column prop="rank" label="排名" width="70" />
<el-table-column prop="name" label="分销员" min-width="140" show-overflow-tooltip />
<el-table-column label="佣金" min-width="120">
<template #default="{ row }">
{{ $filters.unitPrice(row.amount || 0, "¥") }}
</template>
</el-table-column>
<el-table-column prop="num" label="订单数" min-width="100" />
</el-table>
</el-col>
<el-col :xs="24" :md="12">
<div class="rank-title">TOP分销商品</div>
<el-table :data="goodsRank" stripe style="width: 100%">
<el-table-column prop="rank" label="排名" width="70" />
<el-table-column prop="name" label="商品" min-width="160" show-overflow-tooltip />
<el-table-column label="佣金" min-width="120">
<template #default="{ row }">
{{ $filters.unitPrice(row.amount || 0, "¥") }}
</template>
</el-table-column>
<el-table-column prop="num" label="销量" min-width="100" />
</el-table>
</el-col>
</el-row>
</el-card>
</div>
</template>
<script>
import * as API_Statistics from "@/api/statistics";
import affixTime from "@/components/affix-time";
import { QuestionFilled } from "@element-plus/icons-vue";
export default {
components: { affixTime, QuestionFilled },
data() {
return {
params: {
searchType: "LAST_SEVEN",
year: "",
month: "",
storeId: "",
},
overview: {},
distributorRank: [],
goodsRank: [],
metricDefs: [
{ key: "distributorNum", label: "分销员数", tip: "已通过审核的分销员数量" },
{ key: "applyNum", label: "待审核分销员", tip: "申请中待审核的分销员数量" },
{ key: "distributionOrderNum", label: "分销订单数", tip: "周期内分销订单笔数" },
{
key: "distributionOrderAmount",
label: "分销订单金额",
isMoney: true,
tip: "周期内分销订单佣金合计",
},
{
key: "settledCommission",
label: "已结算佣金",
isMoney: true,
tip: "周期内已完成结算的佣金",
},
{
key: "pendingCommission",
label: "待结算佣金",
isMoney: true,
tip: "周期内待结算的佣金",
},
{
key: "cashAmount",
label: "提现金额",
isMoney: true,
tip: "周期内分销员提现金额",
},
],
};
},
watch: {
params: {
handler() {
this.loadData();
},
deep: true,
immediate: true,
},
},
methods: {
clickBreadcrumb(item) {
this.params = {
searchType: item.searchType || "",
year: item.year || "",
month: item.month || "",
storeId: item.storeId || "",
startTime: item.startTime || "",
endTime: item.endTime || "",
};
},
formatNumber(val) {
if (val == null || val === "") return 0;
const num = Number(val);
return Number.isNaN(num) ? 0 : Math.round(num);
},
async loadData() {
const query = { ...this.params };
const [overviewRes, distributorRes, goodsRes] = await Promise.all([
API_Statistics.getDistributionOverview(query),
API_Statistics.getDistributionRankDistributor(query),
API_Statistics.getDistributionRankGoods(query),
]);
if (overviewRes && overviewRes.success) {
this.overview = overviewRes.result || {};
}
if (distributorRes && distributorRes.success) {
this.distributorRank = distributorRes.result || [];
}
if (goodsRes && goodsRes.success) {
this.goodsRank = goodsRes.result || [];
}
},
},
};
</script>
<style scoped lang="scss">
.wrapper {
padding-bottom: 200px;
}
.card {
margin-bottom: 10px;
}
h4 {
margin: 0 0 20px 0;
}
.metric-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
}
.metric-item {
background: #f3f5f7;
border-radius: 8px;
padding: 20px 16px;
min-height: 90px;
}
.metric-label {
display: flex;
align-items: center;
gap: 4px;
font-size: 14px;
color: #666;
margin-bottom: 12px;
}
.metric-tip {
font-size: 14px;
color: #999;
cursor: pointer;
}
.metric-value {
font-size: 22px;
font-weight: 700;
color: $theme_color;
}
.rank-title {
font-weight: 700;
margin-bottom: 12px;
}
@media (max-width: 1200px) {
.metric-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
.rank-title {
margin-top: 16px;
}
}
</style>

View File

@@ -1,107 +1,293 @@
<template> <template>
<div> <div class="wrapper">
<el-affix :offset="100"> <el-affix :offset="100">
<el-card class="card fixed-bottom"> <el-card class="card fixed-bottom">
<affixTime :closeShop="true" @selected="clickBreadcrumb" /> <affixTime @selected="clickBreadcrumb" />
</el-card> </el-card>
</el-affix> </el-affix>
<el-card class="card"> <el-card class="card">
<el-tabs v-model="params.type" @tab-click="handleClickType"> <h4>商品概况</h4>
<el-tab-pane label="热门商品订单数量" name="NUM"> <div class="metric-grid">
<el-table :data="data" stripe style="width: 100%"> <div class="metric-item" v-for="item in metricList" :key="item.key">
<el-table-column prop="goodsName" label="商品名称" min-width="160" /> <div class="metric-label">
<el-table-column prop="num" label="销售数量" min-width="120" /> <span>{{ item.label }}</span>
<el-table-column label="销售金额" min-width="120"> <el-tooltip v-if="item.tip" :content="item.tip" placement="top">
<el-icon class="metric-tip"><QuestionFilled /></el-icon>
</el-tooltip>
</div>
<div class="metric-value">
<template v-if="item.isCount">
{{ formatCount(item.metric) }}
</template>
<template v-else>
{{ $filters.unitPrice(metricCurrent(item.metric), "") }}
</template>
</div>
<div class="metric-rate" :class="rateClass(item.metric)">
环比 {{ rateText(item.metric) }}
</div>
</div>
</div>
</el-card>
<el-card class="card">
<h4>商品排行</h4>
<el-row :gutter="16">
<el-col :xs="24" :md="12">
<div class="rank-title">退货排行榜 TOP10</div>
<el-table :data="refundRank" stripe style="width: 100%">
<el-table-column prop="rank" label="排名" width="70" />
<el-table-column prop="skuName" label="商品名称" min-width="180" show-overflow-tooltip />
<el-table-column label="退款金额" min-width="120">
<template #default="{ row }"> <template #default="{ row }">
<priceColorScheme v-if="row" :value="row.price" :color="$mainColor" /> {{ $filters.unitPrice(row.amount || 0, "¥") }}
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
</el-tab-pane> </el-col>
<el-tab-pane label="热门商品订单金额" name="PRICE"> <el-col :xs="24" :md="12">
<el-table :data="data" stripe style="width: 100%"> <div class="rank-title">畅销排行榜 TOP10</div>
<el-table-column prop="goodsName" label="商品名称" min-width="160" /> <el-table :data="salesRank" stripe style="width: 100%">
<el-table-column prop="num" label="销售数量" min-width="120" /> <el-table-column prop="rank" label="排名" width="70" />
<el-table-column label="销售金额" min-width="120"> <el-table-column prop="skuName" label="商品" min-width="180" show-overflow-tooltip />
<el-table-column label="净销售额" min-width="120">
<template #default="{ row }"> <template #default="{ row }">
<priceColorScheme v-if="row" :value="row.price" :color="$mainColor" /> {{ $filters.unitPrice(row.amount || 0, "¥") }}
</template>
</el-table-column>
</el-table>
</el-col>
</el-row>
</el-card>
<el-card class="card">
<h4>分类排行</h4>
<el-table :data="categoryRank" stripe style="width: 100%">
<el-table-column prop="rank" label="排名" width="70" />
<el-table-column prop="categoryName" label="分类名称" min-width="180" show-overflow-tooltip />
<el-table-column prop="num" label="销售数量" min-width="120" />
<el-table-column label="销售金额" min-width="140">
<template #default="{ row }">
{{ $filters.unitPrice(row.price || 0, "¥") }}
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
</el-tab-pane>
</el-tabs>
</el-card> </el-card>
</div> </div>
</template> </template>
<script> <script>
import * as API_Goods from "@/api/goods"; import * as API_Statistics from "@/api/statistics";
import affixTime from "@/components/affix-time"; import affixTime from "@/components/affix-time";
import { QuestionFilled } from "@element-plus/icons-vue";
export default { export default {
components: { components: { affixTime, QuestionFilled },
affixTime,
},
data() { data() {
return { return {
params: { params: {
searchType: "LAST_SEVEN", searchType: "LAST_SEVEN",
year: "", year: "",
month: "", month: "",
shopId: "", storeId: "",
type: "NUM",
}, },
data: [], overview: {},
refundRank: [],
salesRank: [],
categoryRank: [],
metricDefs: [
{
key: "netSalesAmount",
label: "商品净销售金额",
isCount: false,
tip: "商品净销售金额=商品销售金额-商品退款金额(扣除退款,不包含储值充值、商品返现,运费)",
},
{
key: "salePriceAmount",
label: "商品售价金额",
isCount: false,
tip: "商品售价金额=商品净销售金额+商品优惠金额,统计时间范围内销售商品折前金额(扣除退款,不包含储值充值、商品返现,运费)",
},
{
key: "discountAmount",
label: "商品优惠金额",
isCount: false,
tip: "统计时间范围内,商品销售时的优惠金额(扣除退款,不包含储值充值、商品返现,运费)",
},
{
key: "salesAmount",
label: "商品销售金额",
isCount: false,
tip: "统计时间范围内,商品折扣后支付金额(不扣除退款,不包含储值充值、商品返现,运费)",
},
{
key: "refundAmount",
label: "商品退款金额",
isCount: false,
tip: "统计时间范围内,商品成功退款金额(不包含储值充值、商品返现,运费)",
},
{
key: "netSalesNum",
label: "商品净销售数量",
isCount: true,
tip: "商品净销售数量=商品销售数量-商品退货数量(含退款,不包含储值充值、商品返现,运费)",
},
{
key: "salesNum",
label: "商品销售数量",
isCount: true,
tip: "按客户支付完成时间统计的订单商品销售数量(包含商品下单,不包含储值充值)",
},
{
key: "refundNum",
label: "商品退货数量",
isCount: true,
tip: "按商家同意退款时间统计的退款单商品退货数量(不包含储值退款、商品返现,运费)",
},
],
}; };
}, },
computed: {
metricList() {
return this.metricDefs.map((def) => ({
...def,
metric: this.overview[def.key] || {},
}));
},
},
watch: {
params: {
handler() {
this.loadData();
},
deep: true,
immediate: true,
},
},
methods: { methods: {
handleClickType(tab) {
this.params.type = tab.paneName;
this.getData();
},
clickBreadcrumb(item) { clickBreadcrumb(item) {
const type = this.params.type; this.params = {
this.params = { ...item }; searchType: item.searchType || "",
this.params.type = type; year: item.year || "",
this.getData(); month: item.month || "",
storeId: item.storeId || "",
startTime: item.startTime || "",
endTime: item.endTime || "",
};
}, },
getData() { metricCurrent(metric) {
Promise.all([API_Goods.goodsStatistics(this.params)]).then((res) => { if (!metric || metric.current == null) return 0;
if (res[0].result) { return Number(metric.current) || 0;
this.data = res[0].result; },
formatCount(metric) {
return Math.round(this.metricCurrent(metric));
},
rateText(metric) {
if (!metric || !metric.rate) return "0%";
return metric.rate;
},
rateClass(metric) {
const trend = metric && metric.trend;
if (trend === "UP") return "up";
if (trend === "DOWN") return "down";
return "flat";
},
async loadData() {
const query = { ...this.params };
const categoryQuery = { ...query, type: "PRICE" };
const [overviewRes, refundRes, salesRes, categoryRes] = await Promise.all([
API_Statistics.getGoodsOverview(query),
API_Statistics.getGoodsRefundRank(query),
API_Statistics.getGoodsSalesRank(query),
API_Statistics.getGoodsCategoryRank(categoryQuery),
]);
if (overviewRes && overviewRes.success) {
this.overview = overviewRes.result || {};
}
if (refundRes && refundRes.success) {
this.refundRank = refundRes.result || [];
}
if (salesRes && salesRes.success) {
this.salesRank = salesRes.result || [];
}
if (categoryRes && categoryRes.success) {
const list = categoryRes.result || [];
this.categoryRank = list.map((item, index) => ({
...item,
rank: index + 1,
}));
} }
});
}, },
}, },
mounted() {
this.getData();
},
}; };
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.page-col { .wrapper {
text-align: right; padding-bottom: 200px;
margin: 10px 0;
} }
.order-col {
display: flex;
> div {
margin-right: 8px;
padding: 16px;
font-size: 15px;
}
}
.order-list {
display: flex;
}
.tips {
margin: 0 8px;
}
.card { .card {
margin-bottom: 10px; margin-bottom: 10px;
} }
h4 {
margin: 0 0 20px 0;
}
.metric-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
}
.metric-item {
background: #f3f5f7;
border-radius: 8px;
padding: 20px 16px;
min-height: 110px;
}
.metric-label {
display: flex;
align-items: center;
gap: 4px;
font-size: 14px;
color: #666;
margin-bottom: 12px;
}
.metric-tip {
font-size: 14px;
color: #999;
cursor: pointer;
}
.metric-value {
font-size: 22px;
font-weight: 700;
color: $theme_color;
margin-bottom: 8px;
}
.metric-rate {
font-size: 13px;
font-weight: 600;
}
.metric-rate.up {
color: #f5222d;
}
.metric-rate.down {
color: #52c41a;
}
.metric-rate.flat {
color: #999;
}
.rank-title {
font-weight: 700;
margin-bottom: 12px;
}
@media (max-width: 1200px) {
.metric-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
.rank-title {
margin-top: 16px;
}
}
</style> </style>

View File

@@ -0,0 +1,171 @@
<template>
<div class="wrapper">
<el-affix :offset="100">
<el-card class="card fixed-bottom">
<affixTime @selected="clickBreadcrumb" />
</el-card>
</el-affix>
<el-card class="card">
<h4>营销活动</h4>
<div class="metric-grid">
<div class="metric-item" v-for="item in activityMetrics" :key="item.key">
<div class="metric-label">{{ item.label }}</div>
<div class="metric-value">{{ formatNumber(overview[item.key]) }}</div>
</div>
</div>
</el-card>
<el-card class="card">
<h4>优惠券与优惠</h4>
<div class="metric-grid">
<div class="metric-item" v-for="item in couponMetrics" :key="item.key">
<div class="metric-label">
<span>{{ item.label }}</span>
<el-tooltip v-if="item.tip" :content="item.tip" placement="top">
<el-icon class="metric-tip"><QuestionFilled /></el-icon>
</el-tooltip>
</div>
<div class="metric-value">
<template v-if="item.isMoney">
{{ $filters.unitPrice(overview[item.key] || 0, "") }}
</template>
<template v-else>
{{ formatNumber(overview[item.key]) }}
</template>
</div>
</div>
</div>
</el-card>
</div>
</template>
<script>
import * as API_Statistics from "@/api/statistics";
import affixTime from "@/components/affix-time";
import { QuestionFilled } from "@element-plus/icons-vue";
export default {
components: { affixTime, QuestionFilled },
data() {
return {
params: {
searchType: "LAST_SEVEN",
year: "",
month: "",
storeId: "",
},
overview: {},
activityMetrics: [
{ key: "couponActiveNum", label: "进行中优惠券" },
{ key: "seckillActiveNum", label: "进行中秒杀" },
{ key: "pintuanActiveNum", label: "进行中拼团" },
{ key: "fullDiscountActiveNum", label: "进行中满减" },
{ key: "kanjiaActiveNum", label: "进行中砍价商品" },
{ key: "pointsGoodsActiveNum", label: "进行中积分商品" },
],
couponMetrics: [
{
key: "couponPublishNum",
label: "优惠券发放量",
tip: "优惠券配置的累计发放数量",
},
{
key: "couponReceivedNum",
label: "优惠券领取量",
tip: "客户已领取的优惠券数量",
},
{
key: "couponUsedNum",
label: "优惠券核销量",
tip: "客户已使用的优惠券数量",
},
{
key: "discountAmount",
label: "活动优惠总额",
isMoney: true,
tip: "统计周期内订单产生的优惠金额合计",
},
],
};
},
watch: {
params: {
handler() {
this.loadData();
},
deep: true,
immediate: true,
},
},
methods: {
clickBreadcrumb(item) {
this.params = {
searchType: item.searchType || "",
year: item.year || "",
month: item.month || "",
storeId: item.storeId || "",
startTime: item.startTime || "",
endTime: item.endTime || "",
};
},
formatNumber(val) {
if (val == null || val === "") return 0;
const num = Number(val);
return Number.isNaN(num) ? 0 : Math.round(num);
},
async loadData() {
const res = await API_Statistics.getMarketingOverview({ ...this.params });
if (res && res.success) {
this.overview = res.result || {};
}
},
},
};
</script>
<style scoped lang="scss">
.wrapper {
padding-bottom: 200px;
}
.card {
margin-bottom: 10px;
}
h4 {
margin: 0 0 20px 0;
}
.metric-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
.metric-item {
background: #f3f5f7;
border-radius: 8px;
padding: 20px 16px;
min-height: 90px;
}
.metric-label {
display: flex;
align-items: center;
gap: 4px;
font-size: 14px;
color: #666;
margin-bottom: 12px;
}
.metric-tip {
font-size: 14px;
color: #999;
cursor: pointer;
}
.metric-value {
font-size: 22px;
font-weight: 700;
color: $theme_color;
}
@media (max-width: 1200px) {
.metric-grid {
grid-template-columns: repeat(2, 1fr);
}
}
</style>

View File

@@ -2,167 +2,311 @@
<div class="wrapper"> <div class="wrapper">
<el-affix :offset="100"> <el-affix :offset="100">
<el-card class="card fixed-bottom"> <el-card class="card fixed-bottom">
<affixTime :closeShop="true" @selected="clickBreadcrumb" /> <affixTime @selected="clickBreadcrumb" />
</el-card> </el-card>
</el-affix> </el-affix>
<el-card class="card"> <el-card class="card">
<div> <h4>会员概况</h4>
<h4> <div class="metric-grid">
客户增长趋势 <div class="metric-item" v-for="item in metricList" :key="item.key">
<el-button style="margin-left: 10px" size="small" @click="toggleMemberCount"> <div class="metric-label">
{{ enableShowMemberCount ? "关闭" : "显示" }}用户总人数 <span>{{ item.label }}</span>
</el-button> <el-tooltip v-if="item.tip" :content="item.tip" placement="top">
</h4> <el-icon class="metric-tip"><QuestionFilled /></el-icon>
<div id="orderChart"></div> </el-tooltip>
</div>
<div class="metric-value">
<template v-if="item.isMoney">
{{ $filters.unitPrice(metricCurrent(item.metric), "") }}
</template>
<template v-else>
{{ formatCount(item.metric) }}
</template>
</div>
<div class="metric-rate" :class="rateClass(item.metric)">
环比 {{ rateText(item.metric) }}
</div>
</div>
</div> </div>
</el-card> </el-card>
<el-card class="card"> <el-card class="card">
<div> <h4>会员人数趋势图</h4>
<h4>客户增长报表</h4> <div id="memberTrendChart"></div>
<el-table class="mt_10" stripe :data="data" style="width: 100%"> </el-card>
<el-table-column prop="createDate" label="日期" min-width="120" sortable />
<el-table-column prop="memberCount" label="当前会员" min-width="120" /> <el-card class="card">
<el-table-column prop="newlyAdded" label="新增会员" min-width="120" /> <h4>客户分析</h4>
<el-table-column prop="activeQuantity" label="活跃会员" min-width="120" /> <div class="analysis-grid">
</el-table> <div class="analysis-item">
<div class="metric-label">活跃客户数</div>
<div class="metric-value">{{ analysis.activeMemberNum || 0 }}</div>
</div> </div>
<div class="analysis-item">
<div class="metric-label">复购率</div>
<div class="metric-value">{{ formatPercent(analysis.repurchaseRate) }}</div>
</div>
<div class="analysis-item">
<div class="metric-label">新客占比</div>
<div class="metric-value">{{ formatPercent(analysis.newCustomerRatio) }}</div>
</div>
<div class="analysis-item">
<div class="metric-label">老客占比</div>
<div class="metric-value">{{ formatPercent(analysis.oldCustomerRatio) }}</div>
</div>
</div>
</el-card>
<el-card class="card">
<h4>客户分布</h4>
<el-row :gutter="16">
<el-col :xs="24" :md="12">
<div class="rank-title">性别分布</div>
<el-table :data="genderList" stripe style="width: 100%">
<el-table-column label="性别" min-width="100">
<template #default="{ row }">
{{ sexText(row.sex) }}
</template>
</el-table-column>
<el-table-column prop="num" label="客户数" min-width="100" />
<el-table-column label="占比" min-width="100">
<template #default="{ row }">
{{ formatPercent(row.proportion) }}
</template>
</el-table-column>
</el-table>
</el-col>
<el-col :xs="24" :md="12">
<div class="rank-title">地域分布</div>
<el-table :data="regionList" stripe style="width: 100%" max-height="360">
<el-table-column prop="region" label="地域" min-width="140" show-overflow-tooltip />
<el-table-column prop="num" label="客户数" min-width="100" />
<el-table-column label="占比" min-width="100">
<template #default="{ row }">
{{ formatPercent(row.proportion) }}
</template>
</el-table-column>
</el-table>
</el-col>
</el-row>
</el-card> </el-card>
</div> </div>
</template> </template>
<script> <script>
import * as API_Member from "@/api/member"; import * as API_Statistics from "@/api/statistics";
import { Chart } from "@antv/g2";
import affixTime from "@/components/affix-time"; import affixTime from "@/components/affix-time";
import { QuestionFilled } from "@element-plus/icons-vue";
import { Chart } from "@antv/g2";
export default { export default {
components: { affixTime }, components: { affixTime, QuestionFilled },
data() { data() {
return { return {
orderChart: "",
params: { params: {
searchType: "LAST_SEVEN", searchType: "LAST_SEVEN",
year: "", year: "",
month: "", month: "",
shopId: "", storeId: "",
}, },
data: [], overview: {},
enableShowMemberCount: false, analysis: {},
year: "", genderList: [],
regionList: [],
trendChart: null,
metricDefs: [
{
key: "totalMemberNum",
label: "累积会员数",
isMoney: false,
tip: "累积会员数:合计会员数量",
},
{
key: "newMemberNum",
label: "新增会员数",
isMoney: false,
tip: "新增会员数:周期内新增的会员数量",
},
{
key: "payMemberNum",
label: "支付会员数",
isMoney: false,
tip: "支付会员数:周期内支付的会员数量",
},
{
key: "rechargeMemberNum",
label: "储值会员数",
isMoney: false,
tip: "储值会员数:周期内充值的会员数量",
},
{
key: "payAmount",
label: "会员支付金额",
isMoney: true,
tip: "会员支付金额周期内会员支付的金额四舍五入保留2位小数",
},
{
key: "payOrderNum",
label: "会员支付订单数",
isMoney: false,
tip: "会员支付订单数:周期内会员支付的订单数量",
},
{
key: "customerPrice",
label: "会员客单价",
isMoney: true,
tip: "会员客单价:周期内支付金额/支付人数四舍五入保留2位小数",
},
],
}; };
}, },
computed: {
metricList() {
return this.metricDefs.map((def) => ({
...def,
metric: this.overview[def.key] || {},
}));
},
},
watch: { watch: {
params: { params: {
handler() { handler() {
this.init(); this.loadData();
}, },
deep: true, deep: true,
immediate: true, immediate: true,
}, },
year(val) {
this.params.year = new Date(val).getFullYear();
}, },
beforeUnmount() {
if (this.trendChart) {
this.trendChart.destroy();
this.trendChart = null;
}
}, },
methods: { methods: {
toggleMemberCount() { clickBreadcrumb(item) {
this.enableShowMemberCount = !this.enableShowMemberCount; this.params = {
this.initMemberChart(); ...this.params,
searchType: item.searchType || "",
year: item.year || "",
month: item.month || "",
storeId: item.storeId || "",
startTime: item.startTime || "",
endTime: item.endTime || "",
};
}, },
initMemberChart() { metricCurrent(metric) {
const count = []; if (!metric || metric.current == null) return 0;
const newly = []; return Number(metric.current) || 0;
const actives = []; },
formatCount(metric) {
this.data.forEach((item) => { return Math.round(this.metricCurrent(metric));
if (this.enableShowMemberCount && (item.memberCount != "" || item.memberCount != null)) { },
count.push({ rateText(metric) {
createDate: item.createDate, if (!metric || !metric.rate) return "0%";
memberCount: item.memberCount, return metric.rate;
title: "当前会员数量", },
}); rateClass(metric) {
const trend = metric && metric.trend;
if (trend === "UP") return "up";
if (trend === "DOWN") return "down";
return "flat";
},
formatPercent(val) {
if (val == null || val === "") return "0%";
const num = Number(val);
if (Number.isNaN(num)) return val;
return `${num}%`;
},
sexText(sex) {
if (sex === 1 || sex === "1") return "男";
if (sex === 0 || sex === "0") return "女";
return "未知";
},
formatTrendDate(val) {
if (!val) return "";
if (typeof val === "string") return val.slice(0, 10);
const d = new Date(val);
if (Number.isNaN(d.getTime())) return String(val);
const m = `${d.getMonth() + 1}`.padStart(2, "0");
const day = `${d.getDate()}`.padStart(2, "0");
return `${d.getFullYear()}-${m}-${day}`;
},
renderTrendChart(list) {
const chartData = (list || []).map((item) => ({
date: this.formatTrendDate(item.date),
newlyAdded: Number(item.newlyAdded) || 0,
title: "新增会员数",
}));
if (this.trendChart) {
this.trendChart.destroy();
this.trendChart = null;
} }
if (!this.enableShowMemberCount && (item.newlyAdded != "" || item.newlyAdded != null)) { this.$nextTick(() => {
newly.push({ this.trendChart = new Chart({
createDate: item.createDate, container: "memberTrendChart",
memberCount: item.newlyAdded, autoFit: true,
title: "新增会员数量", height: 360,
padding: [40, 40, 50, 50],
}); });
} this.trendChart.data(chartData);
if (!this.enableShowMemberCount && (item.activeQuantity != "" || item.activeQuantity != null)) { this.trendChart.scale({
actives.push({ newlyAdded: {
createDate: item.createDate,
memberCount: item.activeQuantity,
title: "当日活跃数量",
});
}
});
const chartData = [...count, ...newly, ...actives];
this.orderChart.data(chartData);
this.orderChart.scale({
activeQuantity: {
range: [0, 1],
nice: true, nice: true,
min: 0,
}, },
}); });
this.orderChart.tooltip({ this.trendChart.tooltip({
showCrosshairs: true, showCrosshairs: true,
shared: true, shared: true,
}); });
this.trendChart
this.orderChart
.line() .line()
.position("createDate*memberCount") .position("date*newlyAdded")
.color("title") .color("title")
.label("memberCount")
.shape("smooth"); .shape("smooth");
this.trendChart
this.orderChart
.point() .point()
.position("createDate*memberCount") .position("date*newlyAdded")
.color("title") .color("title")
.label("memberCount")
.shape("circle") .shape("circle")
.style({ .style({
stroke: "#fff", stroke: "#fff",
lineWidth: 1, lineWidth: 1,
}); });
this.orderChart.area().position("createDate*memberCount").color("title").shape("smooth"); this.trendChart.render();
this.orderChart.render();
},
clickBreadcrumb(item) {
this.params = { ...item };
},
init() {
this.orderChart ? this.orderChart.clear() : "";
API_Member.getMemberStatistics(this.params).then((res) => {
if (res.result) {
res.result.forEach((item) => {
item.activeQuantity += "";
});
this.data = res.result;
if (!this.orderChart) {
this.orderChart = new Chart({
container: "orderChart",
autoFit: true,
height: 500,
padding: [70, 70, 70, 70],
}); });
},
async loadData() {
const query = { ...this.params };
const [overviewRes, analysisRes, genderRes, regionRes, trendRes] = await Promise.all([
API_Statistics.getMemberOverview(query),
API_Statistics.getMemberAnalysis(query),
API_Statistics.getMemberGenderDistribution(),
API_Statistics.getMemberRegionDistribution(),
API_Statistics.getMemberNewTrend(query),
]);
if (overviewRes && overviewRes.success) {
this.overview = overviewRes.result || {};
} }
this.initMemberChart(); if (analysisRes && analysisRes.success) {
this.analysis = analysisRes.result || {};
}
if (genderRes && genderRes.success) {
this.genderList = genderRes.result || [];
}
if (regionRes && regionRes.success) {
this.regionList = regionRes.result || [];
}
if (trendRes && trendRes.success) {
this.renderTrendChart(trendRes.result || []);
} }
});
}, },
}, },
mounted() {
this.year = new Date();
},
}; };
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.wrapper { .wrapper {
padding-bottom: 200px; padding-bottom: 200px;
@@ -170,7 +314,67 @@ export default {
.card { .card {
margin-bottom: 10px; margin-bottom: 10px;
} }
.mt_10 { h4 {
margin-top: 10px; margin: 0 0 20px 0;
}
.metric-grid,
.analysis-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
}
.metric-item,
.analysis-item {
background: #f3f5f7;
border-radius: 8px;
padding: 20px 16px;
min-height: 110px;
}
.metric-label {
display: flex;
align-items: center;
gap: 4px;
font-size: 14px;
color: #666;
margin-bottom: 12px;
}
.metric-tip {
font-size: 14px;
color: #999;
cursor: pointer;
}
.metric-value {
font-size: 22px;
font-weight: 700;
color: $theme_color;
margin-bottom: 8px;
}
.metric-rate {
font-size: 13px;
font-weight: 600;
}
.metric-rate.up {
color: #f5222d;
}
.metric-rate.down {
color: #52c41a;
}
.metric-rate.flat {
color: #999;
}
.rank-title {
font-weight: 700;
margin-bottom: 12px;
}
@media (max-width: 1200px) {
.metric-grid,
.analysis-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
.rank-title {
margin-top: 16px;
}
} }
</style> </style>

View File

@@ -0,0 +1,351 @@
<template>
<div class="wrapper">
<el-affix :offset="100">
<el-card class="card fixed-bottom">
<affixTime @selected="clickBreadcrumb" />
</el-card>
</el-affix>
<el-card class="card">
<h4>营业概况</h4>
<div class="metric-grid">
<div class="metric-item" v-for="item in metricList" :key="item.key">
<div class="metric-label">
<span>{{ item.label }}</span>
<el-tooltip v-if="item.tip" :content="item.tip" placement="top">
<el-icon class="metric-tip"><QuestionFilled /></el-icon>
</el-tooltip>
</div>
<div class="metric-value">
<template v-if="item.isCount">
{{ formatCount(item.metric) }}
</template>
<template v-else>
{{ $filters.unitPrice(metricCurrent(item.metric), "") }}
</template>
</div>
<div class="metric-rate" :class="rateClass(item.metric)">
环比 {{ rateText(item.metric) }}
</div>
</div>
</div>
</el-card>
<el-card class="card">
<h4>收款构成</h4>
<el-table :data="sourceList" stripe style="width: 100%">
<el-table-column prop="payType" label="支付方式" min-width="120" />
<el-table-column label="收款合计" min-width="140">
<template #default="{ row }">
{{ $filters.unitPrice(row.total || 0, "¥") }}
</template>
</el-table-column>
<el-table-column label="营业收入" min-width="140">
<template #default="{ row }">
{{ $filters.unitPrice(row.income || 0, "¥") }}
</template>
</el-table-column>
<el-table-column label="新增储值金额" min-width="140">
<template #default="{ row }">
{{ $filters.unitPrice(row.recharge || 0, "¥") }}
</template>
</el-table-column>
</el-table>
</el-card>
<el-card class="card">
<h4>营业构成</h4>
<div class="composition-sections">
<div class="composition-block">
<div class="block-title">订单分类构成</div>
<div class="block-row">
<span>到店自提</span>
<span>{{ $filters.unitPrice(composition.storeSelf || 0, "¥") }}</span>
</div>
<div class="block-row">
<span>快递发货</span>
<span>{{ $filters.unitPrice(composition.express || 0, "¥") }}</span>
</div>
<div class="block-row">
<span>线上无需配送</span>
<span>{{ $filters.unitPrice(composition.online || 0, "¥") }}</span>
</div>
</div>
<div class="composition-block">
<div class="block-title">营业收入</div>
<div class="block-row">
<span>商品销售</span>
<span>{{ $filters.unitPrice(composition.income || 0, "¥") }}</span>
</div>
<div class="block-row">
<span>运费</span>
<span>{{ $filters.unitPrice(composition.freight || 0, "¥") }}</span>
</div>
<div class="block-row">
<span>商品返现分销返佣</span>
<span>{{ $filters.unitPrice(composition.incomeBack || 0, "¥") }}</span>
</div>
<div class="block-row">
<span>商品销售+费用构成</span>
<span>{{ $filters.unitPrice(composition.incomeComposition || 0, "¥") }}</span>
</div>
</div>
<div class="composition-block">
<div class="block-title">退款统计</div>
<div class="block-row">
<span>退款订单笔数</span>
<span>{{ composition.refundOrderNum || 0 }}</span>
</div>
<div class="block-row">
<span>退款金额</span>
<span>{{ $filters.unitPrice(composition.refund || 0, "¥") }}</span>
</div>
<div class="block-row">
<span>退款率</span>
<span>{{ formatPercent(composition.refundRate) }}</span>
</div>
</div>
<div class="composition-block">
<div class="block-title">消费指标</div>
<div class="block-row">
<span>支付金额</span>
<span>{{ $filters.unitPrice(composition.pay || 0, "¥") }}</span>
</div>
<div class="block-row">
<span>折后笔单价</span>
<span>{{ $filters.unitPrice(composition.price || 0, "¥") }}</span>
</div>
<div class="block-row">
<span>支付人数</span>
<span>{{ composition.payNum || 0 }}</span>
</div>
<div class="block-row">
<span>折后客单价</span>
<span>{{ $filters.unitPrice(composition.priceNum || 0, "¥") }}</span>
</div>
</div>
</div>
</el-card>
</div>
</template>
<script>
import * as API_Statistics from "@/api/statistics";
import affixTime from "@/components/affix-time";
import { QuestionFilled } from "@element-plus/icons-vue";
export default {
components: { affixTime, QuestionFilled },
data() {
return {
params: {
searchType: "LAST_SEVEN",
year: "",
month: "",
storeId: "",
},
overview: {},
sourceList: [],
composition: {},
metricDefs: [
{
key: "income",
label: "营业收入",
isCount: false,
tip: "营业收入:订单扣除退款折扣后金额(不含储值充值)",
},
{
key: "turnover",
label: "营业额",
isCount: false,
tip: "营业额:营业额=营业收入+优惠金额,订单扣除退款折扣前金额(不含储值充值)",
},
{
key: "discount",
label: "优惠金额",
isCount: false,
tip: "优惠金额:订单的优惠金额(扣除退款,不含储值充值)",
},
{
key: "incomeNoStoreValue",
label: "营业收入不含充值",
isCount: false,
tip: "营业收入不含储值金额:订单扣除退款折扣后金额(不含储值充值)",
},
{
key: "payOrderNum",
label: "支付订单数",
isCount: true,
tip: "支付订单数:按客户支付完成时间统计的成功付款的订单数(不含退款、储值充值)",
},
{
key: "recharge",
label: "新增充值金额",
isCount: false,
tip: "新增充值金额:按客户支付完成时间统计的客户储值充值本金金额",
},
{
key: "rechargeUse",
label: "使用充值金额",
isCount: false,
tip: "使用储值支付本金金额:统计时间范围内,支付和退款成功的订单中,使用储值支付且扣除退款的金额",
},
],
};
},
computed: {
metricList() {
return this.metricDefs.map((def) => ({
...def,
metric: this.overview[def.key] || {},
}));
},
},
watch: {
params: {
handler() {
this.loadData();
},
deep: true,
immediate: true,
},
},
methods: {
clickBreadcrumb(item) {
this.params = {
searchType: item.searchType || "",
year: item.year || "",
month: item.month || "",
storeId: item.storeId || "",
};
},
metricCurrent(metric) {
if (!metric || metric.current == null) return 0;
return Number(metric.current) || 0;
},
formatCount(metric) {
return Math.round(this.metricCurrent(metric));
},
rateText(metric) {
if (!metric || !metric.rate) return "0%";
return metric.rate;
},
rateClass(metric) {
const trend = metric && metric.trend;
if (trend === "UP") return "up";
if (trend === "DOWN") return "down";
return "flat";
},
formatPercent(val) {
if (val == null || val === "") return "0%";
const num = Number(val);
if (Number.isNaN(num)) return val;
return `${num}%`;
},
async loadData() {
const query = { ...this.params };
const [overviewRes, sourceRes, compositionRes] = await Promise.all([
API_Statistics.getBusinessOverview(query),
API_Statistics.getBusinessSource(query),
API_Statistics.getBusinessComposition(query),
]);
if (overviewRes && overviewRes.success) {
this.overview = overviewRes.result || {};
}
if (sourceRes && sourceRes.success) {
this.sourceList = sourceRes.result || [];
}
if (compositionRes && compositionRes.success) {
this.composition = compositionRes.result || {};
}
},
},
};
</script>
<style scoped lang="scss">
.wrapper {
padding-bottom: 200px;
}
.card {
margin-bottom: 10px;
}
h4 {
margin: 0 0 20px 0;
}
.metric-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
}
.metric-item {
background: #f3f5f7;
border-radius: 8px;
padding: 20px 16px;
min-height: 110px;
}
.metric-label {
display: flex;
align-items: center;
gap: 4px;
font-size: 14px;
color: #666;
margin-bottom: 12px;
}
.metric-tip {
font-size: 14px;
color: #999;
cursor: pointer;
}
.metric-value {
font-size: 22px;
font-weight: 700;
color: $theme_color;
margin-bottom: 8px;
}
.metric-rate {
font-size: 13px;
font-weight: 600;
}
.metric-rate.up {
color: #f5222d;
}
.metric-rate.down {
color: #52c41a;
}
.metric-rate.flat {
color: #999;
}
.composition-sections {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
.composition-block {
background: #f3f5f7;
border-radius: 8px;
padding: 16px;
}
.block-title {
font-weight: 700;
margin-bottom: 12px;
}
.block-row {
display: flex;
justify-content: space-between;
padding: 8px 0;
font-size: 14px;
border-bottom: 1px dashed #e5e5e5;
}
.block-row:last-child {
border-bottom: none;
}
@media (max-width: 1200px) {
.metric-grid {
grid-template-columns: repeat(2, 1fr);
}
.composition-sections {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,202 @@
<template>
<div class="wrapper">
<el-card class="card">
<h4>积分分析</h4>
<div class="metric-grid">
<div class="metric-item" v-for="item in metricList" :key="item.key">
<div class="metric-label">
<span>{{ item.label }}</span>
<el-tooltip v-if="item.tip" :content="item.tip" placement="top">
<el-icon class="metric-tip"><QuestionFilled /></el-icon>
</el-tooltip>
</div>
<div class="metric-value">
<template v-if="item.isRate">
{{ formatPercent(overview[item.key]) }}
</template>
<template v-else>
{{ formatNumber(overview[item.key]) }}
</template>
</div>
</div>
</div>
</el-card>
<el-card class="card">
<h4>客户可用积分分布</h4>
<el-table :data="distributionList" stripe style="width: 100%">
<el-table-column prop="pointRange" label="积分值区间" min-width="140" />
<el-table-column prop="memberNum" label="客户数" min-width="120" />
<el-table-column label="占比" min-width="120">
<template #default="{ row }">
{{ formatPercent(row.proportion) }}
</template>
</el-table-column>
</el-table>
</el-card>
<el-card class="card">
<h4>积分累计分发分布</h4>
<el-table :data="sourceList" stripe style="width: 100%">
<el-table-column prop="sourceName" label="发放途径" min-width="160" show-overflow-tooltip />
<el-table-column prop="point" label="发送积分值" min-width="140" />
<el-table-column label="发送占比" min-width="120">
<template #default="{ row }">
{{ formatPercent(row.proportion) }}
</template>
</el-table-column>
</el-table>
</el-card>
<el-card class="card">
<h4>客户身份积分累计统计</h4>
<el-table :data="identityList" stripe style="width: 100%">
<el-table-column prop="identity" label="客户身份" min-width="140" show-overflow-tooltip />
<el-table-column prop="totalIssued" label="累计发送积分" min-width="130" />
<el-table-column prop="usedPoint" label="累计消耗积分" min-width="130" />
<el-table-column prop="available" label="可用积分" min-width="120" />
<el-table-column label="可用积分占比" min-width="120">
<template #default="{ row }">
{{ formatPercent(row.proportion) }}
</template>
</el-table-column>
<el-table-column label="人均可用积分" min-width="130">
<template #default="{ row }">
{{ formatNumber(row.avgAvailable) }}
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
<script>
import * as API_Statistics from "@/api/statistics";
import { QuestionFilled } from "@element-plus/icons-vue";
export default {
components: { QuestionFilled },
data() {
return {
overview: {},
distributionList: [],
sourceList: [],
identityList: [],
metricDefs: [
{
key: "totalIssued",
label: "累积发放积分",
isRate: false,
tip: "累积发放积分:历史累计发放给客户的积分总数",
},
{
key: "availablePoint",
label: "可用积分",
isRate: false,
tip: "可用积分:当前客户账户中尚未消耗的积分总和",
},
{
key: "usedPoint",
label: "累积消耗积分",
isRate: false,
tip: "累积消耗积分:历史累计已消耗的积分",
},
{
key: "usedRate",
label: "积分消耗率",
isRate: true,
tip: "积分消耗率:累积消耗积分 / 累积发放积分",
},
],
};
},
computed: {
metricList() {
return this.metricDefs;
},
},
mounted() {
this.loadData();
},
methods: {
formatNumber(val) {
if (val == null || val === "") return 0;
const num = Number(val);
if (Number.isNaN(num)) return val;
return Math.round(num * 100) / 100;
},
formatPercent(val) {
if (val == null || val === "") return "0%";
const num = Number(val);
if (Number.isNaN(num)) return val;
return `${num}%`;
},
async loadData() {
const [overviewRes, distributionRes, sourceRes, identityRes] = await Promise.all([
API_Statistics.getPointsOverview(),
API_Statistics.getPointsDistribution(),
API_Statistics.getPointsSourceDistribution(),
API_Statistics.getPointsIdentityStat(),
]);
if (overviewRes && overviewRes.success) {
this.overview = overviewRes.result || {};
}
if (distributionRes && distributionRes.success) {
this.distributionList = distributionRes.result || [];
}
if (sourceRes && sourceRes.success) {
this.sourceList = sourceRes.result || [];
}
if (identityRes && identityRes.success) {
this.identityList = identityRes.result || [];
}
},
},
};
</script>
<style scoped lang="scss">
.wrapper {
padding-bottom: 40px;
}
.card {
margin-bottom: 10px;
}
h4 {
margin: 0 0 20px 0;
}
.metric-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
}
.metric-item {
background: #f3f5f7;
border-radius: 8px;
padding: 20px 16px;
min-height: 90px;
}
.metric-label {
display: flex;
align-items: center;
gap: 4px;
font-size: 14px;
color: #666;
margin-bottom: 12px;
}
.metric-tip {
font-size: 14px;
color: #999;
cursor: pointer;
}
.metric-value {
font-size: 22px;
font-weight: 700;
color: $theme_color;
}
@media (max-width: 1200px) {
.metric-grid {
grid-template-columns: repeat(2, 1fr);
}
}
</style>