Files
medical-mall/pages/mall/analytics/market-trends.uvue
2026-01-29 17:30:39 +08:00

732 lines
19 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="page" @click="closeMoreMenu">
<!-- 固定顶部导航栏 -->
<AnalyticsTopBar
:title="'市场趋势'"
:lastUpdateTime="lastUpdateTime"
:sidebarVisible="showSidebarMenu"
@menu-click="handleMenu"
@refresh="refreshData"
@search="handleSearch"
@notification="handleNotification"
@fullscreen="handleFullscreen"
@mobile="handleMobile"
@dropdown="handleDropdown"
@settings="handleSettings"
/>
<view class="page-layout">
<!-- 侧边栏菜单组件 -->
<AnalyticsSidebarMenu
:visible="showSidebarMenu"
:currentPath="currentPath"
@visible-change="handleSidebarUpdate"
/>
<!-- 主内容区域 -->
<view class="main-content">
<view class="container">
<!-- 时间维度筛选 -->
<view class="tabs">
<view
v-for="p in timePeriods"
:key="p.value"
class="tab"
:class="{ active: selectedPeriod === p.value }"
@click="selectPeriod(p.value)"
>
{{ p.label }}
</view>
</view>
<!-- 市场整体趋势 -->
<view class="card card-full">
<view class="card-head">
<text class="card-title">市场整体趋势</text>
<text class="card-desc">{{ selectedPeriodText }} · GMV、订单数、用户数</text>
</view>
<EChartsView class="chart-box" :option="marketTrendOption" />
</view>
<!-- 行业对比分析 -->
<view class="card">
<view class="card-head">
<text class="card-title">行业对比分析</text>
<text class="card-desc">不同行业表现对比</text>
</view>
<EChartsView class="chart-box" :option="industryCompareOption" />
</view>
<!-- 季节性趋势 -->
<view class="card">
<view class="card-head">
<text class="card-title">季节性趋势</text>
<text class="card-desc">按月份统计</text>
</view>
<EChartsView class="chart-box" :option="seasonalTrendOption" />
</view>
<!-- 价格趋势分析 -->
<view class="card card-full">
<view class="card-head">
<text class="card-title">价格趋势分析</text>
<text class="card-desc">平均价格变化趋势</text>
</view>
<EChartsView class="chart-box" :option="priceTrendOption" />
</view>
<!-- 竞争分析 -->
<view class="card">
<view class="card-head">
<text class="card-title">竞争分析</text>
<text class="card-desc">市场份额、增长率对比</text>
</view>
<EChartsView class="chart-box" :option="competitionOption" />
</view>
<!-- 留白 -->
<view style="height: 24px;"></view>
</view>
</view>
</view>
</view>
</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'
type TimePeriod = { value: string; label: string }
export default {
components: {
AnalyticsSidebarMenu,
AnalyticsTopBar,
EChartsView
},
data() {
return {
lastUpdateTime: '',
selectedPeriod: '7d',
showMoreMenu: false,
showSidebarMenu: false,
currentPath: '/pages/mall/analytics/market-trends',
timePeriods: [
{ value: '7d', label: '7天' },
{ value: '30d', label: '30天' },
{ value: '90d', label: '90天' },
{ value: '1y', label: '1年' }
] as Array<TimePeriod>,
marketTrendOption: {} as any,
industryCompareOption: {} as any,
seasonalTrendOption: {} as any,
priceTrendOption: {} as any,
competitionOption: {} as any
}
},
computed: {
selectedPeriodText(): string {
const p = this.timePeriods.find((t) => t.value === this.selectedPeriod)
return p ? p.label : '7天'
}
},
onLoad() {
this.currentPath = '/pages/mall/analytics/market-trends'
this.updateTime()
this.loadMarketData()
},
onShow() {
this.currentPath = '/pages/mall/analytics/market-trends'
},
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()
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.updateTime()
this.buildChartOptions()
} catch (e) {
console.error('loadMarketData failed:', e)
this.updateTime()
this.buildChartOptions()
uni.showToast({ title: '市场趋势数据加载失败', icon: 'none' })
}
},
selectPeriod(p: string) {
this.selectedPeriod = p
this.loadMarketData()
},
refreshData() {
this.loadMarketData()
uni.showToast({ title: '已刷新', icon: 'success' })
},
exportReport() {
uni.showActionSheet({
itemList: ['导出Excel', '导出PDF', '导出图片'],
success: () => uni.showToast({ title: '导出成功', icon: 'success' })
})
},
updateTime() {
const now = new Date()
const hh = now.getHours().toString().padStart(2, '0')
const mm = now.getMinutes().toString().padStart(2, '0')
this.lastUpdateTime = `${hh}:${mm}`
},
buildChartOptions() {
const trendAny = (this as any)._marketTrendRows as any
const industryAny = (this as any)._industryRows as any
const seasonalAny = (this as any)._seasonalRows as any
const priceAny = (this as any)._priceRows as any
const compAny = (this as any)._competitionRows as any
const trendRows = Array.isArray(trendAny) ? trendAny as Array<UTSJSONObject> : []
const industryRows = Array.isArray(industryAny) ? industryAny as Array<UTSJSONObject> : []
const seasonalRows = Array.isArray(seasonalAny) ? seasonalAny as Array<UTSJSONObject> : []
const priceRows = Array.isArray(priceAny) ? priceAny as Array<UTSJSONObject> : []
const compRows = Array.isArray(compAny) ? compAny as Array<UTSJSONObject> : []
// 1) 市场整体趋势GMV / 订单数 / 用户数
const mtDays: string[] = []
const mtGmv: number[] = []
const mtOrders: number[] = []
const mtUsers: number[] = []
for (let i = 0; i < trendRows.length; i++) {
const r = trendRows[i]
const dayStr = r.getString('day') ?? ''
mtDays.push(dayStr.length >= 10 ? dayStr.substring(5, 10) : dayStr)
mtGmv.push(r.getNumber('gmv') ?? 0)
mtOrders.push(r.getNumber('orders') ?? 0)
mtUsers.push(r.getNumber('users') ?? 0)
}
this.marketTrendOption = {
tooltip: { trigger: 'axis' },
legend: {
data: ['GMV', '订单数', '用户数'],
top: 'bottom'
},
grid: { left: 50, right: 60, top: 40, bottom: 60 },
xAxis: { type: 'category', data: mtDays },
yAxis: [
{ type: 'value', name: 'GMV', splitLine: { lineStyle: { color: '#e5e7eb' } } },
{ type: 'value', name: '数量', position: 'right', splitLine: { show: false } }
],
series: [
{
name: 'GMV',
type: 'bar',
data: mtGmv,
barMaxWidth: 26,
itemStyle: { color: '#3b82f6' }
},
{
name: '订单数',
type: 'line',
yAxisIndex: 1,
smooth: true,
data: mtOrders
},
{
name: '用户数',
type: 'line',
yAxisIndex: 1,
smooth: true,
data: mtUsers
}
]
}
// 2) 行业对比:分类 GMV
const catNames: string[] = []
const catSales: number[] = []
for (let i = 0; i < industryRows.length; i++) {
const r = industryRows[i]
catNames.push(r.getString('category_name') ?? '未分类')
catSales.push(r.getNumber('total_sales') ?? 0)
}
this.industryCompareOption = {
tooltip: { trigger: 'axis' },
grid: { left: 80, right: 20, top: 30, bottom: 60 },
xAxis: { type: 'value' },
yAxis: { type: 'category', data: catNames },
series: [
{
name: 'GMV',
type: 'bar',
data: catSales
}
]
}
// 3) 季节性趋势:按月 GMV
const seaMonths: string[] = []
const seaGmv: number[] = []
for (let i = 0; i < seasonalRows.length; i++) {
const r = seasonalRows[i]
seaMonths.push(r.getString('month') ?? '')
seaGmv.push(r.getNumber('total_gmv') ?? 0)
}
this.seasonalTrendOption = {
tooltip: { trigger: 'axis' },
grid: { left: 50, right: 20, top: 30, bottom: 60 },
xAxis: { type: 'category', data: seaMonths },
yAxis: { type: 'value', name: 'GMV' },
series: [
{
name: 'GMV',
type: 'line',
smooth: true,
data: seaGmv
}
]
}
// 4) 价格趋势:按天平均价格
const priceDays: string[] = []
const avgPrices: number[] = []
for (let i = 0; i < priceRows.length; i++) {
const r = priceRows[i]
const d = r.getString('day') ?? ''
priceDays.push(d.length >= 10 ? d.substring(5, 10) : d)
avgPrices.push(r.getNumber('avg_price') ?? 0)
}
this.priceTrendOption = {
tooltip: { trigger: 'axis' },
grid: { left: 50, right: 20, top: 30, bottom: 60 },
xAxis: { type: 'category', data: priceDays },
yAxis: { type: 'value', name: '平均价格' },
series: [
{
name: '平均价格',
type: 'line',
smooth: true,
data: avgPrices
}
]
}
// 5) 竞争分析:商家 GMV 对比
const merchantNames: string[] = []
const merchantGmv: number[] = []
for (let i = 0; i < compRows.length; i++) {
const r = compRows[i]
merchantNames.push(r.getString('merchant_name') ?? '未知商家')
merchantGmv.push(r.getNumber('gmv') ?? 0)
}
this.competitionOption = {
tooltip: { trigger: 'item' },
legend: { top: 'bottom' },
series: [
{
name: '商家GMV',
type: 'pie',
radius: ['35%', '65%'],
center: ['50%', '50%'],
data: merchantNames.map((n, idx) => {
return { name: n, value: merchantGmv[idx] }
})
}
]
}
},
handleMenu() {
this.showSidebarMenu = true
},
handleSidebarUpdate(visible: boolean) {
this.showSidebarMenu = visible
},
toggleMoreMenu() {
this.showMoreMenu = !this.showMoreMenu
},
closeMoreMenu() {
this.showMoreMenu = false
},
handleSearch() {
uni.showToast({ title: '搜索', icon: 'none' })
},
handleNotification() {
uni.showToast({ title: '通知', icon: 'none' })
},
handleFullscreen() {
uni.showToast({ title: '全屏', icon: 'none' })
},
handleMobile() {
uni.showToast({ title: '移动端', icon: 'none' })
},
handleDropdown() {
uni.showToast({ title: '下拉菜单', icon: 'none' })
},
handleSettings() {
uni.showToast({ title: '设置', icon: 'none' })
}
}
}
</script>
<style>
.page {
min-height: 100vh;
background: #f6f7fb;
}
/* 页面布局:宽屏时侧边栏+内容,窄屏时全屏内容 */
.page-layout {
display: flex;
flex-direction: row !important;
min-height: 100vh;
}
.main-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
padding-top: 64px; /* 为固定顶部导航栏留出空间 */
}
.container {
width: 100%;
max-width: 1280px;
margin: 0 auto;
padding: 16px 16px 28px;
box-sizing: border-box;
}
/* 顶部栏 */
.topbar {
display: flex;
flex-direction: row !important;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 14px 16px;
background: #fff;
border-radius: 14px;
border: 1px solid rgba(0,0,0,0.06);
width: 100%;
box-sizing: border-box;
overflow: hidden;
}
.topbar-left {
display: flex;
flex-direction: row !important;
align-items: center;
gap: 12px;
flex: 1;
min-width: 0;
}
.menu-icon {
width: 32px;
height: 32px;
border-radius: 8px;
background: #f3f4f6;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
}
.menu-icon:active {
background: #e5e7eb;
transform: scale(0.95);
}
.menu-icon .icon {
font-size: 18px;
color: #111;
line-height: 1;
}
.title-group {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
min-width: 0;
}
.title {
font-size: 18px;
font-weight: 700;
color: #111;
max-width: 420px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.subtitle {
font-size: 12px;
color: rgba(0,0,0,0.55);
max-width: 420px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.topbar-right {
display: flex;
flex-direction: row !important;
gap: 8px;
align-items: center;
flex-wrap: nowrap;
flex-shrink: 0;
position: relative;
white-space: nowrap;
}
.icon-btn-icon {
width: 32px;
height: 32px;
border-radius: 8px;
background: #f3f4f6;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
flex-shrink: 0;
}
.icon-btn-icon:active {
background: #e5e7eb;
transform: scale(0.95);
}
.icon-btn-icon .icon {
font-size: 16px;
line-height: 1;
}
.more-btn {
display: none;
width: 32px;
height: 32px;
border-radius: 8px;
background: #f3f4f6;
align-items: center;
justify-content: center;
cursor: pointer;
position: relative;
transition: all 0.2s;
flex-shrink: 0;
}
.more-btn.active {
background: #e5e7eb;
}
.more-btn .icon {
font-size: 18px;
line-height: 1;
color: #111;
}
/* 时间维度 tabs */
.tabs {
margin-top: 12px;
display: flex;
flex-direction: row !important;
gap: 8px;
padding: 8px;
background: #fff;
border-radius: 14px;
border: 1px solid rgba(0,0,0,0.06);
overflow-x: auto;
flex-wrap: wrap;
justify-content: center;
}
.tab {
padding: 8px 12px;
border-radius: 999px;
background: #f3f4f6;
color: #111;
font-size: 13px;
white-space: nowrap;
flex-shrink: 0;
}
.tab.active {
background: #111;
color: #fff;
}
/* 卡片 */
.card {
margin-top: 12px;
background: #fff;
border-radius: 16px;
border: 1px solid rgba(0,0,0,0.06);
box-shadow: 0 2px 10px rgba(0,0,0,0.04);
padding: 14px;
box-sizing: border-box;
}
.card-full {
width: 100%;
}
.card-head {
display: flex;
flex-direction: row !important;
align-items: baseline;
justify-content: space-between;
margin-bottom: 16px;
}
.card-title {
font-size: 14px;
font-weight: 600;
color: #111;
}
.card-desc {
font-size: 12px;
color: rgba(0,0,0,0.55);
}
.chart-box {
width: 100%;
height: 360px;
}
/* 响应式:窄屏时全屏显示 */
@media screen and (max-width: 959px) {
.page-layout {
flex-direction: column !important;
}
.main-content {
width: 100%;
}
}
/* 响应式 */
@media screen and (max-width: 960px) {
.title,
.subtitle {
max-width: 200px;
}
.topbar-right .btn-hidden {
display: none !important;
}
.more-btn {
display: flex !important;
}
}
</style>