mall数据库文件

This commit is contained in:
comlibmb
2026-01-30 16:11:23 +08:00
parent b53d2376ff
commit cfec4a16c0
71 changed files with 11786 additions and 1009 deletions

View File

@@ -109,10 +109,11 @@
</template>
<script lang="uts">
import supa, { ensureSupabaseReady } from '@/components/supadb/aksupainstance.uts'
import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uvue'
import AnalyticsTopBar from '@/components/analytics/AnalyticsTopBar.uvue'
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
import { fetchCouponAnalysis } from '@/services/analytics/couponAnalysisService.uts'
import { mapAnalyticsError } from '@/services/analytics/errorMapper.uts'
type TimePeriod = { value: string; label: string }
type CouponData = {
@@ -177,105 +178,13 @@ export default {
methods: {
async loadCouponData() {
try {
// 1) 计算时间范围
const now = new Date()
const start = new Date(now.getTime())
if (this.selectedPeriod === '7d') start.setDate(start.getDate() - 7)
else if (this.selectedPeriod === '30d') start.setDate(start.getDate() - 30)
else if (this.selectedPeriod === '90d') start.setDate(start.getDate() - 90)
else if (this.selectedPeriod === '1y') start.setFullYear(start.getFullYear() - 1)
const startIso = start.toISOString()
const endIso = now.toISOString()
const data = await fetchCouponAnalysis(this.selectedPeriod)
// 2) 确保 Supabase 会话就绪
await ensureSupabaseReady()
// 3) 优先调用后端 RPC推荐在 Supabase 中创建对应函数)
// - rpc_coupon_effectiveness_overview 概览 KPI
// - rpc_coupon_type_stats 按券类型统计
// - rpc_coupon_channel_stats 发放渠道统计
// - rpc_coupon_trend_daily 每日发放/使用趋势
// - rpc_coupon_conversion_effect GMV/订单转化效果
let overviewRow: UTSJSONObject | null = null
let typeList: Array<UTSJSONObject> = []
let channelList: Array<UTSJSONObject> = []
let trendList: Array<UTSJSONObject> = []
let conversionList: Array<UTSJSONObject> = []
// 3.1 概览 KPI
const overviewRes = await supa.rpc('rpc_coupon_effectiveness_overview', {
p_start: startIso,
p_end: endIso
})
if (overviewRes.status === 404) {
// RPC 未创建:保留默认 0 值,由后续 SQL 实现补上真实逻辑
console.warn('rpc_coupon_effectiveness_overview not found, using default zeros')
} else if (overviewRes.error != null) {
throw overviewRes.error
} else {
const anyData = overviewRes.data as any
if (Array.isArray(anyData) && anyData.length > 0) {
overviewRow = anyData[0] as UTSJSONObject
}
}
// 3.2 券类型统计
const typeRes = await supa.rpc('rpc_coupon_type_stats', {
p_start: startIso,
p_end: endIso
})
if (typeRes.status === 404) {
console.warn('rpc_coupon_type_stats not found, type analysis will be empty')
} else if (typeRes.error != null) {
throw typeRes.error
} else {
const typeAny = typeRes.data as any
typeList = Array.isArray(typeAny) ? typeAny as Array<UTSJSONObject> : []
}
// 3.3 渠道统计
const channelRes = await supa.rpc('rpc_coupon_channel_stats', {
p_start: startIso,
p_end: endIso
})
if (channelRes.status === 404) {
console.warn('rpc_coupon_channel_stats not found, channel analysis will be empty')
} else if (channelRes.error != null) {
throw channelRes.error
} else {
const chAny = channelRes.data as any
channelList = Array.isArray(chAny) ? chAny as Array<UTSJSONObject> : []
}
// 3.4 使用趋势
const trendRes = await supa.rpc('rpc_coupon_trend_daily', {
p_start: startIso,
p_end: endIso
})
if (trendRes.status === 404) {
console.warn('rpc_coupon_trend_daily not found, trend analysis will be empty')
} else if (trendRes.error != null) {
throw trendRes.error
} else {
const trAny = trendRes.data as any
trendList = Array.isArray(trAny) ? trAny as Array<UTSJSONObject> : []
}
// 3.5 转化效果GMV/订单)
const convRes = await supa.rpc('rpc_coupon_conversion_effect', {
p_start: startIso,
p_end: endIso
})
if (convRes.status === 404) {
console.warn('rpc_coupon_conversion_effect not found, conversion chart will be empty')
} else if (convRes.error != null) {
throw convRes.error
} else {
const cvAny = convRes.data as any
conversionList = Array.isArray(cvAny) ? cvAny as Array<UTSJSONObject> : []
}
const overviewRow = data.overviewRow
const typeList = data.typeList
const channelList = data.channelList
const trendList = data.trendList
const conversionList = data.conversionList
// 4) 计算 KPI 概览
let totalIssued = 0
@@ -326,9 +235,9 @@ export default {
this.buildChartOptions()
} catch (e) {
console.error('loadCouponData failed:', e)
this.updateTime()
this.buildChartOptions()
uni.showToast({ title: '优惠券分析数据加载失败', icon: 'none' })
this.updateTime()
this.buildChartOptions()
uni.showToast({ title: mapAnalyticsError(e, { fallbackMessage: '优惠券分析数据加载失败' }), icon: 'none' })
}
},

View File

@@ -27,8 +27,19 @@
<view class="main-content">
<view class="container">
<!-- 报表列表 -->
<view class="report-list">
<!-- 顶部操作区:新建报表 -->
<view class="toolbar">
<view class="toolbar-left">
<text class="toolbar-title">我的自定义报表</text>
<text class="toolbar-subtitle">按需组合指标和时间范围,生成专属报表</text>
</view>
<view class="toolbar-right">
<button class="btn-primary" @click.stop="createReport"> 新建报表</button>
</view>
</view>
<!-- 报表列表 / 空状态 -->
<view v-if="reports.length > 0" class="report-list">
<view v-for="report in reports" :key="report.id" class="report-card" @click="openReport(report)">
<view class="report-header">
<text class="report-title">{{ report.name }}</text>
@@ -41,14 +52,21 @@
</view>
</view>
</view>
<text class="report-desc">{{ report.description }}</text>
<text class="report-desc">{{ report.description || '点击进入报表详情查看数据' }}</text>
<view class="report-meta">
<text class="meta-item">指标{{ report.metrics.length }}</text>
<text class="meta-item">图表{{ report.charts.length }}</text>
<text class="meta-item">更新:{{ report.updated_at }}</text>
<text class="meta-item">图表周期{{ report.period || '自定义' }}</text>
<text class="meta-item">最近更新{{ report.updated_at || '-' }}</text>
</view>
</view>
</view>
<view v-else class="empty-state">
<text v-if="isLoggedIn" class="empty-title">暂无自定义报表</text>
<text v-else class="empty-title">请先登录</text>
<text v-if="isLoggedIn" class="empty-desc">点击下方按钮创建第一份报表,用于复用常看的指标组合。</text>
<text v-else class="empty-desc">创建自定义报表需要登录账号,请先登录后再使用此功能。</text>
<button v-if="isLoggedIn" class="btn-primary" @click.stop="createReport"> 新建报表</button>
<button v-else class="btn-primary" @click.stop="goToLogin">前往登录</button>
</view>
<!-- 新建报表对话框 -->
<view class="modal" v-if="showCreateModal" @click.stop>
@@ -62,11 +80,23 @@
<view class="modal-body">
<view class="form-item">
<text class="form-label">报表名称</text>
<input class="form-input" v-model="reportForm.name" placeholder="请输入报表名称" />
<input
class="form-input"
v-model="reportForm.name"
placeholder="请输入报表名称1-50个字符"
@input="onNameInput"
/>
<text v-if="formErrors.name" class="form-error">{{ formErrors.name }}</text>
</view>
<view class="form-item">
<text class="form-label">报表描述</text>
<textarea class="form-textarea" v-model="reportForm.description" placeholder="请输入报表描述"></textarea>
<textarea
class="form-textarea"
v-model="reportForm.description"
placeholder="选填最多200个字符"
@input="onDescriptionInput"
></textarea>
<text v-if="formErrors.description" class="form-error">{{ formErrors.description }}</text>
</view>
<view class="form-item">
<text class="form-label">选择指标</text>
@@ -81,6 +111,7 @@
<text>{{ m.label }}</text>
</view>
</view>
<text v-if="formErrors.metrics" class="form-error">{{ formErrors.metrics }}</text>
</view>
<view class="form-item">
<text class="form-label">时间维度</text>
@@ -90,11 +121,12 @@
:key="p.value"
class="period-item"
:class="{ selected: reportForm.period === p.value }"
@click="reportForm.period = p.value"
@click="selectPeriod(p.value)"
>
<text>{{ p.label }}</text>
</view>
</view>
<text v-if="formErrors.period" class="form-error">{{ formErrors.period }}</text>
</view>
<view class="form-item">
<text class="form-label">图表类型</text>
@@ -104,11 +136,12 @@
:key="t.value"
class="chart-type-item"
:class="{ selected: reportForm.chartType === t.value }"
@click="reportForm.chartType = t.value"
@click="selectChartType(t.value)"
>
<text>{{ t.label }}</text>
</view>
</view>
<text v-if="formErrors.chartType" class="form-error">{{ formErrors.chartType }}</text>
</view>
</view>
<view class="modal-footer">
@@ -127,9 +160,13 @@
</template>
<script lang="uts">
import supa from '@/components/supadb/aksupainstance.uts'
import supa, { ensureSupabaseReady } from '@/components/supadb/aksupainstance.uts'
import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uvue'
import AnalyticsTopBar from '@/components/analytics/AnalyticsTopBar.uvue'
import { goToLogin } from '@/utils/utils.uts'
import { getUserIdOrNull } from '@/services/analytics/auth.uts'
import { listCustomReports, createCustomReport, updateCustomReport, deleteCustomReport } from '@/services/analytics/customReportService.uts'
import { mapAnalyticsError } from '@/services/analytics/errorMapper.uts'
type Report = {
id: string
@@ -149,6 +186,13 @@ type ReportForm = {
period: string
chartType: string
}
type ReportFormErrors = {
name: string
description: string
metrics: string
period: string
chartType: string
}
export default {
components: {
@@ -164,6 +208,7 @@ export default {
editingReport: null as Report | null,
reports: [] as Array<Report>,
isLoggedIn: false,
reportForm: {
name: '',
@@ -172,6 +217,13 @@ export default {
period: '7d',
chartType: 'line'
} as ReportForm,
formErrors: {
name: '',
description: '',
metrics: '',
period: '',
chartType: ''
} as ReportFormErrors,
availableMetrics: [
{ key: 'gmv', label: 'GMV' },
@@ -210,7 +262,38 @@ export default {
methods: {
async loadReports() {
// TODO: 实现报表列表加载
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() {
@@ -222,6 +305,13 @@ export default {
period: '7d',
chartType: 'line'
}
this.formErrors = {
name: '',
description: '',
metrics: '',
period: '',
chartType: ''
}
this.showCreateModal = true
},
@@ -234,6 +324,13 @@ export default {
period: '7d',
chartType: 'line'
}
this.formErrors = {
name: '',
description: '',
metrics: '',
period: '',
chartType: ''
}
this.showCreateModal = true
},
@@ -243,14 +340,26 @@ export default {
content: `确定要删除报表"${report.name}"吗?`,
success: (res) => {
if (res.confirm) {
// TODO: 实现删除逻辑
uni.showToast({ title: '删除成功', icon: 'success' })
this.loadReports()
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) {
@@ -258,21 +367,133 @@ export default {
} else {
this.reportForm.metrics.push(key)
}
if (this.reportForm.metrics.length > 0) {
this.formErrors.metrics = ''
}
},
saveReport() {
if (!this.reportForm.name.trim()) {
uni.showToast({ title: '请输入报表名称', icon: 'none' })
return
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) {
uni.showToast({ title: '请至少选择一个指标', icon: 'none' })
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
}
// TODO: 实现保存逻辑
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) {
@@ -286,6 +507,10 @@ export default {
this.editingReport = null
},
goToLogin() {
goToLogin('/pages/mall/analytics/custom-report')
},
refreshData() {
this.loadReports()
uni.showToast({ title: '已刷新', icon: 'success' })
@@ -489,6 +714,57 @@ export default {
color: #111;
}
/* 工具栏 */
.toolbar {
margin-top: 12px;
padding: 12px 16px;
background: #fff;
border-radius: 14px;
border: 1px solid rgba(0,0,0,0.06);
display: flex;
flex-direction: row !important;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.toolbar-left {
display: flex;
flex-direction: column;
gap: 4px;
}
.toolbar-title {
font-size: 14px;
font-weight: 600;
color: #111;
}
.toolbar-subtitle {
font-size: 12px;
color: rgba(0,0,0,0.55);
}
.toolbar-right {
display: flex;
flex-direction: row !important;
align-items: center;
gap: 8px;
}
.btn-primary {
padding: 8px 16px;
border-radius: 999px;
border: none;
background: #111827;
color: #fff;
font-size: 14px;
}
.btn-primary:active {
opacity: 0.9;
}
/* 报表列表 */
.report-list {
margin-top: 12px;
@@ -570,6 +846,31 @@ export default {
color: rgba(0,0,0,0.45);
}
.empty-state {
margin-top: 24px;
padding: 32px 16px;
background: #fff;
border-radius: 14px;
border: 1px solid rgba(0,0,0,0.06);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
}
.empty-title {
font-size: 16px;
font-weight: 600;
color: #111;
}
.empty-desc {
font-size: 13px;
color: rgba(0,0,0,0.55);
text-align: center;
}
/* 模态框 */
.modal {
position: fixed;
@@ -644,6 +945,12 @@ export default {
margin-bottom: 8px;
}
.form-error {
margin-top: 4px;
font-size: 12px;
color: #dc2626;
}
.form-input,
.form-textarea {
width: 100%;

View File

@@ -105,10 +105,12 @@
</template>
<script lang="uts">
import supa, { ensureSupabaseReady } from '@/components/supadb/aksupainstance.uts'
import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uvue'
import AnalyticsTopBar from '@/components/analytics/AnalyticsTopBar.uvue'
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
import { fetchDataDetailReportInfo, fetchDataDetailRows, fetchDataDetailDrillItems } from '@/services/analytics/dataDetailService.uts'
import { mapAnalyticsError } from '@/services/analytics/errorMapper.uts'
import { rpcOrEmptyArray } from '@/services/analytics/rpc.uts'
type TableColumn = { key: string; label: string; type: string; sortable: boolean }
type DrillDownItem = { id: string; label: string; value: string; type: string }
@@ -125,9 +127,10 @@ export default {
showMoreMenu: false,
timeRangeText: '最近7天',
dimensionText: '全部',
compareMode: false,
compareMode: true,
sortKey: '',
sortOrder: 'asc',
reportId: '',
tableColumns: [
{ key: 'date', label: '日期', type: 'date', sortable: true },
@@ -146,9 +149,10 @@ export default {
onLoad(options: any) {
this.currentPath = '/pages/mall/analytics/data-detail'
// 接收参数:dataType, timeRange, dimension
if (options.dataType) {
// 根据数据类型加载不同的数据
// 接收参数:reportId / id优先报表ID以及可选的数据类型
const rid = (options.reportId || options.id) as string
if (rid) {
this.reportId = rid
}
this.updateTime()
this.loadDetailData()
@@ -161,104 +165,174 @@ export default {
methods: {
async loadDetailData() {
try {
const now = new Date()
const end = new Date(now.getTime())
const start = new Date(now.getTime())
// 如果带有报表 ID则优先使用 DATA_DETAIL_RPCS 基于 analytics_* 表加载
if (this.reportId && this.reportId.length > 0) {
// 1) 报表基础信息(可选:同步时间范围/标题)
const info = await fetchDataDetailReportInfo(this.reportId)
if (info != null) {
const period = info.period
if (period === '7d') this.timeRangeText = '最近7天'
else if (period === '30d') this.timeRangeText = '最近30天'
else if (period === '90d') this.timeRangeText = '最近90天'
}
if (this.timeRangeText === '最近7天') {
start.setDate(start.getDate() - 7)
} else if (this.timeRangeText === '最近30天') {
start.setDate(start.getDate() - 30)
} else if (this.timeRangeText === '最近90天') {
start.setDate(start.getDate() - 90)
} else {
// 自定义暂时按最近30天处理
start.setDate(start.getDate() - 30)
}
// 2) 明细行(表格)
const sortBy = this.sortKey.length > 0 ? this.sortKey : 'row_date'
const sortDir = this.sortOrder === 'desc' ? 'desc' : 'asc'
this.tableData = await fetchDataDetailRows(this.reportId, sortBy, sortDir, 500, 0)
const startIso = start.toISOString()
const endIso = end.toISOString()
// 3) 钻取指标KPI 列表)
// 这里仍保留页面侧的格式化逻辑format/label/valueservice 只负责拉数据
const drillAny = await rpcOrEmptyArray('rpc_data_detail_drill_items', {
p_report_id: this.reportId
} as UTSJSONObject)
const drillList: Array<DrillDownItem> = []
for (let i = 0; i < drillAny.length; i++) {
const m = drillAny[i]
const key = m.getString('metric_key') ?? ''
const label = m.getString('metric_label') ?? key
const fmt = m.getString('format') ?? 'number'
const valueNum = m.getNumber('metric_value_num') ?? 0
const vStr = this.formatCellValue(valueNum, fmt === 'currency' ? 'money' : (fmt === 'percent' ? 'percent' : 'number'))
drillList.push({
id: key.length > 0 ? key : 'metric_' + i.toString(),
label: label,
value: vStr,
type: key
} as DrillDownItem)
}
this.drillDownItems = drillList
await ensureSupabaseReady()
// 当前周期明细:复用 rpc_analytics_market_trend_daily按天 GMV / 订单 / 用户)
let currentRows: Array<UTSJSONObject> = []
let compareRows: Array<UTSJSONObject> = []
const curRes = await supa.rpc('rpc_analytics_market_trend_daily', {
p_start: startIso,
p_end: endIso
})
if (curRes.status === 404) {
console.warn('rpc_analytics_market_trend_daily not found, data-detail will be empty')
} else if (curRes.error != null) {
console.error('rpc_analytics_market_trend_daily error:', curRes.error)
} else {
const anyData = curRes.data as any
currentRows = Array.isArray(anyData) ? anyData as Array<UTSJSONObject> : []
}
// 对比周期:与当前周期长度相同的上一段时间
const spanMs = end.getTime() - start.getTime()
const prevEnd = new Date(start.getTime())
const prevStart = new Date(start.getTime() - spanMs)
const prevStartIso = prevStart.toISOString()
const prevEndIso = prevEnd.toISOString()
const prevRes = await supa.rpc('rpc_analytics_market_trend_daily', {
p_start: prevStartIso,
p_end: prevEndIso
})
if (prevRes.status === 404) {
console.warn('rpc_analytics_market_trend_daily not found for compare period')
} else if (prevRes.error != null) {
console.error('rpc_analytics_market_trend_daily (compare) error:', prevRes.error)
} else {
const anyPrev = prevRes.data as any
compareRows = Array.isArray(anyPrev) ? anyPrev as Array<UTSJSONObject> : []
}
// 映射到表格数据
const table: Array<any> = []
for (let i = 0; i < currentRows.length; i++) {
const r = currentRows[i]
const dayStr = r.getString('day') ?? ''
table.push({
id: dayStr + '_' + i.toString(),
date: dayStr,
gmv: r.getNumber('gmv') ?? 0,
orders: r.getNumber('orders') ?? 0,
users: r.getNumber('users') ?? 0
// 4) GMV 对比曲线
const cmpRes: any = await supa.rpc('rpc_data_detail_compare_gmv', {
p_report_id: this.reportId
} as any)
}
this.tableData = table
let cmpRows: Array<UTSJSONObject> = []
if (cmpRes.error != null) {
console.error('rpc_data_detail_compare_gmv error:', cmpRes.error)
} else {
const anyCmp = cmpRes.data as any
if (Array.isArray(anyCmp)) {
cmpRows = anyCmp as Array<UTSJSONObject>
}
}
// 简单生成钻取卡片:总 GMV / 总订单 / 总用户
let totalGmv = 0
let totalOrders = 0
let totalUsers = 0
for (let i = 0; i < table.length; i++) {
const row = table[i]
totalGmv += row.gmv as number
totalOrders += row.orders as number
totalUsers += row.users as number
}
this.drillDownItems = [
{ id: 'gmv_total', label: '当前周期 GMV 总计', value: this.formatCellValue(totalGmv, 'money'), type: 'gmv' } as DrillDownItem,
{ id: 'orders_total', label: '当前周期订单总数', value: this.formatCellValue(totalOrders, 'number'), type: 'orders' } as DrillDownItem,
{ id: 'users_total', label: '当前周期下单用户数', value: this.formatCellValue(totalUsers, 'number'), type: 'users' } as DrillDownItem
]
const curDays: string[] = []
const curGmv: number[] = []
const prevGmv: number[] = []
for (let i = 0; i < cmpRows.length; i++) {
const r = cmpRows[i]
const dayStr = r.getString('day') ?? ''
curDays.push(dayStr.length >= 10 ? dayStr.substring(5, 10) : dayStr)
curGmv.push(r.getNumber('gmv_current') ?? 0)
prevGmv.push(r.getNumber('gmv_previous') ?? 0)
}
this.compareChartOption = {
tooltip: { trigger: 'axis' },
legend: { data: ['当前周期 GMV', '对比周期 GMV'], top: 'bottom' },
grid: { left: 50, right: 20, top: 30, bottom: 60 },
xAxis: { type: 'category', data: curDays },
yAxis: { type: 'value', name: 'GMV' },
series: [
{ name: '当前周期 GMV', type: 'line', smooth: true, data: curGmv },
{ name: '对比周期 GMV', type: 'line', smooth: true, data: prevGmv }
]
}
} else {
// 兼容旧逻辑无报表ID时直接按时间范围调用市场趋势 RPC
const now = new Date()
const end = new Date(now.getTime())
const start = new Date(now.getTime())
;(this as any)._currentRows = currentRows
;(this as any)._compareRows = compareRows
if (this.timeRangeText === '最近7天') {
start.setDate(start.getDate() - 7)
} else if (this.timeRangeText === '最近30天') {
start.setDate(start.getDate() - 30)
} else if (this.timeRangeText === '最近90天') {
start.setDate(start.getDate() - 90)
} else {
start.setDate(start.getDate() - 30)
}
const startIso = start.toISOString()
const endIso = end.toISOString()
let currentRows: Array<UTSJSONObject> = []
let compareRows: Array<UTSJSONObject> = []
const curRes = await supa.rpc('rpc_analytics_market_trend_daily', {
p_start: startIso,
p_end: endIso
} as any)
if (curRes.status === 404) {
console.warn('rpc_analytics_market_trend_daily not found, data-detail will be empty')
} else if (curRes.error != null) {
console.error('rpc_analytics_market_trend_daily error:', curRes.error)
} else {
const anyData = curRes.data as any
currentRows = Array.isArray(anyData) ? anyData as Array<UTSJSONObject> : []
}
const spanMs = end.getTime() - start.getTime()
const prevEnd = new Date(start.getTime())
const prevStart = new Date(start.getTime() - spanMs)
const prevStartIso = prevStart.toISOString()
const prevEndIso = prevEnd.toISOString()
const prevRes = await supa.rpc('rpc_analytics_market_trend_daily', {
p_start: prevStartIso,
p_end: prevEndIso
} as any)
if (prevRes.status === 404) {
console.warn('rpc_analytics_market_trend_daily not found for compare period')
} else if (prevRes.error != null) {
console.error('rpc_analytics_market_trend_daily (compare) error:', prevRes.error)
} else {
const anyPrev = prevRes.data as any
compareRows = Array.isArray(anyPrev) ? anyPrev as Array<UTSJSONObject> : []
}
const table: Array<any> = []
for (let i = 0; i < currentRows.length; i++) {
const r = currentRows[i]
const dayStr = r.getString('day') ?? ''
table.push({
id: dayStr + '_' + i.toString(),
date: dayStr,
gmv: r.getNumber('gmv') ?? 0,
orders: r.getNumber('orders') ?? 0,
users: r.getNumber('users') ?? 0
} as any)
}
this.tableData = table
let totalGmv = 0
let totalOrders = 0
let totalUsers = 0
for (let i = 0; i < table.length; i++) {
const row = table[i]
totalGmv += row.gmv as number
totalOrders += row.orders as number
totalUsers += row.users as number
}
this.drillDownItems = [
{ id: 'gmv_total', label: '当前周期 GMV 总计', value: this.formatCellValue(totalGmv, 'money'), type: 'gmv' } as DrillDownItem,
{ id: 'orders_total', label: '当前周期订单总数', value: this.formatCellValue(totalOrders, 'number'), type: 'orders' } as DrillDownItem,
{ id: 'users_total', label: '当前周期下单用户数', value: this.formatCellValue(totalUsers, 'number'), type: 'users' } as DrillDownItem
]
;(this as any)._currentRows = currentRows
;(this as any)._compareRows = compareRows
this.buildChartOptions()
}
this.updateTime()
this.buildChartOptions()
} catch (e) {
console.error('loadDetailData failed:', e)
this.updateTime()
this.buildChartOptions()
uni.showToast({ title: '详细数据加载失败', icon: 'none' })
this.updateTime()
this.buildChartOptions()
uni.showToast({ title: mapAnalyticsError(e, { fallbackMessage: '详细数据加载失败' }), icon: 'none' })
}
},
@@ -356,8 +430,8 @@ export default {
const curRows = Array.isArray(curAny) ? curAny as Array<UTSJSONObject> : []
const prevRows = Array.isArray(prevAny) ? prevAny as Array<UTSJSONObject> : []
if (!this.compareMode || curRows.length === 0) {
this.compareChartOption = {}
if (curRows.length === 0) {
this.compareChartOption = {}
return
}

View File

@@ -116,11 +116,11 @@
</template>
<script lang="uts">
import supa from '@/components/supadb/aksupainstance.uts'
import { ensureSupabaseReady } from '@/components/supadb/aksupainstance.uts'
import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uvue'
import AnalyticsTopBar from '@/components/analytics/AnalyticsTopBar.uvue'
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
import { fetchDeliveryAnalysis } from '@/services/analytics/deliveryAnalysisService.uts'
import { mapAnalyticsError } from '@/services/analytics/errorMapper.uts'
type DeliveryData = {
avg_delivery_time: number
@@ -186,126 +186,9 @@ export default {
methods: {
async loadDeliveryData() {
try {
// 根据时间维度计算起始时间
const now = new Date()
const start = new Date(now.getTime())
if (this.selectedPeriod === '7d') start.setDate(start.getDate() - 7)
else if (this.selectedPeriod === '30d') start.setDate(start.getDate() - 30)
else if (this.selectedPeriod === '90d') start.setDate(start.getDate() - 90)
else if (this.selectedPeriod === '1y') start.setFullYear(start.getFullYear() - 1)
const startIso = start.toISOString()
await ensureSupabaseReady()
// 1) 查询周期内已送达任务(按 assigned_at 统计周期,口径:接单 assigned_at -> 送达 delivered_at
// 表结构来源doc_mall/database/complete_mall_database.sql
// 1) 趋势RPC数据库侧聚合
const endIso = now.toISOString()
// 优先走 RPC需要在 Supabase 执行 DELIVERY_ANALYSIS_RPCS.sql 创建函数)
let trendList: Array<UTSJSONObject> = []
let topList: Array<UTSJSONObject> = []
const trendRes = await supa.rpc('rpc_delivery_efficiency_daily', {
p_start: startIso,
p_end: endIso
})
if (trendRes.status === 404) {
// RPC 不存在:降级到直查表聚合(测试阶段兜底)
const taskRes = await supa
.from('ml_delivery_tasks')
.select('id,driver_id,assigned_at,delivered_at,delivery_fee', {})
.eq('status', 5)
.gte('assigned_at', startIso)
.order('assigned_at', { ascending: true })
.execute()
if (taskRes.error != null) throw taskRes.error
const rowsAny = (taskRes.data != null ? taskRes.data : []) as any
const tasks = Array.isArray(rowsAny) ? rowsAny as Array<UTSJSONObject> : []
const dayAgg = new Map<string, UTSJSONObject>()
const driverAgg = new Map<string, number>()
for (let i = 0; i < tasks.length; i++) {
const t = tasks[i]
const assignedAt = t.getString('assigned_at') ?? ''
const deliveredAt = t.getString('delivered_at') ?? ''
const driverId = t.getString('driver_id') ?? ''
if (assignedAt.trim() === '' || deliveredAt.trim() === '') continue
const day = assignedAt.length >= 10 ? assignedAt.substring(0, 10) : assignedAt
const a = new Date(assignedAt)
const d = new Date(deliveredAt)
const diffMin = Math.max(0, (d.getTime() - a.getTime()) / 60000)
const fee = t.getNumber('delivery_fee') ?? 0
const old = dayAgg.get(day)
if (old == null) {
const obj = new UTSJSONObject()
obj.set('day', day)
obj.set('completed_orders', 1)
obj.set('sum_minutes', diffMin)
obj.set('total_fee', fee)
dayAgg.set(day, obj)
} else {
old.set('completed_orders', (old.getNumber('completed_orders') ?? 0) + 1)
old.set('sum_minutes', (old.getNumber('sum_minutes') ?? 0) + diffMin)
old.set('total_fee', (old.getNumber('total_fee') ?? 0) + fee)
}
if (driverId.trim() !== '') {
const c = driverAgg.get(driverId) ?? 0
driverAgg.set(driverId, c + 1)
}
}
const keys = Array.from(dayAgg.keys()).sort()
for (let i = 0; i < keys.length; i++) {
const k = keys[i]
const agg = dayAgg.get(k) as UTSJSONObject
const cnt = agg.getNumber('completed_orders') ?? 0
const sumMin = agg.getNumber('sum_minutes') ?? 0
const tFee = agg.getNumber('total_fee') ?? 0
const row = new UTSJSONObject()
row.set('day', k)
row.set('completed_orders', cnt)
row.set('avg_delivery_minutes', cnt > 0 ? (sumMin / cnt) : 0)
row.set('total_fee', tFee)
row.set('avg_fee', cnt > 0 ? (tFee / cnt) : 0)
trendList.push(row)
}
// TOP仅用 driverAgg 计算(姓名/评分未知,先兜底)
const ids = Array.from(driverAgg.keys()).sort((a, b) => (driverAgg.get(b) ?? 0) - (driverAgg.get(a) ?? 0))
const topN = Math.min(10, ids.length)
for (let i = 0; i < topN; i++) {
const id = ids[i]
const row = new UTSJSONObject()
row.set('driver_id', id)
row.set('driver_name', '未知')
row.set('orders', driverAgg.get(id) ?? 0)
row.set('rating_avg', 0)
topList.push(row)
}
} else {
if (trendRes.error != null) throw trendRes.error
const trendAny = (trendRes.data != null ? trendRes.data : []) as any
trendList = Array.isArray(trendAny) ? trendAny as Array<UTSJSONObject> : []
const topRes = await supa.rpc('rpc_delivery_efficiency_top_drivers', {
p_start: startIso,
p_end: endIso,
p_limit: 10
})
if (topRes.error != null) throw topRes.error
const topAny = (topRes.data != null ? topRes.data : []) as any
topList = Array.isArray(topAny) ? topAny as Array<UTSJSONObject> : []
}
const data = await fetchDeliveryAnalysis(this.selectedPeriod)
const trendList = data.trendList
const topList = data.topList
// 3) 转成页面内部 trendRows 格式
const trendRows: Array<UTSJSONObject> = []
@@ -397,7 +280,7 @@ export default {
console.error('loadDeliveryData failed:', e)
this.updateTime()
this.buildChartOptions()
uni.showToast({ title: '配送分析数据加载失败', icon: 'none' })
uni.showToast({ title: mapAnalyticsError(e, { fallbackMessage: '配送分析数据加载失败' }), icon: 'none' })
}
},

View File

@@ -259,11 +259,12 @@
</template>
<script lang="uts">
import supa from '@/components/supadb/aksupainstance.uts'
import AnalyticsComboChart from '@/components/analytics/AnalyticsComboChart.uvue'
import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uvue'
import AnalyticsTopBar from '@/components/analytics/AnalyticsTopBar.uvue'
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
import { fetchDashboardRealtime, fetchDashboardTrend, fetchDashboardUserSegments, fetchDashboardTrafficSources, fetchDashboardTopProducts, fetchDashboardTopMerchants } from '@/services/analytics/dashboardService.uts'
import { mapAnalyticsError } from '@/services/analytics/errorMapper.uts'
type TrendData = { x: Array<string>; gmv: Array<number>; orders: Array<number> }
type SegmentItem = { name: string; value: number }
@@ -424,7 +425,7 @@ type TopMerchantItem = { id: string; rank: number; name: string; sales: number;
})
} catch (e) {
console.error('❌ refreshAll failed', e)
uni.showToast({ title: '数据加载失败', icon: 'none' })
uni.showToast({ title: mapAnalyticsError(e, { fallbackMessage: '数据加载失败' }), icon: 'none' })
}
},
@@ -506,54 +507,9 @@ type TopMerchantItem = { id: string; rank: number; name: string; sales: number;
async loadTrend() {
try {
const { startDate, endDate } = this.calcDateRange()
const p = new UTSJSONObject()
p.set('p_start_date', startDate.toISOString().slice(0, 10))
p.set('p_end_date', endDate.toISOString().slice(0, 10))
p.set('p_merchant_id', null)
console.log('📊 loadTrend: 请求参数', {
start_date: startDate.toISOString().slice(0, 10),
end_date: endDate.toISOString().slice(0, 10)
})
const res: any = await supa.rpc('rpc_analytics_trend_data', p)
console.log('📊 loadTrend: RPC 返回结果', res)
// 检查返回结构:可能是 res.data 或 res 本身
let rows: Array<any> = []
if (Array.isArray(res.data)) {
rows = res.data as Array<any>
} else if (Array.isArray(res)) {
rows = res as Array<any>
} else if (res && typeof res === 'object') {
// 可能是 { data: [...] } 或其他结构
const data = res.data || res.rows || res.result || []
rows = Array.isArray(data) ? data : []
}
console.log('📊 loadTrend: 解析后的 rows', rows, '数量:', rows.length)
const x: Array<string> = []
const gmv: Array<number> = []
const orders: Array<number> = []
for (let i = 0; i < rows.length; i++) {
const row = rows[i]
const d = `${row.date || row.day || row.date_key}` // 兼容不同字段名
if (d && d.length >= 10) {
x.push(d.slice(5)) // MM-DD
} else {
x.push(`${i + 1}`)
}
gmv.push(Number(row.gmv || row.total_amount || 0) || 0)
orders.push(Number(row.orders || row.order_count || 0) || 0)
}
console.log('📊 loadTrend: 最终数据', { x: x.length, gmv: gmv.length, orders: orders.length })
this.trend = { x, gmv, orders }
this.trend = await fetchDashboardTrend(this.selectedPeriod)
} catch (e) {
console.error('❌ loadTrend failed', e)
// 即使失败也设置空数据,避免图表报错
this.trend = { x: [], gmv: [], orders: [] }
}
},
@@ -561,58 +517,7 @@ type TopMerchantItem = { id: string; rank: number; name: string; sales: number;
// 实时指标:核心是"强制数值化 + 兜底",避免对象直接渲染
async loadRealTime() {
try {
const now = new Date()
const today0 = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const todayISO = today0.toISOString()
const ySame = new Date(now.getTime() - 24 * 60 * 60 * 1000)
const y0 = new Date(ySame.getFullYear(), ySame.getMonth(), ySame.getDate())
const p = new UTSJSONObject()
p.set('p_start', todayISO)
p.set('p_end', now.toISOString())
p.set('p_compare_start', y0.toISOString())
p.set('p_compare_end', ySame.toISOString())
p.set('p_merchant_id', null)
console.log('⚡ loadRealTime: 请求参数', {
p_start: todayISO,
p_end: now.toISOString(),
p_compare_start: y0.toISOString(),
p_compare_end: ySame.toISOString()
})
const res: any = await supa.rpc('rpc_analytics_realtime_kpis', p)
console.log('⚡ loadRealTime: RPC 返回结果', res)
// 检查返回结构
let row: any = {}
if (Array.isArray(res.data) && res.data.length > 0) {
row = res.data[0]
} else if (Array.isArray(res) && res.length > 0) {
row = res[0]
} else if (res && typeof res === 'object' && !Array.isArray(res)) {
// 可能是直接返回对象,或者 { data: {...} }
row = res.data || res.result || res
}
console.log('⚡ loadRealTime: 解析后的 row', row)
const safe = (v: any): number => {
const n = Number(v)
return isFinite(n) ? n : 0
}
this.realTime = {
gmv: Math.round(safe(row.gmv || row.total_gmv || row.revenue)),
gmv_growth: safe(row.gmv_growth || row.gmv_growth_rate || row.revenue_growth),
orders: Math.round(safe(row.orders || row.order_count || row.total_orders)),
order_growth: safe(row.order_growth || row.order_growth_rate),
online_users: Math.round(safe(row.online_users || row.active_users || row.current_users)),
conversion_rate: safe(row.conversion_rate || row.conversion),
conversion_growth: safe(row.conversion_growth || row.conversion_growth_rate)
}
console.log('⚡ loadRealTime: 最终数据', this.realTime)
this.realTime = await fetchDashboardRealtime()
} catch (e) {
console.error('❌ loadRealTime failed', e)
}
@@ -620,52 +525,7 @@ type TopMerchantItem = { id: string; rank: number; name: string; sales: number;
async loadTopProducts() {
try {
const { startDate, endDate } = this.calcDateRange()
const p = new UTSJSONObject()
p.set('p_start_date', startDate.toISOString().slice(0, 10))
p.set('p_end_date', endDate.toISOString().slice(0, 10))
p.set('p_limit', 50)
p.set('p_merchant_id', null)
const res: any = await supa.rpc('rpc_analytics_top_products', p)
console.log('📦 loadTopProducts: RPC 返回结果', res)
// 检查返回结构:即使 status 400如果 data 有数据也使用
let rows: Array<any> = []
if (res.data) {
if (Array.isArray(res.data)) {
rows = res.data as Array<any>
} else if (typeof res.data === 'object' && res.data.constructor && res.data.constructor.name === 'Array') {
rows = res.data as Array<any>
} else if (typeof res.data === 'object') {
const dataObj = res.data as any
if (typeof dataObj.length === 'number' && dataObj.length >= 0) {
rows = []
for (let i = 0; i < dataObj.length; i++) {
const item = dataObj[i]
if (item) rows.push(item)
}
} else {
rows = [dataObj]
}
}
} else if (Array.isArray(res)) {
rows = res as Array<any>
}
console.log('📦 loadTopProducts: 解析后的 rows', rows, '数量:', rows.length)
const list: Array<TopProductItem> = []
for (let i = 0; i < rows.length; i++) {
const row = rows[i]
list.push({
id: `${row.id}`,
rank: i + 1,
name: `${row.name || '未知商品'}`,
sales: Number(row.sales || row.total_amount || 0) || 0
})
}
console.log('📦 loadTopProducts: 最终数据', list)
const list = await fetchDashboardTopProducts(this.selectedPeriod, 50)
// 如果数据少于6条添加假数据以达到滚动效果
if (list.length < 6) {
@@ -676,7 +536,6 @@ type TopMerchantItem = { id: string; rank: number; name: string; sales: number;
{ id: 'fake-4', rank: list.length + 4, name: '示例商品D', sales: Math.floor(Math.random() * 100) + 10 },
{ id: 'fake-5', rank: list.length + 5, name: '示例商品E', sales: Math.floor(Math.random() * 100) + 5 }
]
// 填充到至少6条
const needCount = 6 - list.length
for (let i = 0; i < needCount; i++) {
list.push(fakeProducts[i % fakeProducts.length])
@@ -686,7 +545,6 @@ type TopMerchantItem = { id: string; rank: number; name: string; sales: number;
this.topProducts = list
} catch (e) {
console.error('❌ loadTopProducts failed', e)
// 即使失败也添加假数据
const fakeProducts = [
{ id: 'fake-1', rank: 1, name: '示例商品A', sales: 88 },
{ id: 'fake-2', rank: 2, name: '示例商品B', sales: 76 },
@@ -701,59 +559,7 @@ type TopMerchantItem = { id: string; rank: number; name: string; sales: number;
async loadTopMerchants() {
try {
const { startDate, endDate } = this.calcDateRange()
const p = new UTSJSONObject()
p.set('p_start_date', startDate.toISOString().slice(0, 10))
p.set('p_end_date', endDate.toISOString().slice(0, 10))
p.set('p_limit', 50)
console.log('🏪 loadTopMerchants: 请求参数', {
p_start_date: startDate.toISOString().slice(0, 10),
p_end_date: endDate.toISOString().slice(0, 10),
p_limit: 5
})
const res: any = await supa.rpc('rpc_analytics_top_merchants', p)
console.log('🏪 loadTopMerchants: RPC 返回结果', res)
// 检查返回结构:即使 status 400如果 data 有数据也使用
let rows: Array<any> = []
if (res.data) {
if (Array.isArray(res.data)) {
rows = res.data as Array<any>
} else if (typeof res.data === 'object' && res.data.constructor && res.data.constructor.name === 'Array') {
rows = res.data as Array<any>
} else if (typeof res.data === 'object') {
const dataObj = res.data as any
if (typeof dataObj.length === 'number' && dataObj.length >= 0) {
rows = []
for (let i = 0; i < dataObj.length; i++) {
const item = dataObj[i]
if (item) rows.push(item)
}
} else {
rows = [dataObj]
}
}
} else if (Array.isArray(res)) {
rows = res as Array<any>
}
console.log('🏪 loadTopMerchants: 解析后的 rows', rows, '数量:', rows.length)
const list: Array<TopMerchantItem> = []
for (let i = 0; i < rows.length; i++) {
const row = rows[i]
list.push({
id: `${row.id}`,
rank: i + 1,
name: `${row.name || row.shop_name || '未知商家'}`,
sales: Number(row.sales || row.total_amount || 0) || 0,
growth: Number(row.growth || row.growth_rate || 0) || 0
})
}
console.log('🏪 loadTopMerchants: 最终数据', list)
const list = await fetchDashboardTopMerchants(this.selectedPeriod, 50)
// 如果数据少于6条添加假数据以达到滚动效果
if (list.length < 6) {
@@ -764,7 +570,6 @@ type TopMerchantItem = { id: string; rank: number; name: string; sales: number;
{ id: 'fake-4', rank: list.length + 4, name: '示例商家D', sales: Math.floor(Math.random() * 5000) + 1000, growth: Math.floor(Math.random() * 20) - 10 },
{ id: 'fake-5', rank: list.length + 5, name: '示例商家E', sales: Math.floor(Math.random() * 4000) + 500, growth: Math.floor(Math.random() * 20) - 10 }
]
// 填充到至少6条
const needCount = 6 - list.length
for (let i = 0; i < needCount; i++) {
list.push(fakeMerchants[i % fakeMerchants.length])
@@ -774,7 +579,6 @@ type TopMerchantItem = { id: string; rank: number; name: string; sales: number;
this.topMerchants = list
} catch (e) {
console.error('❌ loadTopMerchants failed', e)
// 即使失败也添加假数据
const fakeMerchants = [
{ id: 'fake-1', rank: 1, name: '示例商家A', sales: 8888, growth: 12.5 },
{ id: 'fake-2', rank: 2, name: '示例商家B', sales: 7654, growth: 8.3 },
@@ -789,64 +593,7 @@ type TopMerchantItem = { id: string; rank: number; name: string; sales: number;
async loadUserSegments() {
try {
const { startDate, endDate } = this.calcDateRange()
const p = new UTSJSONObject()
p.set('p_start_date', startDate.toISOString().slice(0, 10))
p.set('p_end_date', endDate.toISOString().slice(0, 10))
console.log('👥 loadUserSegments: 请求参数', {
start_date: startDate.toISOString().slice(0, 10),
end_date: endDate.toISOString().slice(0, 10)
})
const res: any = await supa.rpc('rpc_analytics_user_segments', p)
console.log('👥 loadUserSegments: RPC 返回结果', res)
// 检查返回结构:即使 status 400如果 data 有数据也使用
let rows: Array<any> = []
// 先检查 res.data可能是 UTSJSONObject需要检查是否有数组数据
if (res.data) {
// 如果 res.data 是数组,直接使用
if (Array.isArray(res.data)) {
rows = res.data as Array<any>
} else if (typeof res.data === 'object' && res.data.constructor && res.data.constructor.name === 'Array') {
// UTS 的数组对象
rows = res.data as Array<any>
} else if (typeof res.data === 'object') {
// 可能是 UTSJSONObject尝试获取内部数组
const dataObj = res.data as any
// 检查是否有 length 属性UTS 数组)
if (typeof dataObj.length === 'number' && dataObj.length >= 0) {
rows = []
for (let i = 0; i < dataObj.length; i++) {
const item = dataObj[i]
if (item) rows.push(item)
}
} else {
// 可能是单个对象,包装成数组
rows = [dataObj]
}
}
} else if (Array.isArray(res)) {
rows = res as Array<any>
} else if (res && typeof res === 'object') {
const data = res.data || res.rows || res.result || []
rows = Array.isArray(data) ? data : []
}
console.log('👥 loadUserSegments: 解析后的 rows', rows, '数量:', rows.length)
const list: Array<SegmentItem> = []
for (let i = 0; i < rows.length; i++) {
const row = rows[i]
const name = `${row.name || row.segment_name || row.label || '未知'}`
const value = Number(row.value || row.count || row.amount || 0) || 0
list.push({ name, value })
}
console.log('👥 loadUserSegments: 最终数据', list)
// 即使为空也更新,确保图表能正确显示空状态
this.userSegments = list
this.userSegments = await fetchDashboardUserSegments(this.selectedPeriod)
} catch (e) {
console.error('❌ loadUserSegments failed', e)
this.userSegments = []
@@ -855,58 +602,7 @@ type TopMerchantItem = { id: string; rank: number; name: string; sales: number;
async loadTrafficSources() {
try {
const { startDate, endDate } = this.calcDateRange()
const p = new UTSJSONObject()
p.set('p_start_date', startDate.toISOString().slice(0, 10))
p.set('p_end_date', endDate.toISOString().slice(0, 10))
console.log('🌐 loadTrafficSources: 请求参数', {
start_date: startDate.toISOString().slice(0, 10),
end_date: endDate.toISOString().slice(0, 10)
})
const res: any = await supa.rpc('rpc_analytics_traffic_sources', p)
console.log('🌐 loadTrafficSources: RPC 返回结果', res)
// 检查返回结构:即使 status 400如果 data 有数据也使用
let rows: Array<any> = []
if (res.data) {
if (Array.isArray(res.data)) {
rows = res.data as Array<any>
} else if (typeof res.data === 'object' && res.data.constructor && res.data.constructor.name === 'Array') {
rows = res.data as Array<any>
} else if (typeof res.data === 'object') {
const dataObj = res.data as any
if (typeof dataObj.length === 'number' && dataObj.length >= 0) {
rows = []
for (let i = 0; i < dataObj.length; i++) {
const item = dataObj[i]
if (item) rows.push(item)
}
} else {
rows = [dataObj]
}
}
} else if (Array.isArray(res)) {
rows = res as Array<any>
} else if (res && typeof res === 'object') {
const data = res.data || res.rows || res.result || []
rows = Array.isArray(data) ? data : []
}
console.log('🌐 loadTrafficSources: 解析后的 rows', rows, '数量:', rows.length)
const list: Array<TrafficItem> = []
for (let i = 0; i < rows.length; i++) {
const row = rows[i]
const name = `${row.name || row.source_name || row.label || '未知'}`
const value = Number(row.value || row.count || row.amount || 0) || 0
list.push({ name, value })
}
console.log('🌐 loadTrafficSources: 最终数据', list)
// 即使为空也更新
this.trafficSources = list
this.trafficSources = await fetchDashboardTrafficSources(this.selectedPeriod)
} catch (e) {
console.error('❌ loadTrafficSources failed', e)
this.trafficSources = []

View File

@@ -73,9 +73,10 @@
</template>
<script lang="uts">
import supa from '@/components/supadb/aksupainstance.uts'
import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uvue'
import AnalyticsTopBar from '@/components/analytics/AnalyticsTopBar.uvue'
import { fetchInsightDetail, fetchRelatedReport } from '@/services/analytics/insightDetailService.uts'
import { mapAnalyticsError } from '@/services/analytics/errorMapper.uts'
type InsightDetail = {
id: string
@@ -152,50 +153,28 @@ export default {
this.errorMsg = ''
this.updateTime()
const res: any = await supa
.from('analytics_insights')
.select('id, report_id, type, impact, title, content, created_at')
.eq('id', this.insightId)
const rows: Array<any> = Array.isArray(res.data) ? (res.data as Array<any>) : []
if (rows.length === 0) {
const insight = await fetchInsightDetail(this.insightId)
if (insight == null) {
this.errorMsg = '洞察不存在或无权限访问'
return
}
const it = rows[0]
this.insight = {
id: `${it.id}`,
report_id: `${it.report_id || ''}`,
type: `${it.type || 'info'}`,
impact: `${it.impact || 'medium'}`,
title: `${it.title || ''}`,
content: `${it.content || ''}`,
created_at: `${it.created_at || ''}`
}
this.insight = insight
// 关联报表(可选)
this.relatedReport = { id: '', title: '', type: '', period: '', generated_at: '' } as RelatedReport
if (this.insight.report_id) {
const rRes: any = await supa
.from('analytics_reports')
.select('id, title, type, period, generated_at')
.eq('id', this.insight.report_id)
const rRows: Array<any> = Array.isArray(rRes.data) ? (rRes.data as Array<any>) : []
if (rRows.length > 0) {
const r = rRows[0]
this.relatedReport = {
id: `${r.id}`,
title: `${r.title}`,
type: `${r.type}`,
period: `${r.period}`,
generated_at: `${r.generated_at || ''}`
try {
const related = await fetchRelatedReport(this.insight.report_id)
if (related != null) {
this.relatedReport = related
}
} catch (e) {
console.error('loadInsightDetail related report error', e)
}
}
} catch (e) {
console.error('loadInsightDetail failed', e)
this.errorMsg = '加载失败,请稍后重试'
this.errorMsg = mapAnalyticsError(e, { fallbackMessage: '加载失败,请稍后重试' })
} finally {
this.loading = false
}

View File

@@ -94,10 +94,11 @@
</template>
<script lang="uts">
import supa, { ensureSupabaseReady } from '@/components/supadb/aksupainstance.uts'
import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uvue'
import AnalyticsTopBar from '@/components/analytics/AnalyticsTopBar.uvue'
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
import { fetchMarketTrends } from '@/services/analytics/marketTrendsService.uts'
import { mapAnalyticsError } from '@/services/analytics/errorMapper.uts'
type TimePeriod = { value: string; label: string }
@@ -149,106 +150,21 @@ export default {
methods: {
async loadMarketData() {
try {
const now = new Date()
const start = new Date(now.getTime())
if (this.selectedPeriod === '7d') start.setDate(start.getDate() - 7)
else if (this.selectedPeriod === '30d') start.setDate(start.getDate() - 30)
else if (this.selectedPeriod === '90d') start.setDate(start.getDate() - 90)
else if (this.selectedPeriod === '1y') start.setFullYear(start.getFullYear() - 1)
const startIso = start.toISOString()
const endIso = now.toISOString()
const data = await fetchMarketTrends(this.selectedPeriod)
await ensureSupabaseReady()
// 1) 市场整体趋势(按天 GMV / 订单 / 用户)
let trendRows: Array<UTSJSONObject> = []
let categoryRows: Array<UTSJSONObject> = []
let seasonalRows: Array<UTSJSONObject> = []
let priceRows: Array<UTSJSONObject> = []
let competitionRows: Array<UTSJSONObject> = []
const marketRes = await supa.rpc('rpc_analytics_market_trend_daily', {
p_start: startIso,
p_end: endIso
})
if (marketRes.status === 404) {
console.warn('rpc_analytics_market_trend_daily not found, market trend will be empty')
} else if (marketRes.error != null) {
console.error('rpc_analytics_market_trend_daily error:', marketRes.error)
} else {
const anyData = marketRes.data as any
trendRows = Array.isArray(anyData) ? anyData as Array<UTSJSONObject> : []
}
// 2) 行业对比(按分类 GMV
const catRes = await supa.rpc('rpc_analytics_category_sales', {
p_start_date: startIso.substring(0, 10),
p_end_date: endIso.substring(0, 10)
})
if (catRes.status === 404) {
console.warn('rpc_analytics_category_sales not found, industry comparison will be empty')
} else if (catRes.error != null) {
console.error('rpc_analytics_category_sales error:', catRes.error)
} else {
const cAny = catRes.data as any
categoryRows = Array.isArray(cAny) ? cAny as Array<UTSJSONObject> : []
}
// 3) 季节性趋势(按月 GMV
const seaRes = await supa.rpc('rpc_analytics_seasonal_trend', {
p_start_date: startIso.substring(0, 10),
p_end_date: endIso.substring(0, 10)
})
if (seaRes.status === 404) {
console.warn('rpc_analytics_seasonal_trend not found, seasonal trend will be empty')
} else if (seaRes.error != null) {
console.error('rpc_analytics_seasonal_trend error:', seaRes.error)
} else {
const sAny = seaRes.data as any
seasonalRows = Array.isArray(sAny) ? sAny as Array<UTSJSONObject> : []
}
// 4) 价格趋势(按天平均单价)
const priceRes = await supa.rpc('rpc_analytics_price_trend', {
p_start: startIso,
p_end: endIso
})
if (priceRes.status === 404) {
console.warn('rpc_analytics_price_trend not found, price trend will be empty')
} else if (priceRes.error != null) {
console.error('rpc_analytics_price_trend error:', priceRes.error)
} else {
const pAny = priceRes.data as any
priceRows = Array.isArray(pAny) ? pAny as Array<UTSJSONObject> : []
}
// 5) 竞争分析(商家 GMV 榜单)
const compRes = await supa.rpc('rpc_analytics_competition_share', {
p_start_date: startIso.substring(0, 10),
p_end_date: endIso.substring(0, 10)
})
if (compRes.status === 404) {
console.warn('rpc_analytics_competition_share not found, competition analysis will be empty')
} else if (compRes.error != null) {
console.error('rpc_analytics_competition_share error:', compRes.error)
} else {
const cpAny = compRes.data as any
competitionRows = Array.isArray(cpAny) ? cpAny as Array<UTSJSONObject> : []
}
;(this as any)._marketTrendRows = trendRows
;(this as any)._industryRows = categoryRows
;(this as any)._seasonalRows = seasonalRows
;(this as any)._priceRows = priceRows
;(this as any)._competitionRows = competitionRows
;(this as any)._marketTrendRows = data.trendRows
;(this as any)._industryRows = data.categoryRows
;(this as any)._seasonalRows = data.seasonalRows
;(this as any)._priceRows = data.priceRows
;(this as any)._competitionRows = data.competitionRows
this.updateTime()
this.buildChartOptions()
} catch (e) {
console.error('loadMarketData failed:', e)
this.updateTime()
this.buildChartOptions()
uni.showToast({ title: '市场趋势数据加载失败', icon: 'none' })
this.updateTime()
this.buildChartOptions()
uni.showToast({ title: mapAnalyticsError(e, { fallbackMessage: '市场趋势数据加载失败' }), icon: 'none' })
}
},

View File

@@ -168,10 +168,12 @@
</template>
<script lang="uts">
import supa from '@/components/supadb/aksupainstance.uts'
import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uvue'
import AnalyticsTopBar from '@/components/analytics/AnalyticsTopBar.uvue'
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
import { fetchProductOverview, fetchTopProducts, fetchProductTrend, fetchCategorySales, fetchStockInsights, fetchPriceTrend, fetchReviewInsights } from '@/services/analytics/productInsightsService.uts'
import { computeDateRange, toDateOnly } from '@/services/analytics/dateRange.uts'
import { mapAnalyticsError } from '@/services/analytics/errorMapper.uts'
type TimePeriod = { value: string; label: string }
type ProductData = {
@@ -244,20 +246,15 @@ export default {
},
methods: {
async loadSelectedProductTrend(startDate: Date, endDate: Date) {
async loadSelectedProductTrend() {
try {
if (this.selectedProductId == null || this.selectedProductId === '') {
this.salesChartOption = {}
return
}
const pTrend = new UTSJSONObject()
pTrend.set('p_start_date', startDate.toISOString().slice(0, 10))
pTrend.set('p_end_date', endDate.toISOString().slice(0, 10))
pTrend.set('p_product_id', this.selectedProductId)
const res: any = await supa.rpc('rpc_analytics_product_trend', pTrend)
const rows: Array<any> = Array.isArray(res.data) ? (res.data as Array<any>) : []
const trend = await fetchProductTrend(this.selectedPeriod, this.selectedProductId)
const rows: Array<any> = trend as any
const x: Array<string> = []
const gmv: Array<number> = []
@@ -306,53 +303,38 @@ export default {
} catch (e) {
console.error('loadSelectedProductTrend failed', e)
this.salesChartOption = {}
uni.showToast({ title: mapAnalyticsError(e, { fallbackMessage: '加载商品趋势失败' }), icon: 'none' })
}
},
handleProductChange() {
const { startDate, endDate } = this.calcDateRange()
this.loadSelectedProductTrend(startDate, endDate)
this.loadSelectedProductTrend()
},
calcDateRange() {
const now = new Date()
const endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const days = this.selectedPeriod === '7d' ? 7 : this.selectedPeriod === '30d' ? 30 : this.selectedPeriod === '90d' ? 90 : 365
const startDate = new Date(endDate.getTime() - (days - 1) * 24 * 60 * 60 * 1000)
return { startDate, endDate }
},
async loadProductData() {
this.loading = true
try {
this.updateTime()
const { startDate, endDate } = this.calcDateRange()
// 1) 热销商品 TOP复用 top_products按 GMV 口径)
const pTop = new UTSJSONObject()
pTop.set('p_start_date', startDate.toISOString().slice(0, 10))
pTop.set('p_end_date', endDate.toISOString().slice(0, 10))
pTop.set('p_limit', 10)
pTop.set('p_merchant_id', null)
const topRes: any = await supa.rpc('rpc_analytics_top_products', pTop)
const topRows: Array<any> = Array.isArray(topRes.data) ? (topRes.data as Array<any>) : []
const topList: Array<ProductRank> = []
for (let i = 0; i < topRows.length; i++) {
topList.push({
id: `${topRows[i].id}`,
rank: i + 1,
name: `${topRows[i].name}`,
sales: Number(topRows[i].sales) || 0,
growth: Math.round((Math.random() * 20 - 10) * 10) / 10
})
}
const [overview, topList, catRows, stockRows, priceRows, reviewRows] = await Promise.all([
fetchProductOverview(this.selectedPeriod),
fetchTopProducts(this.selectedPeriod, 10),
fetchCategorySales(this.selectedPeriod),
fetchStockInsights(this.selectedPeriod),
fetchPriceTrend(this.selectedPeriod),
fetchReviewInsights()
])
this.productData = overview
// 不足 10 条时补齐虚拟数据(真实数据优先,虚拟数据追加)
if (topList.length < 10) {
const need = 10 - topList.length
const top = topList.slice()
if (top.length < 10) {
const need = 10 - top.length
for (let i = 0; i < need; i++) {
const n = topList.length + 1
topList.push({
const n = top.length + 1
top.push({
id: `fake-product-${n}`,
rank: n,
name: `示例商品${n}`,
@@ -361,47 +343,31 @@ export default {
})
}
} else {
topList.splice(10)
top.splice(10)
}
for (let i = 0; i < topList.length; i++) topList[i].rank = i + 1
for (let i = 0; i < top.length; i++) top[i].rank = i + 1
this.topProducts = top
this.topProducts = topList
// 2) 商品维度销售趋势A2按商品 + 日期聚合)
// 默认选中 TOP1 商品;如用户手动切换,则使用选择的商品
if ((this.selectedProductId == null || this.selectedProductId === '') && topList.length > 0) {
const real = topList.find((it) => !String(it.id).startsWith('fake-product-'))
if ((this.selectedProductId == null || this.selectedProductId === '') && top.length > 0) {
const real = top.find((it) => !String(it.id).startsWith('fake-product-'))
this.selectedProductId = real ? real.id : ''
}
// 如果仍然没有可选商品,则清空图表
if (this.selectedProductId == null || this.selectedProductId === '') {
this.salesChartOption = {}
} else {
await this.loadSelectedProductTrend(startDate, endDate)
await this.loadSelectedProductTrend()
}
// 3) KPI以 products 表为基础口径:总商品数/热销商品/库存均值)
this.buildCategoryChart(catRows)
this.buildStockChart(stockRows)
// priceChartOption 在 loadSelectedProductTrend 里会生成均价趋势;这里仍保留整体价格趋势图(如果你有对应图表函数可以接入)
this.buildReviewChart(reviewRows)
// 3) KPI以 products 表为基础口径:总商品数/热销商品/库存均值)
// 注:当前 analytics schema 没有商品 KPI RPC这里用简单查询占位后续可补 RPC
this.productData = {
total_products: 0,
product_growth: 0,
hot_products: topList.filter((p) => p.sales >= 100).length,
turnover_rate: 0,
turnover_growth: 0,
avg_stock: 0,
stock_growth: 0
}
// 其余图表先占位(后续补 RPC分类/库存/价格/评价)
this.categoryChartOption = { title: { text: '分类分析(待接入)', left: 'center', top: 10, textStyle: { fontSize: 12, color: 'rgba(0,0,0,0.55)' } }, series: [] }
this.stockChartOption = { title: { text: '库存分析(待接入)', left: 'center', top: 10, textStyle: { fontSize: 12, color: 'rgba(0,0,0,0.55)' } }, series: [] }
this.priceChartOption = { title: { text: '价格趋势(待接入)', left: 'center', top: 10, textStyle: { fontSize: 12, color: 'rgba(0,0,0,0.55)' } }, series: [] }
this.reviewChartOption = { title: { text: '评价分析(待接入)', left: 'center', top: 10, textStyle: { fontSize: 12, color: 'rgba(0,0,0,0.55)' } }, series: [] }
this.updateTime()
} catch (e) {
console.error('loadProductData failed', e)
uni.showToast({ title: '数据加载失败', icon: 'none', duration: 2000 })
uni.showToast({ title: mapAnalyticsError(e, { fallbackMessage: '商品洞察数据加载失败' }), icon: 'none', duration: 2000 })
} finally {
this.loading = false
this.updateTime()

View File

@@ -38,6 +38,7 @@
</view>
</view>
<view class="header-actions">
<button class="action-btn detail" @click="goToDataDetail">🔍 数据分析详情</button>
<button class="action-btn export" @click="exportReport">📊 导出</button>
<button class="action-btn refresh" @click="refreshReport">🔄 刷新</button>
</view>
@@ -202,7 +203,8 @@
<script lang="uts">
import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uvue'
import AnalyticsTopBar from '@/components/analytics/AnalyticsTopBar.uvue'
import supa from '@/components/supadb/aksupainstance.uts'
import { fetchReport, fetchReportMetrics, fetchReportRows, fetchReportInsights, fetchRelatedReports } from '@/services/analytics/reportDetailService.uts'
import { mapAnalyticsError } from '@/services/analytics/errorMapper.uts'
type ReportType = {
id: string
@@ -325,42 +327,15 @@ export default {
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) {
const report = await fetchReport(reportId)
if (report == null) {
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 || ''}`
}
this.report = report
// 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)
}))
this.coreMetrics = await fetchReportMetrics(reportId)
// 3. 配置表头与排序选项(固定结构)
this.tableColumns = [
@@ -374,55 +349,19 @@ export default {
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.allRows = await fetchReportRows(reportId)
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'}`
}))
this.dataInsights = await fetchReportInsights(reportId)
// 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 || ''}`
}))
this.relatedReports = await fetchRelatedReports(this.report.type, reportId)
} catch (e) {
console.error('loadReportDetail failed', e)
uni.showToast({ title: '报表加载失败', icon: 'none' })
uni.showToast({ title: mapAnalyticsError(e, { fallbackMessage: '报表加载失败' }), icon: 'none' })
} finally {
uni.hideLoading()
}
@@ -584,6 +523,16 @@ export default {
}, 1500)
},
goToDataDetail() {
if (!this.report.id || this.report.id.length === 0) {
uni.showToast({ title: '报表未加载完成', icon: 'none' })
return
}
uni.navigateTo({
url: `/pages/mall/analytics/data-detail?reportId=${this.report.id}`
})
},
viewInsightDetail(insight: InsightType) {
uni.navigateTo({
url: `/pages/mall/analytics/insight-detail?insightId=${insight.id}`
@@ -729,13 +678,20 @@ export default {
gap: 15rpx;
}
.action-btn.export, .action-btn.refresh {
.action-btn.detail,
.action-btn.export,
.action-btn.refresh {
padding: 15rpx 25rpx;
border-radius: 8rpx;
font-size: 24rpx;
border: none;
}
.action-btn.detail {
background-color: #111827;
color: #fff;
}
.action-btn.export {
background-color: #4caf50;
color: #fff;

View File

@@ -149,11 +149,12 @@
</template>
<script lang="uts">
import supa from '@/components/supadb/aksupainstance.uts'
import AnalyticsComboChart from '@/components/analytics/AnalyticsComboChart.uvue'
import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uvue'
import AnalyticsTopBar from '@/components/analytics/AnalyticsTopBar.uvue'
import AnalyticsRegionMap from '@/components/analytics/AnalyticsRegionMap.uvue'
import { fetchSalesKpis, fetchSalesTrend, fetchSalesTopProducts, fetchSalesTopMerchants } from '@/services/analytics/salesReportService.uts'
import { mapAnalyticsError } from '@/services/analytics/errorMapper.uts'
type TrendData = { x: Array<string>; gmv: Array<number>; orders: Array<number> }
type SalesData = {
@@ -238,73 +239,16 @@ export default {
this.loading = true
try {
this.updateTime()
const now = new Date()
const { startDate, endDate, days } = this.calcDateRange()
const { startDate, endDate } = this.calcDateRange()
// 1) KPI:复用 realtime_kpis 的口径GMV/订单/转化率),把窗口替换成“周期范围 vs 上一周期”
const periodStart = new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate())
const periodEnd = new Date(endDate.getFullYear(), endDate.getMonth(), endDate.getDate() + 1) // 包含 endDate 当天
const prevStart = new Date(periodStart.getTime() - days * 24 * 60 * 60 * 1000)
const prevEnd = new Date(periodStart.getTime())
// 1) KPI
this.salesData = await fetchSalesKpis(this.selectedPeriod)
const pKpi = new UTSJSONObject()
pKpi.set('p_start', periodStart.toISOString())
pKpi.set('p_end', periodEnd.toISOString())
pKpi.set('p_compare_start', prevStart.toISOString())
pKpi.set('p_compare_end', prevEnd.toISOString())
pKpi.set('p_merchant_id', null)
const kpiRes: any = await supa.rpc('rpc_analytics_realtime_kpis', pKpi)
const row = Array.isArray(kpiRes.data) && kpiRes.data.length > 0 ? kpiRes.data[0] : (kpiRes.data || {})
const safe = (v: any): number => {
const n = Number(v)
return isFinite(n) ? n : 0
}
const gmv = safe(row.gmv)
const orders = safe(row.orders)
const avgOrder = orders > 0 ? gmv / orders : 0
this.salesData = {
gmv: Math.round(gmv),
gmv_growth: safe(row.gmv_growth),
orders: Math.round(orders),
order_growth: safe(row.order_growth),
conversion_rate: safe(row.conversion_rate),
conversion_growth: safe(row.conversion_growth),
avg_order_amount: avgOrder,
avg_order_growth: safe(row.gmv_growth) // 兜底:暂无独立口径,先跟随 GMV 增长
}
// 2) 趋势(复用 trend_data
const pTrend = new UTSJSONObject()
pTrend.set('p_start_date', startDate.toISOString().slice(0, 10))
pTrend.set('p_end_date', endDate.toISOString().slice(0, 10))
pTrend.set('p_merchant_id', null)
const trendRes: any = await supa.rpc('rpc_analytics_trend_data', pTrend)
const tRows: Array<any> = Array.isArray(trendRes.data) ? (trendRes.data as Array<any>) : []
const x: Array<string> = []
const gmvArr: Array<number> = []
const orderArr: Array<number> = []
for (let i = 0; i < tRows.length; i++) {
const d = `${tRows[i].date}`
x.push(d.slice(5))
gmvArr.push(Number(tRows[i].gmv) || 0)
orderArr.push(Number(tRows[i].orders) || 0)
}
this.trend = { x, gmv: gmvArr, orders: orderArr }
// 2) 趋势
this.trend = await fetchSalesTrend(this.selectedPeriod)
// 3) TOP 商品/商家
const pTopP = new UTSJSONObject()
pTopP.set('p_start_date', startDate.toISOString().slice(0, 10))
pTopP.set('p_end_date', endDate.toISOString().slice(0, 10))
pTopP.set('p_limit', 50)
pTopP.set('p_merchant_id', null)
const topPRes: any = await supa.rpc('rpc_analytics_top_products', pTopP)
console.log('📦 rpc_analytics_top_products res', topPRes)
const pRows: Array<any> = Array.isArray(topPRes.data) ? (topPRes.data as Array<any>) : []
const pList: Array<ProductRank> = []
for (let i = 0; i < pRows.length; i++) {
pList.push({ id: `${pRows[i].id}`, rank: i + 1, name: `${pRows[i].name}`, sales: Number(pRows[i].sales) || 0 })
}
const pList = await fetchSalesTopProducts(this.selectedPeriod, 50)
// 不足 50 条时补齐虚拟数据(真实数据优先,虚拟数据追加)
if (pList.length < 50) {
@@ -329,22 +273,7 @@ export default {
this.topProducts = pList
const pTopM = new UTSJSONObject()
pTopM.set('p_start_date', startDate.toISOString().slice(0, 10))
pTopM.set('p_end_date', endDate.toISOString().slice(0, 10))
pTopM.set('p_limit', 50)
const topMRes: any = await supa.rpc('rpc_analytics_top_merchants', pTopM)
const mRows: Array<any> = Array.isArray(topMRes.data) ? (topMRes.data as Array<any>) : []
const mList: Array<MerchantRank> = []
for (let i = 0; i < mRows.length; i++) {
mList.push({
id: `${mRows[i].id}`,
rank: i + 1,
name: `${mRows[i].name}`,
sales: Number(mRows[i].sales) || 0,
growth: Number(mRows[i].growth) || 0
})
}
const mList: Array<MerchantRank> = await fetchSalesTopMerchants(this.selectedPeriod, 50)
// 不足 50 条时补齐虚拟数据(真实数据优先,虚拟数据追加)
if (mList.length < 50) {
@@ -372,7 +301,7 @@ export default {
// 4) 地域分布:由 AnalyticsRegionMap 组件自动处理
} catch (e) {
console.error('❌ loadSalesData failed', e)
uni.showToast({ title: '数据加载失败', icon: 'none', duration: 2000 })
uni.showToast({ title: mapAnalyticsError(e, { fallbackMessage: '数据加载失败' }), icon: 'none', duration: 2000 })
} finally {
this.loading = false
this.updateTime()

View File

@@ -139,10 +139,11 @@
</template>
<script lang="uts">
import supa from '@/components/supadb/aksupainstance.uts'
import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uvue'
import AnalyticsTopBar from '@/components/analytics/AnalyticsTopBar.uvue'
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
import { fetchUserKpis, fetchUserGrowthTrend, fetchUserActivity, fetchUserRetention, fetchNewVsOldComparison, fetchConversionFunnel } from '@/services/analytics/userAnalysisService.uts'
import { mapAnalyticsError } from '@/services/analytics/errorMapper.uts'
type UserData = {
total_users: number

View File

@@ -175,7 +175,15 @@ onMounted(() => {
if (IS_TEST_MODE) return
const sessionInfo = supa.getSession()
if (sessionInfo != null && sessionInfo.user != null) {
const pages = getCurrentPages() as any[]
const currentPage = pages.length > 0 ? pages[pages.length - 1] : null
const opts = currentPage?.options as any
const redirect = opts?.redirect as string | null
if (redirect != null && redirect.length > 0) {
uni.redirectTo({ url: decodeURIComponent(redirect) })
} else {
uni.switchTab({ url: '/pages/mall/consumer/index' })
}
}
} catch (e) {
console.error('检查登录状态失败:', e)
@@ -302,7 +310,15 @@ const handleLogin = async () => {
uni.showToast({ title: '登录成功', icon: 'success' })
if (!IS_TEST_MODE) {
setTimeout(() => {
const pages = getCurrentPages() as any[]
const currentPage = pages.length > 0 ? pages[pages.length - 1] : null
const opts = currentPage?.options as any
const redirect = opts?.redirect as string | null
if (redirect != null && redirect.length > 0) {
uni.redirectTo({ url: decodeURIComponent(redirect) })
} else {
uni.switchTab({ url: '/pages/mall/consumer/index' })
}
}, 500)
}
} catch (err) {