数据分析页面骨架

This commit is contained in:
comlibmb
2026-01-23 16:33:11 +08:00
parent fdbee0fa32
commit c14f67cfc8
24 changed files with 9986 additions and 986 deletions

View File

@@ -1,176 +1,209 @@
<!-- 数据分析端 - 报表详情页 -->
<template>
<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 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 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 export" @click="exportReport">📊 导出</button>
<button class="action-btn refresh" @click="refreshReport">🔄 刷新</button>
</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="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="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 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 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 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>
</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 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>
<text class="report-arrow">></text>
</view>
</view>
</view>
</view>
</template>
<script>
<script lang="uts">
import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uvue'
import AnalyticsTopBar from '@/components/analytics/AnalyticsTopBar.uvue'
import supa from '@/components/supadb/aksupainstance.uts'
type ReportType = {
id: string
title: string
@@ -217,8 +250,14 @@ type InsightType = {
}
export default {
components: {
AnalyticsSidebarMenu,
AnalyticsTopBar
},
data() {
return {
showSidebarMenu: false,
currentPath: '/pages/mall/analytics/report-detail',
report: {
id: '',
title: '',
@@ -232,6 +271,7 @@ export default {
activeChartTab: '',
chartLegends: [] as Array<ChartLegendType>,
tableColumns: [] as Array<TableColumnType>,
allRows: [] as Array<any>,
tableData: [] as Array<any>,
dataInsights: [] as Array<InsightType>,
relatedReports: [] as Array<ReportType>,
@@ -261,147 +301,158 @@ export default {
uni.navigateBack()
}, 1500)
}
this.currentPath = '/pages/mall/analytics/report-detail'
},
onShow() {
this.currentPath = '/pages/mall/analytics/report-detail'
},
methods: {
loadReportDetail(reportId: string) {
// 模拟加载报表详情数据
this.report = {
id: reportId,
title: '销售业绩分析报表',
type: 'sales',
period: '2024年1月',
generated_at: '2024-01-15T14:30:00',
description: '详细分析1月份的销售业绩情况'
handleMenu() {
this.showSidebarMenu = true
},
handleSidebarUpdate(visible: boolean) {
this.showSidebarMenu = visible
},
safeNumber(v: any): number {
const n = Number(v)
return isFinite(n) ? n : 0
},
async loadReportDetail(reportId: string) {
try {
uni.showLoading({ title: '加载中...' })
// 1. 加载报表主体
const reportRes: any = await supa
.from('analytics_reports')
.select('id, title, type, period, generated_at, description')
.eq('id', reportId)
const reportRows: Array<any> = Array.isArray(reportRes.data) ? (reportRes.data as Array<any>) : []
if (reportRows.length === 0) {
uni.showToast({ title: '报表不存在', icon: 'none' })
return
}
const r = reportRows[0]
this.report = {
id: `${r.id}`,
title: `${r.title}`,
type: `${r.type}`,
period: `${r.period}`,
generated_at: `${r.generated_at}`,
description: `${r.description || ''}`
}
// 2. 加载核心指标
const metricRes: any = await supa
.from('analytics_report_metrics')
.select('metric_key, metric_label, metric_value_num, format, icon, color, change_pct')
.eq('report_id', reportId)
const metricRows: Array<any> = Array.isArray(metricRes.data) ? (metricRes.data as Array<any>) : []
this.coreMetrics = metricRows.map((m: any) => ({
key: `${m.metric_key}`,
label: `${m.metric_label}`,
value: this.safeNumber(m.metric_value_num),
format: `${m.format || 'number'}`,
icon: `${m.icon || '📊'}`,
color: `${m.color || '#4caf50'}`,
change: this.safeNumber(m.change_pct)
}))
// 3. 配置表头与排序选项(固定结构)
this.tableColumns = [
{ 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' }
]
this.sortOptions = ['按日期降序', '按销售额降序', '按订单数降序', '按转化率降序']
// 4. 加载明细行(趋势/表格)
const rowsRes: any = await supa
.from('analytics_report_rows')
.select('row_date, gmv, orders, users, conversion, avg_order_amount')
.eq('report_id', reportId)
.order('row_date', { ascending: true } as any)
const rows: Array<any> = Array.isArray(rowsRes.data) ? (rowsRes.data as Array<any>) : []
this.allRows = rows
this.currentPage = 1
this.updateTotalPages()
this.generateTableData()
// 5. 加载洞察
const insightRes: any = await supa
.from('analytics_insights')
.select('id, type, title, content, impact')
.eq('report_id', reportId)
.order('created_at', { ascending: false } as any)
const insRows: Array<any> = Array.isArray(insightRes.data) ? (insightRes.data as Array<any>) : []
this.dataInsights = insRows.map((it: any) => ({
id: `${it.id}`,
type: `${it.type || 'info'}`,
title: `${it.title}`,
content: `${it.content}`,
impact: `${it.impact || 'medium'}`
}))
// 6. 相关报表(同类型最近报表)
const relatedRes: any = await supa
.from('analytics_reports')
.select('id, title, type, period, generated_at, description')
.eq('type', this.report.type)
.neq('id', reportId)
.order('generated_at', { ascending: false } as any)
.limit(3 as any)
const relRows: Array<any> = Array.isArray(relatedRes.data) ? (relatedRes.data as Array<any>) : []
this.relatedReports = relRows.map((it: any) => ({
id: `${it.id}`,
title: `${it.title}`,
type: `${it.type}`,
period: `${it.period}`,
generated_at: `${it.generated_at}`,
description: `${it.description || ''}`
}))
} catch (e) {
console.error('loadReportDetail failed', e)
uni.showToast({ title: '报表加载失败', icon: 'none' })
} finally {
uni.hideLoading()
}
this.coreMetrics = [
{
key: 'total_sales',
label: '总销售额',
value: 1250000,
format: 'currency',
icon: '💰',
color: '#4caf50',
change: 15.6
},
{
key: 'order_count',
label: '订单数量',
value: 8650,
format: 'number',
icon: '📦',
color: '#2196f3',
change: 8.3
},
{
key: 'avg_order_value',
label: '客单价',
value: 144.5,
format: 'currency',
icon: '🛍️',
color: '#ff9800',
change: 6.8
},
{
key: 'conversion_rate',
label: '转化率',
value: 3.2,
format: 'percent',
icon: '📈',
color: '#9c27b0',
change: -2.1
}
]
this.chartTabs = [
{ key: 'sales', label: '销售额' },
{ key: 'orders', label: '订单量' },
{ key: 'users', label: '用户数' }
]
this.activeChartTab = 'sales'
this.chartLegends = [
{ key: 'current', label: '当期', color: '#2196f3' },
{ key: 'previous', label: '上期', color: '#ff9800' },
{ key: 'target', label: '目标', color: '#4caf50' }
]
this.tableColumns = [
{ 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' }
]
this.sortOptions = ['按日期降序', '按销售额降序', '按订单数降序', '按转化率降序']
// 模拟表格数据
this.generateTableData()
this.dataInsights = [
{
id: 'insight_001',
type: 'positive',
title: '销售额显著增长',
content: '相比上月本月销售额增长15.6%,主要得益于新产品上线和营销活动效果显著。',
impact: 'high'
},
{
id: 'insight_002',
type: 'warning',
title: '转化率轻微下降',
content: '转化率较上月下降2.1%,建议优化商品页面和购买流程,提升用户体验。',
impact: 'medium'
},
{
id: 'insight_003',
title: '周末销售高峰',
content: '数据显示周末周六、周日的销售额占总销售额的35%,建议加强周末营销投入。',
impact: 'medium',
type: 'info'
}
]
this.relatedReports = [
{
id: 'report_002',
title: '用户行为分析报表',
type: 'user',
period: '2024年1月',
generated_at: '2024-01-15T10:00:00',
description: '分析用户浏览、搜索、购买行为'
},
{
id: 'report_003',
title: '商品销售排行报表',
type: 'product',
period: '2024年1月',
generated_at: '2024-01-15T09:30:00',
description: '商品销售排行和库存分析'
}
]
this.totalPages = Math.ceil(31 / parseInt(this.limitOptions[this.limitIndex]))
},
updateTotalPages() {
const total = this.allRows.length
const limit = parseInt(this.limitOptions[this.limitIndex])
this.totalPages = total > 0 ? Math.ceil(total / limit) : 1
},
generateTableData() {
this.tableData = []
const days = 31
const total = this.allRows.length
if (total === 0) {
return
}
const limit = parseInt(this.limitOptions[this.limitIndex])
const start = (this.currentPage - 1) * limit
const end = Math.min(start + limit, days)
const end = Math.min(start + limit, total)
for (let i = start; i < end; i++) {
const day = i + 1
const row = this.allRows[i]
this.tableData.push({
date: `2024-01-${day.toString().padStart(2, '0')}`,
sales: Math.floor(Math.random() * 50000) + 20000,
orders: Math.floor(Math.random() * 300) + 200,
users: Math.floor(Math.random() * 1000) + 500,
conversion: (Math.random() * 5 + 1).toFixed(1),
avg_value: (Math.random() * 100 + 50).toFixed(2)
date: `${row.row_date}`,
sales: this.safeNumber(row.gmv),
orders: this.safeNumber(row.orders),
users: this.safeNumber(row.users),
conversion: this.safeNumber(row.conversion).toFixed(1),
avg_value: this.safeNumber(row.avg_order_amount).toFixed(2)
})
}
},
@@ -483,7 +534,7 @@ export default {
onLimitChange(e: any) {
this.limitIndex = e.detail.value
this.currentPage = 1
this.totalPages = Math.ceil(31 / parseInt(this.limitOptions[this.limitIndex]))
this.updateTotalPages()
this.generateTableData()
},
@@ -563,6 +614,24 @@ export default {
icon: 'success'
})
},
handleSearch() {
uni.showToast({ title: '搜索', icon: 'none' })
},
handleNotification() {
uni.showToast({ title: '通知', icon: 'none' })
},
handleFullscreen() {
uni.showToast({ title: '全屏', icon: 'none' })
},
handleMobile() {
uni.showToast({ title: '移动端', icon: 'none' })
},
handleDropdown() {
uni.showToast({ title: '下拉菜单', icon: 'none' })
},
handleSettings() {
uni.showToast({ title: '设置', icon: 'none' })
},
resetConfig() {
this.autoRefresh = false
@@ -578,6 +647,42 @@ export default {
</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;
@@ -1040,4 +1145,15 @@ export default {
font-size: 24rpx;
color: #999;
}
/* 响应式:窄屏时全屏显示 */
@media screen and (max-width: 959px) {
.page-layout {
flex-direction: column !important;
}
.main-content {
width: 100%;
}
}
</style>