506 lines
16 KiB
Plaintext
506 lines
16 KiB
Plaintext
<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 { onLoad, onShow, reactive, ref } from 'vue'
|
||
|
||
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>
|