数据分析ui补充完善,接入数据库
This commit is contained in:
@@ -2,11 +2,11 @@
|
|||||||
// 内网环境 - 本地部署的 Supabase
|
// 内网环境 - 本地部署的 Supabase
|
||||||
// IP: 192.168.1.63
|
// IP: 192.168.1.63
|
||||||
// Kong HTTP Port: 8000
|
// Kong HTTP Port: 8000
|
||||||
export const SUPA_URL: string = 'http://192.168.1.63:8000'
|
export const SUPA_URL: string = 'http://192.168.1.63:18000'
|
||||||
export const SUPA_KEY: string = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzY4ODMwNjI0LCJleHAiOjE5MjY1MTA2MjR9.mDVl-kIOdRK9v6VTxo0TDF8r7X7xk3PZXazaavHyVvg'
|
export const SUPA_KEY: string = 'eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9.eyJyb2xlIjogImFub24iLCAiaXNzIjogInN1cGFiYXNlIiwgImlhdCI6IDE3Njk4NDczMzQsICJleHAiOiAyMDg1MjA3MzM0fQ.js-2CS5_cUmf4iVv8aCmmx9iyFsQvLNDbt8YYOngeLU'
|
||||||
|
|
||||||
// WebSocket 实时连接(内网使用 ws:// 而非 wss://)
|
// WebSocket 实时连接(内网使用 ws:// 而非 wss://)
|
||||||
export const WS_URL: string = 'ws://192.168.1.63:8000/realtime/v1/websocket'
|
export const WS_URL: string = 'ws://192.168.1.63:18000/realtime/v1/websocket'
|
||||||
|
|
||||||
// 备用配置(已注释,如需切换可取消注释)
|
// 备用配置(已注释,如需切换可取消注释)
|
||||||
// 开发环境 - 其他内网地址
|
// 开发环境 - 其他内网地址
|
||||||
|
|||||||
129
components/analytics/AnalyticsDateRangePicker.uvue
Normal file
129
components/analytics/AnalyticsDateRangePicker.uvue
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
<template>
|
||||||
|
<view class="date-range-picker">
|
||||||
|
<view class="picker-item">
|
||||||
|
<text class="label">开始日期</text>
|
||||||
|
<picker mode="date" :value="startDate" @change="onStartDateChange">
|
||||||
|
<view class="picker-value">{{ startDate || '请选择' }}</view>
|
||||||
|
</picker>
|
||||||
|
</view>
|
||||||
|
<view class="picker-item">
|
||||||
|
<text class="label">结束日期</text>
|
||||||
|
<picker mode="date" :value="endDate" @change="onEndDateChange">
|
||||||
|
<view class="picker-value">{{ endDate || '请选择' }}</view>
|
||||||
|
</picker>
|
||||||
|
</view>
|
||||||
|
<view class="actions">
|
||||||
|
<button class="btn apply" @click="applyRange">应用</button>
|
||||||
|
<button class="btn clear" @click="clearRange">清空</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="uts">
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
initialStartDate: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
initialEndDate: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['apply', 'clear'])
|
||||||
|
|
||||||
|
const startDate = ref(props.initialStartDate)
|
||||||
|
const endDate = ref(props.initialEndDate)
|
||||||
|
|
||||||
|
watch(() => props.initialStartDate, (val) => {
|
||||||
|
startDate.value = val
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.initialEndDate, (val) => {
|
||||||
|
endDate.value = val
|
||||||
|
})
|
||||||
|
|
||||||
|
function onStartDateChange(e : any) {
|
||||||
|
startDate.value = e.detail.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function onEndDateChange(e : any) {
|
||||||
|
endDate.value = e.detail.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyRange() {
|
||||||
|
if (startDate.value && endDate.value) {
|
||||||
|
emit('apply', { start: startDate.value, end: endDate.value })
|
||||||
|
} else {
|
||||||
|
uni.showToast({ title: '请选择完整的日期范围', icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearRange() {
|
||||||
|
startDate.value = ''
|
||||||
|
endDate.value = ''
|
||||||
|
emit('clear')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.date-range-picker {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 12px;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-value {
|
||||||
|
padding: 8px 12px;
|
||||||
|
background-color: #fff;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.apply {
|
||||||
|
background-color: #007bff;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.clear {
|
||||||
|
background-color: #6c757d;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -411,12 +411,6 @@
|
|||||||
"navigationBarTitleText": "数据洞察详情",
|
"navigationBarTitleText": "数据洞察详情",
|
||||||
"enablePullDownRefresh": false
|
"enablePullDownRefresh": false
|
||||||
}
|
}
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "test/test-connection",
|
|
||||||
"style": {
|
|
||||||
"navigationBarTitleText": "Supabase 连接测试"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -109,7 +109,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="uts">
|
<script setup lang="uts">
|
||||||
import { computed, onLoad, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
import { onLoad } from '@dcloudio/uni-app'
|
||||||
import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uvue'
|
import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uvue'
|
||||||
import AnalyticsTopBar from '@/components/analytics/AnalyticsTopBar.uvue'
|
import AnalyticsTopBar from '@/components/analytics/AnalyticsTopBar.uvue'
|
||||||
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
|
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
|
||||||
|
|||||||
@@ -163,12 +163,13 @@
|
|||||||
import supa, { ensureSupabaseReady } from '@/components/supadb/aksupainstance.uts'
|
import supa, { ensureSupabaseReady } from '@/components/supadb/aksupainstance.uts'
|
||||||
import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uvue'
|
import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uvue'
|
||||||
import AnalyticsTopBar from '@/components/analytics/AnalyticsTopBar.uvue'
|
import AnalyticsTopBar from '@/components/analytics/AnalyticsTopBar.uvue'
|
||||||
import { goToLogin } from '@/utils/utils.uts'
|
import { goToLogin as goToLoginPage } from '@/utils/utils.uts'
|
||||||
import { getUserIdOrNull } from '@/services/analytics/auth.uts'
|
import { getUserIdOrNull } from '@/services/analytics/auth.uts'
|
||||||
import { listCustomReports, createCustomReport, updateCustomReport, deleteCustomReport } from '@/services/analytics/customReportService.uts'
|
import { listCustomReports, createCustomReport, updateCustomReport, deleteCustomReport } from '@/services/analytics/customReportService.uts'
|
||||||
import { mapAnalyticsError } from '@/services/analytics/errorMapper.uts'
|
import { mapAnalyticsError } from '@/services/analytics/errorMapper.uts'
|
||||||
|
|
||||||
import { onLoad, onShow, reactive, ref } from 'vue'
|
import { reactive, ref } from 'vue'
|
||||||
|
import { onLoad, onShow } from '@dcloudio/uni-app'
|
||||||
|
|
||||||
import type { CustomReport, ReportForm, ReportFormErrors } from '@/types/analytics/custom-report.uts'
|
import type { CustomReport, ReportForm, ReportFormErrors } from '@/types/analytics/custom-report.uts'
|
||||||
import type { Metric, TimePeriod, ChartType } from '@/types/analytics/common.uts'
|
import type { Metric, TimePeriod, ChartType } from '@/types/analytics/common.uts'
|
||||||
@@ -516,360 +517,10 @@ function handleSettings() {
|
|||||||
uni.showToast({ title: '设置', icon: 'none' })
|
uni.showToast({ title: '设置', icon: 'none' })
|
||||||
}
|
}
|
||||||
|
|
||||||
function goToLogin() {
|
function handleGoToLogin() {
|
||||||
goToLogin('/pages/mall/analytics/custom-report')
|
goToLoginPage('/pages/mall/analytics/custom-report')
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
|
||||||
showMoreMenu: false,
|
|
||||||
showSidebarMenu: false,
|
|
||||||
currentPath: '/pages/mall/analytics/custom-report',
|
|
||||||
showCreateModal: false,
|
|
||||||
editingReport: null as Report | null,
|
|
||||||
|
|
||||||
reports: [] as Array<Report>,
|
|
||||||
isLoggedIn: false,
|
|
||||||
|
|
||||||
reportForm: {
|
|
||||||
name: '',
|
|
||||||
description: '',
|
|
||||||
metrics: [] as Array<string>,
|
|
||||||
period: '7d',
|
|
||||||
chartType: 'line'
|
|
||||||
} as ReportForm,
|
|
||||||
formErrors: {
|
|
||||||
name: '',
|
|
||||||
description: '',
|
|
||||||
metrics: '',
|
|
||||||
period: '',
|
|
||||||
chartType: ''
|
|
||||||
} as ReportFormErrors,
|
|
||||||
|
|
||||||
availableMetrics: [
|
|
||||||
{ key: 'gmv', label: 'GMV' },
|
|
||||||
{ key: 'orders', label: '订单数' },
|
|
||||||
{ key: 'users', label: '用户数' },
|
|
||||||
{ key: 'conversion', label: '转化率' },
|
|
||||||
{ key: 'avg_order', label: '客单价' },
|
|
||||||
{ key: 'repurchase', label: '复购率' }
|
|
||||||
] as Array<Metric>,
|
|
||||||
|
|
||||||
timePeriods: [
|
|
||||||
{ value: '7d', label: '7天' },
|
|
||||||
{ value: '30d', label: '30天' },
|
|
||||||
{ value: '90d', label: '90天' },
|
|
||||||
{ value: '1y', label: '1年' }
|
|
||||||
] as Array<TimePeriod>,
|
|
||||||
|
|
||||||
chartTypes: [
|
|
||||||
{ value: 'line', label: '折线图' },
|
|
||||||
{ value: 'bar', label: '柱状图' },
|
|
||||||
{ value: 'pie', label: '饼图' },
|
|
||||||
{ value: 'area', label: '面积图' },
|
|
||||||
{ value: 'combo', label: '组合图' }
|
|
||||||
] as Array<ChartType>
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
onLoad() {
|
|
||||||
this.currentPath = '/pages/mall/analytics/custom-report'
|
|
||||||
this.loadReports()
|
|
||||||
},
|
|
||||||
|
|
||||||
onShow() {
|
|
||||||
this.currentPath = '/pages/mall/analytics/custom-report'
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
async loadReports() {
|
|
||||||
try {
|
|
||||||
await ensureSupabaseReady()
|
|
||||||
|
|
||||||
// 获取当前登录用户,用于按 owner_user_id 过滤自定义报表
|
|
||||||
const uid = getUserIdOrNull()
|
|
||||||
if (!uid || uid.length === 0) {
|
|
||||||
// 未登录时显示空列表
|
|
||||||
this.isLoggedIn = false
|
|
||||||
this.reports = []
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
this.isLoggedIn = true
|
|
||||||
|
|
||||||
const items = await listCustomReports(uid)
|
|
||||||
const list: Array<Report> = []
|
|
||||||
for (let i = 0; i < items.length; i++) {
|
|
||||||
const r = items[i]
|
|
||||||
list.push({
|
|
||||||
id: `${r.id}`,
|
|
||||||
name: `${r.title}`,
|
|
||||||
description: `${r.description || ''}`,
|
|
||||||
metrics: [] as Array<string>,
|
|
||||||
charts: [] as Array<string>,
|
|
||||||
updated_at: `${r.updated_at || ''}`
|
|
||||||
} as Report)
|
|
||||||
}
|
|
||||||
this.reports = list
|
|
||||||
} catch (e) {
|
|
||||||
console.error('loadReports failed', e)
|
|
||||||
uni.showToast({ title: '报表加载失败', icon: 'none' })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
createReport() {
|
|
||||||
this.editingReport = null
|
|
||||||
this.reportForm = {
|
|
||||||
name: '',
|
|
||||||
description: '',
|
|
||||||
metrics: [],
|
|
||||||
period: '7d',
|
|
||||||
chartType: 'line'
|
|
||||||
}
|
|
||||||
this.formErrors = {
|
|
||||||
name: '',
|
|
||||||
description: '',
|
|
||||||
metrics: '',
|
|
||||||
period: '',
|
|
||||||
chartType: ''
|
|
||||||
}
|
|
||||||
this.showCreateModal = true
|
|
||||||
},
|
|
||||||
|
|
||||||
editReport(report: Report) {
|
|
||||||
this.editingReport = report
|
|
||||||
this.reportForm = {
|
|
||||||
name: report.name,
|
|
||||||
description: report.description,
|
|
||||||
metrics: report.metrics,
|
|
||||||
period: '7d',
|
|
||||||
chartType: 'line'
|
|
||||||
}
|
|
||||||
this.formErrors = {
|
|
||||||
name: '',
|
|
||||||
description: '',
|
|
||||||
metrics: '',
|
|
||||||
period: '',
|
|
||||||
chartType: ''
|
|
||||||
}
|
|
||||||
this.showCreateModal = true
|
|
||||||
},
|
|
||||||
|
|
||||||
deleteReport(report: Report) {
|
|
||||||
uni.showModal({
|
|
||||||
title: '确认删除',
|
|
||||||
content: `确定要删除报表"${report.name}"吗?`,
|
|
||||||
success: (res) => {
|
|
||||||
if (res.confirm) {
|
|
||||||
this.doDeleteReport(report)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
async doDeleteReport(report: Report) {
|
|
||||||
try {
|
|
||||||
await ensureSupabaseReady()
|
|
||||||
|
|
||||||
await deleteCustomReport(report.id)
|
|
||||||
uni.showToast({ title: '删除成功', icon: 'success' })
|
|
||||||
this.loadReports()
|
|
||||||
} catch (e: any) {
|
|
||||||
console.error('doDeleteReport failed', e)
|
|
||||||
const errorMsg = e?.message || '删除失败'
|
|
||||||
uni.showToast({ title: errorMsg, icon: 'none' })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
toggleMetric(key: string) {
|
|
||||||
const index = this.reportForm.metrics.indexOf(key)
|
|
||||||
if (index >= 0) {
|
|
||||||
this.reportForm.metrics.splice(index, 1)
|
|
||||||
} else {
|
|
||||||
this.reportForm.metrics.push(key)
|
|
||||||
}
|
|
||||||
if (this.reportForm.metrics.length > 0) {
|
|
||||||
this.formErrors.metrics = ''
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
onNameInput() {
|
|
||||||
const name = this.reportForm.name.trim()
|
|
||||||
if (name.length === 0) {
|
|
||||||
this.formErrors.name = '报表名称不能为空'
|
|
||||||
} else if (name.length > 50) {
|
|
||||||
this.formErrors.name = '报表名称不能超过50个字符'
|
|
||||||
} else {
|
|
||||||
this.formErrors.name = ''
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
onDescriptionInput() {
|
|
||||||
const desc = this.reportForm.description
|
|
||||||
if (desc.length > 200) {
|
|
||||||
this.formErrors.description = '报表描述不能超过200个字符'
|
|
||||||
} else {
|
|
||||||
this.formErrors.description = ''
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
selectPeriod(value: string) {
|
|
||||||
this.reportForm.period = value
|
|
||||||
this.formErrors.period = ''
|
|
||||||
},
|
|
||||||
|
|
||||||
selectChartType(value: string) {
|
|
||||||
this.reportForm.chartType = value
|
|
||||||
this.formErrors.chartType = ''
|
|
||||||
},
|
|
||||||
|
|
||||||
validateReportForm(): boolean {
|
|
||||||
this.onNameInput()
|
|
||||||
this.onDescriptionInput()
|
|
||||||
|
|
||||||
if (this.reportForm.metrics.length === 0) {
|
|
||||||
this.formErrors.metrics = '请至少选择一个指标'
|
|
||||||
} else {
|
|
||||||
this.formErrors.metrics = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.reportForm.period) {
|
|
||||||
this.formErrors.period = '请选择时间维度'
|
|
||||||
}
|
|
||||||
if (!this.reportForm.chartType) {
|
|
||||||
this.formErrors.chartType = '请选择图表类型'
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.formErrors.name || this.formErrors.description || this.formErrors.metrics || this.formErrors.period || this.formErrors.chartType) {
|
|
||||||
uni.showToast({ title: '请先修正表单中的错误提示', icon: 'none' })
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
},
|
|
||||||
|
|
||||||
async saveReport() {
|
|
||||||
if (!this.validateReportForm()) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
uni.showLoading({ title: '保存中...' })
|
|
||||||
await ensureSupabaseReady()
|
|
||||||
|
|
||||||
// 获取当前登录用户,作为 owner_user_id
|
|
||||||
const uid = getUserIdOrNull()
|
|
||||||
if (!uid || uid.length === 0) {
|
|
||||||
uni.hideLoading()
|
|
||||||
uni.showModal({
|
|
||||||
title: '需要登录',
|
|
||||||
content: '创建自定义报表需要先登录,是否前往登录页面?',
|
|
||||||
success: (res) => {
|
|
||||||
if (res.confirm) {
|
|
||||||
goToLogin('/pages/mall/analytics/custom-report')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let newReportId = ''
|
|
||||||
|
|
||||||
// 1) 创建或更新自定义报表
|
|
||||||
if (this.editingReport == null) {
|
|
||||||
newReportId = await createCustomReport({
|
|
||||||
title: this.reportForm.name,
|
|
||||||
description: this.reportForm.description || '',
|
|
||||||
period: this.reportForm.period,
|
|
||||||
metrics: this.reportForm.metrics,
|
|
||||||
chartType: this.reportForm.chartType || 'line'
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
await updateCustomReport({
|
|
||||||
reportId: this.editingReport.id,
|
|
||||||
title: this.reportForm.name,
|
|
||||||
description: this.reportForm.description || null,
|
|
||||||
period: this.reportForm.period || null
|
|
||||||
})
|
|
||||||
newReportId = this.editingReport.id
|
|
||||||
}
|
|
||||||
|
|
||||||
uni.hideLoading()
|
|
||||||
uni.showToast({ title: '保存成功', icon: 'success' })
|
|
||||||
this.closeModal()
|
|
||||||
this.loadReports()
|
|
||||||
|
|
||||||
// 新建或编辑成功后,直接进入报表详情页,给用户明确反馈
|
|
||||||
if (newReportId.length > 0) {
|
|
||||||
setTimeout(() => {
|
|
||||||
uni.navigateTo({
|
|
||||||
url: `/pages/mall/analytics/report-detail?reportId=${newReportId}`
|
|
||||||
})
|
|
||||||
}, 400)
|
|
||||||
}
|
|
||||||
} catch (e: any) {
|
|
||||||
uni.hideLoading()
|
|
||||||
console.error('saveReport exception:', e)
|
|
||||||
uni.showToast({
|
|
||||||
title: mapAnalyticsError(e, { fallbackMessage: '保存失败' }),
|
|
||||||
icon: 'none',
|
|
||||||
duration: 3000
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
openReport(report: Report) {
|
|
||||||
uni.navigateTo({
|
|
||||||
url: `/pages/mall/analytics/report-detail?reportId=${report.id}`
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
closeModal() {
|
|
||||||
this.showCreateModal = false
|
|
||||||
this.editingReport = null
|
|
||||||
},
|
|
||||||
|
|
||||||
goToLogin() {
|
|
||||||
goToLogin('/pages/mall/analytics/custom-report')
|
|
||||||
},
|
|
||||||
|
|
||||||
refreshData() {
|
|
||||||
this.loadReports()
|
|
||||||
uni.showToast({ title: '已刷新', icon: 'success' })
|
|
||||||
},
|
|
||||||
|
|
||||||
handleMenu() {
|
|
||||||
this.showSidebarMenu = true
|
|
||||||
},
|
|
||||||
handleSidebarUpdate(visible: boolean) {
|
|
||||||
this.showSidebarMenu = visible
|
|
||||||
},
|
|
||||||
|
|
||||||
toggleMoreMenu() {
|
|
||||||
this.showMoreMenu = !this.showMoreMenu
|
|
||||||
},
|
|
||||||
|
|
||||||
closeMoreMenu() {
|
|
||||||
this.showMoreMenu = false
|
|
||||||
},
|
|
||||||
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' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -90,7 +90,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="uts">
|
<script setup lang="uts">
|
||||||
import { onLoad, onShow, reactive, ref } from 'vue'
|
import { reactive, ref } from 'vue'
|
||||||
|
import { onLoad, onShow } from '@dcloudio/uni-app'
|
||||||
|
|
||||||
import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uvue'
|
import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uvue'
|
||||||
import AnalyticsTopBar from '@/components/analytics/AnalyticsTopBar.uvue'
|
import AnalyticsTopBar from '@/components/analytics/AnalyticsTopBar.uvue'
|
||||||
|
|||||||
@@ -115,7 +115,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="uts">
|
<script setup lang="uts">
|
||||||
import { computed, onLoad, reactive, ref } from 'vue'
|
import { computed, reactive, ref } from 'vue'
|
||||||
|
import { onLoad } from '@dcloudio/uni-app'
|
||||||
|
|
||||||
import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uvue'
|
import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uvue'
|
||||||
import AnalyticsTopBar from '@/components/analytics/AnalyticsTopBar.uvue'
|
import AnalyticsTopBar from '@/components/analytics/AnalyticsTopBar.uvue'
|
||||||
|
|||||||
@@ -76,19 +76,34 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 时间维度:横排 -->
|
<!-- 时间维度筛选(快捷 + 自定义) -->
|
||||||
<view class="tabs">
|
<view class="tabs">
|
||||||
<view
|
<view
|
||||||
v-for="p in timePeriods"
|
v-for="p in timePeriods"
|
||||||
:key="p.value"
|
:key="p.value"
|
||||||
class="tab"
|
class="tab"
|
||||||
:class="{ active: selectedPeriod === p.value }"
|
:class="{ active: selectedPeriod === p.value && !customRangeEnabled }"
|
||||||
@click="selectPeriod(p.value)"
|
@click="selectPeriod(p.value)"
|
||||||
>
|
>
|
||||||
{{ p.label }}
|
{{ p.label }}
|
||||||
</view>
|
</view>
|
||||||
|
<view
|
||||||
|
class="tab"
|
||||||
|
:class="{ active: customRangeEnabled }"
|
||||||
|
@click="toggleCustomRange"
|
||||||
|
>
|
||||||
|
自定义
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<AnalyticsDateRangePicker
|
||||||
|
v-if="customRangeEnabled"
|
||||||
|
:initialStartDate="selectedStartDate"
|
||||||
|
:initialEndDate="selectedEndDate"
|
||||||
|
@apply="onDateRangeApply"
|
||||||
|
@clear="onDateRangeClear"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- 核心趋势:占满横向(柱+折 组合图) -->
|
<!-- 核心趋势:占满横向(柱+折 组合图) -->
|
||||||
<view class="card card-full">
|
<view class="card card-full">
|
||||||
<view class="card-head">
|
<view class="card-head">
|
||||||
@@ -252,10 +267,10 @@
|
|||||||
|
|
||||||
<!-- 留白 -->
|
<!-- 留白 -->
|
||||||
<view style="height: 24px;"></view>
|
<view style="height: 24px;"></view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="uts">
|
<script setup lang="uts">
|
||||||
@@ -264,6 +279,7 @@ import { computed, reactive, ref, watch } from 'vue'
|
|||||||
import AnalyticsComboChart from '@/components/analytics/AnalyticsComboChart.uvue'
|
import AnalyticsComboChart from '@/components/analytics/AnalyticsComboChart.uvue'
|
||||||
import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uvue'
|
import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uvue'
|
||||||
import AnalyticsTopBar from '@/components/analytics/AnalyticsTopBar.uvue'
|
import AnalyticsTopBar from '@/components/analytics/AnalyticsTopBar.uvue'
|
||||||
|
import AnalyticsDateRangePicker from '@/components/analytics/AnalyticsDateRangePicker.uvue'
|
||||||
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
|
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
|
||||||
import { fetchDashboardRealtime, fetchDashboardTrend, fetchDashboardUserSegments, fetchDashboardTrafficSources, fetchDashboardTopProducts, fetchDashboardTopMerchants } from '@/services/analytics/dashboardService.uts'
|
import { fetchDashboardRealtime, fetchDashboardTrend, fetchDashboardUserSegments, fetchDashboardTrafficSources, fetchDashboardTopProducts, fetchDashboardTopMerchants } from '@/services/analytics/dashboardService.uts'
|
||||||
import { mapAnalyticsError } from '@/services/analytics/errorMapper.uts'
|
import { mapAnalyticsError } from '@/services/analytics/errorMapper.uts'
|
||||||
@@ -271,6 +287,10 @@ import type { TrendData, SegmentItem, TrafficItem, TopProductItem, TopMerchantIt
|
|||||||
|
|
||||||
const lastUpdateTime = ref('')
|
const lastUpdateTime = ref('')
|
||||||
const selectedPeriod = ref('7d')
|
const selectedPeriod = ref('7d')
|
||||||
|
|
||||||
|
const customRangeEnabled = ref(false)
|
||||||
|
const selectedStartDate = ref('')
|
||||||
|
const selectedEndDate = ref('')
|
||||||
const showMoreMenu = ref(false)
|
const showMoreMenu = ref(false)
|
||||||
const showSidebarMenu = ref(false)
|
const showSidebarMenu = ref(false)
|
||||||
const currentPath = ref('/pages/mall/analytics/index')
|
const currentPath = ref('/pages/mall/analytics/index')
|
||||||
@@ -333,7 +353,11 @@ function stopAutoRefresh() {
|
|||||||
|
|
||||||
async function loadTrend() {
|
async function loadTrend() {
|
||||||
try {
|
try {
|
||||||
const data = await fetchDashboardTrend(selectedPeriod.value)
|
const range = selectedStartDate.value && selectedEndDate.value
|
||||||
|
? { start: selectedStartDate.value, end: selectedEndDate.value }
|
||||||
|
: null
|
||||||
|
|
||||||
|
const data = await fetchDashboardTrend(selectedPeriod.value, range)
|
||||||
trend.x = data.x
|
trend.x = data.x
|
||||||
trend.gmv = data.gmv
|
trend.gmv = data.gmv
|
||||||
trend.orders = data.orders
|
trend.orders = data.orders
|
||||||
@@ -362,7 +386,10 @@ async function loadRealTime() {
|
|||||||
|
|
||||||
async function loadTopProducts() {
|
async function loadTopProducts() {
|
||||||
try {
|
try {
|
||||||
const list = await fetchDashboardTopProducts(selectedPeriod.value, 50)
|
const range = selectedStartDate.value && selectedEndDate.value
|
||||||
|
? { start: selectedStartDate.value, end: selectedEndDate.value }
|
||||||
|
: null
|
||||||
|
const list = await fetchDashboardTopProducts(selectedPeriod.value, 50, range)
|
||||||
topProducts.splice(0, topProducts.length, ...list)
|
topProducts.splice(0, topProducts.length, ...list)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('❌ loadTopProducts failed', e)
|
console.error('❌ loadTopProducts failed', e)
|
||||||
@@ -372,7 +399,10 @@ async function loadTopProducts() {
|
|||||||
|
|
||||||
async function loadTopMerchants() {
|
async function loadTopMerchants() {
|
||||||
try {
|
try {
|
||||||
const list = await fetchDashboardTopMerchants(selectedPeriod.value, 50)
|
const range = selectedStartDate.value && selectedEndDate.value
|
||||||
|
? { start: selectedStartDate.value, end: selectedEndDate.value }
|
||||||
|
: null
|
||||||
|
const list = await fetchDashboardTopMerchants(selectedPeriod.value, 50, range)
|
||||||
topMerchants.splice(0, topMerchants.length, ...list)
|
topMerchants.splice(0, topMerchants.length, ...list)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('❌ loadTopMerchants failed', e)
|
console.error('❌ loadTopMerchants failed', e)
|
||||||
@@ -382,7 +412,10 @@ async function loadTopMerchants() {
|
|||||||
|
|
||||||
async function loadUserSegments() {
|
async function loadUserSegments() {
|
||||||
try {
|
try {
|
||||||
const list = await fetchDashboardUserSegments(selectedPeriod.value)
|
const range = selectedStartDate.value && selectedEndDate.value
|
||||||
|
? { start: selectedStartDate.value, end: selectedEndDate.value }
|
||||||
|
: null
|
||||||
|
const list = await fetchDashboardUserSegments(selectedPeriod.value, range)
|
||||||
userSegments.splice(0, userSegments.length, ...list)
|
userSegments.splice(0, userSegments.length, ...list)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('❌ loadUserSegments failed', e)
|
console.error('❌ loadUserSegments failed', e)
|
||||||
@@ -392,7 +425,10 @@ async function loadUserSegments() {
|
|||||||
|
|
||||||
async function loadTrafficSources() {
|
async function loadTrafficSources() {
|
||||||
try {
|
try {
|
||||||
const list = await fetchDashboardTrafficSources(selectedPeriod.value)
|
const range = selectedStartDate.value && selectedEndDate.value
|
||||||
|
? { start: selectedStartDate.value, end: selectedEndDate.value }
|
||||||
|
: null
|
||||||
|
const list = await fetchDashboardTrafficSources(selectedPeriod.value, range)
|
||||||
trafficSources.splice(0, trafficSources.length, ...list)
|
trafficSources.splice(0, trafficSources.length, ...list)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('❌ loadTrafficSources failed', e)
|
console.error('❌ loadTrafficSources failed', e)
|
||||||
@@ -713,6 +749,24 @@ function handleSettings() {
|
|||||||
uni.showToast({ title: '设置', icon: 'none' })
|
uni.showToast({ title: '设置', icon: 'none' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleCustomRange() {
|
||||||
|
customRangeEnabled.value = !customRangeEnabled.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDateRangeApply(range: { start: string; end: string }) {
|
||||||
|
selectedStartDate.value = range.start
|
||||||
|
selectedEndDate.value = range.end
|
||||||
|
customRangeEnabled.value = true
|
||||||
|
refreshAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDateRangeClear() {
|
||||||
|
selectedStartDate.value = ''
|
||||||
|
selectedEndDate.value = ''
|
||||||
|
customRangeEnabled.value = false
|
||||||
|
refreshAll()
|
||||||
|
}
|
||||||
|
|
||||||
function formatInt(n: number): string {
|
function formatInt(n: number): string {
|
||||||
const v = isFinite(n) ? Math.round(n) : 0
|
const v = isFinite(n) ? Math.round(n) : 0
|
||||||
if (v >= 10000) return (v / 10000).toFixed(1) + '万'
|
if (v >= 10000) return (v / 10000).toFixed(1) + '万'
|
||||||
|
|||||||
@@ -67,7 +67,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="uts">
|
<script setup lang="uts">
|
||||||
import { onLoad, onShow, reactive, ref } from 'vue'
|
import { reactive, ref } from 'vue'
|
||||||
|
import { onLoad, onShow } from '@dcloudio/uni-app'
|
||||||
|
|
||||||
import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uvue'
|
import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uvue'
|
||||||
import AnalyticsTopBar from '@/components/analytics/AnalyticsTopBar.uvue'
|
import AnalyticsTopBar from '@/components/analytics/AnalyticsTopBar.uvue'
|
||||||
|
|||||||
@@ -94,7 +94,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="uts">
|
<script setup lang="uts">
|
||||||
import { computed, onLoad, onShow, reactive, ref } from 'vue'
|
import { computed, reactive, ref } from 'vue'
|
||||||
|
import { onLoad, onShow } from '@dcloudio/uni-app'
|
||||||
|
|
||||||
import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uvue'
|
import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uvue'
|
||||||
import AnalyticsTopBar from '@/components/analytics/AnalyticsTopBar.uvue'
|
import AnalyticsTopBar from '@/components/analytics/AnalyticsTopBar.uvue'
|
||||||
|
|||||||
@@ -168,7 +168,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="uts">
|
<script setup lang="uts">
|
||||||
import { computed, onLoad, reactive, ref } from 'vue'
|
import { computed, reactive, ref } from 'vue'
|
||||||
|
import { onLoad } from '@dcloudio/uni-app'
|
||||||
|
|
||||||
import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uvue'
|
import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uvue'
|
||||||
import AnalyticsTopBar from '@/components/analytics/AnalyticsTopBar.uvue'
|
import AnalyticsTopBar from '@/components/analytics/AnalyticsTopBar.uvue'
|
||||||
|
|||||||
@@ -265,6 +265,7 @@ import { ref, onMounted, computed } from 'vue'
|
|||||||
import supa from '@/components/supadb/aksupainstance.uts'
|
import supa from '@/components/supadb/aksupainstance.uts'
|
||||||
import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uvue'
|
import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uvue'
|
||||||
import type { UserType } from '@/types/mall-types'
|
import type { UserType } from '@/types/mall-types'
|
||||||
|
import { formatTime } from '@/utils/utils.uts'
|
||||||
|
|
||||||
import type { RecentReport, OverviewData, ReportCounts, TodayInsights, TrendDatum } from '@/types/analytics/profile.uts'
|
import type { RecentReport, OverviewData, ReportCounts, TodayInsights, TrendDatum } from '@/types/analytics/profile.uts'
|
||||||
|
|
||||||
@@ -607,20 +608,7 @@ function getReportStatusText(status: any): string {
|
|||||||
return statusMap[s] || '未知'
|
return statusMap[s] || '未知'
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTime(dateStr: string): string {
|
|
||||||
const date = new Date(dateStr)
|
|
||||||
const now = new Date()
|
|
||||||
const diff = now.getTime() - date.getTime()
|
|
||||||
const hours = Math.floor(diff / (1000 * 60 * 60))
|
|
||||||
|
|
||||||
if (hours < 1) {
|
|
||||||
return '刚刚'
|
|
||||||
} else if (hours < 24) {
|
|
||||||
return `${hours}小时前`
|
|
||||||
} else {
|
|
||||||
return `${Math.floor(hours / 24)}天前`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function changeTrendPeriod(period: string) {
|
function changeTrendPeriod(period: string) {
|
||||||
trendPeriod.value = period
|
trendPeriod.value = period
|
||||||
|
|||||||
@@ -201,12 +201,14 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="uts">
|
<script setup lang="uts">
|
||||||
import { onLoad, onShow, reactive, ref } from 'vue'
|
import { reactive, ref } from 'vue'
|
||||||
|
import { onLoad, onShow } from '@dcloudio/uni-app'
|
||||||
|
|
||||||
import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uvue'
|
import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uvue'
|
||||||
import AnalyticsTopBar from '@/components/analytics/AnalyticsTopBar.uvue'
|
import AnalyticsTopBar from '@/components/analytics/AnalyticsTopBar.uvue'
|
||||||
import { fetchReport, fetchReportMetrics, fetchReportRows, fetchReportInsights, fetchRelatedReports } from '@/services/analytics/reportDetailService.uts'
|
import { fetchReport, fetchReportMetrics, fetchReportRows, fetchReportInsights, fetchRelatedReports } from '@/services/analytics/reportDetailService.uts'
|
||||||
import { mapAnalyticsError } from '@/services/analytics/errorMapper.uts'
|
import { mapAnalyticsError } from '@/services/analytics/errorMapper.uts'
|
||||||
|
import { formatTime } from '@/utils/utils.uts'
|
||||||
|
|
||||||
import type { ReportType, MetricType, ChartTabType, ChartLegendType, TableColumnType, InsightType } from '@/types/analytics/report-detail.uts'
|
import type { ReportType, MetricType, ChartTabType, ChartLegendType, TableColumnType, InsightType } from '@/types/analytics/report-detail.uts'
|
||||||
|
|
||||||
@@ -233,14 +235,19 @@ const dataInsights = reactive<Array<InsightType>>([])
|
|||||||
const relatedReports = reactive<Array<ReportType>>([])
|
const relatedReports = reactive<Array<ReportType>>([])
|
||||||
|
|
||||||
const sortIndex = ref(0)
|
const sortIndex = ref(0)
|
||||||
const sortOptions = ref<Array<string>>([])
|
const sortOptions: Array<string> = []
|
||||||
|
|
||||||
const limitIndex = ref(1)
|
const limitIndex = ref(1)
|
||||||
const limitOptions = ref<Array<string>>(['10条', '20条', '50条', '100条'])
|
const limitOptions: Array<string> = ['10条', '20条', '50条', '100条']
|
||||||
|
|
||||||
const currentPage = ref(1)
|
const currentPage = ref(1)
|
||||||
const totalPages = ref(1)
|
const totalPages = ref(1)
|
||||||
|
|
||||||
const autoRefresh = ref(false)
|
const autoRefresh = ref(false)
|
||||||
|
|
||||||
const intervalIndex = ref(1)
|
const intervalIndex = ref(1)
|
||||||
const intervalOptions = ref<Array<string>>(['1分钟', '5分钟', '10分钟', '30分钟', '1小时'])
|
const intervalOptions: Array<string> = ['1分钟', '5分钟', '10分钟', '30分钟', '1小时']
|
||||||
|
|
||||||
const emailNotify = ref(false)
|
const emailNotify = ref(false)
|
||||||
|
|
||||||
onLoad((options: any) => {
|
onLoad((options: any) => {
|
||||||
@@ -302,7 +309,7 @@ async function loadReportDetail(reportId: string) {
|
|||||||
{ key: 'avg_value', title: '客单价', width: '120rpx', type: 'currency' }
|
{ key: 'avg_value', title: '客单价', width: '120rpx', type: 'currency' }
|
||||||
)
|
)
|
||||||
|
|
||||||
sortOptions.value = ['按日期降序', '按销售额降序', '按订单数降序', '按转化率降序']
|
sortOptions.splice(0, sortOptions.length, '按日期降序', '按销售额降序', '按订单数降序', '按转化率降序')
|
||||||
|
|
||||||
const rows = await fetchReportRows(reportId)
|
const rows = await fetchReportRows(reportId)
|
||||||
allRows.splice(0, allRows.length, ...rows)
|
allRows.splice(0, allRows.length, ...rows)
|
||||||
@@ -326,7 +333,7 @@ async function loadReportDetail(reportId: string) {
|
|||||||
|
|
||||||
function updateTotalPages() {
|
function updateTotalPages() {
|
||||||
const total = allRows.length
|
const total = allRows.length
|
||||||
const limit = parseInt(limitOptions.value[limitIndex.value])
|
const limit = parseInt(limitOptions[limitIndex.value])
|
||||||
totalPages.value = total > 0 ? Math.ceil(total / limit) : 1
|
totalPages.value = total > 0 ? Math.ceil(total / limit) : 1
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -336,7 +343,7 @@ function generateTableData() {
|
|||||||
if (total === 0) {
|
if (total === 0) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const limit = parseInt(limitOptions.value[limitIndex.value])
|
const limit = parseInt(limitOptions[limitIndex.value])
|
||||||
const start = (currentPage.value - 1) * limit
|
const start = (currentPage.value - 1) * limit
|
||||||
const end = Math.min(start + limit, total)
|
const end = Math.min(start + limit, total)
|
||||||
|
|
||||||
@@ -377,9 +384,7 @@ function formatMetricValue(value: number, format: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTime(timeStr: string): string {
|
|
||||||
return timeStr.replace('T', ' ').split('.')[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
function getInsightIcon(type: string): string {
|
function getInsightIcon(type: string): string {
|
||||||
const icons: Record<string, string> = {
|
const icons: Record<string, string> = {
|
||||||
|
|||||||
@@ -27,19 +27,34 @@
|
|||||||
<view class="main-content">
|
<view class="main-content">
|
||||||
<view class="container">
|
<view class="container">
|
||||||
|
|
||||||
<!-- 时间维度筛选 -->
|
<!-- 时间维度筛选(快捷 + 自定义) -->
|
||||||
<view class="tabs">
|
<view class="tabs">
|
||||||
<view
|
<view
|
||||||
v-for="p in timePeriods"
|
v-for="p in timePeriods"
|
||||||
:key="p.value"
|
:key="p.value"
|
||||||
class="tab"
|
class="tab"
|
||||||
:class="{ active: selectedPeriod === p.value }"
|
:class="{ active: selectedPeriod === p.value && !customRangeEnabled }"
|
||||||
@click="selectPeriod(p.value)"
|
@click="selectPeriod(p.value)"
|
||||||
>
|
>
|
||||||
{{ p.label }}
|
{{ p.label }}
|
||||||
</view>
|
</view>
|
||||||
|
<view
|
||||||
|
class="tab"
|
||||||
|
:class="{ active: customRangeEnabled }"
|
||||||
|
@click="toggleCustomRange"
|
||||||
|
>
|
||||||
|
自定义
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<AnalyticsDateRangePicker
|
||||||
|
v-if="customRangeEnabled"
|
||||||
|
:initialStartDate="selectedStartDate"
|
||||||
|
:initialEndDate="selectedEndDate"
|
||||||
|
@apply="onDateRangeApply"
|
||||||
|
@clear="onDateRangeClear"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- KPI 指标卡片 -->
|
<!-- KPI 指标卡片 -->
|
||||||
<view class="kpi-grid">
|
<view class="kpi-grid">
|
||||||
<view class="kpi-card">
|
<view class="kpi-card">
|
||||||
@@ -153,7 +168,9 @@ import AnalyticsComboChart from '@/components/analytics/AnalyticsComboChart.uvue
|
|||||||
import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uvue'
|
import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uvue'
|
||||||
import AnalyticsTopBar from '@/components/analytics/AnalyticsTopBar.uvue'
|
import AnalyticsTopBar from '@/components/analytics/AnalyticsTopBar.uvue'
|
||||||
import AnalyticsRegionMap from '@/components/analytics/AnalyticsRegionMap.uvue'
|
import AnalyticsRegionMap from '@/components/analytics/AnalyticsRegionMap.uvue'
|
||||||
import { computed, onLoad, reactive, ref } from 'vue'
|
import AnalyticsDateRangePicker from '@/components/analytics/AnalyticsDateRangePicker.uvue'
|
||||||
|
import { computed, reactive, ref } from 'vue'
|
||||||
|
import { onLoad } from '@dcloudio/uni-app'
|
||||||
|
|
||||||
import { fetchSalesKpis, fetchSalesTrend, fetchSalesTopProducts, fetchSalesTopMerchants } from '@/services/analytics/salesReportService.uts'
|
import { fetchSalesKpis, fetchSalesTrend, fetchSalesTopProducts, fetchSalesTopMerchants } from '@/services/analytics/salesReportService.uts'
|
||||||
import { mapAnalyticsError } from '@/services/analytics/errorMapper.uts'
|
import { mapAnalyticsError } from '@/services/analytics/errorMapper.uts'
|
||||||
@@ -162,6 +179,10 @@ import type { SalesTrendData, SalesData, ProductRank, MerchantRank } from '@/typ
|
|||||||
|
|
||||||
const lastUpdateTime = ref('')
|
const lastUpdateTime = ref('')
|
||||||
const selectedPeriod = ref('7d')
|
const selectedPeriod = ref('7d')
|
||||||
|
|
||||||
|
const customRangeEnabled = ref(false)
|
||||||
|
const selectedStartDate = ref('')
|
||||||
|
const selectedEndDate = ref('')
|
||||||
const showMoreMenu = ref(false)
|
const showMoreMenu = ref(false)
|
||||||
const showSidebarMenu = ref(false)
|
const showSidebarMenu = ref(false)
|
||||||
const currentPath = ref('/pages/mall/analytics/sales-report')
|
const currentPath = ref('/pages/mall/analytics/sales-report')
|
||||||
@@ -212,8 +233,12 @@ async function loadSalesData() {
|
|||||||
try {
|
try {
|
||||||
updateTime()
|
updateTime()
|
||||||
|
|
||||||
|
const range = selectedStartDate.value && selectedEndDate.value
|
||||||
|
? { start: selectedStartDate.value, end: selectedEndDate.value }
|
||||||
|
: null
|
||||||
|
|
||||||
// KPI
|
// KPI
|
||||||
const kpi = await fetchSalesKpis(selectedPeriod.value)
|
const kpi = await fetchSalesKpis(selectedPeriod.value, range)
|
||||||
salesData.gmv = kpi.gmv
|
salesData.gmv = kpi.gmv
|
||||||
salesData.gmv_growth = kpi.gmv_growth
|
salesData.gmv_growth = kpi.gmv_growth
|
||||||
salesData.orders = kpi.orders
|
salesData.orders = kpi.orders
|
||||||
@@ -224,19 +249,19 @@ async function loadSalesData() {
|
|||||||
salesData.avg_order_growth = kpi.avg_order_growth
|
salesData.avg_order_growth = kpi.avg_order_growth
|
||||||
|
|
||||||
// 趋势
|
// 趋势
|
||||||
const t = await fetchSalesTrend(selectedPeriod.value)
|
const t = await fetchSalesTrend(selectedPeriod.value, range)
|
||||||
trend.x = t.x
|
trend.x = t.x
|
||||||
trend.gmv = t.gmv
|
trend.gmv = t.gmv
|
||||||
trend.orders = t.orders
|
trend.orders = t.orders
|
||||||
|
|
||||||
// TOP 商品/商家
|
// TOP 商品/商家
|
||||||
const pList = await fetchSalesTopProducts(selectedPeriod.value, 50)
|
const pList = await fetchSalesTopProducts(selectedPeriod.value, 50, range)
|
||||||
for (let i = 0; i < pList.length; i++) {
|
for (let i = 0; i < pList.length; i++) {
|
||||||
pList[i].rank = i + 1
|
pList[i].rank = i + 1
|
||||||
}
|
}
|
||||||
topProducts.splice(0, topProducts.length, ...pList)
|
topProducts.splice(0, topProducts.length, ...pList)
|
||||||
|
|
||||||
const mList = await fetchSalesTopMerchants(selectedPeriod.value, 50)
|
const mList = await fetchSalesTopMerchants(selectedPeriod.value, 50, range)
|
||||||
for (let i = 0; i < mList.length; i++) {
|
for (let i = 0; i < mList.length; i++) {
|
||||||
mList[i].rank = i + 1
|
mList[i].rank = i + 1
|
||||||
}
|
}
|
||||||
@@ -331,6 +356,24 @@ function handleDropdown() {
|
|||||||
function handleSettings() {
|
function handleSettings() {
|
||||||
uni.showToast({ title: '设置', icon: 'none' })
|
uni.showToast({ title: '设置', icon: 'none' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleCustomRange() {
|
||||||
|
customRangeEnabled.value = !customRangeEnabled.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDateRangeApply(range: { start: string; end: string }) {
|
||||||
|
selectedStartDate.value = range.start
|
||||||
|
selectedEndDate.value = range.end
|
||||||
|
customRangeEnabled.value = true
|
||||||
|
loadSalesData()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDateRangeClear() {
|
||||||
|
selectedStartDate.value = ''
|
||||||
|
selectedEndDate.value = ''
|
||||||
|
customRangeEnabled.value = false
|
||||||
|
loadSalesData()
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -214,7 +214,8 @@ import EChartsView from '@/uni_modules/charts/EChartsView.vue'
|
|||||||
import supa, { ensureSupabaseReady } from '@/components/supadb/aksupainstance.uts'
|
import supa, { ensureSupabaseReady } from '@/components/supadb/aksupainstance.uts'
|
||||||
import { getUserIdOrNull } from '@/services/analytics/auth.uts'
|
import { getUserIdOrNull } from '@/services/analytics/auth.uts'
|
||||||
import { mapAnalyticsError } from '@/services/analytics/errorMapper.uts'
|
import { mapAnalyticsError } from '@/services/analytics/errorMapper.uts'
|
||||||
import { computed, onLoad, reactive, ref } from 'vue'
|
import { computed, reactive, ref } from 'vue'
|
||||||
|
import { onLoad } from '@dcloudio/uni-app'
|
||||||
|
|
||||||
import type { TimePeriod } from '@/types/analytics/common.uts'
|
import type { TimePeriod } from '@/types/analytics/common.uts'
|
||||||
import type { UserData, FunnelStep } from '@/types/analytics/user.uts'
|
import type { UserData, FunnelStep } from '@/types/analytics/user.uts'
|
||||||
@@ -514,14 +515,6 @@ function handleSettings() {
|
|||||||
uni.showToast({ title: '设置', icon: 'none' })
|
uni.showToast({ title: '设置', icon: 'none' })
|
||||||
}
|
}
|
||||||
|
|
||||||
components: {
|
|
||||||
AnalyticsSidebarMenu,
|
|
||||||
AnalyticsTopBar,
|
|
||||||
EChartsView
|
|
||||||
|
|
||||||
calcDateRange() {
|
|
||||||
const now = new Date()
|
|
||||||
const endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -12,8 +12,19 @@ function safeNumber(v: any): number {
|
|||||||
return isFinite(n) ? n : 0
|
return isFinite(n) ? n : 0
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchDashboardTrend(period: string): Promise<TrendData> {
|
export async function fetchDashboardTrend(period: string, range?: { start: string; end: string } | null): Promise<TrendData> {
|
||||||
const { startIso, endIso } = computeDateRange(period)
|
let startIso: string;
|
||||||
|
let endIso: string;
|
||||||
|
|
||||||
|
if (range != null && range.start && range.end) {
|
||||||
|
startIso = range.start;
|
||||||
|
endIso = range.end;
|
||||||
|
} else {
|
||||||
|
const computedRange = computeDateRange(period)
|
||||||
|
startIso = computedRange.startIso
|
||||||
|
endIso = computedRange.endIso
|
||||||
|
}
|
||||||
|
|
||||||
const p_start_date = toDateOnly(startIso)
|
const p_start_date = toDateOnly(startIso)
|
||||||
const p_end_date = toDateOnly(endIso)
|
const p_end_date = toDateOnly(endIso)
|
||||||
|
|
||||||
|
|||||||
@@ -22,8 +22,18 @@ function safeNumber(v: any): number {
|
|||||||
return isFinite(n) ? n : 0
|
return isFinite(n) ? n : 0
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchSalesKpis(period: string): Promise<SalesKpis> {
|
export async function fetchSalesKpis(period: string, range?: { start: string; end: string } | null): Promise<SalesKpis> {
|
||||||
const { startIso, endIso } = computeDateRange(period)
|
let startIso: string;
|
||||||
|
let endIso: string;
|
||||||
|
|
||||||
|
if (range != null && range.start && range.end) {
|
||||||
|
startIso = range.start;
|
||||||
|
endIso = range.end;
|
||||||
|
} else {
|
||||||
|
const computedRange = computeDateRange(period);
|
||||||
|
startIso = computedRange.startIso;
|
||||||
|
endIso = computedRange.endIso;
|
||||||
|
}
|
||||||
const row = await rpcOrNull('rpc_analytics_sales_kpis', {
|
const row = await rpcOrNull('rpc_analytics_sales_kpis', {
|
||||||
p_start_date: toDateOnly(startIso),
|
p_start_date: toDateOnly(startIso),
|
||||||
p_end_date: toDateOnly(endIso)
|
p_end_date: toDateOnly(endIso)
|
||||||
@@ -46,8 +56,18 @@ export async function fetchSalesKpis(period: string): Promise<SalesKpis> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchSalesTrend(period: string): Promise<TrendData> {
|
export async function fetchSalesTrend(period: string, range?: { start: string; end: string } | null): Promise<TrendData> {
|
||||||
const { startIso, endIso } = computeDateRange(period)
|
let startIso: string;
|
||||||
|
let endIso: string;
|
||||||
|
|
||||||
|
if (range != null && range.start && range.end) {
|
||||||
|
startIso = range.start;
|
||||||
|
endIso = range.end;
|
||||||
|
} else {
|
||||||
|
const computedRange = computeDateRange(period);
|
||||||
|
startIso = computedRange.startIso;
|
||||||
|
endIso = computedRange.endIso;
|
||||||
|
}
|
||||||
const rows = await rpcOrEmptyArray('rpc_analytics_sales_trend', {
|
const rows = await rpcOrEmptyArray('rpc_analytics_sales_trend', {
|
||||||
p_start_date: toDateOnly(startIso),
|
p_start_date: toDateOnly(startIso),
|
||||||
p_end_date: toDateOnly(endIso)
|
p_end_date: toDateOnly(endIso)
|
||||||
@@ -68,8 +88,18 @@ export async function fetchSalesTrend(period: string): Promise<TrendData> {
|
|||||||
return { x, gmv: gmvArr, orders: orderArr }
|
return { x, gmv: gmvArr, orders: orderArr }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchSalesTopProducts(period: string, limit: number = 50): Promise<Array<ProductRank>> {
|
export async function fetchSalesTopProducts(period: string, limit: number = 50, range?: { start: string; end: string } | null): Promise<Array<ProductRank>> {
|
||||||
const { startIso, endIso } = computeDateRange(period)
|
let startIso: string;
|
||||||
|
let endIso: string;
|
||||||
|
|
||||||
|
if (range != null && range.start && range.end) {
|
||||||
|
startIso = range.start;
|
||||||
|
endIso = range.end;
|
||||||
|
} else {
|
||||||
|
const computedRange = computeDateRange(period);
|
||||||
|
startIso = computedRange.startIso;
|
||||||
|
endIso = computedRange.endIso;
|
||||||
|
}
|
||||||
const rows = await rpcOrEmptyArray('rpc_analytics_top_products', {
|
const rows = await rpcOrEmptyArray('rpc_analytics_top_products', {
|
||||||
p_start_date: toDateOnly(startIso),
|
p_start_date: toDateOnly(startIso),
|
||||||
p_end_date: toDateOnly(endIso),
|
p_end_date: toDateOnly(endIso),
|
||||||
@@ -89,8 +119,18 @@ export async function fetchSalesTopProducts(period: string, limit: number = 50):
|
|||||||
return list
|
return list
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchSalesTopMerchants(period: string, limit: number = 50): Promise<Array<MerchantRank>> {
|
export async function fetchSalesTopMerchants(period: string, limit: number = 50, range?: { start: string; end: string } | null): Promise<Array<MerchantRank>> {
|
||||||
const { startIso, endIso } = computeDateRange(period)
|
let startIso: string;
|
||||||
|
let endIso: string;
|
||||||
|
|
||||||
|
if (range != null && range.start && range.end) {
|
||||||
|
startIso = range.start;
|
||||||
|
endIso = range.end;
|
||||||
|
} else {
|
||||||
|
const computedRange = computeDateRange(period);
|
||||||
|
startIso = computedRange.startIso;
|
||||||
|
endIso = computedRange.endIso;
|
||||||
|
}
|
||||||
const rows = await rpcOrEmptyArray('rpc_analytics_top_merchants', {
|
const rows = await rpcOrEmptyArray('rpc_analytics_top_merchants', {
|
||||||
p_start_date: toDateOnly(startIso),
|
p_start_date: toDateOnly(startIso),
|
||||||
p_end_date: toDateOnly(endIso),
|
p_end_date: toDateOnly(endIso),
|
||||||
|
|||||||
@@ -19,16 +19,25 @@ export async function ensureUserProfile(sessionUser: UTSJSONObject): Promise<Use
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查用户是否已存在
|
// 检查用户是否已存在(ak_users 通过 auth_id 关联 auth.users.id)
|
||||||
const checkRes = await supabase.from('ak_users')
|
const checkRes = await supabase.from('ak_users')
|
||||||
.select('*', {})
|
.select('*', {})
|
||||||
.eq('id', userId)
|
.eq('auth_id', userId)
|
||||||
.single()
|
.single()
|
||||||
.execute()
|
.execute()
|
||||||
|
|
||||||
|
console.log('ensureUserProfile check ak_users:', {
|
||||||
|
status: checkRes.status,
|
||||||
|
hasData: checkRes.data != null,
|
||||||
|
hasError: (checkRes as any).error != null,
|
||||||
|
error: (checkRes as any).error
|
||||||
|
})
|
||||||
|
|
||||||
if (checkRes.status >= 200 && checkRes.status < 300 && checkRes.data != null) {
|
if (checkRes.status >= 200 && checkRes.status < 300 && checkRes.data != null) {
|
||||||
// 用户已存在,返回现有资料
|
// 用户已存在,返回现有资料(H5 下 checkRes.data 可能是 plain object,不一定是 UTSJSONObject)
|
||||||
const existingUser = checkRes.data as UTSJSONObject
|
const existingUser = (typeof (checkRes.data as any).getString === 'function')
|
||||||
|
? (checkRes.data as UTSJSONObject)
|
||||||
|
: new UTSJSONObject(checkRes.data as any)
|
||||||
return {
|
return {
|
||||||
id: existingUser.getString('id') ?? '',
|
id: existingUser.getString('id') ?? '',
|
||||||
username: existingUser.getString('username') ?? '',
|
username: existingUser.getString('username') ?? '',
|
||||||
@@ -58,8 +67,17 @@ export async function ensureUserProfile(sessionUser: UTSJSONObject): Promise<Use
|
|||||||
.single()
|
.single()
|
||||||
.execute()
|
.execute()
|
||||||
|
|
||||||
|
console.log('ensureUserProfile insert ak_users:', {
|
||||||
|
status: insertRes.status,
|
||||||
|
hasData: insertRes.data != null,
|
||||||
|
hasError: (insertRes as any).error != null,
|
||||||
|
error: (insertRes as any).error
|
||||||
|
})
|
||||||
|
|
||||||
if (insertRes.status >= 200 && insertRes.status < 300 && insertRes.data != null) {
|
if (insertRes.status >= 200 && insertRes.status < 300 && insertRes.data != null) {
|
||||||
const newUser = insertRes.data as UTSJSONObject
|
const newUser = (typeof (insertRes.data as any).getString === 'function')
|
||||||
|
? (insertRes.data as UTSJSONObject)
|
||||||
|
: new UTSJSONObject(insertRes.data as any)
|
||||||
return {
|
return {
|
||||||
id: newUser.getString('id') ?? '',
|
id: newUser.getString('id') ?? '',
|
||||||
username: newUser.getString('username') ?? '',
|
username: newUser.getString('username') ?? '',
|
||||||
|
|||||||
@@ -173,3 +173,29 @@ export function setClipboard(text: string): void {
|
|||||||
// #endif
|
// #endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化时间,显示为相对时间(如:刚刚,几小时前)
|
||||||
|
* @param dateStr ISO 格式的日期字符串
|
||||||
|
* @returns 格式化后的相对时间字符串
|
||||||
|
*/
|
||||||
|
export function formatTime(dateStr: string): string {
|
||||||
|
if (!dateStr) return ''
|
||||||
|
try {
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
const now = new Date()
|
||||||
|
const diff = now.getTime() - date.getTime()
|
||||||
|
const hours = Math.floor(diff / (1000 * 60 * 60))
|
||||||
|
|
||||||
|
if (hours < 1) {
|
||||||
|
return '刚刚'
|
||||||
|
} else if (hours < 24) {
|
||||||
|
return `${hours}小时前`
|
||||||
|
} else {
|
||||||
|
return `${Math.floor(hours / 24)}天前`
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('formatTime error:', e)
|
||||||
|
return dateStr.replace('T', ' ').split('.')[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user