mall数据库文件
This commit is contained in:
@@ -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' })
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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/value),service 只负责拉数据
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user