refactor(分析模块): 优化组件结构和搜索功能

- 合并和简化模板中的header部分,提升代码可读性
- 统一搜索条件的布局,增强用户体验
- 更新数据绑定逻辑,确保搜索参数的正确处理
- 精简和优化表格及分页组件的实现,提升性能和可维护性
This commit is contained in:
2026-04-08 17:36:56 +08:00
parent f89063af6f
commit 93cb47deaf
8 changed files with 1103 additions and 1916 deletions
+174 -176
View File
@@ -1,66 +1,38 @@
<template>
<div class="trade-operation-stats-anchor">
<el-card shadow="hover">
<template #header>
<div class="card-header">
<span>主播维度统计</span>
</div>
</template>
<!-- 主播销售趋势 -->
<template #header><div class="card-header"><span>主播维度统计</span></div></template>
<div class="chart-container">
<el-card>
<template #header>
<div class="card-header">主播销售趋势</div>
</template>
<template #header><div class="card-header">主播销售趋势 - {{ currentAnchorName }}</div></template>
<div ref="salesChartRef" class="chart"></div>
</el-card>
</div>
<!-- 搜索条件 -->
<div class="search-container">
<el-form :model="searchParams" :inline="true" class="search-form">
<el-form-item label="时间范围">
<el-date-picker
v-model="searchParams.dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
/>
</el-form-item>
<el-form-item label="主播">
<el-input v-model="searchParams.anchorName" placeholder="请输入主播名称" clearable />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</div>
<!-- 主播业绩排行 -->
<div class="anchor-ranking">
<el-card>
<template #header>
<div class="card-header">主播业绩排行</div>
</template>
<el-table :data="anchorList" style="width: 100%">
<template #header><div class="card-header">主播业绩排行</div></template>
<div class="search-container">
<el-form :model="searchParams" :inline="true" class="search-form">
<el-form-item label="时间范围"><el-date-picker v-model="searchParams.dateRange" type="daterange" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" value-format="YYYY-MM-DD" /></el-form-item>
<el-form-item label="主播"><el-input v-model="searchParams.anchorName" placeholder="请输入主播名称" clearable @keyup.enter="handleSearch" /></el-form-item>
<el-form-item><el-button type="primary" @click="handleSearch">查询</el-button><el-button @click="handleReset">重置</el-button></el-form-item>
</el-form>
</div>
<el-table ref="anchorTableRef" :data="pagedAnchorList" style="width: 100%" row-key="anchorName" highlight-current-row @row-click="handleAnchorClick">
<el-table-column prop="rank" label="排名" width="80" />
<el-table-column prop="anchorName" label="主播名称" />
<el-table-column prop="sales" label="销售额" />
<el-table-column prop="orderCount" label="订单数" />
<el-table-column prop="signRate" label="签收率" />
<el-table-column prop="repurchaseRate" label="复购率" />
<el-table-column label="操作" width="120">
<template #default="scope">
<el-button type="primary" link @click.stop="handleAnchorSelect(scope.row.anchorName)">查看趋势</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-container">
<el-pagination
v-model:current-page="pagination.currentPage"
v-model:page-size="pagination.pageSize"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
:total="pagination.total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
<el-pagination v-model:current-page="pagination.currentPage" v-model:page-size="pagination.pageSize" :page-sizes="[10, 20, 50, 100]" layout="total, sizes, prev, pager, next, jumper" :total="pagination.total" @size-change="handleSizeChange" @current-change="handleCurrentChange" />
</div>
</el-card>
</div>
@@ -69,169 +41,195 @@
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue';
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue';
import * as echarts from 'echarts';
const searchParams = reactive({
dateRange: [],
anchorName: '',
interface AnchorItem {
rank: number;
anchorName: string;
sales: number;
orderCount: number;
signRate: string;
repurchaseRate: string;
}
interface AnchorTrend {
anchorName: string;
dates: string[];
sales: number[];
orders: number[];
}
const searchParams = reactive({ dateRange: [], anchorName: '' });
const anchorList = ref<AnchorItem[]>([]);
const anchorTableRef = ref();
const selectedAnchorName = ref('');
const filteredAnchorList = computed(() => {
const keyword = searchParams.anchorName.trim().toLowerCase();
if (!keyword) return anchorList.value;
return anchorList.value.filter((item) => item.anchorName.toLowerCase().includes(keyword));
});
const anchorList = ref([]);
const pagination = reactive({
currentPage: 1,
pageSize: 10,
total: 0,
});
const pagination = reactive({ currentPage: 1, pageSize: 10, total: 0 });
const pagedAnchorList = computed(() =>
filteredAnchorList.value.slice((pagination.currentPage - 1) * pagination.pageSize, pagination.currentPage * pagination.pageSize)
);
const currentAnchorName = computed(() => selectedAnchorName.value || filteredAnchorList.value[0]?.anchorName || '暂无主播');
const salesChartRef = ref();
let salesChart: echarts.ECharts | null = null;
// 模拟主播列表数据
const getMockAnchorList = () => {
const anchors = [
{ rank: 1, anchorName: '主播A', sales: 528000, orderCount: 1250, signRate: '98.5%', repurchaseRate: '32.8%' },
{ rank: 2, anchorName: '主播B', sales: 465000, orderCount: 1080, signRate: '97.2%', repurchaseRate: '28.5%' },
{ rank: 3, anchorName: '主播C', sales: 389000, orderCount: 920, signRate: '99.1%', repurchaseRate: '35.2%' },
{ rank: 4, anchorName: '主播D', sales: 320000, orderCount: 780, signRate: '96.8%', repurchaseRate: '25.3%' },
{ rank: 5, anchorName: '主播E', sales: 285000, orderCount: 690, signRate: '98.2%', repurchaseRate: '30.1%' },
{ rank: 6, anchorName: '主播F', sales: 256000, orderCount: 620, signRate: '97.5%', repurchaseRate: '27.8%' },
{ rank: 7, anchorName: '主播G', sales: 210000, orderCount: 510, signRate: '96.3%', repurchaseRate: '24.5%' },
{ rank: 8, anchorName: '主播H', sales: 185000, orderCount: 450, signRate: '98.8%', repurchaseRate: '31.2%' },
{ rank: 9, anchorName: '主播I', sales: 162000, orderCount: 390, signRate: '97.9%', repurchaseRate: '29.5%' },
{ rank: 10, anchorName: '主播J', sales: 145000, orderCount: 350, signRate: '96.7%', repurchaseRate: '26.8%' },
];
return anchors;
const getMockAnchorList = () => [
{ rank: 1, anchorName: '主播A', sales: 528000, orderCount: 1250, signRate: '98.5%', repurchaseRate: '32.8%' },
{ rank: 2, anchorName: '主播B', sales: 465000, orderCount: 1080, signRate: '97.2%', repurchaseRate: '28.5%' },
{ rank: 3, anchorName: '主播C', sales: 389000, orderCount: 920, signRate: '99.1%', repurchaseRate: '35.2%' },
{ rank: 4, anchorName: '主播D', sales: 320000, orderCount: 780, signRate: '96.8%', repurchaseRate: '25.3%' },
{ rank: 5, anchorName: '主播E', sales: 285000, orderCount: 690, signRate: '98.2%', repurchaseRate: '30.1%' },
{ rank: 6, anchorName: '主播F', sales: 256000, orderCount: 620, signRate: '97.5%', repurchaseRate: '27.8%' },
{ rank: 7, anchorName: '主播G', sales: 210000, orderCount: 510, signRate: '96.3%', repurchaseRate: '24.5%' },
{ rank: 8, anchorName: '主播H', sales: 185000, orderCount: 450, signRate: '98.8%', repurchaseRate: '31.2%' },
{ rank: 9, anchorName: '主播I', sales: 162000, orderCount: 390, signRate: '97.9%', repurchaseRate: '29.5%' },
{ rank: 10, anchorName: '主播J', sales: 145000, orderCount: 350, signRate: '96.7%', repurchaseRate: '26.8%' },
];
const getTrendLabels = () => {
if (Array.isArray(searchParams.dateRange) && searchParams.dateRange.length === 2) {
const [startText, endText] = searchParams.dateRange as string[];
const start = new Date(`${startText}T00:00:00`);
const end = new Date(`${endText}T00:00:00`);
const labels: string[] = [];
const cursor = new Date(start.getTime());
while (cursor <= end && labels.length < 31) {
labels.push(`${cursor.getMonth() + 1}/${cursor.getDate()}`);
cursor.setDate(cursor.getDate() + 1);
}
return labels;
}
return ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
};
// 模拟主播销售趋势数据
const getMockSalesTrend = () => {
const dates = ['1月', '2月', '3月', '4月', '5月', '6月'];
const anchors = ['主播A', '主播B', '主播C'];
return anchors.map((anchorName) => ({
const getMockSalesTrend = (anchorName: string): AnchorTrend => {
const labels = getTrendLabels();
const anchorIndex = Math.max(anchorList.value.findIndex((item) => item.anchorName === anchorName), 0);
const baseSales = 120000 + anchorIndex * 26000;
const baseOrders = 260 + anchorIndex * 36;
return {
anchorName,
dates,
sales: dates.map(() => 100000 + Math.random() * 300000),
}));
dates: labels,
sales: labels.map((_, index) => {
const trendFactor = 1 + index * 0.06;
const fluctuation = 0.9 + ((index + anchorIndex) % 4) * 0.08;
return Math.round(baseSales * trendFactor * fluctuation);
}),
orders: labels.map((_, index) => {
const trendFactor = 1 + index * 0.045;
const fluctuation = 0.92 + ((index + anchorIndex + 1) % 5) * 0.05;
return Math.round(baseOrders * trendFactor * fluctuation);
}),
};
};
const setCurrentAnchorRow = async () => {
await nextTick();
const currentRow = pagedAnchorList.value.find((item) => item.anchorName === selectedAnchorName.value) || null;
anchorTableRef.value?.setCurrentRow(currentRow);
};
const syncSelectedAnchor = () => {
const hasSelectedAnchor = filteredAnchorList.value.some((item) => item.anchorName === selectedAnchorName.value);
if (!hasSelectedAnchor) {
selectedAnchorName.value = filteredAnchorList.value[0]?.anchorName || '';
}
};
const updateChartByAnchor = (anchorName: string) => {
selectedAnchorName.value = anchorName;
initSalesChart(getMockSalesTrend(anchorName));
setCurrentAnchorRow();
};
const handleSearch = () => {
// 使用模拟数据
anchorList.value = getMockAnchorList();
pagination.total = 100;
initSalesChart(getMockSalesTrend());
if (!anchorList.value.length) {
anchorList.value = getMockAnchorList();
}
pagination.currentPage = 1;
pagination.total = filteredAnchorList.value.length;
syncSelectedAnchor();
if (selectedAnchorName.value) {
updateChartByAnchor(selectedAnchorName.value);
} else {
initSalesChart(null);
setCurrentAnchorRow();
}
};
const handleReset = () => {
searchParams.dateRange = [];
searchParams.anchorName = '';
selectedAnchorName.value = anchorList.value[0]?.anchorName || '';
pagination.currentPage = 1;
pagination.pageSize = 10;
handleSearch();
};
const handleAnchorClick = (row: AnchorItem) => {
updateChartByAnchor(row.anchorName);
};
const handleAnchorSelect = (anchorName: string) => {
updateChartByAnchor(anchorName);
};
const handleSizeChange = (size: number) => {
pagination.pageSize = size;
handleSearch();
pagination.currentPage = 1;
setCurrentAnchorRow();
};
const handleCurrentChange = (current: number) => {
pagination.currentPage = current;
handleSearch();
setCurrentAnchorRow();
};
const initSalesChart = (salesTrend: any[]) => {
const initSalesChart = (salesTrend: AnchorTrend | null) => {
if (!salesChartRef.value) return;
if (salesChart) {
salesChart.dispose();
}
if (salesChart) salesChart.dispose();
salesChart = echarts.init(salesChartRef.value);
const option = {
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross',
label: {
backgroundColor: '#6a7985',
},
},
},
legend: {
data: salesTrend.map((item) => item.anchorName),
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true,
},
xAxis: {
type: 'category',
boundaryGap: false,
data: salesTrend[0]?.dates || [],
},
yAxis: {
type: 'value',
name: '销售额',
},
series: salesTrend.map((item) => ({
name: item.anchorName,
type: 'line',
data: item.sales,
smooth: true,
})),
};
salesChart.setOption(option);
const xAxisData = salesTrend?.dates || [];
const isSingle = xAxisData.length <= 1;
salesChart.setOption({
tooltip: { trigger: 'axis', axisPointer: { type: 'cross', label: { backgroundColor: '#6a7985' } } },
legend: { data: salesTrend ? ['销售额', '订单数'] : [] },
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
xAxis: { type: 'category', boundaryGap: isSingle, data: xAxisData },
yAxis: [
{ type: 'value', name: '销售额', position: 'left' },
{ type: 'value', name: '订单数', position: 'right' },
],
series: salesTrend
? [
{ name: '销售额', type: 'line', data: salesTrend.sales, smooth: true, showSymbol: true, symbolSize: isSingle ? 10 : 6 },
{ name: '订单数', type: 'line', yAxisIndex: 1, data: salesTrend.orders, smooth: true, showSymbol: true, symbolSize: isSingle ? 10 : 6 },
]
: [],
});
};
watch(
() => filteredAnchorList.value.length,
(length) => {
pagination.total = length;
const maxPage = Math.max(1, Math.ceil(length / pagination.pageSize));
if (pagination.currentPage > maxPage) {
pagination.currentPage = maxPage;
}
setCurrentAnchorRow();
}
);
onMounted(() => {
anchorList.value = getMockAnchorList();
selectedAnchorName.value = anchorList.value[0]?.anchorName || '';
handleSearch();
window.addEventListener('resize', () => {
salesChart?.resize();
});
window.addEventListener('resize', () => salesChart?.resize());
});
</script>
<style scoped>
.trade-operation-stats-anchor {
padding: 20px;
}
.card-header {
font-size: 16px;
font-weight: bold;
}
.search-container {
margin-bottom: 20px;
}
.search-form {
display: flex;
align-items: center;
}
.anchor-ranking {
margin-bottom: 20px;
}
.pagination-container {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
.chart-container {
margin: 20px 0;
}
.chart {
width: 100%;
height: 400px;
}
.trade-operation-stats-anchor { padding: 20px; }
.card-header { display: flex; align-items: center; justify-content: space-between; font-size: 16px; font-weight: 600; }
.search-container { margin-bottom: 16px; }
.search-form { display: flex; align-items: center; flex-wrap: wrap; }
.search-form :deep(.el-form-item) { margin-right: 12px; margin-bottom: 12px; }
.anchor-ranking { margin-bottom: 20px; }
.pagination-container { margin-top: 16px; display: flex; justify-content: flex-end; }
.chart-container { margin: 0 0 20px; }
.chart { width: 100%; height: 400px; }
</style>
+329 -301
View File
@@ -1,158 +1,114 @@
<template>
<div class="trade-operation-stats-shop">
<el-card shadow="hover">
<template #header>
<div class="card-header">
<span>店铺维度统计</span>
</div>
</template>
<!-- 搜索条件 -->
<div class="search-container">
<el-form :model="searchParams" :inline="true" class="search-form">
<el-form-item label="店铺">
<el-select v-model="searchParams.shopId" placeholder="选择店铺">
<el-option label="全部店铺" value="" />
<el-option v-for="shop in shopList" :key="shop.id" :label="shop.name" :value="shop.id" />
</el-select>
</el-form-item>
<el-form-item label="时间范围">
<el-date-picker
v-model="searchParams.dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
:shortcuts="[
{
text: '最近7天',
value: () => {
const end = new Date();
const start = new Date();
start.setTime(start.getTime() - 3600 * 1000 * 24 * 7);
return [start, end];
},
},
{
text: '最近30天',
value: () => {
const end = new Date();
const start = new Date();
start.setTime(start.getTime() - 3600 * 1000 * 24 * 30);
return [start, end];
},
},
{
text: '最近90天',
value: () => {
const end = new Date();
const start = new Date();
start.setTime(start.getTime() - 3600 * 1000 * 24 * 90);
return [start, end];
},
},
{
text: '今年',
value: () => {
const end = new Date();
const start = new Date(new Date().getFullYear(), 0, 1);
return [start, end];
},
},
{
text: '去年',
value: () => {
const end = new Date(new Date().getFullYear() - 1, 11, 31);
const start = new Date(new Date().getFullYear() - 1, 0, 1);
return [start, end];
},
},
]"
/>
</el-form-item>
<el-form-item label="时间粒度">
<el-select v-model="searchParams.granularity" placeholder="选择时间粒度">
<el-option label="小时" value="hour" />
<el-option label="日" value="day" />
<el-option label="周" value="week" />
<el-option label="月" value="month" />
<el-option label="季度" value="quarter" />
<el-option label="年" value="year" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</div>
<!-- 销售趋势图表 -->
<template #header
><div class="card-header"><span>店铺维度统计</span></div></template
>
<div class="chart-container">
<el-card>
<template #header>
<div class="card-header">销售趋势 - {{ selectedShopName }}</div>
</template>
<template #header
><div class="card-header">销售趋势 - {{ selectedShopName }}</div></template
>
<div ref="salesChartRef" class="chart"></div>
</el-card>
</div>
<!-- 核心指标 -->
<div class="stats-cards">
<el-card class="stats-card">
<div class="stats-card-title">总销售额</div>
<el-card class="stats-card"
><div class="stats-card-title">总销售额</div>
<div class="stats-card-value">{{ statsData.totalSales || 0 }}</div>
<div class="stats-card-change" :class="{ positive: statsData.salesGrowth > 0, negative: statsData.salesGrowth < 0 }">
{{ statsData.salesGrowth > 0 ? '+' : '' }}{{ statsData.salesGrowth || 0 }}%
</div>
</el-card>
<el-card class="stats-card">
<div class="stats-card-title">订单量</div>
</div></el-card
>
<el-card class="stats-card"
><div class="stats-card-title">订单量</div>
<div class="stats-card-value">{{ statsData.orderCount || 0 }}</div>
<div class="stats-card-change" :class="{ positive: statsData.orderGrowth > 0, negative: statsData.orderGrowth < 0 }">
{{ statsData.orderGrowth > 0 ? '+' : '' }}{{ statsData.orderGrowth || 0 }}%
</div>
</el-card>
<el-card class="stats-card">
<div class="stats-card-title">退款率</div>
</div></el-card
>
<el-card class="stats-card"
><div class="stats-card-title">退款率</div>
<div class="stats-card-value">{{ statsData.refundRate || 0 }}%</div>
<div class="stats-card-change" :class="{ positive: statsData.refundRateChange < 0, negative: statsData.refundRateChange > 0 }">
{{ statsData.refundRateChange > 0 ? '+' : '' }}{{ statsData.refundRateChange || 0 }}%
</div>
</el-card>
<el-card class="stats-card">
<div class="stats-card-title">物流履约率</div>
</div></el-card
>
<el-card class="stats-card"
><div class="stats-card-title">物流履约率</div>
<div class="stats-card-value">{{ statsData.logisticsRate || 0 }}%</div>
<div class="stats-card-change" :class="{ positive: statsData.logisticsRateGrowth > 0, negative: statsData.logisticsRateGrowth < 0 }">
{{ statsData.logisticsRateGrowth > 0 ? '+' : '' }}{{ statsData.logisticsRateGrowth || 0 }}%
</div>
</el-card>
</div></el-card
>
</div>
<!-- 店铺列表 -->
<div class="shop-list">
<el-card>
<template #header>
<div class="card-header">店铺列表</div>
</template>
<el-table :data="shopList" style="width: 100%" @row-click="handleShopClick">
<template #header><div class="card-header">店铺列表</div></template>
<div class="search-container">
<el-form :model="searchParams" :inline="true" class="search-form">
<el-form-item label="店铺搜索">
<el-input
v-model="searchParams.shopKeyword"
placeholder="请输入店铺名称或店铺ID"
clearable
@keyup.enter="handleSearch"
/>
</el-form-item>
<el-form-item label="时间范围"
><el-date-picker
v-model="searchParams.dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
:shortcuts="dateShortcuts"
/></el-form-item>
<el-form-item label="时间粒度">
<el-radio-group v-model="searchParams.granularity" class="granularity-group">
<el-radio-button v-for="option in granularityOptions" :key="option.value" :label="option.value">
{{ option.label }}
</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item
><el-button type="primary" @click="handleSearch">查询</el-button><el-button @click="handleReset">重置</el-button></el-form-item
>
</el-form>
</div>
<el-table :data="pagedShopList" style="width: 100%" @row-click="handleShopClick">
<el-table-column prop="id" label="店铺ID" width="100" />
<el-table-column prop="name" label="店铺名称" />
<el-table-column prop="type" label="店铺类型">
<template #default="scope">
<el-tag size="small">{{ scope.row.type === 'physical' ? '线下店铺' : '线上店铺' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="状态">
<template #default="scope">
<el-tag size="small" :type="scope.row.status === 'active' ? 'success' : 'danger'">
{{ scope.row.status === 'active' ? '营业中' : '已关闭' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作">
<template #default="scope">
<el-button type="primary" size="small" @click="handleShopSelect(scope.row.id)">查看详情</el-button>
</template>
</el-table-column>
<el-table-column prop="type" label="店铺类型"
><template #default="scope"
><el-tag size="small">{{ scope.row.type === 'physical' ? '线下店铺' : '线上店铺' }}</el-tag></template
></el-table-column
>
<el-table-column prop="status" label="状态"
><template #default="scope"
><el-tag size="small" :type="scope.row.status === 'active' ? 'success' : 'danger'">{{
scope.row.status === 'active' ? '营业中' : '已关闭'
}}</el-tag></template
></el-table-column
>
<el-table-column label="操作"
><template #default="scope"
><el-button type="primary" size="small" @click.stop="handleShopSelect(scope.row.id)">查看详情</el-button></template
></el-table-column
>
</el-table>
<div class="pagination-container">
<el-pagination
v-model:current-page="pagination.currentPage"
v-model:page-size="pagination.pageSize"
:page-sizes="[5, 10, 20, 50]"
layout="total, sizes, prev, pager, next, jumper"
:total="pagination.total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</el-card>
</div>
</el-card>
@@ -160,51 +116,78 @@
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed, watch } from 'vue';
import { computed, onMounted, reactive, ref, watch } from 'vue';
import * as echarts from 'echarts';
const searchParams = reactive({
shopId: '',
dateRange: [],
granularity: 'day',
});
// 监听店铺选择变化,自动更新数据
watch(
() => searchParams.shopId,
() => {
handleSearch();
}
);
// 监听时间粒度变化,自动更新数据
watch(
() => searchParams.granularity,
() => {
handleSearch();
}
);
const granularityOptions = [
{ label: '小时', value: 'hour' },
{ label: '日', value: 'day' },
{ label: '周', value: 'week' },
{ label: '月', value: 'month' },
{ label: '季度', value: 'quarter' },
{ label: '年', value: 'year' },
];
const searchParams = reactive({ shopId: 'all', shopKeyword: '', dateRange: [], granularity: 'day' });
const dateShortcuts = [
{
text: '最近7天',
value: () => {
const end = new Date();
const start = new Date();
start.setTime(start.getTime() - 3600 * 1000 * 24 * 7);
return [start, end];
},
},
{
text: '最近30天',
value: () => {
const end = new Date();
const start = new Date();
start.setTime(start.getTime() - 3600 * 1000 * 24 * 30);
return [start, end];
},
},
{
text: '最近90天',
value: () => {
const end = new Date();
const start = new Date();
start.setTime(start.getTime() - 3600 * 1000 * 24 * 90);
return [start, end];
},
},
{
text: '今年',
value: () => {
const end = new Date();
const start = new Date(new Date().getFullYear(), 0, 1);
return [start, end];
},
},
{
text: '去年',
value: () => {
const end = new Date(new Date().getFullYear() - 1, 11, 31);
const start = new Date(new Date().getFullYear() - 1, 0, 1);
return [start, end];
},
},
];
interface Shop {
id: string;
name: string;
type: string;
status: string;
}
const shopList = ref<Shop[]>([]);
// 模拟店铺列表数据
const getMockShopList = () => {
return [
{ id: '1', name: '店铺A', type: 'physical', status: 'active' },
{ id: '2', name: '店铺B', type: 'online', status: 'active' },
{ id: '3', name: '店铺C', type: 'physical', status: 'active' },
{ id: '4', name: '店铺D', type: 'online', status: 'active' },
{ id: '5', name: '店铺E', type: 'physical', status: 'active' },
];
};
const filteredShopList = computed(() => {
const keyword = searchParams.shopKeyword.trim().toLowerCase();
if (!keyword) return shopList.value;
return shopList.value.filter((shop) => shop.name.toLowerCase().includes(keyword) || String(shop.id).toLowerCase().includes(keyword));
});
const pagination = reactive({ currentPage: 1, pageSize: 5, total: 0 });
const pagedShopList = computed(() =>
filteredShopList.value.slice((pagination.currentPage - 1) * pagination.pageSize, pagination.currentPage * pagination.pageSize)
);
const statsData = reactive({
totalSales: 1258000,
orderCount: 5230,
@@ -215,161 +198,202 @@ const statsData = reactive({
refundRateChange: -0.5,
logisticsRateGrowth: 1.2,
});
const salesChartRef = ref();
let salesChart: echarts.ECharts | null = null;
// 计算选中的店铺名称
const getMockShopList = () => [
{ id: '1', name: '店铺A', type: 'physical', status: 'active' },
{ id: '2', name: '店铺B', type: 'online', status: 'active' },
{ id: '3', name: '店铺C', type: 'physical', status: 'active' },
{ id: '4', name: '店铺D', type: 'online', status: 'active' },
{ id: '5', name: '店铺E', type: 'physical', status: 'active' },
];
const selectedShopName = computed(() => {
if (!searchParams.shopId) return '全部店铺';
const shop = shopList.value.find((s) => s.id === searchParams.shopId);
if (searchParams.shopId === 'all') return '全部店铺';
const shop = shopList.value.find((s) => String(s.id) === searchParams.shopId);
return shop ? shop.name : '未知店铺';
});
// 模拟销售趋势数据
const DAY_IN_MS = 24 * 60 * 60 * 1000;
const toDate = (value?: string | Date) => {
if (!value) return new Date();
return value instanceof Date ? new Date(value.getTime()) : new Date(`${value}T00:00:00`);
};
const formatDate = (date: Date) => {
const year = date.getFullYear();
const month = `${date.getMonth() + 1}`.padStart(2, '0');
const day = `${date.getDate()}`.padStart(2, '0');
return `${year}-${month}-${day}`;
};
const getDateRange = () => {
if (Array.isArray(searchParams.dateRange) && searchParams.dateRange.length === 2) {
const [start, end] = searchParams.dateRange as Array<string | Date>;
return { start: toDate(start), end: toDate(end) };
}
const end = new Date();
const start = new Date(end.getTime() - 6 * DAY_IN_MS);
return { start, end };
};
const getWeekLabel = (date: Date) => {
const firstDay = new Date(date.getFullYear(), 0, 1);
const dayOffset = Math.floor((date.getTime() - firstDay.getTime()) / DAY_IN_MS);
return `${date.getFullYear()}年第${Math.floor(dayOffset / 7) + 1}`;
};
const getQuarterLabel = (date: Date) => `${date.getFullYear()}年Q${Math.floor(date.getMonth() / 3) + 1}`;
const buildTrendLabels = () => {
const { start, end } = getDateRange();
const labels: string[] = [];
const cursor = new Date(start.getTime());
if (searchParams.granularity === 'hour') {
return Array.from({ length: 24 }, (_, index) => `${index}:00`);
}
if (searchParams.granularity === 'day') {
while (cursor <= end) {
labels.push(formatDate(cursor));
cursor.setDate(cursor.getDate() + 1);
}
return labels;
}
if (searchParams.granularity === 'week') {
while (cursor <= end) {
labels.push(getWeekLabel(cursor));
cursor.setDate(cursor.getDate() + 7);
}
return [...new Set(labels)];
}
if (searchParams.granularity === 'month') {
cursor.setDate(1);
while (cursor <= end) {
labels.push(`${cursor.getFullYear()}-${`${cursor.getMonth() + 1}`.padStart(2, '0')}`);
cursor.setMonth(cursor.getMonth() + 1);
}
return labels;
}
if (searchParams.granularity === 'quarter') {
cursor.setMonth(Math.floor(cursor.getMonth() / 3) * 3, 1);
while (cursor <= end) {
labels.push(getQuarterLabel(cursor));
cursor.setMonth(cursor.getMonth() + 3);
}
return [...new Set(labels)];
}
cursor.setMonth(0, 1);
while (cursor <= end) {
labels.push(`${cursor.getFullYear()}`);
cursor.setFullYear(cursor.getFullYear() + 1);
}
return [...new Set(labels)];
};
const getMockSalesTrend = (shopId: string) => {
let dates: string[] = [];
const baseSales = shopId ? 800000 + parseInt(shopId) * 100000 : 1000000;
const baseOrders = shopId ? 3000 + parseInt(shopId) * 500 : 4000;
// 根据时间粒度生成不同的时间标签
switch (searchParams.granularity) {
case 'hour':
dates = ['0时', '4时', '8时', '12时', '16时', '20时'];
break;
case 'day':
dates = ['1日', '5日', '10日', '15日', '20日', '25日'];
break;
case 'week':
dates = ['第1周', '第2周', '第3周', '第4周', '第5周', '第6周'];
break;
case 'month':
dates = ['1月', '2月', '3月', '4月', '5月', '6月'];
break;
case 'quarter':
dates = ['Q1', 'Q2', 'Q3', 'Q4'];
break;
case 'year':
dates = ['2022年', '2023年', '2024年', '2025年', '2026年'];
break;
default:
dates = ['1月', '2月', '3月', '4月', '5月', '6月'];
const selectedShopId = shopId === 'all' ? 0 : parseInt(shopId, 10) || 0;
const labels = buildTrendLabels();
const baseSales = 880000 + selectedShopId * 120000;
const baseOrders = 3200 + selectedShopId * 380;
return labels.map((date, index) => {
const trendFactor = 1 + index * 0.035;
const seasonalFactor = 0.92 + ((index + selectedShopId) % 5) * 0.045;
return {
date,
sales: Math.round(baseSales * trendFactor * seasonalFactor),
orders: Math.round(baseOrders * trendFactor * (0.95 + (index % 4) * 0.03)),
};
});
};
const syncSelectedShop = () => {
if (searchParams.shopId === 'all') return;
const hasSelectedShop = filteredShopList.value.some((shop) => String(shop.id) === searchParams.shopId);
if (!hasSelectedShop) {
searchParams.shopId = 'all';
}
return dates.map((date) => ({
date,
sales: baseSales + Math.random() * 500000,
orders: baseOrders + Math.random() * 2000,
}));
};
const handleSearch = () => {
// 使用模拟数据
const mockData = {
totalSales: 1258000 + Math.random() * 100000,
orderCount: 5230 + Math.random() * 500,
refundRate: 2.5 + Math.random() * 1,
logisticsRate: 98.2 + Math.random() * 1,
salesGrowth: 15.8 + Math.random() * 5,
orderGrowth: 12.3 + Math.random() * 3,
refundRateChange: -0.5 + Math.random() * 1,
logisticsRateGrowth: 1.2 + Math.random() * 0.5,
salesTrend: getMockSalesTrend(searchParams.shopId),
};
Object.assign(statsData, mockData);
initSalesChart(mockData.salesTrend);
pagination.currentPage = 1;
syncSelectedShop();
pagination.total = filteredShopList.value.length;
const salesTrend = getMockSalesTrend(searchParams.shopId);
const totalSales = salesTrend.reduce((sum, item) => sum + item.sales, 0);
const totalOrders = salesTrend.reduce((sum, item) => sum + item.orders, 0);
const selectedShopId = searchParams.shopId === 'all' ? 0 : parseInt(searchParams.shopId, 10) || 0;
Object.assign(statsData, {
totalSales,
orderCount: totalOrders,
refundRate: Number((2.1 + selectedShopId * 0.18).toFixed(2)),
logisticsRate: Number((97.2 + selectedShopId * 0.25).toFixed(2)),
salesGrowth: Number((8 + salesTrend.length * 0.6 + selectedShopId).toFixed(1)),
orderGrowth: Number((6 + salesTrend.length * 0.45 + selectedShopId * 0.8).toFixed(1)),
refundRateChange: Number((-0.8 + selectedShopId * 0.12).toFixed(1)),
logisticsRateGrowth: Number((0.6 + salesTrend.length * 0.08).toFixed(1)),
});
initSalesChart(salesTrend);
};
const handleReset = () => {
searchParams.shopId = '';
searchParams.shopId = 'all';
searchParams.shopKeyword = '';
searchParams.dateRange = [];
searchParams.granularity = 'day';
pagination.currentPage = 1;
handleSearch();
};
const handleShopClick = (shop: Shop) => {
searchParams.shopId = shop.id;
searchParams.shopId = String(shop.id);
handleSearch();
};
const handleShopSelect = (shopId: string) => {
searchParams.shopId = shopId;
searchParams.shopId = String(shopId);
handleSearch();
};
const handleSizeChange = (size: number) => {
pagination.pageSize = size;
pagination.currentPage = 1;
};
const handleCurrentChange = (current: number) => {
pagination.currentPage = current;
};
const initSalesChart = (salesTrend: any[]) => {
if (!salesChartRef.value) return;
if (salesChart) {
salesChart.dispose();
}
if (salesChart) salesChart.dispose();
salesChart = echarts.init(salesChartRef.value);
const option = {
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross',
label: {
backgroundColor: '#6a7985',
},
},
},
legend: {
data: ['销售额', '订单量'],
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true,
},
xAxis: {
type: 'category',
boundaryGap: false,
data: salesTrend.map((item) => item.date),
},
const isSingle = salesTrend.length <= 1;
salesChart.setOption({
tooltip: { trigger: 'axis', axisPointer: { type: 'cross', label: { backgroundColor: '#6a7985' } } },
legend: { data: ['销售额', '订单量'] },
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
xAxis: { type: 'category', boundaryGap: isSingle, data: salesTrend.map((item) => item.date) },
yAxis: [
{
type: 'value',
name: '销售额',
position: 'left',
},
{
type: 'value',
name: '订单量',
position: 'right',
},
{ type: 'value', name: '销售额', position: 'left' },
{ type: 'value', name: '订单量', position: 'right' },
],
series: [
{
name: '销售额',
type: 'line',
data: salesTrend.map((item) => item.sales),
smooth: true,
},
{ name: '销售额', type: 'line', data: salesTrend.map((item) => item.sales), smooth: true, showSymbol: true, symbolSize: isSingle ? 10 : 6 },
{
name: '订单量',
type: 'line',
yAxisIndex: 1,
data: salesTrend.map((item) => item.orders),
smooth: true,
showSymbol: true,
symbolSize: isSingle ? 10 : 6,
},
],
};
salesChart.setOption(option);
};
onMounted(() => {
// 初始化店铺列表
shopList.value = getMockShopList();
// 初始化数据
handleSearch();
window.addEventListener('resize', () => {
salesChart?.resize();
});
};
watch(
() => filteredShopList.value.length,
(length) => {
pagination.total = length;
const maxPage = Math.max(1, Math.ceil(length / pagination.pageSize));
if (pagination.currentPage > maxPage) {
pagination.currentPage = maxPage;
}
}
);
watch(
() => searchParams.granularity,
() => handleSearch()
);
onMounted(() => {
shopList.value = getMockShopList();
pagination.total = shopList.value.length;
handleSearch();
window.addEventListener('resize', () => salesChart?.resize());
});
</script>
@@ -377,67 +401,71 @@ onMounted(() => {
.trade-operation-stats-shop {
padding: 20px;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 16px;
font-weight: bold;
font-weight: 600;
}
.search-container {
margin-bottom: 20px;
margin-bottom: 16px;
}
.search-form {
display: flex;
align-items: center;
flex-wrap: wrap;
}
.search-form :deep(.el-form-item) {
margin-right: 12px;
margin-bottom: 12px;
}
.granularity-group {
flex-wrap: wrap;
}
.stats-cards {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20px;
margin-bottom: 20px;
}
.stats-card {
text-align: center;
padding: 20px;
}
.stats-card-title {
font-size: 14px;
color: #606266;
margin-bottom: 10px;
}
.stats-card-value {
font-size: 24px;
font-weight: bold;
margin-bottom: 5px;
}
.stats-card-change {
font-size: 12px;
}
.positive {
color: #67c23a;
}
.negative {
color: #f56c6c;
}
.chart-container {
margin: 20px 0;
margin: 0 0 20px;
}
.chart {
width: 100%;
height: 400px;
}
.shop-list {
margin-top: 20px;
}
.pagination-container {
margin-top: 16px;
display: flex;
justify-content: flex-end;
}
</style>