Files
medical-mall/pages/mall/analytics/coupon-analysis.uvue
2026-01-29 17:30:39 +08:00

870 lines
24 KiB
Plaintext
Raw 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.
<template>
<view class="page" @click="closeMoreMenu">
<!-- 固定顶部导航栏 -->
<AnalyticsTopBar
:title="'优惠券效果分析'"
:lastUpdateTime="lastUpdateTime"
:sidebarVisible="showSidebarMenu"
@menu-click="handleMenu"
@refresh="refreshData"
@search="handleSearch"
@notification="handleNotification"
@fullscreen="handleFullscreen"
@mobile="handleMobile"
@dropdown="handleDropdown"
@settings="handleSettings"
/>
<view class="page-layout">
<!-- 侧边栏菜单组件 -->
<AnalyticsSidebarMenu
:visible="showSidebarMenu"
:currentPath="currentPath"
@visible-change="handleSidebarUpdate"
/>
<!-- 主内容区域 -->
<view class="main-content">
<view class="container">
<!-- 时间维度筛选 -->
<view class="tabs">
<view
v-for="p in timePeriods"
:key="p.value"
class="tab"
:class="{ active: selectedPeriod === p.value }"
@click="selectPeriod(p.value)"
>
{{ p.label }}
</view>
</view>
<!-- KPI 指标卡片 -->
<view class="kpi-grid">
<view class="kpi-card">
<text class="kpi-label">发放总数</text>
<text class="kpi-value">{{ formatInt(couponData.total_issued) }}</text>
<text class="kpi-meta">较上期:{{ formatPct(couponData.issued_growth) }}</text>
</view>
<view class="kpi-card">
<text class="kpi-label">使用数量</text>
<text class="kpi-value">{{ formatInt(couponData.total_used) }}</text>
<text class="kpi-meta">使用率:{{ formatPct(couponData.usage_rate) }}</text>
</view>
<view class="kpi-card">
<text class="kpi-label">GMV 提升</text>
<text class="kpi-value">¥{{ formatMoney(couponData.gmv_increase) }}</text>
<text class="kpi-meta">较上期:{{ formatPct(couponData.gmv_growth) }}</text>
</view>
<view class="kpi-card">
<text class="kpi-label">ROI</text>
<text class="kpi-value">{{ formatPct(couponData.roi) }}</text>
<text class="kpi-meta">投入产出比</text>
</view>
</view>
<!-- 优惠券类型分析 -->
<view class="card card-full">
<view class="card-head">
<text class="card-title">优惠券类型分析</text>
<text class="card-desc">8种券类型满减券、折扣券、免运费券、新人券、会员券、品类券、商家券、限时券</text>
</view>
<EChartsView class="chart-box" :option="typeChartOption" />
</view>
<!-- 发放渠道效果 -->
<view class="card">
<view class="card-head">
<text class="card-title">发放渠道效果</text>
<text class="card-desc">主动领取、自动发放、活动赠送、邀请奖励、客服赠送、积分兑换</text>
</view>
<EChartsView class="chart-box" :option="channelChartOption" />
</view>
<!-- 优惠券使用趋势 -->
<view class="card">
<view class="card-head">
<text class="card-title">优惠券使用趋势</text>
<text class="card-desc">{{ selectedPeriodText }} · 发放 vs 使用</text>
</view>
<EChartsView class="chart-box" :option="trendChartOption" />
</view>
<!-- 优惠券转化效果 -->
<view class="card card-full">
<view class="card-head">
<text class="card-title">优惠券转化效果</text>
<text class="card-desc">GMV提升、订单增长</text>
</view>
<EChartsView class="chart-box" :option="conversionChartOption" />
</view>
<!-- 留白 -->
<view style="height: 24px;"></view>
</view>
</view>
</view>
</view>
</template>
<script lang="uts">
import supa, { ensureSupabaseReady } from '@/components/supadb/aksupainstance.uts'
import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uvue'
import AnalyticsTopBar from '@/components/analytics/AnalyticsTopBar.uvue'
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
type TimePeriod = { value: string; label: string }
type CouponData = {
total_issued: number
issued_growth: number
total_used: number
usage_rate: number
gmv_increase: number
gmv_growth: number
roi: number
}
export default {
components: {
AnalyticsSidebarMenu,
AnalyticsTopBar,
EChartsView
},
data() {
return {
lastUpdateTime: '',
selectedPeriod: '7d',
showMoreMenu: false,
showSidebarMenu: false,
currentPath: '/pages/mall/analytics/coupon-analysis',
timePeriods: [
{ value: '7d', label: '7天' },
{ value: '30d', label: '30天' },
{ value: '90d', label: '90天' },
{ value: '1y', label: '1年' }
] as Array<TimePeriod>,
couponData: {
total_issued: 0,
issued_growth: 0,
total_used: 0,
usage_rate: 0,
gmv_increase: 0,
gmv_growth: 0,
roi: 0
} as CouponData,
typeChartOption: {} as any,
channelChartOption: {} as any,
trendChartOption: {} as any,
conversionChartOption: {} as any
}
},
computed: {
selectedPeriodText(): string {
const p = this.timePeriods.find((t) => t.value === this.selectedPeriod)
return p ? p.label : '7天'
}
},
onLoad() {
this.updateTime()
this.loadCouponData()
},
methods: {
async loadCouponData() {
try {
// 1) 计算时间范围
const now = new Date()
const start = new Date(now.getTime())
if (this.selectedPeriod === '7d') start.setDate(start.getDate() - 7)
else if (this.selectedPeriod === '30d') start.setDate(start.getDate() - 30)
else if (this.selectedPeriod === '90d') start.setDate(start.getDate() - 90)
else if (this.selectedPeriod === '1y') start.setFullYear(start.getFullYear() - 1)
const startIso = start.toISOString()
const endIso = now.toISOString()
// 2) 确保 Supabase 会话就绪
await ensureSupabaseReady()
// 3) 优先调用后端 RPC推荐在 Supabase 中创建对应函数)
// - rpc_coupon_effectiveness_overview 概览 KPI
// - rpc_coupon_type_stats 按券类型统计
// - rpc_coupon_channel_stats 发放渠道统计
// - rpc_coupon_trend_daily 每日发放/使用趋势
// - rpc_coupon_conversion_effect GMV/订单转化效果
let overviewRow: UTSJSONObject | null = null
let typeList: Array<UTSJSONObject> = []
let channelList: Array<UTSJSONObject> = []
let trendList: Array<UTSJSONObject> = []
let conversionList: Array<UTSJSONObject> = []
// 3.1 概览 KPI
const overviewRes = await supa.rpc('rpc_coupon_effectiveness_overview', {
p_start: startIso,
p_end: endIso
})
if (overviewRes.status === 404) {
// RPC 未创建:保留默认 0 值,由后续 SQL 实现补上真实逻辑
console.warn('rpc_coupon_effectiveness_overview not found, using default zeros')
} else if (overviewRes.error != null) {
throw overviewRes.error
} else {
const anyData = overviewRes.data as any
if (Array.isArray(anyData) && anyData.length > 0) {
overviewRow = anyData[0] as UTSJSONObject
}
}
// 3.2 券类型统计
const typeRes = await supa.rpc('rpc_coupon_type_stats', {
p_start: startIso,
p_end: endIso
})
if (typeRes.status === 404) {
console.warn('rpc_coupon_type_stats not found, type analysis will be empty')
} else if (typeRes.error != null) {
throw typeRes.error
} else {
const typeAny = typeRes.data as any
typeList = Array.isArray(typeAny) ? typeAny as Array<UTSJSONObject> : []
}
// 3.3 渠道统计
const channelRes = await supa.rpc('rpc_coupon_channel_stats', {
p_start: startIso,
p_end: endIso
})
if (channelRes.status === 404) {
console.warn('rpc_coupon_channel_stats not found, channel analysis will be empty')
} else if (channelRes.error != null) {
throw channelRes.error
} else {
const chAny = channelRes.data as any
channelList = Array.isArray(chAny) ? chAny as Array<UTSJSONObject> : []
}
// 3.4 使用趋势
const trendRes = await supa.rpc('rpc_coupon_trend_daily', {
p_start: startIso,
p_end: endIso
})
if (trendRes.status === 404) {
console.warn('rpc_coupon_trend_daily not found, trend analysis will be empty')
} else if (trendRes.error != null) {
throw trendRes.error
} else {
const trAny = trendRes.data as any
trendList = Array.isArray(trAny) ? trAny as Array<UTSJSONObject> : []
}
// 3.5 转化效果GMV/订单)
const convRes = await supa.rpc('rpc_coupon_conversion_effect', {
p_start: startIso,
p_end: endIso
})
if (convRes.status === 404) {
console.warn('rpc_coupon_conversion_effect not found, conversion chart will be empty')
} else if (convRes.error != null) {
throw convRes.error
} else {
const cvAny = convRes.data as any
conversionList = Array.isArray(cvAny) ? cvAny as Array<UTSJSONObject> : []
}
// 4) 计算 KPI 概览
let totalIssued = 0
let totalUsed = 0
let gmvIncrease = 0.0
let issuedGrowth = 0.0
let usageRate = 0.0
let gmvGrowth = 0.0
let roi = 0.0
if (overviewRow != null) {
totalIssued = overviewRow.getNumber('total_issued') ?? 0
totalUsed = overviewRow.getNumber('total_used') ?? 0
gmvIncrease = overviewRow.getNumber('gmv_increase') ?? 0
issuedGrowth = overviewRow.getNumber('issued_growth') ?? 0
usageRate = overviewRow.getNumber('usage_rate') ?? 0
gmvGrowth = overviewRow.getNumber('gmv_growth') ?? 0
roi = overviewRow.getNumber('roi') ?? 0
} else {
// 概览 RPC 不存在时,使用类型统计简单近似(只保证页面可用)
for (let i = 0; i < typeList.length; i++) {
const r = typeList[i]
totalIssued += r.getNumber('total_issued') ?? 0
totalUsed += r.getNumber('total_used') ?? 0
}
if (totalIssued > 0) {
usageRate = (totalUsed / totalIssued) * 100
}
}
this.couponData = {
total_issued: totalIssued,
issued_growth: issuedGrowth,
total_used: totalUsed,
usage_rate: usageRate,
gmv_increase: gmvIncrease,
gmv_growth: gmvGrowth,
roi: roi
} as CouponData
// 将原始行数据挂到实例上,方便绘制图表
;(this as any)._typeRows = typeList
;(this as any)._channelRows = channelList
;(this as any)._trendRows = trendList
;(this as any)._conversionRows = conversionList
this.updateTime()
this.buildChartOptions()
} catch (e) {
console.error('loadCouponData failed:', e)
this.updateTime()
this.buildChartOptions()
uni.showToast({ title: '优惠券分析数据加载失败', icon: 'none' })
}
},
selectPeriod(p: string) {
this.selectedPeriod = p
this.loadCouponData()
},
refreshData() {
this.loadCouponData()
uni.showToast({ title: '已刷新', icon: 'success' })
},
exportReport() {
uni.showActionSheet({
itemList: ['导出Excel', '导出PDF', '导出图片'],
success: () => uni.showToast({ title: '导出成功', icon: 'success' })
})
},
updateTime() {
const now = new Date()
const hh = now.getHours().toString().padStart(2, '0')
const mm = now.getMinutes().toString().padStart(2, '0')
this.lastUpdateTime = `${hh}:${mm}`
},
formatInt(n: number): string {
const v = isFinite(n) ? Math.round(n) : 0
if (v >= 10000) return (v / 10000).toFixed(1) + '万'
return v.toString()
},
formatMoney(n: number): string {
const v = isFinite(n) ? n : 0
if (v >= 10000) return (v / 10000).toFixed(1) + '万'
return v.toFixed(0)
},
formatPct(n: number): string {
const v = isFinite(n) ? n : 0
const sign = v > 0 ? '+' : ''
return `${sign}${v.toFixed(1)}%`
},
buildChartOptions() {
const typeAny = (this as any)._typeRows as any
const channelAny = (this as any)._channelRows as any
const trendAny = (this as any)._trendRows as any
const convAny = (this as any)._conversionRows as any
const typeRows = Array.isArray(typeAny) ? typeAny as Array<UTSJSONObject> : []
const channelRows = Array.isArray(channelAny) ? channelAny as Array<UTSJSONObject> : []
const trendRows = Array.isArray(trendAny) ? trendAny as Array<UTSJSONObject> : []
const convRows = Array.isArray(convAny) ? convAny as Array<UTSJSONObject> : []
// 1) 券类型分析:柱状图(发放/使用/使用率)
const typeNames: string[] = []
const typeIssued: number[] = []
const typeUsed: number[] = []
const typeUsageRate: number[] = []
for (let i = 0; i < typeRows.length; i++) {
const r = typeRows[i]
const t = r.getNumber('coupon_type') ?? 0
// 映射 coupon_type 枚举到中文名称1..8
let label = '未知'
if (t === 1) label = '满减券'
else if (t === 2) label = '折扣券'
else if (t === 3) label = '免运费券'
else if (t === 4) label = '新人券'
else if (t === 5) label = '会员券'
else if (t === 6) label = '品类券'
else if (t === 7) label = '商家券'
else if (t === 8) label = '限时券'
typeNames.push(label)
typeIssued.push(r.getNumber('total_issued') ?? 0)
typeUsed.push(r.getNumber('total_used') ?? 0)
typeUsageRate.push(r.getNumber('usage_rate') ?? 0)
}
this.typeChartOption = {
tooltip: { trigger: 'axis' },
legend: {
data: ['发放数量', '使用数量', '使用率'],
top: 'bottom'
},
// 增加 top 间距,避免左侧“数量”与上方说明文字发生遮挡
grid: { left: 40, right: 40, top: 40, bottom: 60 },
xAxis: {
type: 'category',
data: typeNames,
axisLabel: { interval: 0, rotate: 20 }
},
yAxis: [
{ type: 'value', name: '数量' },
{ type: 'value', name: '使用率', min: 0, max: 100, position: 'right' }
],
series: [
{
name: '发放数量',
type: 'bar',
data: typeIssued,
barMaxWidth: 22,
itemStyle: { color: '#3b82f6' }
},
{
name: '使用数量',
type: 'bar',
data: typeUsed,
barMaxWidth: 22,
itemStyle: { color: '#22c55e' }
},
{
name: '使用率',
type: 'line',
yAxisIndex: 1,
smooth: true,
symbol: 'circle',
symbolSize: 6,
lineStyle: { width: 2, color: '#111827' },
itemStyle: { color: '#111827' },
z: 5,
data: typeUsageRate
}
]
}
// 2) 发放渠道效果:条形图
const channelNames: string[] = []
const channelIssued: number[] = []
const channelUsed: number[] = []
for (let i = 0; i < channelRows.length; i++) {
const r = channelRows[i]
const ch = r.getString('channel') ?? ''
let chLabel = ch
if (ch === 'manual') chLabel = '主动领取'
else if (ch === 'auto') chLabel = '自动发放'
else if (ch === 'campaign') chLabel = '活动赠送'
else if (ch === 'invite') chLabel = '邀请奖励'
else if (ch === 'cs') chLabel = '客服赠送'
else if (ch === 'points') chLabel = '积分兑换'
else if (ch.trim() === '') chLabel = '未知'
channelNames.push(chLabel)
channelIssued.push(r.getNumber('total_issued') ?? 0)
channelUsed.push(r.getNumber('total_used') ?? 0)
}
this.channelChartOption = {
tooltip: { trigger: 'axis' },
legend: { data: ['发放数量', '使用数量'], top: 'bottom' },
grid: { left: 80, right: 30, top: 20, bottom: 60 },
xAxis: { type: 'value' },
yAxis: { type: 'category', data: channelNames },
series: [
{ name: '发放数量', type: 'bar', data: channelIssued },
{ name: '使用数量', type: 'bar', data: channelUsed }
]
}
// 3) 使用趋势:发放 vs 使用
const trendDays: string[] = []
const trendIssued: number[] = []
const trendUsed: number[] = []
for (let i = 0; i < trendRows.length; i++) {
const r = trendRows[i]
const day = r.getString('day') ?? ''
trendDays.push(day.length >= 10 ? day.substring(5, 10) : day)
trendIssued.push(r.getNumber('issued') ?? 0)
trendUsed.push(r.getNumber('used') ?? 0)
}
this.trendChartOption = {
tooltip: { trigger: 'axis' },
legend: { data: ['发放数量', '使用数量'], top: 'bottom' },
// 增加顶部间距,避免“数量”与上方说明文字遮挡
grid: { left: 40, right: 20, top: 40, bottom: 60 },
xAxis: { type: 'category', data: trendDays },
yAxis: { type: 'value', name: '数量' },
series: [
{ name: '发放数量', type: 'bar', data: trendIssued },
{ name: '使用数量', type: 'line', smooth: true, data: trendUsed }
]
}
// 4) 转化效果:对比有券/无券 GMV & 订单数
const convNames: string[] = []
const convWith: number[] = []
const convWithout: number[] = []
for (let i = 0; i < convRows.length; i++) {
const r = convRows[i]
const metric = r.getString('metric') ?? ''
let metricLabel = metric
if (metric === 'GMV') metricLabel = 'GMV成交额'
else if (metric === 'orders') metricLabel = '订单数'
else if (metric === 'avg_order_amount') metricLabel = '客单价'
else if (metric.trim() === '') metricLabel = '未知'
convNames.push(metricLabel)
convWith.push(r.getNumber('with_coupon') ?? 0)
convWithout.push(r.getNumber('without_coupon') ?? 0)
}
this.conversionChartOption = {
tooltip: { trigger: 'axis' },
legend: { data: ['使用优惠券', '未使用优惠券'], top: 'bottom' },
grid: { left: 40, right: 20, top: 20, bottom: 60 },
xAxis: { type: 'category', data: convNames },
yAxis: { type: 'value' },
series: [
{ name: '使用优惠券', type: 'bar', data: convWith },
{ name: '未使用优惠券', type: 'bar', data: convWithout }
]
}
},
toggleMoreMenu() {
this.showMoreMenu = !this.showMoreMenu
},
closeMoreMenu() {
this.showMoreMenu = false
},
handleSidebarUpdate(visible: boolean) {
this.showSidebarMenu = visible
},
handleMenu() {
this.showSidebarMenu = true
}
}
}
</script>
<style>
.page {
min-height: 100vh;
background: #f6f7fb;
}
/* 页面布局:宽屏时侧边栏+内容,窄屏时全屏内容 */
.page-layout {
display: flex;
flex-direction: row !important;
min-height: 100vh;
}
.main-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
padding-top: 64px; /* 为固定顶部导航栏留出空间 */
}
.container {
width: 100%;
max-width: 1280px;
margin: 0 auto;
padding: 16px 16px 28px;
box-sizing: border-box;
}
/* 顶部栏 */
.topbar {
display: flex;
flex-direction: row !important;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 14px 16px;
background: #fff;
border-radius: 14px;
border: 1px solid rgba(0,0,0,0.06);
width: 100%;
box-sizing: border-box;
overflow: hidden;
}
.topbar-left {
display: flex;
flex-direction: row !important;
align-items: center;
gap: 12px;
flex: 1;
min-width: 0;
}
.menu-icon {
width: 32px;
height: 32px;
border-radius: 8px;
background: #f3f4f6;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
}
.menu-icon:active {
background: #e5e7eb;
transform: scale(0.95);
}
.menu-icon .icon {
font-size: 18px;
color: #111;
line-height: 1;
}
.title-group {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
min-width: 0;
}
.title {
font-size: 18px;
font-weight: 700;
color: #111;
max-width: 420px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.subtitle {
font-size: 12px;
color: rgba(0,0,0,0.55);
max-width: 420px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.topbar-right {
display: flex;
flex-direction: row !important;
gap: 8px;
align-items: center;
flex-wrap: nowrap;
flex-shrink: 0;
position: relative;
white-space: nowrap;
}
.icon-btn-icon {
width: 32px;
height: 32px;
border-radius: 8px;
background: #f3f4f6;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
flex-shrink: 0;
}
.icon-btn-icon:active {
background: #e5e7eb;
transform: scale(0.95);
}
.icon-btn-icon .icon {
font-size: 16px;
line-height: 1;
}
.more-btn {
display: none;
width: 32px;
height: 32px;
border-radius: 8px;
background: #f3f4f6;
align-items: center;
justify-content: center;
cursor: pointer;
position: relative;
transition: all 0.2s;
flex-shrink: 0;
}
.more-btn.active {
background: #e5e7eb;
}
.more-btn .icon {
font-size: 18px;
line-height: 1;
color: #111;
}
/* 时间维度 tabs */
.tabs {
margin-top: 12px;
display: flex;
flex-direction: row !important;
gap: 8px;
padding: 8px;
background: #fff;
border-radius: 14px;
border: 1px solid rgba(0,0,0,0.06);
overflow-x: auto;
flex-wrap: wrap;
justify-content: center;
}
.tab {
padding: 8px 12px;
border-radius: 999px;
background: #f3f4f6;
color: #111;
font-size: 13px;
white-space: nowrap;
flex-shrink: 0;
}
.tab.active {
background: #111;
color: #fff;
}
/* KPI 网格 */
.kpi-grid {
margin-top: 12px;
display: flex;
flex-direction: row !important;
flex-wrap: wrap;
gap: 12px;
}
.kpi-card {
background: #fff;
border-radius: 14px;
border: 1px solid rgba(0,0,0,0.06);
padding: 14px;
box-sizing: border-box;
flex: 1 1 calc(50% - 6px);
min-width: 260px;
}
.kpi-label {
font-size: 12px;
color: rgba(0,0,0,0.55);
}
.kpi-value {
margin-top: 8px;
font-size: 22px;
font-weight: 800;
color: #111;
}
.kpi-meta {
margin-top: 8px;
font-size: 12px;
color: rgba(0,0,0,0.55);
}
/* 卡片 */
.card {
margin-top: 12px;
background: #fff;
border-radius: 16px;
border: 1px solid rgba(0,0,0,0.06);
box-shadow: 0 2px 10px rgba(0,0,0,0.04);
padding: 14px;
box-sizing: border-box;
}
.card-full {
width: 100%;
}
.card-head {
display: flex;
flex-direction: row !important;
align-items: baseline;
justify-content: space-between;
margin-bottom: 16px;
}
.card-title {
font-size: 14px;
font-weight: 600;
color: #111;
}
.card-desc {
font-size: 12px;
color: rgba(0,0,0,0.55);
}
.chart-box {
width: 100%;
height: 360px;
}
/* 响应式 */
@media screen and (min-width: 960px) {
.kpi-card {
flex: 1 1 calc(25% - 9px);
min-width: 200px;
}
}
/* 响应式:窄屏时全屏显示 */
@media screen and (max-width: 959px) {
.page-layout {
flex-direction: column !important;
}
.main-content {
width: 100%;
}
}
@media screen and (max-width: 960px) {
.title,
.subtitle {
max-width: 200px;
}
.topbar-right .btn-hidden {
display: none !important;
}
.more-btn {
display: flex !important;
}
}
</style>