Files
medical-mall/pages/mall/analytics/data-detail.uvue
2026-02-01 20:17:37 +08:00

507 lines
16 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="filter-bar">
<view class="filter-item">
<text class="filter-label">时间范围:</text>
<view class="filter-value" @click="selectTimeRange">{{ timeRangeText }}</view>
</view>
<view class="filter-item">
<text class="filter-label">数据维度:</text>
<view class="filter-value" @click="selectDimension">{{ dimensionText }}</view>
</view>
<view class="filter-item">
<text class="filter-label">对比模式:</text>
<view class="filter-value" @click="toggleCompare">{{ compareMode ? '开启' : '关闭' }}</view>
</view>
</view>
<view class="card card-full">
<view class="card-head">
<text class="card-title">详细数据</text>
<text class="card-desc">支持排序、筛选、导出</text>
</view>
<view class="data-table">
<view class="table-header">
<view class="table-cell" v-for="col in tableColumns" :key="col.key">
<text>{{ col.label }}</text>
<text class="sort-icon" v-if="col.sortable" @click="sortBy(col.key)">⇅</text>
</view>
</view>
<view class="table-body">
<view class="table-row" v-for="row in tableData" :key="row.id">
<view class="table-cell" v-for="col in tableColumns" :key="col.key">
<text>{{ formatCellValue(row[col.key], col.type) }}</text>
</view>
</view>
</view>
</view>
</view>
<view class="card card-full" v-if="compareMode">
<view class="card-head">
<text class="card-title">数据对比</text>
<text class="card-desc">当前周期 vs 对比周期</text>
</view>
<EChartsView class="chart-box" :option="compareChartOption" />
</view>
<view class="card">
<view class="card-head">
<text class="card-title">数据钻取</text>
<text class="card-desc">点击数据项查看详情</text>
</view>
<view class="drill-down-list">
<view v-for="item in drillDownItems" :key="item.id" class="drill-item" @click="drillDown(item)">
<text class="drill-label">{{ item.label }}</text>
<text class="drill-value">{{ item.value }}</text>
<text class="drill-arrow">→</text>
</view>
</view>
</view>
<view style="height: 24px;"></view>
</view>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { reactive, ref } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uvue'
import AnalyticsTopBar from '@/components/analytics/AnalyticsTopBar.uvue'
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
import supa from '@/components/supadb/aksupainstance.uts'
import { fetchDataDetailReportInfo, fetchDataDetailRows } from '@/services/analytics/dataDetailService.uts'
import { mapAnalyticsError } from '@/services/analytics/errorMapper.uts'
import { rpcOrEmptyArray } from '@/services/analytics/rpc.uts'
import type { TableColumn, DrillDownItem } from '@/types/analytics/data-detail.uts'
const lastUpdateTime = ref('')
const showMoreMenu = ref(false)
const showSidebarMenu = ref(false)
const currentPath = ref('/pages/mall/analytics/data-detail')
const timeRangeText = ref('最近7天')
const dimensionText = ref('全部')
const compareMode = ref(true)
const sortKey = ref('')
const sortOrder = ref<'asc' | 'desc'>('asc')
const reportId = ref('')
const tableColumns = ref<Array<TableColumn>>([
{ key: 'date', label: '日期', type: 'date', sortable: true },
{ key: 'gmv', label: 'GMV', type: 'money', sortable: true },
{ key: 'orders', label: '订单数', type: 'number', sortable: true },
{ key: 'users', label: '用户数', type: 'number', sortable: true }
])
const tableData = reactive<Array<any>>([])
const drillDownItems = reactive<Array<DrillDownItem>>([])
const compareChartOption = ref<any>({})
const _currentRows = ref<Array<UTSJSONObject>>([])
const _compareRows = ref<Array<UTSJSONObject>>([])
onLoad((options: any) => {
currentPath.value = '/pages/mall/analytics/data-detail'
const rid = (options.reportId || options.id) as string
if (rid) {
reportId.value = rid
}
updateTime()
void loadDetailData()
})
onShow(() => {
currentPath.value = '/pages/mall/analytics/data-detail'
})
async function loadDetailData() {
try {
if (reportId.value && reportId.value.length > 0) {
const info = await fetchDataDetailReportInfo(reportId.value)
if (info != null) {
const period = info.period
if (period === '7d') timeRangeText.value = '最近7天'
else if (period === '30d') timeRangeText.value = '最近30天'
else if (period === '90d') timeRangeText.value = '最近90天'
}
const sortByKey = sortKey.value.length > 0 ? sortKey.value : 'row_date'
const sortDir = sortOrder.value === 'desc' ? 'desc' : 'asc'
const rows = await fetchDataDetailRows(reportId.value, sortByKey, sortDir, 500, 0)
tableData.splice(0, tableData.length, ...rows)
const drillAny = await rpcOrEmptyArray('rpc_data_detail_drill_items', {
p_report_id: reportId.value
} as UTSJSONObject)
const drillList: Array<DrillDownItem> = []
for (let i = 0; i < drillAny.length; i++) {
const m = drillAny[i]
const key = m.getString('metric_key') ?? ''
const label = m.getString('metric_label') ?? key
const fmt = m.getString('format') ?? 'number'
const valueNum = m.getNumber('metric_value_num') ?? 0
const vStr = formatCellValue(valueNum, fmt === 'currency' ? 'money' : (fmt === 'percent' ? 'percent' : 'number'))
drillList.push({
id: key.length > 0 ? key : 'metric_' + i.toString(),
label,
value: vStr,
type: key
})
}
drillDownItems.splice(0, drillDownItems.length, ...drillList)
const cmpRes: any = await supa.rpc('rpc_data_detail_compare_gmv', {
p_report_id: reportId.value
} as any)
let cmpRows: Array<UTSJSONObject> = []
if (cmpRes.error != null) {
console.error('rpc_data_detail_compare_gmv error:', cmpRes.error)
} else {
const anyCmp = cmpRes.data as any
if (Array.isArray(anyCmp)) {
cmpRows = anyCmp as Array<UTSJSONObject>
}
}
const curDays: string[] = []
const curGmv: number[] = []
const prevGmv: number[] = []
for (let i = 0; i < cmpRows.length; i++) {
const r = cmpRows[i]
const dayStr = r.getString('day') ?? ''
curDays.push(dayStr.length >= 10 ? dayStr.substring(5, 10) : dayStr)
curGmv.push(r.getNumber('gmv_current') ?? 0)
prevGmv.push(r.getNumber('gmv_previous') ?? 0)
}
compareChartOption.value = {
tooltip: { trigger: 'axis' },
legend: { data: ['当前周期 GMV', '对比周期 GMV'], top: 'bottom' },
grid: { left: 50, right: 20, top: 30, bottom: 60 },
xAxis: { type: 'category', data: curDays },
yAxis: { type: 'value', name: 'GMV' },
series: [
{ name: '当前周期 GMV', type: 'line', smooth: true, data: curGmv },
{ name: '对比周期 GMV', type: 'line', smooth: true, data: prevGmv }
]
}
_currentRows.value = []
_compareRows.value = []
} else {
// 兼容旧逻辑无报表ID时直接按时间范围调用市场趋势 RPC
const now = new Date()
const end = new Date(now.getTime())
const start = new Date(now.getTime())
if (timeRangeText.value === '最近7天') {
start.setDate(start.getDate() - 7)
} else if (timeRangeText.value === '最近30天') {
start.setDate(start.getDate() - 30)
} else if (timeRangeText.value === '最近90天') {
start.setDate(start.getDate() - 90)
} else {
start.setDate(start.getDate() - 30)
}
const startIso = start.toISOString()
const endIso = end.toISOString()
let currentRows: Array<UTSJSONObject> = []
let compareRows: Array<UTSJSONObject> = []
const curRes = await supa.rpc('rpc_analytics_market_trend_daily', {
p_start: startIso,
p_end: endIso
} as any)
if (curRes.status === 404) {
console.warn('rpc_analytics_market_trend_daily not found, data-detail will be empty')
} else if (curRes.error != null) {
console.error('rpc_analytics_market_trend_daily error:', curRes.error)
} else {
const anyData = curRes.data as any
currentRows = Array.isArray(anyData) ? (anyData as Array<UTSJSONObject>) : []
}
const spanMs = end.getTime() - start.getTime()
const prevEnd = new Date(start.getTime())
const prevStart = new Date(start.getTime() - spanMs)
const prevStartIso = prevStart.toISOString()
const prevEndIso = prevEnd.toISOString()
const prevRes = await supa.rpc('rpc_analytics_market_trend_daily', {
p_start: prevStartIso,
p_end: prevEndIso
} as any)
if (prevRes.status === 404) {
console.warn('rpc_analytics_market_trend_daily not found for compare period')
} else if (prevRes.error != null) {
console.error('rpc_analytics_market_trend_daily (compare) error:', prevRes.error)
} else {
const anyPrev = prevRes.data as any
compareRows = Array.isArray(anyPrev) ? (anyPrev as Array<UTSJSONObject>) : []
}
const table: Array<any> = []
for (let i = 0; i < currentRows.length; i++) {
const r = currentRows[i]
const dayStr = r.getString('day') ?? ''
table.push({
id: dayStr + '_' + i.toString(),
date: dayStr,
gmv: r.getNumber('gmv') ?? 0,
orders: r.getNumber('orders') ?? 0,
users: r.getNumber('users') ?? 0
} as any)
}
tableData.splice(0, tableData.length, ...table)
let totalGmv = 0
let totalOrders = 0
let totalUsers = 0
for (let i = 0; i < table.length; i++) {
const row = table[i]
totalGmv += row.gmv as number
totalOrders += row.orders as number
totalUsers += row.users as number
}
drillDownItems.splice(0, drillDownItems.length,
{ id: 'gmv_total', label: '当前周期 GMV 总计', value: formatCellValue(totalGmv, 'money'), type: 'gmv' },
{ id: 'orders_total', label: '当前周期订单总数', value: formatCellValue(totalOrders, 'number'), type: 'orders' },
{ id: 'users_total', label: '当前周期下单用户数', value: formatCellValue(totalUsers, 'number'), type: 'users' }
)
_currentRows.value = currentRows
_compareRows.value = compareRows
buildChartOptions()
}
updateTime()
} catch (e) {
console.error('loadDetailData failed:', e)
updateTime()
buildChartOptions()
uni.showToast({ title: mapAnalyticsError(e, { fallbackMessage: '详细数据加载失败' }), icon: 'none' })
}
}
function selectTimeRange() {
uni.showActionSheet({
itemList: ['最近7天', '最近30天', '最近90天', '自定义'],
success: (res) => {
const ranges = ['最近7天', '最近30天', '最近90天', '自定义']
timeRangeText.value = ranges[res.tapIndex]
void loadDetailData()
}
})
}
function selectDimension() {
uni.showActionSheet({
itemList: ['全部', '按商家', '按分类', '按地域'],
success: (res) => {
const dims = ['全部', '按商家', '按分类', '按地域']
dimensionText.value = dims[res.tapIndex]
void loadDetailData()
}
})
}
function toggleCompare() {
compareMode.value = !compareMode.value
if (compareMode.value) {
buildChartOptions()
}
}
function sortBy(key: string) {
if (sortKey.value === key) {
sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc'
} else {
sortKey.value = key
sortOrder.value = 'asc'
}
const data = tableData.slice()
data.sort((a: any, b: any) => {
const va = a[key]
const vb = b[key]
const na = typeof va === 'number' ? va : Number(va)
const nb = typeof vb === 'number' ? vb : Number(vb)
if (sortOrder.value === 'asc') return na - nb
return nb - na
})
tableData.splice(0, tableData.length, ...data)
}
function formatCellValue(value: any, type: string): string {
if (value == null) return '-'
if (type === 'money') {
const v = Number(value)
if (v >= 10000) return (v / 10000).toFixed(1) + '万'
return v.toFixed(2)
}
if (type === 'percent') {
const v = Number(value)
return `${v.toFixed(1)}%`
}
if (type === 'number') {
return String(Math.round(Number(value)))
}
if (type === 'date') {
return String(value)
}
return String(value)
}
function drillDown(item: DrillDownItem) {
uni.showToast({ title: `查看 ${item.label} 详情`, icon: 'none' })
}
function refreshData() {
void loadDetailData()
uni.showToast({ title: '已刷新', icon: 'success' })
}
function exportReport() {
uni.showActionSheet({
itemList: ['导出Excel', '导出PDF', '导出CSV'],
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 buildChartOptions() {
const curRows = Array.isArray(_currentRows.value) ? (_currentRows.value as Array<UTSJSONObject>) : []
const prevRows = Array.isArray(_compareRows.value) ? (_compareRows.value as Array<UTSJSONObject>) : []
if (curRows.length === 0) {
compareChartOption.value = {}
return
}
const curDays: string[] = []
const curGmv: number[] = []
const prevGmv: number[] = []
for (let i = 0; i < curRows.length; i++) {
const r = curRows[i]
const dayStr = r.getString('day') ?? ''
curDays.push(dayStr.length >= 10 ? dayStr.substring(5, 10) : dayStr)
curGmv.push(r.getNumber('gmv') ?? 0)
}
for (let i = 0; i < curRows.length; i++) {
const rPrev = i < prevRows.length ? prevRows[i] : null
if (rPrev == null) {
prevGmv.push(0)
} else {
prevGmv.push(rPrev.getNumber('gmv') ?? 0)
}
}
compareChartOption.value = {
tooltip: { trigger: 'axis' },
legend: { data: ['当前周期 GMV', '对比周期 GMV'], top: 'bottom' },
grid: { left: 50, right: 20, top: 30, bottom: 60 },
xAxis: { type: 'category', data: curDays },
yAxis: { type: 'value', name: 'GMV' },
series: [
{ name: '当前周期 GMV', type: 'line', smooth: true, data: curGmv },
{ name: '对比周期 GMV', type: 'line', smooth: true, data: prevGmv }
]
}
}
function handleMenu() {
showSidebarMenu.value = true
}
function handleSidebarUpdate(visible: boolean) {
showSidebarMenu.value = visible
}
function toggleMoreMenu() {
showMoreMenu.value = !showMoreMenu.value
}
function closeMoreMenu() {
showMoreMenu.value = false
}
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;
}
</style>