# 渠道统计 — 前端盈亏展示
## 📋 实施计划:渠道统计页面新增成本/盈亏展示
### 任务类型
- [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
```
**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
用户增长
付费数据
盈亏分析
```
**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({
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