Files
medical-mall/pages/mall/analytics/coupon-analysis.uvue
2026-02-06 12:06:33 +08:00

808 lines
21 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.self="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 && !customRangeEnabled }"
@click="selectPeriod(p.value)"
>
{{ p.label }}
</view>
<view
class="tab"
:class="{ active: customRangeEnabled }"
@click="toggleCustomRange"
>
鑷畾涔? </view>
</view>
<AnalyticsDateRangePicker
v-if="customRangeEnabled"
:initialStartDate="selectedStartDate"
:initialEndDate="selectedEndDate"
@apply="onDateRangeApply"
@clear="onDateRangeClear"
/>
<!-- 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 setup lang="uts">
import { computed, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uvue'
import AnalyticsTopBar from '@/components/analytics/AnalyticsTopBar.uvue'
import AnalyticsDateRangePicker from '@/components/analytics/AnalyticsDateRangePicker.uvue'
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
import { fetchCouponAnalysis } from '@/services/analytics/couponAnalysisService.uts'
import { mapAnalyticsError } from '@/services/analytics/errorMapper.uts'
import type { CouponData } from '@/types/analytics/coupon.uts'
import type { TimePeriod } from '@/types/analytics/common.uts'
const lastUpdateTime = ref('')
const selectedPeriod = ref('7d')
const customRangeEnabled = ref(false)
const selectedStartDate = ref('')
const selectedEndDate = ref('')
const showMoreMenu = ref(false)
const showSidebarMenu = ref(false)
const currentPath = ref('/pages/mall/analytics/coupon-analysis')
const timePeriods = ref<Array<TimePeriod>>([
{ value: '7d', label: '7澶? },
{ value: '30d', label: '30澶? },
{ value: '90d', label: '90澶? },
{ value: '1y', label: '1骞? }
])
const couponData = ref<CouponData>({
total_issued: 0,
issued_growth: 0,
total_used: 0,
usage_rate: 0,
gmv_increase: 0,
gmv_growth: 0,
roi: 0
})
const typeChartOption = ref({} as any)
const channelChartOption = ref({} as any)
const trendChartOption = ref({} as any)
const conversionChartOption = ref({} as any)
// 鍘熷鏁版嵁
const _typeRows = ref<Array<UTSJSONObject>>([])
const _channelRows = ref<Array<UTSJSONObject>>([])
const _trendRows = ref<Array<UTSJSONObject>>([])
const _conversionRows = ref<Array<UTSJSONObject>>([])
const selectedPeriodText = computed(() => {
const p = timePeriods.value.find((t) => t.value === selectedPeriod.value)
return p ? p.label : '7澶?
})
onLoad(() => {
updateTime()
loadCouponData()
})
async function loadCouponData() {
try {
const range = selectedStartDate.value && selectedEndDate.value
? { start: selectedStartDate.value, end: selectedEndDate.value }
: null
const data = await fetchCouponAnalysis(selectedPeriod.value, range)
const overviewRow = data.overviewRow
const typeList = data.typeList
const channelList = data.channelList
const trendList = data.trendList
const conversionList = data.conversionList
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 {
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
}
}
couponData.value = {
total_issued: totalIssued,
issued_growth: issuedGrowth,
total_used: totalUsed,
usage_rate: usageRate,
gmv_increase: gmvIncrease,
gmv_growth: gmvGrowth,
roi: roi
}
_typeRows.value = typeList
_channelRows.value = channelList
_trendRows.value = trendList
_conversionRows.value = conversionList
updateTime()
buildChartOptions()
} catch (e) {
console.error('loadCouponData failed:', e)
updateTime()
buildChartOptions()
uni.showToast({ title: mapAnalyticsError(e, { fallbackMessage: '浼樻儬鍒稿垎鏋愭暟鎹姞杞藉け璐? }), icon: 'none' })
}
}
function selectPeriod(p: string) {
selectedPeriod.value = p
customRangeEnabled.value = false
selectedStartDate.value = ''
selectedEndDate.value = ''
loadCouponData()
}
function toggleCustomRange() {
customRangeEnabled.value = !customRangeEnabled.value
}
function onDateRangeApply(range: { start: string; end: string }) {
selectedStartDate.value = range.start
selectedEndDate.value = range.end
customRangeEnabled.value = true
loadCouponData()
}
function onDateRangeClear() {
selectedStartDate.value = ''
selectedEndDate.value = ''
customRangeEnabled.value = false
loadCouponData()
}
function refreshData() {
loadCouponData()
uni.showToast({ title: '宸插埛鏂?, icon: 'success' })
}
function exportReport() {
uni.showActionSheet({
itemList: ['瀵煎嚭Excel', '瀵煎嚭PDF', '瀵煎嚭鍥剧墖'],
success: () => uni.showToast({ title: '瀵煎嚭鎴愬姛', icon: 'success' })
})
}
function updateTime() {
const now = new Date()
const hh = now.getHours().toString().padStart(2, '0')
const mm = now.getMinutes().toString().padStart(2, '0')
lastUpdateTime.value = `${hh}:${mm}`
}
function formatInt(n: number): string {
const v = isFinite(n) ? Math.round(n) : 0
if (v >= 10000) return (v / 10000).toFixed(1) + '涓?
return v.toString()
}
function formatMoney(n: number): string {
const v = isFinite(n) ? n : 0
if (v >= 10000) return (v / 10000).toFixed(1) + '涓?
return v.toFixed(0)
}
function formatPct(n: number): string {
const v = isFinite(n) ? n : 0
const sign = v > 0 ? '+' : ''
return `${sign}${v.toFixed(1)}%`
}
function buildChartOptions() {
const typeRows = _typeRows.value
const channelRows = _channelRows.value
const trendRows = _trendRows.value
const convRows = _conversionRows.value
// 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
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)
}
typeChartOption.value = {
tooltip: { trigger: 'axis' },
legend: {
data: ['鍙戞斁鏁伴噺', '浣跨敤鏁伴噺', '浣跨敤鐜?],
top: 'bottom'
},
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)
}
channelChartOption.value = {
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) 浣跨敤瓒嬪娍
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)
}
trendChartOption.value = {
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) 杞寲鏁堟灉
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)
}
conversionChartOption.value = {
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 }
]
}
}
function toggleMoreMenu() {
showMoreMenu.value = !showMoreMenu.value
}
function closeMoreMenu() {
showMoreMenu.value = false
}
function handleSidebarUpdate(visible: boolean) {
showSidebarMenu.value = visible
}
function handleMenu() {
showSidebarMenu.value = true
}
// 妯℃嫙鐨?TopBar 浜嬩欢澶勭悊
function handleSearch() { uni.showToast({ title: '鎼滅储', icon: 'none' }) }
function handleNotification() { uni.showToast({ title: '閫氱煡', icon: 'none' }) }
function handleFullscreen() { uni.showToast({ title: '鍏ㄥ睆', icon: 'none' }) }
function handleMobile() { uni.showToast({ title: '绉诲姩绔?, icon: 'none' }) }
function handleDropdown() { uni.showToast({ title: '涓嬫媺鑿滃崟', icon: 'none' }) }
function handleSettings() { uni.showToast({ title: '璁剧疆', icon: 'none' }) }
</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 removed */ 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>