即时销售报表

This commit is contained in:
zjf committed 2025-09-22 19:32:29 +08:00
1 parent ca3c0b2a13
commit 45c2bd0e88
7 files changed
+2623

No files matched your search

@@ -0,0 +1,339 @@
<template>
<el-card class="ranking-table-card" shadow="never">
<template #header>
<div class="card-header">
<span class="card-title">{{ title }}</span>
<div class="card-actions">
<el-button size="small" @click="refreshData">
<el-icon><Refresh /></el-icon>
刷新
</el-button>
<el-button size="small" @click="viewAll">
查看全部
</el-button>
</div>
</div>
</template>
<el-table
:data="data"
stripe
:loading="loading"
@row-click="handleRowClick"
class="ranking-table"
>
<el-table-column prop="ranking" label="排名" width="60" align="center">
<template #default="{ row }">
<el-tag
:type="getRankingTagType(row.ranking)"
size="small"
class="ranking-tag"
>
{{ row.ranking }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="productName" label="商品名称" min-width="150" show-overflow-tooltip>
<template #default="{ row }">
<div class="product-info">
<div class="product-name">{{ row.productName }}</div>
<div class="product-code">{{ row.productCode }}</div>
</div>
</template>
</el-table-column>
<el-table-column prop="categoryName" label="分类" width="100" show-overflow-tooltip />
<el-table-column prop="salesAmount" label="销售额" width="120" align="right">
<template #default="{ row }">
<div class="amount-cell">
<div class="amount-value">{{ formatCurrency(row.salesAmount) }}</div>
<div v-if="row.yearOverYearGrowthRate !== undefined" class="growth-rate">
<el-icon
v-if="row.yearOverYearGrowthRate > 0"
class="growth-icon up"
>
<ArrowUp />
</el-icon>
<el-icon
v-else-if="row.yearOverYearGrowthRate < 0"
class="growth-icon down"
>
<ArrowDown />
</el-icon>
<span
:class="getGrowthRateClass(row.yearOverYearGrowthRate)"
>
{{ Math.abs(row.yearOverYearGrowthRate).toFixed(1) }}%
</span>
</div>
</div>
</template>
</el-table-column>
<el-table-column prop="salesQuantity" label="销量" width="100" align="right">
<template #default="{ row }">
{{ formatNumber(row.salesQuantity) }}
</template>
</el-table-column>
<el-table-column prop="salesCount" label="笔数" width="80" align="right">
<template #default="{ row }">
{{ formatNumber(row.salesCount) }}
</template>
</el-table-column>
<el-table-column prop="averagePrice" label="均价" width="100" align="right">
<template #default="{ row }">
{{ formatCurrency(row.averagePrice) }}
</template>
</el-table-column>
<el-table-column prop="marketShare" label="市场份额" width="100" align="right">
<template #default="{ row }">
<div class="market-share">
<div class="share-value">{{ (row.marketShare * 100).toFixed(1) }}%</div>
<div class="share-bar">
<div
class="share-progress"
:style="{ width: (row.marketShare * 100) + '%' }"
></div>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="操作" width="80" align="center" fixed="right">
<template #default="{ row }">
<el-button
type="primary"
size="small"
text
@click.stop="viewDetail(row)"
>
详情
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div v-if="showPagination" class="pagination-wrapper">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</el-card>
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue';
import { Refresh, ArrowUp, ArrowDown } from '@element-plus/icons-vue';
interface ProductRankingItem {
productId: string;
productName: string;
productCode: string;
categoryName: string;
salesAmount: number;
salesQuantity: number;
salesCount: number;
averagePrice: number;
ranking: number;
marketShare: number;
yearOverYearGrowthRate?: number;
monthOverMonthGrowthRate?: number;
}
interface Props {
title: string;
data: ProductRankingItem[];
loading?: boolean;
showPagination?: boolean;
total?: number;
pageSize?: number;
currentPage?: number;
}
const props = withDefaults(defineProps<Props>(), {
loading: false,
showPagination: false,
total: 0,
pageSize: 10,
currentPage: 1,
});
const emit = defineEmits<{
refresh: [];
viewAll: [];
viewDetail: [item: ProductRankingItem];
rowClick: [item: ProductRankingItem];
pageChange: [page: number, size: number];
}>();
// 获取排名标签类型
const getRankingTagType = (ranking: number) => {
if (ranking <= 3) return 'danger';
if (ranking <= 10) return 'warning';
return 'primary';
};
// 获取增长率样式类
const getGrowthRateClass = (rate: number) => {
if (rate > 0) return 'growth-up';
if (rate < 0) return 'growth-down';
return 'growth-stable';
};
// 格式化货币
const formatCurrency = (value: number) => {
return new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency: 'CNY',
}).format(value);
};
// 格式化数字
const formatNumber = (value: number) => {
return new Intl.NumberFormat('zh-CN').format(value);
};
// 刷新数据
const refreshData = () => {
emit('refresh');
};
// 查看全部
const viewAll = () => {
emit('viewAll');
};
// 查看详情
const viewDetail = (item: ProductRankingItem) => {
emit('viewDetail', item);
};
// 行点击
const handleRowClick = (item: ProductRankingItem) => {
emit('rowClick', item);
};
// 分页大小变化
const handleSizeChange = (size: number) => {
emit('pageChange', props.currentPage, size);
};
// 当前页变化
const handleCurrentChange = (page: number) => {
emit('pageChange', page, props.pageSize);
};
</script>
<style lang="scss" scoped>
.ranking-table-card {
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
.card-title {
font-weight: 600;
font-size: 16px;
}
.card-actions {
display: flex;
gap: 8px;
}
}
.ranking-table {
.ranking-tag {
font-weight: 600;
}
.product-info {
.product-name {
font-weight: 500;
margin-bottom: 4px;
}
.product-code {
font-size: 12px;
color: #909399;
}
}
.amount-cell {
.amount-value {
font-weight: 500;
margin-bottom: 2px;
}
.growth-rate {
display: flex;
align-items: center;
font-size: 12px;
.growth-icon {
margin-right: 2px;
&.up {
color: #67c23a;
}
&.down {
color: #f56c6c;
}
}
.growth-up {
color: #67c23a;
}
.growth-down {
color: #f56c6c;
}
.growth-stable {
color: #909399;
}
}
}
.market-share {
.share-value {
font-weight: 500;
margin-bottom: 4px;
}
.share-bar {
height: 4px;
background-color: #f0f0f0;
border-radius: 2px;
overflow: hidden;
.share-progress {
height: 100%;
background: linear-gradient(90deg, #409eff, #67c23a);
border-radius: 2px;
transition: width 0.3s ease;
}
}
}
}
.pagination-wrapper {
margin-top: 16px;
display: flex;
justify-content: center;
}
}
</style>
@@ -0,0 +1,191 @@
<template>
<div ref="chartContainer" class="sales-chart" :style="{ height: height + 'px' }"></div>
</template>
<script lang="ts" setup>
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue';
import * as echarts from 'echarts';
interface Props {
data: any[];
type: 'line' | 'bar' | 'pie' | 'area';
height?: number;
title?: string;
xAxisData?: string[];
series?: any[];
options?: any;
}
const props = withDefaults(defineProps<Props>(), {
height: 300,
type: 'line',
data: () => [],
xAxisData: () => [],
series: () => [],
options: () => ({}),
});
const chartContainer = ref<HTMLElement>();
let chartInstance: echarts.ECharts | null = null;
// 初始化图表
const initChart = () => {
if (!chartContainer.value) return;
chartInstance = echarts.init(chartContainer.value);
updateChart();
};
// 更新图表
const updateChart = () => {
if (!chartInstance) return;
const option = getChartOption();
chartInstance.setOption(option, true);
};
// 获取图表配置
const getChartOption = () => {
const baseOption = {
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross',
},
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true,
},
...props.options,
};
switch (props.type) {
case 'line':
return {
...baseOption,
xAxis: {
type: 'category',
data: props.xAxisData,
},
yAxis: {
type: 'value',
},
series: props.series.map(item => ({
type: 'line',
smooth: true,
...item,
})),
};
case 'bar':
return {
...baseOption,
xAxis: {
type: 'category',
data: props.xAxisData,
},
yAxis: {
type: 'value',
},
series: props.series.map(item => ({
type: 'bar',
...item,
})),
};
case 'pie':
return {
...baseOption,
tooltip: {
trigger: 'item',
formatter: '{a} <br/>{b}: {c} ({d}%)',
},
legend: {
orient: 'vertical',
left: 'left',
},
series: [{
name: props.title || '数据',
type: 'pie',
radius: '50%',
data: props.data,
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)',
},
},
}],
};
case 'area':
return {
...baseOption,
xAxis: {
type: 'category',
data: props.xAxisData,
},
yAxis: {
type: 'value',
},
series: props.series.map(item => ({
type: 'line',
areaStyle: {},
smooth: true,
...item,
})),
};
default:
return baseOption;
}
};
// 监听数据变化
watch(() => props.data, () => {
nextTick(() => {
updateChart();
});
}, { deep: true });
watch(() => props.series, () => {
nextTick(() => {
updateChart();
});
}, { deep: true });
// 监听窗口大小变化
const handleResize = () => {
if (chartInstance) {
chartInstance.resize();
}
};
onMounted(() => {
initChart();
window.addEventListener('resize', handleResize);
});
onUnmounted(() => {
if (chartInstance) {
chartInstance.dispose();
}
window.removeEventListener('resize', handleResize);
});
// 暴露方法
defineExpose({
updateChart,
getChartInstance: () => chartInstance,
});
</script>
<style lang="scss" scoped>
.sales-chart {
width: 100%;
}
</style>
@@ -0,0 +1,203 @@
<template>
<el-card class="summary-card" :class="cardClass" shadow="never">
<div class="card-content">
<div class="card-icon" :class="iconClass">
<el-icon :size="iconSize">
<component :is="icon" />
</el-icon>
</div>
<div class="card-info">
<div class="card-title">{{ title }}</div>
<div class="card-value">{{ formattedValue }}</div>
<div v-if="subtitle" class="card-subtitle">{{ subtitle }}</div>
<div v-if="trend !== undefined" class="card-trend" :class="trendClass">
<el-icon v-if="trend > 0"><ArrowUp /></el-icon>
<el-icon v-else-if="trend < 0"><ArrowDown /></el-icon>
<span>{{ Math.abs(trend).toFixed(2) }}%</span>
</div>
</div>
</div>
</el-card>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import { ArrowUp, ArrowDown } from '@element-plus/icons-vue';
interface Props {
title: string;
value: number | string;
subtitle?: string;
trend?: number;
icon: string;
type?: 'primary' | 'success' | 'warning' | 'danger' | 'info';
format?: 'currency' | 'number' | 'percent';
iconSize?: number;
}
const props = withDefaults(defineProps<Props>(), {
type: 'primary',
format: 'number',
iconSize: 24,
});
// 格式化值
const formattedValue = computed(() => {
if (typeof props.value === 'string') {
return props.value;
}
switch (props.format) {
case 'currency':
return new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency: 'CNY',
}).format(props.value);
case 'percent':
return `${props.value.toFixed(2)}%`;
case 'number':
default:
return new Intl.NumberFormat('zh-CN').format(props.value);
}
});
// 卡片样式类
const cardClass = computed(() => {
return `summary-card--${props.type}`;
});
// 图标样式类
const iconClass = computed(() => {
return `card-icon--${props.type}`;
});
// 趋势样式类
const trendClass = computed(() => {
if (props.trend === undefined) return '';
if (props.trend > 0) return 'trend-up';
if (props.trend < 0) return 'trend-down';
return 'trend-stable';
});
</script>
<style lang="scss" scoped>
.summary-card {
height: 120px;
border: none;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transition: transform 0.2s ease;
&:hover {
transform: translateY(-2px);
}
&--primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
.card-icon {
background: rgba(255, 255, 255, 0.2);
}
}
&--success {
background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%);
color: white;
.card-icon {
background: rgba(255, 255, 255, 0.2);
}
}
&--warning {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
color: white;
.card-icon {
background: rgba(255, 255, 255, 0.2);
}
}
&--danger {
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a24 100%);
color: white;
.card-icon {
background: rgba(255, 255, 255, 0.2);
}
}
&--info {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
color: white;
.card-icon {
background: rgba(255, 255, 255, 0.2);
}
}
.card-content {
display: flex;
align-items: center;
height: 100%;
.card-icon {
width: 60px;
height: 60px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-right: 16px;
}
.card-info {
flex: 1;
.card-title {
font-size: 14px;
margin-bottom: 8px;
opacity: 0.9;
}
.card-value {
font-size: 24px;
font-weight: 600;
margin-bottom: 4px;
line-height: 1.2;
}
.card-subtitle {
font-size: 12px;
opacity: 0.8;
margin-bottom: 4px;
}
.card-trend {
display: flex;
align-items: center;
font-size: 12px;
margin-top: 4px;
&.trend-up {
color: #67c23a;
}
&.trend-down {
color: #f56c6c;
}
&.trend-stable {
color: #909399;
}
.el-icon {
margin-right: 4px;
}
}
}
}
}
</style>
@@ -0,0 +1,7 @@
/**
* 即时销售报表组件导出
*/
export { default as SalesChart } from './SalesChart.vue';
export { default as SummaryCard } from './SummaryCard.vue';
export { default as ProductRankingTable } from './ProductRankingTable.vue';
@@ -0,0 +1,207 @@
/**
* 即时销售报表常量定义
*/
/**
* 查询类型枚举
*/
export enum QueryType {
HOUR = 'hour',
DAY = 'day',
MONTH = 'month',
}
/**
* 查询类型选项
*/
export const QUERY_TYPE_OPTIONS = [
{ label: '按小时', value: QueryType.HOUR },
{ label: '按天', value: QueryType.DAY },
{ label: '按月', value: QueryType.MONTH },
];
/**
* 图表类型枚举
*/
export enum ChartType {
LINE = 'line',
BAR = 'bar',
PIE = 'pie',
AREA = 'area',
}
/**
* 趋势类型枚举
*/
export enum TrendType {
UP = 'up',
DOWN = 'down',
STABLE = 'stable',
}
/**
* 排名类型枚举
*/
export enum RankingType {
TOP_SELLING = 'topSelling',
SLOW_SELLING = 'slowSelling',
}
/**
* 时间间隔配置
*/
export const TIME_INTERVALS = {
[QueryType.HOUR]: 60, // 分钟
[QueryType.DAY]: 24, // 小时
[QueryType.MONTH]: 30, // 天
};
/**
* 分页配置
*/
export const PAGINATION_CONFIG = {
pageSizes: [10, 20, 50, 100],
defaultPageSize: 10,
defaultCurrentPage: 1,
};
/**
* 表格列配置
*/
export const TABLE_COLUMNS = {
PRODUCT_RANKING: [
{ prop: 'ranking', label: '排名', width: 60, align: 'center' },
{ prop: 'productName', label: '商品名称', minWidth: 150 },
{ prop: 'categoryName', label: '分类', width: 100 },
{ prop: 'salesAmount', label: '销售额', width: 120, align: 'right' },
{ prop: 'salesQuantity', label: '销量', width: 100, align: 'right' },
{ prop: 'salesCount', label: '笔数', width: 80, align: 'right' },
{ prop: 'averagePrice', label: '均价', width: 100, align: 'right' },
{ prop: 'marketShare', label: '市场份额', width: 100, align: 'right' },
],
TIME_PERIOD_SALES: [
{ prop: 'timePeriodName', label: '时段', width: 120 },
{ prop: 'salesAmount', label: '销售额', width: 120, align: 'right' },
{ prop: 'salesCount', label: '销售笔数', width: 100, align: 'right' },
{ prop: 'salesQuantity', label: '销售数量', width: 100, align: 'right' },
{ prop: 'averageCustomerPrice', label: '平均客单价', width: 120, align: 'right' },
{ prop: 'customerCount', label: '客户数', width: 80, align: 'right' },
{ prop: 'productVarietyCount', label: '商品种类', width: 100, align: 'right' },
{ prop: 'yearOverYearSalesAmount', label: '同比销售额', width: 120, align: 'right' },
{ prop: 'monthOverMonthSalesAmount', label: '环比销售额', width: 120, align: 'right' },
],
};
/**
* 图表配置
*/
export const CHART_OPTIONS = {
SALES_TREND: {
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross',
},
},
legend: {
data: ['销售额', '销售笔数'],
},
xAxis: {
type: 'category',
},
yAxis: [
{
type: 'value',
name: '销售额(元)',
position: 'left',
},
{
type: 'value',
name: '销售笔数',
position: 'right',
},
],
},
CUSTOMER_ANALYSIS: {
tooltip: {
trigger: 'item',
formatter: '{a} <br/>{b}: {c}% ({d}%)',
},
legend: {
orient: 'vertical',
left: 'left',
},
},
};
/**
* 权限配置
*/
export const PERMISSIONS = {
VIEW: 'supermarket_realtimeSalesReport_view',
EDIT: 'supermarket_realtimeSalesReport_edit',
EXPORT: 'supermarket_realtimeSalesReport_export',
};
/**
* 默认查询条件
*/
export const DEFAULT_QUERY = {
queryType: QueryType.DAY,
startTime: '',
endTime: '',
storeId: '',
categoryId: '',
productId: '',
topLimit: 10,
current: 1,
size: 10,
};
/**
* 刷新间隔配置(毫秒)
*/
export const REFRESH_INTERVALS = {
REALTIME: 60000, // 1分钟
NORMAL: 300000, // 5分钟
SLOW: 900000, // 15分钟
};
/**
* 导出配置
*/
export const EXPORT_CONFIG = {
FORMATS: ['xlsx', 'pdf', 'csv'],
DEFAULT_FORMAT: 'xlsx',
MAX_ROWS: 10000,
};
/**
* 缓存配置
*/
export const CACHE_CONFIG = {
TTL: 300000, // 5分钟
KEY_PREFIX: 'realtime_sales_report_',
};
/**
* 错误消息
*/
export const ERROR_MESSAGES = {
LOAD_DATA_FAILED: '加载数据失败',
REFRESH_DATA_FAILED: '刷新数据失败',
EXPORT_DATA_FAILED: '导出数据失败',
INVALID_QUERY_PARAMS: '查询参数无效',
NO_DATA: '暂无数据',
NETWORK_ERROR: '网络错误,请检查网络连接',
};
/**
* 成功消息
*/
export const SUCCESS_MESSAGES = {
DATA_LOADED: '数据加载成功',
DATA_REFRESHED: '数据刷新成功',
DATA_EXPORTED: '数据导出成功',
CACHE_REFRESHED: '缓存刷新成功',
};
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,196 @@
/**
* 即时销售报表工具函数
*/
/**
* 格式化货币
* @param value 数值
* @param currency 货币类型
* @returns 格式化后的货币字符串
*/
export const formatCurrency = (value: number, currency: string = 'CNY'): string => {
return new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency,
}).format(value);
};
/**
* 格式化数字
* @param value 数值
* @returns 格式化后的数字字符串
*/
export const formatNumber = (value: number): string => {
return new Intl.NumberFormat('zh-CN').format(value);
};
/**
* 格式化百分比
* @param value 数值
* @param decimals 小数位数
* @returns 格式化后的百分比字符串
*/
export const formatPercent = (value: number, decimals: number = 2): string => {
return `${(value * 100).toFixed(decimals)}%`;
};
/**
* 格式化日期时间
* @param date 日期
* @param format 格式
* @returns 格式化后的日期字符串
*/
export const formatDateTime = (date: Date | string, format: string = 'YYYY-MM-DD HH:mm:ss'): string => {
const d = new Date(date);
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
const hours = String(d.getHours()).padStart(2, '0');
const minutes = String(d.getMinutes()).padStart(2, '0');
const seconds = String(d.getSeconds()).padStart(2, '0');
return format
.replace('YYYY', String(year))
.replace('MM', month)
.replace('DD', day)
.replace('HH', hours)
.replace('mm', minutes)
.replace('ss', seconds);
};
/**
* 获取趋势样式类
* @param rate 变化率
* @returns 样式类名
*/
export const getTrendClass = (rate: number): string => {
if (rate > 0) return 'trend-up';
if (rate < 0) return 'trend-down';
return 'trend-stable';
};
/**
* 获取排名标签类型
* @param ranking 排名
* @returns 标签类型
*/
export const getRankingTagType = (ranking: number): 'danger' | 'warning' | 'primary' => {
if (ranking <= 3) return 'danger';
if (ranking <= 10) return 'warning';
return 'primary';
};
/**
* 计算增长率
* @param current 当前值
* @param previous 之前值
* @returns 增长率
*/
export const calculateGrowthRate = (current: number, previous: number): number => {
if (previous === 0) return current > 0 ? 100 : 0;
return ((current - previous) / previous) * 100;
};
/**
* 生成随机数据(用于演示)
* @param min 最小值
* @param max 最大值
* @returns 随机数
*/
export const generateRandomData = (min: number, max: number): number => {
return Math.floor(Math.random() * (max - min + 1)) + min;
};
/**
* 生成时间序列数据
* @param startTime 开始时间
* @param endTime 结束时间
* @param interval 间隔(分钟)
* @returns 时间序列数组
*/
export const generateTimeSeries = (startTime: Date, endTime: Date, interval: number = 60): string[] => {
const times: string[] = [];
const current = new Date(startTime);
while (current <= endTime) {
times.push(formatDateTime(current, 'HH:mm'));
current.setMinutes(current.getMinutes() + interval);
}
return times;
};
/**
* 获取默认查询时间范围
* @param type 查询类型
* @returns 查询时间范围
*/
export const getDefaultTimeRange = (type: 'hour' | 'day' | 'month' = 'day') => {
const now = new Date();
const startTime = new Date(now);
switch (type) {
case 'hour':
startTime.setHours(now.getHours() - 24);
break;
case 'day':
startTime.setDate(now.getDate() - 7);
break;
case 'month':
startTime.setMonth(now.getMonth() - 6);
break;
}
return {
startTime: formatDateTime(startTime),
endTime: formatDateTime(now),
};
};
/**
* 颜色配置
*/
export const CHART_COLORS = {
primary: '#409EFF',
success: '#67C23A',
warning: '#E6A23C',
danger: '#F56C6C',
info: '#909399',
gradient: {
blue: ['#667eea', '#764ba2'],
green: ['#43e97b', '#38f9d7'],
orange: ['#f093fb', '#f5576c'],
purple: ['#4facfe', '#00f2fe'],
},
};
/**
* 图表配置
*/
export const CHART_CONFIG = {
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross',
},
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true,
},
legend: {
data: [],
},
};
/**
* 表格配置
*/
export const TABLE_CONFIG = {
stripe: true,
border: false,
size: 'default' as const,
showOverflowTooltip: true,
};