Files
medical-mall/pages/mall/analytics/report-detail.uvue
2026-01-31 21:47:42 +08:00

1027 lines
25 KiB
Plaintext
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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">
<!-- 固定顶部导航栏 -->
<AnalyticsTopBar
:title="report.title || '报表详情'"
:lastUpdateTime="formatTime(report.generated_at)"
:sidebarVisible="showSidebarMenu"
@menu-click="handleMenu"
@refresh="refreshReport"
@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="report-detail-page">
<!-- 报表头部 -->
<view class="report-header">
<view class="header-info">
<text class="report-title">{{ report.title }}</text>
<view class="report-meta">
<text class="meta-item">{{ getReportTypeText() }}</text>
<text class="meta-item">{{ report.period }}</text>
<text class="meta-item">{{ formatTime(report.generated_at) }}</text>
</view>
</view>
<view class="header-actions">
<button class="action-btn detail" @click="goToDataDetail">🔍 数据分析详情</button>
<button class="action-btn export" @click="exportReport">📊 导出</button>
<button class="action-btn refresh" @click="refreshReport">🔄 刷新</button>
</view>
</view>
<!-- 核心指标概览 -->
<view class="metrics-overview">
<view class="section-title">核心指标</view>
<view class="metrics-grid">
<view v-for="metric in coreMetrics" :key="metric.key" class="metric-card">
<view class="metric-icon" :style="{ backgroundColor: metric.color }">{{ metric.icon }}</view>
<view class="metric-content">
<text class="metric-value">{{ formatMetricValue(metric.value, metric.format) }}</text>
<text class="metric-label">{{ metric.label }}</text>
<view class="metric-change" :class="{ positive: metric.change > 0, negative: metric.change < 0 }">
<text class="change-icon">{{ metric.change > 0 ? '↗' : metric.change < 0 ? '↘' : '→' }}</text>
<text class="change-value">{{ Math.abs(metric.change) }}%</text>
</view>
</view>
</view>
</view>
</view>
<!-- 趋势图表 -->
<view class="chart-section">
<view class="section-header">
<text class="section-title">趋势分析</text>
<view class="chart-tabs">
<text v-for="tab in chartTabs" :key="tab.key"
class="chart-tab"
:class="{ active: activeChartTab === tab.key }"
@click="switchChartTab(tab.key)">{{ tab.label }}</text>
</view>
</view>
<view class="chart-container">
<canvas class="chart-canvas" canvas-id="trendChart" @touchstart="onChartTouch" @touchmove="onChartTouch" @touchend="onChartTouch"></canvas>
</view>
<view class="chart-legend">
<view v-for="legend in chartLegends" :key="legend.key" class="legend-item">
<view class="legend-color" :style="{ backgroundColor: legend.color }"></view>
<text class="legend-label">{{ legend.label }}</text>
</view>
</view>
</view>
<!-- 数据表格 -->
<view class="data-table">
<view class="section-title">详细数据</view>
<view class="table-filters">
<view class="filter-item">
<text class="filter-label">排序方式:</text>
<picker :value="sortIndex" :range="sortOptions" @change="onSortChange">
<text class="filter-value">{{ sortOptions[sortIndex] }}</text>
</picker>
</view>
<view class="filter-item">
<text class="filter-label">显示条数:</text>
<picker :value="limitIndex" :range="limitOptions" @change="onLimitChange">
<text class="filter-value">{{ limitOptions[limitIndex] }}</text>
</picker>
</view>
</view>
<view class="table-container">
<scroll-view scroll-x="true" class="table-scroll">
<view class="table">
<view class="table-header">
<text v-for="column in tableColumns" :key="column.key"
class="table-cell header-cell"
:style="{ width: column.width }">{{ column.title }}</text>
</view>
<view v-for="(row, index) in tableData" :key="index" class="table-row">
<text v-for="column in tableColumns" :key="column.key"
class="table-cell data-cell"
:style="{ width: column.width }"
:class="{ number: column.type === 'number', currency: column.type === 'currency' }">
{{ formatCellValue(row[column.key], column) }}
</text>
</view>
</view>
</scroll-view>
</view>
<view class="table-pagination">
<button class="page-btn" :disabled="currentPage <= 1" @click="previousPage">上一页</button>
<text class="page-info">{{ currentPage }} / {{ totalPages }}</text>
<button class="page-btn" :disabled="currentPage >= totalPages" @click="nextPage">下一页</button>
</view>
</view>
<!-- 数据洞察 -->
<view class="data-insights">
<view class="section-title">数据洞察</view>
<view v-for="insight in dataInsights" :key="insight.id" class="insight-card">
<view class="insight-header">
<view class="insight-icon" :class="insight.type">{{ getInsightIcon(insight.type) }}</view>
<text class="insight-title">{{ insight.title }}</text>
</view>
<text class="insight-content">{{ insight.content }}</text>
<view class="insight-actions">
<text class="insight-impact" :class="insight.impact">{{ getImpactText(insight.impact) }}</text>
<text class="insight-action" @click="viewInsightDetail(insight)">查看详情 ></text>
</view>
</view>
</view>
<!-- 报表配置 -->
<view class="report-config">
<view class="section-title">报表配置</view>
<view class="config-item">
<text class="config-label">自动刷新</text>
<switch :checked="autoRefresh" @change="toggleAutoRefresh" />
</view>
<view class="config-item">
<text class="config-label">刷新间隔</text>
<picker :value="intervalIndex" :range="intervalOptions" @change="onIntervalChange">
<text class="config-value">{{ intervalOptions[intervalIndex] }}</text>
</picker>
</view>
<view class="config-item">
<text class="config-label">邮件通知</text>
<switch :checked="emailNotify" @change="toggleEmailNotify" />
</view>
<view class="config-actions">
<button class="config-btn save" @click="saveConfig">保存配置</button>
<button class="config-btn reset" @click="resetConfig">重置配置</button>
</view>
</view>
<!-- 相关报表 -->
<view class="related-reports">
<view class="section-title">相关报表</view>
<view class="report-list">
<view v-for="relatedReport in relatedReports" :key="relatedReport.id"
class="report-item" @click="viewRelatedReport(relatedReport)">
<view class="report-icon">📊</view>
<view class="report-info">
<text class="report-name">{{ relatedReport.title }}</text>
<text class="report-desc">{{ relatedReport.description }}</text>
<text class="report-time">{{ formatTime(relatedReport.generated_at) }}</text>
</view>
<text class="report-arrow">></text>
</view>
</view>
</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 { fetchReport, fetchReportMetrics, fetchReportRows, fetchReportInsights, fetchRelatedReports } from '@/services/analytics/reportDetailService.uts'
import { mapAnalyticsError } from '@/services/analytics/errorMapper.uts'
import type { ReportType, MetricType, ChartTabType, ChartLegendType, TableColumnType, InsightType } from '@/types/analytics/report-detail.uts'
const showSidebarMenu = ref(false)
const currentPath = ref('/pages/mall/analytics/report-detail')
const report = reactive<ReportType>({
id: '',
title: '',
type: '',
period: '',
generated_at: '',
description: ''
})
const coreMetrics = reactive<Array<MetricType>>([])
const chartTabs = reactive<Array<ChartTabType>>([])
const activeChartTab = ref('')
const chartLegends = reactive<Array<ChartLegendType>>([])
const tableColumns = reactive<Array<TableColumnType>>([])
const allRows = reactive<Array<any>>([])
const tableData = reactive<Array<any>>([])
const dataInsights = reactive<Array<InsightType>>([])
const relatedReports = reactive<Array<ReportType>>([])
const sortIndex = ref(0)
const sortOptions = ref<Array<string>>([])
const limitIndex = ref(1)
const limitOptions = ref<Array<string>>(['10条', '20条', '50条', '100条'])
const currentPage = ref(1)
const totalPages = ref(1)
const autoRefresh = ref(false)
const intervalIndex = ref(1)
const intervalOptions = ref<Array<string>>(['1分钟', '5分钟', '10分钟', '30分钟', '1小时'])
const emailNotify = ref(false)
onLoad((options: any) => {
const reportId = (options.reportId || options.id) as string
if (reportId) {
void loadReportDetail(reportId)
} else {
uni.showToast({ title: '缺少报表ID', icon: 'none' })
setTimeout(() => {
uni.navigateBack()
}, 1500)
}
currentPath.value = '/pages/mall/analytics/report-detail'
})
onShow(() => {
currentPath.value = '/pages/mall/analytics/report-detail'
})
function handleMenu() {
showSidebarMenu.value = true
}
function handleSidebarUpdate(visible: boolean) {
showSidebarMenu.value = visible
}
function safeNumber(v: any): number {
const n = Number(v)
return isFinite(n) ? n : 0
}
async function loadReportDetail(reportId: string) {
try {
uni.showLoading({ title: '加载中...' })
const rep = await fetchReport(reportId)
if (rep == null) {
uni.showToast({ title: '报表不存在', icon: 'none' })
return
}
report.id = rep.id
report.title = rep.title
report.type = rep.type
report.period = rep.period
report.generated_at = rep.generated_at
report.description = rep.description
const metrics = await fetchReportMetrics(reportId)
coreMetrics.splice(0, coreMetrics.length, ...metrics)
tableColumns.splice(0, tableColumns.length,
{ key: 'date', title: '日期', width: '120rpx', type: 'text' },
{ key: 'sales', title: '销售额', width: '120rpx', type: 'currency' },
{ key: 'orders', title: '订单数', width: '100rpx', type: 'number' },
{ key: 'users', title: '用户数', width: '100rpx', type: 'number' },
{ key: 'conversion', title: '转化率', width: '100rpx', type: 'percent' },
{ key: 'avg_value', title: '客单价', width: '120rpx', type: 'currency' }
)
sortOptions.value = ['按日期降序', '按销售额降序', '按订单数降序', '按转化率降序']
const rows = await fetchReportRows(reportId)
allRows.splice(0, allRows.length, ...rows)
currentPage.value = 1
updateTotalPages()
generateTableData()
const insights = await fetchReportInsights(reportId)
dataInsights.splice(0, dataInsights.length, ...insights)
const rel = await fetchRelatedReports(report.type, reportId)
relatedReports.splice(0, relatedReports.length, ...rel)
} catch (e) {
console.error('loadReportDetail failed', e)
uni.showToast({ title: mapAnalyticsError(e, { fallbackMessage: '报表加载失败' }), icon: 'none' })
} finally {
uni.hideLoading()
}
}
function updateTotalPages() {
const total = allRows.length
const limit = parseInt(limitOptions.value[limitIndex.value])
totalPages.value = total > 0 ? Math.ceil(total / limit) : 1
}
function generateTableData() {
tableData.splice(0, tableData.length)
const total = allRows.length
if (total === 0) {
return
}
const limit = parseInt(limitOptions.value[limitIndex.value])
const start = (currentPage.value - 1) * limit
const end = Math.min(start + limit, total)
for (let i = start; i < end; i++) {
const row = allRows[i]
tableData.push({
date: `${row.row_date}`,
sales: safeNumber(row.gmv),
orders: safeNumber(row.orders),
users: safeNumber(row.users),
conversion: safeNumber(row.conversion).toFixed(1),
avg_value: safeNumber(row.avg_order_amount).toFixed(2)
})
}
}
function getReportTypeText(): string {
const types: Record<string, string> = {
sales: '销售报表',
user: '用户报表',
product: '商品报表',
financial: '财务报表',
marketing: '营销报表'
}
return types[report.type] || '其他报表'
}
function formatMetricValue(value: number, format: string): string {
switch (format) {
case 'currency':
return `¥${(value / 10000).toFixed(1)}万`
case 'percent':
return `${value}%`
case 'number':
return value.toLocaleString()
default:
return value.toString()
}
}
function formatTime(timeStr: string): string {
return timeStr.replace('T', ' ').split('.')[0]
}
function getInsightIcon(type: string): string {
const icons: Record<string, string> = {
positive: '✅',
warning: '⚠️',
negative: '❌',
info: ''
}
return icons[type] || ''
}
function getImpactText(impact: string): string {
const impacts: Record<string, string> = {
high: '高影响',
medium: '中影响',
low: '低影响'
}
return impacts[impact] || '未知影响'
}
function formatCellValue(value: any, column: TableColumnType): string {
switch (column.type) {
case 'currency':
return `¥${parseFloat(value).toLocaleString()}`
case 'percent':
return `${value}%`
case 'number':
return parseInt(value).toLocaleString()
default:
return value.toString()
}
}
function switchChartTab(tabKey: string) {
activeChartTab.value = tabKey
}
function onChartTouch(e: any) {
// 处理图表触摸事件
}
function onSortChange(e: any) {
sortIndex.value = e.detail.value
generateTableData()
}
function onLimitChange(e: any) {
limitIndex.value = e.detail.value
currentPage.value = 1
updateTotalPages()
generateTableData()
}
function previousPage() {
if (currentPage.value > 1) {
currentPage.value--
generateTableData()
}
}
function nextPage() {
if (currentPage.value < totalPages.value) {
currentPage.value++
generateTableData()
}
}
function exportReport() {
uni.showActionSheet({
itemList: ['导出Excel', '导出PDF', '导出图片'],
success: (res) => {
const formats = ['Excel', 'PDF', '图片']
uni.showToast({ title: `正在导出${formats[res.tapIndex]}`, icon: 'loading' })
setTimeout(() => {
uni.showToast({ title: '导出成功', icon: 'success' })
}, 2000)
}
})
}
function refreshReport() {
uni.showLoading({ title: '刷新中...' })
setTimeout(() => {
uni.hideLoading()
void loadReportDetail(report.id)
uni.showToast({ title: '刷新成功', icon: 'success' })
}, 1500)
}
function goToDataDetail() {
if (!report.id || report.id.length === 0) {
uni.showToast({ title: '报表未加载完成', icon: 'none' })
return
}
uni.navigateTo({
url: `/pages/mall/analytics/data-detail?reportId=${report.id}`
})
}
function viewInsightDetail(insight: InsightType) {
uni.navigateTo({
url: `/pages/mall/analytics/insight-detail?insightId=${insight.id}`
})
}
function viewRelatedReport(rep: ReportType) {
uni.navigateTo({
url: `/pages/mall/analytics/report-detail?reportId=${rep.id}`
})
}
function toggleAutoRefresh(e: any) {
autoRefresh.value = e.detail.value
}
function onIntervalChange(e: any) {
intervalIndex.value = e.detail.value
}
function toggleEmailNotify(e: any) {
emailNotify.value = e.detail.value
}
function saveConfig() {
uni.showToast({ title: '配置已保存', icon: 'success' })
}
function resetConfig() {
autoRefresh.value = false
intervalIndex.value = 1
emailNotify.value = false
uni.showToast({ title: '配置已重置', icon: 'success' })
}
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; /* 为固定顶部导航栏留出空间 */
}
.menu-icon {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border-radius: 8px;
background: #f3f4f6;
}
.menu-icon .icon {
font-size: 18px;
color: #333;
}
.report-detail-page {
background-color: #f5f5f5;
min-height: 100vh;
}
.report-header, .metrics-overview, .chart-section, .data-table, .data-insights, .report-config, .related-reports {
background-color: #fff;
margin-bottom: 20rpx;
padding: 30rpx;
}
.report-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.header-info {
flex: 1;
}
.report-title {
font-size: 36rpx;
font-weight: bold;
color: #333;
margin-bottom: 15rpx;
}
.report-meta {
display: flex;
gap: 20rpx;
}
.meta-item {
font-size: 24rpx;
color: #666;
background-color: #f0f0f0;
padding: 6rpx 12rpx;
border-radius: 8rpx;
}
.header-actions {
display: flex;
gap: 15rpx;
}
.action-btn.detail,
.action-btn.export,
.action-btn.refresh {
padding: 15rpx 25rpx;
border-radius: 8rpx;
font-size: 24rpx;
border: none;
}
.action-btn.detail {
background-color: #111827;
color: #fff;
}
.action-btn.export {
background-color: #4caf50;
color: #fff;
}
.action-btn.refresh {
background-color: #2196f3;
color: #fff;
}
.section-title {
font-size: 30rpx;
font-weight: bold;
color: #333;
margin-bottom: 25rpx;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 25rpx;
}
.metrics-grid {
display: flex;
gap: 20rpx;
flex-wrap: wrap;
}
.metric-card {
flex: 1;
min-width: 300rpx;
display: flex;
align-items: center;
padding: 25rpx;
background-color: #f8f9fa;
border-radius: 12rpx;
}
.metric-icon {
width: 80rpx;
height: 80rpx;
border-radius: 40rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 32rpx;
margin-right: 20rpx;
}
.metric-content {
flex: 1;
}
.metric-value {
font-size: 32rpx;
font-weight: bold;
color: #333;
margin-bottom: 5rpx;
}
.metric-label {
font-size: 24rpx;
color: #666;
}
.metric-change {
display: flex;
align-items: center;
margin-top: 8rpx;
}
.metric-change.positive {
color: #4caf50;
}
.metric-change.negative {
color: #f44336;
}
.change-icon {
margin-right: 8rpx;
}
.change-value {
font-size: 22rpx;
}
.chart-tabs {
display: flex;
gap: 15rpx;
}
.chart-tab {
padding: 8rpx 16rpx;
border-radius: 20rpx;
background-color: #f0f0f0;
color: #666;
font-size: 22rpx;
}
.chart-tab.active {
background-color: #111827;
color: #fff;
}
.chart-container {
height: 400rpx;
margin: 20rpx 0;
}
.chart-canvas {
width: 100%;
height: 400rpx;
}
.chart-legend {
display: flex;
justify-content: center;
gap: 30rpx;
}
.legend-item {
display: flex;
align-items: center;
gap: 8rpx;
}
.legend-color {
width: 20rpx;
height: 20rpx;
border-radius: 10rpx;
}
.legend-label {
font-size: 22rpx;
color: #666;
}
.table-filters {
display: flex;
justify-content: space-between;
margin-bottom: 20rpx;
}
.filter-item {
display: flex;
align-items: center;
gap: 10rpx;
}
.filter-label {
font-size: 24rpx;
color: #666;
}
.filter-value {
font-size: 24rpx;
color: #333;
}
.table-container {
margin: 20rpx 0;
}
.table-scroll {
width: 100%;
}
.table {
min-width: 600rpx;
}
.table-header {
display: flex;
background-color: #f8f9fa;
padding: 15rpx;
}
.table-row {
display: flex;
padding: 15rpx;
border-bottom: 1rpx solid #eee;
}
.table-cell {
flex-shrink: 0;
text-align: center;
font-size: 22rpx;
}
.header-cell {
font-weight: bold;
color: #333;
}
.data-cell {
color: #666;
}
.data-cell.number {
color: #2196f3;
}
.data-cell.currency {
color: #4caf50;
}
.table-pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 20rpx;
margin-top: 20rpx;
}
.page-btn {
padding: 10rpx 20rpx;
border-radius: 6rpx;
background-color: #111827;
color: #fff;
font-size: 22rpx;
border: none;
}
.page-btn:disabled {
background-color: #ccc;
}
.page-info {
font-size: 24rpx;
color: #666;
}
.insight-card {
background-color: #f8f9fa;
padding: 20rpx;
border-radius: 12rpx;
margin-bottom: 15rpx;
}
.insight-header {
display: flex;
align-items: center;
gap: 15rpx;
margin-bottom: 10rpx;
}
.insight-icon {
width: 40rpx;
height: 40rpx;
border-radius: 20rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 24rpx;
background-color: #e5e7eb;
}
.insight-title {
font-size: 26rpx;
font-weight: bold;
color: #333;
}
.insight-content {
font-size: 24rpx;
color: #666;
line-height: 1.5;
}
.insight-actions {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 15rpx;
}
.insight-impact {
font-size: 22rpx;
padding: 4rpx 12rpx;
border-radius: 12rpx;
}
.insight-impact.high {
background-color: #ffebee;
color: #f44336;
}
.insight-impact.medium {
background-color: #fff3e0;
color: #ff9800;
}
.insight-impact.low {
background-color: #e8f5e8;
color: #4caf50;
}
.insight-action {
font-size: 22rpx;
color: #2196f3;
}
.config-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20rpx 0;
border-bottom: 1rpx solid #eee;
}
.config-label {
font-size: 24rpx;
color: #333;
}
.config-value {
font-size: 24rpx;
color: #666;
}
.config-actions {
display: flex;
gap: 15rpx;
margin-top: 20rpx;
}
.config-btn {
flex: 1;
padding: 15rpx;
border-radius: 8rpx;
font-size: 24rpx;
border: none;
}
.config-btn.save {
background-color: #4caf50;
color: #fff;
}
.config-btn.reset {
background-color: #f44336;
color: #fff;
}
.report-item {
display: flex;
align-items: center;
padding: 20rpx;
background-color: #f8f9fa;
border-radius: 12rpx;
margin-bottom: 15rpx;
}
.report-icon {
font-size: 32rpx;
margin-right: 15rpx;
}
.report-info {
flex: 1;
}
.report-name {
font-size: 26rpx;
font-weight: bold;
color: #333;
}
.report-desc {
font-size: 22rpx;
color: #666;
margin-top: 5rpx;
}
.report-time {
font-size: 20rpx;
color: #999;
margin-top: 5rpx;
}
.report-arrow {
font-size: 24rpx;
color: #999;
}
</style>