数据分析ui补充完善,接入数据库

This commit is contained in:
comlibmb
2026-01-31 21:47:42 +08:00
parent 8f181b2b6a
commit 6716398175
71 changed files with 6501 additions and 10593 deletions

View File

@@ -200,203 +200,160 @@
</view>
</template>
<script lang="uts">
<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'
type ReportType = {
id: string
title: string
type: string
period: string
generated_at: string
description: string
}
import type { ReportType, MetricType, ChartTabType, ChartLegendType, TableColumnType, InsightType } from '@/types/analytics/report-detail.uts'
type MetricType = {
key: string
label: string
value: number
format: string
icon: string
color: string
change: number
}
const showSidebarMenu = ref(false)
const currentPath = ref('/pages/mall/analytics/report-detail')
type ChartTabType = {
key: string
label: string
}
type ChartLegendType = {
key: string
label: string
color: string
}
type TableColumnType = {
key: string
title: string
width: string
type: string
}
type InsightType = {
id: string
type: string
title: string
content: string
impact: string
}
export default {
components: {
AnalyticsSidebarMenu,
AnalyticsTopBar
},
data() {
return {
showSidebarMenu: false,
currentPath: '/pages/mall/analytics/report-detail',
report: {
const report = reactive<ReportType>({
id: '',
title: '',
type: '',
period: '',
generated_at: '',
description: ''
} as ReportType,
coreMetrics: [] as Array<MetricType>,
chartTabs: [] as Array<ChartTabType>,
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>,
sortIndex: 0,
sortOptions: [] as Array<string>,
limitIndex: 1,
limitOptions: ['10条', '20条', '50条', '100条'],
currentPage: 1,
totalPages: 1,
autoRefresh: false,
intervalIndex: 1,
intervalOptions: ['1分钟', '5分钟', '10分钟', '30分钟', '1小时'],
emailNotify: false
}
},
onLoad(options: any) {
// 兼容两种参数名reportId 和 id
})
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) {
this.loadReportDetail(reportId)
void loadReportDetail(reportId)
} else {
uni.showToast({
title: '缺少报表ID',
icon: 'none'
})
uni.showToast({ title: '缺少报表ID', icon: 'none' })
setTimeout(() => {
uni.navigateBack()
}, 1500)
}
this.currentPath = '/pages/mall/analytics/report-detail'
},
currentPath.value = '/pages/mall/analytics/report-detail'
})
onShow() {
this.currentPath = '/pages/mall/analytics/report-detail'
},
onShow(() => {
currentPath.value = '/pages/mall/analytics/report-detail'
})
methods: {
handleMenu() {
this.showSidebarMenu = true
},
handleSidebarUpdate(visible: boolean) {
this.showSidebarMenu = visible
},
safeNumber(v: any): number {
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 loadReportDetail(reportId: string) {
async function loadReportDetail(reportId: string) {
try {
uni.showLoading({ title: '加载中...' })
// 1. 加载报表主体
const report = await fetchReport(reportId)
if (report == null) {
const rep = await fetchReport(reportId)
if (rep == null) {
uni.showToast({ title: '报表不存在', icon: 'none' })
return
}
this.report = report
// 2. 加载核心指标
this.coreMetrics = await fetchReportMetrics(reportId)
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
// 3. 配置表头与排序选项(固定结构)
this.tableColumns = [
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' }
]
this.sortOptions = ['按日期降序', '按销售额降序', '按订单数降序', '按转化率降序']
)
// 4. 加载明细行(趋势/表格)
this.allRows = await fetchReportRows(reportId)
this.currentPage = 1
this.updateTotalPages()
this.generateTableData()
sortOptions.value = ['按日期降序', '按销售额降序', '按订单数降序', '按转化率降序']
// 5. 加载洞察
this.dataInsights = await fetchReportInsights(reportId)
const rows = await fetchReportRows(reportId)
allRows.splice(0, allRows.length, ...rows)
// 6. 相关报表(同类型最近报表)
this.relatedReports = await fetchRelatedReports(this.report.type, reportId)
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()
}
},
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 total = this.allRows.length
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(this.limitOptions[this.limitIndex])
const start = (this.currentPage - 1) * limit
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 = this.allRows[i]
this.tableData.push({
const row = allRows[i]
tableData.push({
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)
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)
})
}
},
getReportTypeText(): string {
}
function getReportTypeText(): string {
const types: Record<string, string> = {
sales: '销售报表',
user: '用户报表',
@@ -404,10 +361,10 @@ export default {
financial: '财务报表',
marketing: '营销报表'
}
return types[this.report.type] || '其他报表'
},
return types[report.type] || '其他报表'
}
formatMetricValue(value: number, format: string): string {
function formatMetricValue(value: number, format: string): string {
switch (format) {
case 'currency':
return `¥${(value / 10000).toFixed(1)}万`
@@ -418,13 +375,13 @@ export default {
default:
return value.toString()
}
},
}
formatTime(timeStr: string): string {
function formatTime(timeStr: string): string {
return timeStr.replace('T', ' ').split('.')[0]
},
}
getInsightIcon(type: string): string {
function getInsightIcon(type: string): string {
const icons: Record<string, string> = {
positive: '✅',
warning: '⚠️',
@@ -432,18 +389,18 @@ export default {
info: ''
}
return icons[type] || ''
},
}
getImpactText(impact: string): string {
function getImpactText(impact: string): string {
const impacts: Record<string, string> = {
high: '高影响',
medium: '中影响',
low: '低影响'
}
return impacts[impact] || '未知影响'
},
}
formatCellValue(value: any, column: TableColumnType): string {
function formatCellValue(value: any, column: TableColumnType): string {
switch (column.type) {
case 'currency':
return `¥${parseFloat(value).toLocaleString()}`
@@ -454,144 +411,131 @@ export default {
default:
return value.toString()
}
},
switchChartTab(tabKey: string) {
this.activeChartTab = tabKey
// 这里可以重新绘制图表
},
onChartTouch(e: any) {
}
function switchChartTab(tabKey: string) {
activeChartTab.value = tabKey
}
function onChartTouch(e: any) {
// 处理图表触摸事件
},
onSortChange(e: any) {
this.sortIndex = e.detail.value
this.generateTableData()
},
onLimitChange(e: any) {
this.limitIndex = e.detail.value
this.currentPage = 1
this.updateTotalPages()
this.generateTableData()
},
previousPage() {
if (this.currentPage > 1) {
this.currentPage--
this.generateTableData()
}
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()
}
},
nextPage() {
if (this.currentPage < this.totalPages) {
this.currentPage++
this.generateTableData()
}
function nextPage() {
if (currentPage.value < totalPages.value) {
currentPage.value++
generateTableData()
}
},
exportReport() {
}
function exportReport() {
uni.showActionSheet({
itemList: ['导出Excel', '导出PDF', '导出图片'],
success: (res) => {
const formats = ['Excel', 'PDF', '图片']
uni.showToast({
title: `正在导出${formats[res.tapIndex]}`,
icon: 'loading'
})
uni.showToast({ title: `正在导出${formats[res.tapIndex]}`, icon: 'loading' })
setTimeout(() => {
uni.showToast({
title: '导出成功',
icon: 'success'
})
uni.showToast({ title: '导出成功', icon: 'success' })
}, 2000)
}
})
},
}
refreshReport() {
function refreshReport() {
uni.showLoading({ title: '刷新中...' })
setTimeout(() => {
uni.hideLoading()
this.loadReportDetail(this.report.id)
uni.showToast({
title: '刷新成功',
icon: 'success'
})
void loadReportDetail(report.id)
uni.showToast({ title: '刷新成功', icon: 'success' })
}, 1500)
},
}
goToDataDetail() {
if (!this.report.id || this.report.id.length === 0) {
function goToDataDetail() {
if (!report.id || report.id.length === 0) {
uni.showToast({ title: '报表未加载完成', icon: 'none' })
return
}
uni.navigateTo({
url: `/pages/mall/analytics/data-detail?reportId=${this.report.id}`
url: `/pages/mall/analytics/data-detail?reportId=${report.id}`
})
},
}
viewInsightDetail(insight: InsightType) {
function viewInsightDetail(insight: InsightType) {
uni.navigateTo({
url: `/pages/mall/analytics/insight-detail?insightId=${insight.id}`
})
},
}
viewRelatedReport(report: ReportType) {
function viewRelatedReport(rep: ReportType) {
uni.navigateTo({
url: `/pages/mall/analytics/report-detail?reportId=${report.id}`
url: `/pages/mall/analytics/report-detail?reportId=${rep.id}`
})
},
toggleAutoRefresh(e: any) {
this.autoRefresh = e.detail.value
},
onIntervalChange(e: any) {
this.intervalIndex = e.detail.value
},
toggleEmailNotify(e: any) {
this.emailNotify = e.detail.value
},
saveConfig() {
uni.showToast({
title: '配置已保存',
icon: 'success'
})
},
handleSearch() {
}
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' })
},
handleNotification() {
}
function handleNotification() {
uni.showToast({ title: '通知', icon: 'none' })
},
handleFullscreen() {
}
function handleFullscreen() {
uni.showToast({ title: '全屏', icon: 'none' })
},
handleMobile() {
}
function handleMobile() {
uni.showToast({ title: '移动端', icon: 'none' })
},
handleDropdown() {
}
function handleDropdown() {
uni.showToast({ title: '下拉菜单', icon: 'none' })
},
handleSettings() {
}
function handleSettings() {
uni.showToast({ title: '设置', icon: 'none' })
},
resetConfig() {
this.autoRefresh = false
this.intervalIndex = 1
this.emailNotify = false
uni.showToast({
title: '配置已重置',
icon: 'success'
})
}
}
}
</script>
@@ -757,12 +701,12 @@ export default {
.metric-label {
font-size: 24rpx;
color: #666;
margin-bottom: 8rpx;
}
.metric-change {
display: flex;
align-items: center;
margin-top: 8rpx;
}
.metric-change.positive {
@@ -770,17 +714,15 @@ export default {
}
.metric-change.negative {
color: #ff4444;
color: #f44336;
}
.change-icon {
font-size: 20rpx;
margin-right: 5rpx;
margin-right: 8rpx;
}
.change-value {
font-size: 22rpx;
font-weight: bold;
}
.chart-tabs {
@@ -789,111 +731,104 @@ export default {
}
.chart-tab {
padding: 12rpx 24rpx;
padding: 8rpx 16rpx;
border-radius: 20rpx;
font-size: 24rpx;
color: #666;
background-color: #f0f0f0;
color: #666;
font-size: 22rpx;
}
.chart-tab.active {
background-color: #2196f3;
background-color: #111827;
color: #fff;
}
.chart-container {
height: 500rpx;
margin: 30rpx 0;
border: 1rpx solid #eee;
border-radius: 8rpx;
height: 400rpx;
margin: 20rpx 0;
}
.chart-canvas {
width: 100%;
height: 100%;
height: 400rpx;
}
.chart-legend {
display: flex;
justify-content: center;
gap: 40rpx;
gap: 30rpx;
}
.legend-item {
display: flex;
align-items: center;
gap: 8rpx;
}
.legend-color {
width: 20rpx;
height: 20rpx;
border-radius: 10rpx;
margin-right: 10rpx;
}
.legend-label {
font-size: 24rpx;
font-size: 22rpx;
color: #666;
}
.table-filters {
display: flex;
gap: 40rpx;
margin-bottom: 25rpx;
justify-content: space-between;
margin-bottom: 20rpx;
}
.filter-item {
display: flex;
align-items: center;
gap: 10rpx;
}
.filter-label {
font-size: 24rpx;
color: #666;
margin-right: 10rpx;
}
.filter-value, .config-value {
.filter-value {
font-size: 24rpx;
color: #333;
padding: 10rpx 20rpx;
background-color: #f0f0f0;
border-radius: 6rpx;
}
.table-container {
border: 1rpx solid #eee;
border-radius: 8rpx;
margin-bottom: 25rpx;
margin: 20rpx 0;
}
.table-scroll {
white-space: nowrap;
width: 100%;
}
.table {
min-width: 100%;
min-width: 600rpx;
}
.table-header, .table-row {
.table-header {
display: flex;
background-color: #f8f9fa;
padding: 15rpx;
}
.table-row {
display: flex;
padding: 15rpx;
border-bottom: 1rpx solid #eee;
}
.table-row:last-child {
border-bottom: none;
}
.table-cell {
padding: 20rpx 15rpx;
font-size: 24rpx;
flex-shrink: 0;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
font-size: 22rpx;
}
.header-cell {
background-color: #f8f9fa;
font-weight: bold;
color: #333;
}
@@ -902,24 +837,29 @@ export default {
color: #666;
}
.data-cell.number, .data-cell.currency {
text-align: right;
.data-cell.number {
color: #2196f3;
}
.data-cell.currency {
color: #4caf50;
}
.table-pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 30rpx;
gap: 20rpx;
margin-top: 20rpx;
}
.page-btn {
padding: 15rpx 30rpx;
background-color: #2196f3;
color: #fff;
border: none;
padding: 10rpx 20rpx;
border-radius: 6rpx;
font-size: 24rpx;
background-color: #111827;
color: #fff;
font-size: 22rpx;
border: none;
}
.page-btn:disabled {
@@ -932,16 +872,17 @@ export default {
}
.insight-card {
padding: 25rpx;
background-color: #f8f9fa;
border-radius: 10rpx;
margin-bottom: 20rpx;
padding: 20rpx;
border-radius: 12rpx;
margin-bottom: 15rpx;
}
.insight-header {
display: flex;
align-items: center;
margin-bottom: 15rpx;
gap: 15rpx;
margin-bottom: 10rpx;
}
.insight-icon {
@@ -951,24 +892,8 @@ export default {
display: flex;
align-items: center;
justify-content: center;
font-size: 20rpx;
margin-right: 15rpx;
}
.insight-icon.positive {
background-color: #e8f5e8;
}
.insight-icon.warning {
background-color: #fff8e1;
}
.insight-icon.negative {
background-color: #ffebee;
}
.insight-icon.info {
background-color: #e3f2fd;
font-size: 24rpx;
background-color: #e5e7eb;
}
.insight-title {
@@ -980,33 +905,35 @@ export default {
.insight-content {
font-size: 24rpx;
color: #666;
line-height: 1.6;
margin-bottom: 15rpx;
line-height: 1.5;
}
.insight-actions {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 15rpx;
}
.insight-impact {
font-size: 22rpx;
padding: 6rpx 12rpx;
border-radius: 8rpx;
color: #fff;
padding: 4rpx 12rpx;
border-radius: 12rpx;
}
.insight-impact.high {
background-color: #ff4444;
background-color: #ffebee;
color: #f44336;
}
.insight-impact.medium {
background-color: #ffa726;
background-color: #fff3e0;
color: #ff9800;
}
.insight-impact.low {
background-color: #4caf50;
background-color: #e8f5e8;
color: #4caf50;
}
.insight-action {
@@ -1018,30 +945,31 @@ export default {
display: flex;
justify-content: space-between;
align-items: center;
padding: 25rpx 0;
border-bottom: 1rpx solid #f5f5f5;
}
.config-item:last-of-type {
border-bottom: none;
padding: 20rpx 0;
border-bottom: 1rpx solid #eee;
}
.config-label {
font-size: 26rpx;
font-size: 24rpx;
color: #333;
}
.config-value {
font-size: 24rpx;
color: #666;
}
.config-actions {
display: flex;
gap: 20rpx;
margin-top: 30rpx;
gap: 15rpx;
margin-top: 20rpx;
}
.config-btn {
flex: 1;
height: 70rpx;
padding: 15rpx;
border-radius: 8rpx;
font-size: 26rpx;
font-size: 24rpx;
border: none;
}
@@ -1051,28 +979,22 @@ export default {
}
.config-btn.reset {
background-color: #f0f0f0;
color: #666;
}
.report-list {
margin-top: 25rpx;
background-color: #f44336;
color: #fff;
}
.report-item {
display: flex;
align-items: center;
padding: 25rpx 0;
border-bottom: 1rpx solid #f5f5f5;
}
.report-item:last-child {
border-bottom: none;
padding: 20rpx;
background-color: #f8f9fa;
border-radius: 12rpx;
margin-bottom: 15rpx;
}
.report-icon {
font-size: 32rpx;
margin-right: 20rpx;
margin-right: 15rpx;
}
.report-info {
@@ -1081,35 +1003,24 @@ export default {
.report-name {
font-size: 26rpx;
color: #333;
font-weight: bold;
margin-bottom: 5rpx;
color: #333;
}
.report-desc {
font-size: 22rpx;
color: #666;
margin-bottom: 5rpx;
margin-top: 5rpx;
}
.report-time {
font-size: 20rpx;
color: #999;
margin-top: 5rpx;
}
.report-arrow {
font-size: 24rpx;
color: #999;
}
/* 响应式:窄屏时全屏显示 */
@media screen and (max-width: 959px) {
.page-layout {
flex-direction: column !important;
}
.main-content {
width: 100%;
}
}
</style>