bindbox-game/.claude/plan/channel-stats-frontend.md
win 8d1eef2f7f fix(channel): 修复渠道统计GMV重复计数和商城直购误计入
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%
2026-03-16 21:41:39 +08:00

250 lines
7.4 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 渠道统计 — 前端盈亏展示
## 📋 实施计划:渠道统计页面新增成本/盈亏展示
### 任务类型
- [x] 前端
- [ ] 后端
- [ ] 全栈
### 需求概述
后端 `/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` 接口新增:
```typescript
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` 接口新增:
```typescript
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`
```vue
<!-- 总成本 -->
<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 属性:
```typescript
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
```vue
<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` 分支:
```typescript
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** 新增辅助函数:
```typescript
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`
```typescript
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