1. 排除商城直购(source_type=1):GMV和成本过滤条件从IN(1,2,3,4)改为IN(2,3,4) 2. 排除次卡免费使用订单(actual_amount=0):避免购买次卡和使用次卡双重计入GMV - source_type=4 一番赏使用次卡:1578单 44032元重复 - source_type=3 对对碰使用次卡:422单 7042元重复 - 合计去除51074元虚增GMV(29.1%) 3. 成本过滤条件同步修正:source_type IN(2,3,4),total_amount>0 修正后:GMV从175600降至124527元,毛利率从37.4%回到真实的11.8%
7.4 KiB
7.4 KiB
渠道统计 — 前端盈亏展示
📋 实施计划:渠道统计页面新增成本/盈亏展示
任务类型
- 前端
- 后端
- 全栈
需求概述
后端 /admin/channels/:id/stats 接口已新增以下字段:
Overview 新增:
| 字段 | 类型 | 说明 |
|---|---|---|
total_cost_cents |
number | 总成本(分) |
total_profit_cents |
number | 盈亏(分) = paid - cost |
total_cost |
number | 总成本(元) |
total_profit |
number | 盈亏(元) |
趋势图每日新增:
| 字段 | 类型 | 说明 |
|---|---|---|
cost_cents |
number | 当日成本(分) |
profit_cents |
number | 当日盈亏(分) |
技术方案
UI 设计
Overview 区域:
- 现有 3 个卡片(用户、订单、实付金额)→ 扩展为 5 个卡片
- 新增:总成本 卡片 + 盈亏 卡片
- 布局:从
grid-cols-3改为grid-cols-5(或在移动端自适应grid-cols-2 md:grid-cols-5) - 盈亏卡片需根据正/负值显示不同颜色(盈利=绿色,亏损=红色)
趋势图区域:
- 现有 2 个 Tab(用户增长、付费数据)→ 新增第 3 个 Tab:盈亏分析
- 盈亏分析 Tab 包含 3 条曲线:实付金额、成本、盈亏
- 盈亏曲线可使用虚线区分
实施步骤
Step 1: 更新 TypeScript 类型定义
文件:web/admin/src/api/channels.ts
在 StatsOverview 接口新增:
export interface StatsOverview {
total_users: number
total_orders: number
total_gmv: number
total_paid_cents?: number
// 新增
total_cost_cents?: number // 总成本(分)
total_profit_cents?: number // 盈亏(分)
total_cost?: number // 总成本(元)
total_profit?: number // 盈亏(元)
}
在 StatsDailyItem 接口新增:
export interface StatsDailyItem {
date: string
user_count: number
order_count: number
gmv: number
paid_cents?: number
// 新增
cost_cents?: number // 当日成本(分)
profit_cents?: number // 当日盈亏(分)
}
Step 2: 更新 Overview 卡片区域
文件:web/admin/src/views/operations/channels/index.vue
2.1 布局从 grid-cols-3 改为 grid-cols-5
2.2 新增两个 ArtStatsCard:
<!-- 总成本 -->
<ArtStatsCard
title="总成本"
:count="totalCostYuan"
:decimals="2"
icon="ri:funds-line"
box-style="bg-purple-50"
text-color="#7C3AED"
icon-style="bg-purple-500"
description="总奖品成本"
/>
<!-- 盈亏 -->
<ArtStatsCard
title="盈亏"
:count="totalProfitYuan"
:decimals="2"
icon="ri:bar-chart-2-line"
:box-style="profitCardStyle"
:text-color="profitTextColor"
:icon-style="profitIconStyle"
:description="profitDescription"
/>
2.3 新增 computed 属性:
const totalCostYuan = computed(() => {
const cents = statsData.value.overview.total_cost_cents
if (typeof cents === 'number') {
return Number((cents / 100).toFixed(2))
}
return 0
})
const totalProfitYuan = computed(() => {
const cents = statsData.value.overview.total_profit_cents
if (typeof cents === 'number') {
return Number((cents / 100).toFixed(2))
}
return 0
})
const profitCardStyle = computed(() =>
totalProfitYuan.value >= 0 ? 'bg-green-50' : 'bg-red-50'
)
const profitTextColor = computed(() =>
totalProfitYuan.value >= 0 ? '#10B981' : '#EF4444'
)
const profitIconStyle = computed(() =>
totalProfitYuan.value >= 0 ? 'bg-green-500' : 'bg-red-500'
)
const profitDescription = computed(() =>
totalProfitYuan.value >= 0 ? '盈利' : '亏损'
)
Step 3: 更新趋势图 Tab
文件:web/admin/src/views/operations/channels/index.vue
3.1 在 el-radio-group 新增 Tab:
<el-radio-group v-model="statsTab" size="small">
<el-radio-button label="growth">用户增长</el-radio-button>
<el-radio-button label="revenue">付费数据</el-radio-button>
<el-radio-button label="profit">盈亏分析</el-radio-button>
</el-radio-group>
3.2 在 chartData computed 中新增 profit 分支:
const chartData = computed(() => {
if (statsTab.value === 'growth') {
return [
{ name: '新增用户', data: statsData.value.daily.map(i => i.user_count), smooth: true, color: '#409EFF' }
]
} else if (statsTab.value === 'revenue') {
return [
{ name: '订单数', data: statsData.value.daily.map(i => i.order_count), smooth: true, color: '#67C23A' },
{ name: '实付金额', data: statsData.value.daily.map(i => getDailyPaidYuan(i)), smooth: true, color: '#E6A23C' }
]
} else {
// profit tab
return [
{ name: '实付(元)', data: statsData.value.daily.map(i => getDailyPaidYuan(i)), smooth: true, color: '#E6A23C' },
{ name: '成本(元)', data: statsData.value.daily.map(i => getDailyCostYuan(i)), smooth: true, color: '#7C3AED' },
{ name: '盈亏(元)', data: statsData.value.daily.map(i => getDailyProfitYuan(i)), smooth: true, color: '#10B981' }
]
}
})
3.3 新增辅助函数:
function getDailyCostYuan(item: { cost_cents?: number }) {
if (typeof item.cost_cents === 'number') {
return Number((item.cost_cents / 100).toFixed(2))
}
return 0
}
function getDailyProfitYuan(item: { profit_cents?: number }) {
if (typeof item.profit_cents === 'number') {
return Number((item.profit_cents / 100).toFixed(2))
}
return 0
}
Step 4: 更新 statsData 初始值
文件:web/admin/src/views/operations/channels/index.vue
const statsData = ref<ChannelStatsRes>({
overview: {
total_users: 0, total_orders: 0, total_gmv: 0, total_paid_cents: 0,
total_cost_cents: 0, total_profit_cents: 0, total_cost: 0, total_profit: 0
},
daily: []
})
关键文件
| 文件 | 操作 | 说明 |
|---|---|---|
web/admin/src/api/channels.ts:L68-86 |
修改 | 扩展 StatsOverview 和 StatsDailyItem 接口 |
web/admin/src/views/operations/channels/index.vue:L155-184 |
修改 | Overview 卡片区域新增成本/盈亏卡 |
web/admin/src/views/operations/channels/index.vue:L202-205 |
修改 | 趋势图新增盈亏分析 Tab |
web/admin/src/views/operations/channels/index.vue:L482-558 |
修改 | 新增 computed 属性和辅助函数 |
风险与缓解
| 风险 | 严重程度 | 缓解措施 |
|---|---|---|
| 5列卡片在窄屏溢出 | 低 | 使用响应式 grid-cols-2 md:grid-cols-5,必要时改为 grid-cols-3 + 第二行 grid-cols-2 |
| 后端字段为空(旧数据) | 已解决 | 所有新字段使用 ? 可选,computed 中做 typeof 检查,默认 0 |
| ArtStatsCard 不支持负数展示 | 低 | ArtCountTo 组件底层支持负数(基于 countUp.js),无需额外处理 |
| 盈亏曲线可能有负值 | 低 | ECharts 原生支持负值 Y 轴,图表会自动适配 |
验收标准
- TypeScript 类型定义包含新字段
- Overview 展示 5 个卡片(用户、订单、实付、成本、盈亏)
- 盈亏卡片根据正/负值动态切换颜色(绿/红)
- 趋势图新增"盈亏分析"Tab
- 盈亏分析 Tab 展示 3 条曲线(实付、成本、盈亏)
pnpm build编译通过pnpm type-check类型检查通过
SESSION_ID(供 /ccg:execute 使用)
- CODEX_SESSION: N/A
- GEMINI_SESSION: N/A