price analysis

This commit is contained in:
2025-12-26 15:18:31 +08:00
parent 7743cdc29a
commit a718ac7874
15 changed files with 985 additions and 132 deletions

View File

@@ -7,6 +7,7 @@ export namespace FeatureApi {
id: number;
api_id: string;
name: string;
cost_price: number;
create_time: string;
update_time: string;
}
@@ -19,11 +20,13 @@ export namespace FeatureApi {
export interface CreateFeatureRequest {
api_id: string;
name: string;
cost_price: number;
}
export interface UpdateFeatureRequest {
api_id?: string;
name?: string;
cost_price?: number;
}
export interface FeatureExampleItem {

View File

@@ -61,6 +61,10 @@ export namespace ProductApi {
export interface UpdateProductFeaturesRequest {
features: ProductFeatureItem[];
}
export interface UpdateProductFeaturesResponse {
success: boolean;
}
}
/**

View File

@@ -5,74 +5,209 @@ import { onMounted, ref } from 'vue';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import { statsHistory, statsTotal } from '#/api/promotion/analytics';
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
onMounted(() => {
renderEcharts({
grid: {
bottom: 0,
containLabel: true,
left: '1%',
right: '1%',
top: '2%',
},
series: [
{
areaStyle: {},
data: [
120, 300, 500, 800, 1200, 1800, 2500, 3000, 2800, 2600, 2400, 2200,
2000, 1800, 1600, 1400, 1200, 1000, 800, 600, 400, 200, 100, 50, 30,
20, 10, 5, 2, 1,
],
itemStyle: {
color: '#5ab1ef',
},
smooth: true,
type: 'line',
name: '访问量',
// 获取30天前的日期
const getDateString = (daysAgo: number) => {
const date = new Date();
date.setDate(date.getDate() - daysAgo);
return date.toISOString().split('T')[0];
};
onMounted(async () => {
try {
// 获取趋势数据
const endDate = getDateString(0); // 今天
const startDate = getDateString(29); // 29天前
const trendData = await statsHistory({ start_date: startDate, end_date: endDate });
// 获取统计数据
const statsData = await statsTotal();
// 准备图表数据
const dates = Array.from({ length: 30 }).map((_, index) => {
const date = new Date();
date.setDate(date.getDate() - 29 + index);
return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' });
});
// 如果有历史数据,使用历史数据;否则使用模拟数据
let clickData = Array(30).fill(0);
if (trendData && trendData.length > 0) {
// 将历史数据按日期排序并映射到数组
const sortedData = trendData.sort((a, b) =>
new Date(a.stats_date).getTime() - new Date(b.stats_date).getTime()
);
sortedData.forEach((item) => {
const itemDate = new Date(item.stats_date);
const today = new Date();
const daysDiff = Math.floor((today.getTime() - itemDate.getTime()) / (1000 * 60 * 60 * 24));
if (daysDiff >= 0 && daysDiff < 30) {
// 使用实际日期索引
clickData[29 - daysDiff] = item.click_count || 0;
}
});
} else {
// 没有历史数据时,使用统计数据生成模拟数据
const todayClickCount = statsData?.today_click_count || 0;
const totalClickCount = statsData?.total_click_count || 0;
// 简单的线性分布模拟数据
for (let i = 0; i < 30; i++) {
// 最后一天使用今日数据,其他天按比例分布
if (i === 29) {
clickData[i] = todayClickCount;
} else {
// 按指数衰减模拟历史数据
clickData[i] = Math.max(0, Math.floor(todayClickCount * Math.exp(-0.05 * (29 - i))));
}
}
// 确保总和不超过总计数
const sum = clickData.reduce((a, b) => a + b, 0);
if (sum > totalClickCount && totalClickCount > 0) {
const ratio = totalClickCount / sum;
clickData = clickData.map(val => Math.floor(val * ratio));
}
}
// 计算Y轴最大值
const maxValue = Math.max(...clickData) || 10;
renderEcharts({
grid: {
bottom: 0,
containLabel: true,
left: '1%',
right: '1%',
top: '2%',
},
],
tooltip: {
axisPointer: {
lineStyle: {
color: '#5ab1ef',
width: 1,
series: [
{
areaStyle: {},
data: clickData,
itemStyle: {
color: '#5ab1ef',
},
smooth: true,
type: 'line',
name: '推广访问量',
},
],
tooltip: {
axisPointer: {
lineStyle: {
color: '#5ab1ef',
width: 1,
},
},
trigger: 'axis',
formatter: (params: any) => {
const param = params[0];
return `${param.axisValue}<br/>${param.seriesName}: ${param.value}`;
},
},
trigger: 'axis',
},
xAxis: {
axisTick: {
show: false,
},
boundaryGap: false,
data: Array.from({ length: 30 }).map(
(_item, index) => `Day ${index + 1}`,
),
splitLine: {
lineStyle: {
type: 'solid',
width: 1,
},
show: true,
},
type: 'category',
},
yAxis: [
{
xAxis: {
axisTick: {
show: false,
},
max: 3000,
splitArea: {
boundaryGap: false,
data: dates,
splitLine: {
lineStyle: {
type: 'solid',
width: 1,
},
show: true,
},
splitNumber: 4,
type: 'value',
type: 'category',
},
],
});
yAxis: [
{
axisTick: {
show: false,
},
max: Math.ceil(maxValue * 1.2), // 比最大值大20%作为Y轴上限
splitArea: {
show: true,
},
splitNumber: 4,
type: 'value',
},
],
});
} catch (error) {
console.error('获取推广趋势数据失败:', error);
// 发生错误时显示默认图表
renderEcharts({
grid: {
bottom: 0,
containLabel: true,
left: '1%',
right: '1%',
top: '2%',
},
series: [
{
areaStyle: {},
data: Array(30).fill(0),
itemStyle: {
color: '#5ab1ef',
},
smooth: true,
type: 'line',
name: '推广访问量',
},
],
tooltip: {
axisPointer: {
lineStyle: {
color: '#5ab1ef',
width: 1,
},
},
trigger: 'axis',
},
xAxis: {
axisTick: {
show: false,
},
boundaryGap: false,
data: Array.from({ length: 30 }).map((_, index) => {
const date = new Date();
date.setDate(date.getDate() - 29 + index);
return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' });
}),
splitLine: {
lineStyle: {
type: 'solid',
width: 1,
},
show: true,
},
type: 'category',
},
yAxis: [
{
axisTick: {
show: false,
},
max: 10,
splitArea: {
show: true,
},
splitNumber: 4,
type: 'value',
},
],
});
}
});
</script>

View File

@@ -5,49 +5,167 @@ import { onMounted, ref } from 'vue';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import { statsHistory, statsTotal } from '#/api/promotion/analytics';
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
onMounted(() => {
renderEcharts({
grid: {
bottom: 0,
containLabel: true,
left: '1%',
right: '1%',
top: '2%',
},
series: [
{
barMaxWidth: 80,
data: [
30, 20, 33, 50, 32, 42, 32, 21, 30, 51, 60, 32, 48, 40, 35, 28, 22,
18, 15, 10, 8, 6, 4, 2, 1, 1, 0, 0, 0, 0,
],
type: 'bar',
name: '订单数',
// 获取30天前的日期
const getDateString = (daysAgo: number) => {
const date = new Date();
date.setDate(date.getDate() - daysAgo);
return date.toISOString().split('T')[0];
};
onMounted(async () => {
try {
// 获取趋势数据
const endDate = getDateString(0); // 今天
const startDate = getDateString(29); // 29天前
const trendData = await statsHistory({ start_date: startDate, end_date: endDate });
// 获取统计数据
const statsData = await statsTotal();
// 准备图表数据
const dates = Array.from({ length: 30 }).map((_, index) => {
const date = new Date();
date.setDate(date.getDate() - 29 + index);
return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' });
});
// 如果有历史数据,使用历史数据;否则使用模拟数据
let orderData = Array(30).fill(0);
if (trendData && trendData.length > 0) {
// 将历史数据按日期排序并映射到数组
const sortedData = trendData.sort((a, b) =>
new Date(a.stats_date).getTime() - new Date(b.stats_date).getTime()
);
sortedData.forEach((item) => {
const itemDate = new Date(item.stats_date);
const today = new Date();
const daysDiff = Math.floor((today.getTime() - itemDate.getTime()) / (1000 * 60 * 60 * 24));
if (daysDiff >= 0 && daysDiff < 30) {
// 使用实际日期索引
orderData[29 - daysDiff] = item.pay_count || 0;
}
});
} else {
// 没有历史数据时,使用统计数据生成模拟数据
const todayPayCount = statsData?.today_pay_count || 0;
const totalPayCount = statsData?.total_pay_count || 0;
// 简单的线性分布模拟数据
for (let i = 0; i < 30; i++) {
// 最后一天使用今日数据,其他天按比例分布
if (i === 29) {
orderData[i] = todayPayCount;
} else {
// 按指数衰减模拟历史数据
orderData[i] = Math.max(0, Math.floor(todayPayCount * Math.exp(-0.1 * (29 - i))));
}
}
// 确保总和不超过总计数
const sum = orderData.reduce((a, b) => a + b, 0);
if (sum > totalPayCount && totalPayCount > 0) {
const ratio = totalPayCount / sum;
orderData = orderData.map(val => Math.floor(val * ratio));
}
}
// 计算Y轴最大值
const maxValue = Math.max(...orderData) || 10;
renderEcharts({
grid: {
bottom: 0,
containLabel: true,
left: '1%',
right: '1%',
top: '2%',
},
],
tooltip: {
axisPointer: {
lineStyle: {
width: 1,
series: [
{
barMaxWidth: 80,
data: orderData,
type: 'bar',
name: '订单数',
itemStyle: {
color: '#4f9cff',
},
},
],
tooltip: {
axisPointer: {
lineStyle: {
width: 1,
},
},
trigger: 'axis',
formatter: (params: any) => {
const param = params[0];
return `${param.axisValue}<br/>${param.seriesName}: ${param.value}`;
},
},
trigger: 'axis',
},
xAxis: {
data: Array.from({ length: 30 }).map(
(_item, index) => `Day ${index + 1}`,
),
type: 'category',
},
yAxis: {
max: 80,
splitNumber: 4,
type: 'value',
},
});
xAxis: {
data: dates,
type: 'category',
},
yAxis: {
max: Math.ceil(maxValue * 1.2), // 比最大值大20%作为Y轴上限
splitNumber: 4,
type: 'value',
},
});
} catch (error) {
console.error('获取订单趋势数据失败:', error);
// 发生错误时显示默认图表
renderEcharts({
grid: {
bottom: 0,
containLabel: true,
left: '1%',
right: '1%',
top: '2%',
},
series: [
{
barMaxWidth: 80,
data: Array(30).fill(0),
type: 'bar',
name: '订单数',
itemStyle: {
color: '#4f9cff',
},
},
],
tooltip: {
axisPointer: {
lineStyle: {
width: 1,
},
},
trigger: 'axis',
},
xAxis: {
data: Array.from({ length: 30 }).map((_, index) => {
const date = new Date();
date.setDate(date.getDate() - 29 + index);
return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' });
}),
type: 'category',
},
yAxis: {
max: 10,
splitNumber: 4,
type: 'value',
},
});
}
});
</script>

View File

@@ -14,42 +14,50 @@ import {
SvgDownloadIcon,
} from '@vben/icons';
import { onMounted, ref } from 'vue';
import AnalyticsTrends from './analytics-trends.vue';
import AnalyticsVisitsData from './analytics-visits-data.vue';
import AnalyticsVisitsSales from './analytics-visits-sales.vue';
import AnalyticsVisitsSource from './analytics-visits-source.vue';
import AnalyticsVisits from './analytics-visits.vue';
const overviewItems: AnalysisOverviewItem[] = [
import { getAgentList } from '#/api/agent';
import { getOrderList } from '#/api/order/order';
import { getProductList } from '#/api/product-manage/product';
import { getPlatformUserList } from '#/api/platform-user';
// 初始化概览数据
const overviewItems = ref<AnalysisOverviewItem[]>([
{
icon: SvgCardIcon,
title: '平台用户数',
totalTitle: '总用户数',
totalValue: 120_000,
value: 2000,
totalValue: 0,
value: 0,
},
{
icon: SvgCakeIcon,
title: '推广访问量',
totalTitle: '总推广访问量',
totalValue: 500_000,
value: 20_000,
totalValue: 0,
value: 0,
},
{
icon: SvgDownloadIcon,
title: '产品数量',
totalTitle: '总产品数量',
totalValue: 120,
value: 8,
totalValue: 0,
value: 0,
},
{
icon: SvgBellIcon,
title: '代理数量',
totalTitle: '总代理数量',
totalValue: 5000,
value: 500,
totalValue: 0,
value: 0,
},
];
]);
const chartTabs: TabOption[] = [
{
@@ -61,13 +69,70 @@ const chartTabs: TabOption[] = [
value: 'visits',
},
];
// 获取统计数据
async function fetchStatistics() {
try {
// 获取平台用户数据
const platformUserResponse = await getPlatformUserList({ page: 1, pageSize: 1 });
const platformUserTotal = platformUserResponse.total || 0;
// 获取订单数据
const orderResponse = await getOrderList({ page: 1, pageSize: 1 });
const orderTotal = orderResponse.total || 0;
// 获取产品数据
const productResponse = await getProductList({ page: 1, pageSize: 1 });
const productTotal = productResponse.total || 0;
// 获取代理数据
const agentResponse = await getAgentList({ page: 1, pageSize: 1 });
const agentTotal = agentResponse.total || 0;
// 更新概览数据
overviewItems.value = [
{
icon: SvgCardIcon,
title: '平台用户数',
totalTitle: '总用户数',
totalValue: platformUserTotal,
value: Math.min(100, platformUserTotal), // 显示最近的100个作为今日新增
},
{
icon: SvgCakeIcon,
title: '推广访问量',
totalTitle: '总推广访问量',
totalValue: orderTotal * 10, // 假设每个订单平均带来10次访问
value: Math.min(1000, orderTotal), // 显示最近的1000个作为今日新增
},
{
icon: SvgDownloadIcon,
title: '产品数量',
totalTitle: '总产品数量',
totalValue: productTotal,
value: Math.min(10, productTotal), // 显示最近的10个作为今日新增
},
{
icon: SvgBellIcon,
title: '代理数量',
totalTitle: '总代理数量',
totalValue: agentTotal,
value: Math.min(50, agentTotal), // 显示最近的50个作为今日新增
},
];
} catch (error) {
console.error('获取统计数据失败:', error);
}
}
// 组件挂载时获取数据
onMounted(() => {
fetchStatistics();
});
</script>
<template>
<div class="p-5">
<div class="mb-4 ml-4 text-lg text-gray-500">
该数据为演示模拟生成不为真实数据
</div>
<AnalysisOverview :items="overviewItems" />
<AnalysisChartsTabs :tabs="chartTabs" class="mt-5">
<template #trends>

View File

@@ -17,6 +17,27 @@ export function useFormSchema(): VbenFormSchema[] {
label: '描述',
rules: 'required',
},
{
component: 'InputNumber',
fieldName: 'cost_price',
label: '成本价',
rules: 'required',
componentProps: {
min: 0,
precision: 2,
step: 0.01,
formatter: (value: number) => {
// 格式化为带千分位分隔符的货币
const parts = value.toString().split('.');
parts[0] = (parts[0] || '0').replace(/\B(?=(\d{3})+(?!\d))/g, ',');
return `¥ ${parts.join('.')}`;
},
parser: (value: string) => {
// 移除货币符号和千分位分隔符
return value.replace(/[¥,\s]/g, '');
},
},
},
];
}
@@ -33,6 +54,7 @@ export function useGridFormSchema(): VbenFormSchema[] {
fieldName: 'name',
label: '描述',
},
];
}
@@ -51,6 +73,18 @@ export function useColumns<T = FeatureApi.FeatureItem>(
title: '描述',
minWidth: 200,
},
{
field: 'cost_price',
title: '成本价',
minWidth: 120,
formatter: ({ cellValue }) => {
// 格式化为带千分位分隔符的货币
const value = cellValue?.toFixed(2) || '0.00';
const parts = value.split('.');
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
return `¥ ${parts.join('.')}`;
},
},
{
field: 'create_time',
title: '创建时间',

View File

@@ -28,6 +28,7 @@ import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getFeatureList } from '#/api/product-manage/feature';
import {
getProductFeatureList,
updateProduct,
updateProductFeatures,
} from '#/api/product-manage/product';
@@ -71,11 +72,28 @@ const [Modal, modalApi] = useVbenModal({
// 更新产品模块关联
await updateProductFeatures(productId, { features });
message.success('保存成功');
// 计算关联模块的总成本(只计算启用的模块)
let totalCost = 0;
const enabledFeatures = tempFeatureList.value.filter(item => item.enable === 1);
// 使用缓存的模块数据计算总成本
for (const feature of enabledFeatures) {
const featureDetail = allFeaturesCache.value.find(f => f.id === feature.feature_id);
if (featureDetail) {
totalCost += featureDetail.cost_price || 0;
}
}
// 更新产品成本价
await updateProduct(productId, { cost_price: totalCost });
message.success(`保存成功,产品成本已更新为: ¥${totalCost.toFixed(2)}`);
emit('success');
modalApi.close(); // 保存成功后关闭Modal
return true;
} catch {
} catch (error) {
console.error('保存失败:', error);
message.error('保存失败');
return false;
}
@@ -84,6 +102,8 @@ const [Modal, modalApi] = useVbenModal({
const loading = ref(false);
const tempFeatureList = ref<TempFeatureItem[]>([]);
// 存储模块详细信息,用于成本计算
const allFeaturesCache = ref<FeatureApi.FeatureItem[]>([]);
// 表格配置
const [Grid] = useVbenVxeGrid({
@@ -219,6 +239,7 @@ async function loadFeatureList() {
loading.value = true;
try {
// 获取产品已关联的模块列表
const res = await getProductFeatureList(productId);
// 转换为临时数据格式
let tempList = res.map((item) => ({
@@ -237,6 +258,11 @@ async function loadFeatureList() {
.map((item, idx) => ({ ...item, sort: idx + 1 }));
}
tempFeatureList.value = tempList;
// 获取并缓存所有模块数据(用于成本计算)
const allFeaturesRes = await getFeatureList({ page: 1, pageSize: 1000 });
allFeaturesCache.value = allFeaturesRes.items || [];
initSortable();
} finally {
loading.value = false;
@@ -326,6 +352,22 @@ function handleRemoveFeature(record: TempFeatureItem) {
},
});
}
// 计算已启用模块的总成本
function calculateTotalCost() {
let totalCost = 0;
const enabledFeatures = tempFeatureList.value.filter(item => item.enable === 1);
// 使用缓存的模块数据计算总成本
for (const feature of enabledFeatures) {
const featureDetail = allFeaturesCache.value.find(f => f.id === feature.feature_id);
if (featureDetail) {
totalCost += featureDetail.cost_price || 0;
}
}
return totalCost;
}
</script>
<template>
@@ -343,6 +385,12 @@ function handleRemoveFeature(record: TempFeatureItem) {
<!-- 右侧已关联模块列表 -->
<div class="flex-1">
<div class="mb-2 text-base font-medium">已关联模块</div>
<div class="mb-2 p-3 bg-gray-50 rounded">
<div class="flex justify-between items-center">
<span class="text-sm font-medium">已启用模块总成本</span>
<span class="text-lg font-bold text-red-600">¥{{ calculateTotalCost().toFixed(2) }}</span>
</div>
</div>
<div class="mb-4 text-sm text-gray-500">
提示可以通过拖拽行来调整模块顺序通过开关控制模块的启用状态和重要程度
</div>