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%
250 lines
7.4 KiB
Markdown
250 lines
7.4 KiB
Markdown
# 渠道统计 — 前端盈亏展示
|
||
|
||
## 📋 实施计划:渠道统计页面新增成本/盈亏展示
|
||
|
||
### 任务类型
|
||
- [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
|