Files
medical-mall/pages/mall/analytics/market-trends.uvue
2026-02-06 12:06:33 +08:00

699 lines
17 KiB
Plaintext

<template>
<view class="page" @click.self="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 && !customRangeEnabled }"
@click="selectPeriod(p.value)"
>
{{ p.label }}
</view>
<view
class="tab"
:class="{ active: customRangeEnabled }"
@click="toggleCustomRange"
>
鑷畾涔? </view>
</view>
<AnalyticsDateRangePicker
v-if="customRangeEnabled"
:initialStartDate="selectedStartDate"
:initialEndDate="selectedEndDate"
@apply="onDateRangeApply"
@clear="onDateRangeClear"
/>
<!-- 甯傚満鏁翠綋瓒嬪娍 -->
<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>
<!-- 瀛h妭鎬ц秼鍔?-->
<view class="card">
<view class="card-head">
<text class="card-title">瀛h妭鎬ц秼鍔?/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 setup lang="uts">
import { computed, reactive, ref } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uvue'
import AnalyticsTopBar from '@/components/analytics/AnalyticsTopBar.uvue'
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
import AnalyticsDateRangePicker from '@/components/analytics/AnalyticsDateRangePicker.uvue'
import { fetchMarketTrends } from '@/services/analytics/marketTrendsService.uts'
import { mapAnalyticsError } from '@/services/analytics/errorMapper.uts'
import type { TimePeriod } from '@/types/analytics/common.uts'
import type { MarketTrendsResponse } from '@/types/analytics/market.uts'
const lastUpdateTime = ref('')
const selectedPeriod = ref('7d')
const customRangeEnabled = ref(false)
const selectedStartDate = ref('')
const selectedEndDate = ref('')
const showMoreMenu = ref(false)
const showSidebarMenu = ref(false)
const currentPath = ref('/pages/mall/analytics/market-trends')
const timePeriods = ref<Array<TimePeriod>>([
{ value: '7d', label: '7澶? },
{ value: '30d', label: '30澶? },
{ value: '90d', label: '90澶? },
{ value: '1y', label: '1骞? }
])
const marketTrendOption = ref<any>({})
const industryCompareOption = ref<any>({})
const seasonalTrendOption = ref<any>({})
const priceTrendOption = ref<any>({})
const competitionOption = ref<any>({})
const _marketTrendRows = ref<any>(null)
const _industryRows = ref<any>(null)
const _seasonalRows = ref<any>(null)
const _priceRows = ref<any>(null)
const _competitionRows = ref<any>(null)
const selectedPeriodText = computed((): string => {
const p = timePeriods.value.find((t) => t.value === selectedPeriod.value)
return p ? p.label : '7澶?
})
onLoad(() => {
currentPath.value = '/pages/mall/analytics/market-trends'
updateTime()
loadMarketData()
})
onShow(() => {
currentPath.value = '/pages/mall/analytics/market-trends'
})
async function loadMarketData() {
try {
const range = selectedStartDate.value && selectedEndDate.value
? { start: selectedStartDate.value, end: selectedEndDate.value }
: null
const data = (await fetchMarketTrends(selectedPeriod.value, range)) as MarketTrendsResponse
_marketTrendRows.value = data.trendRows
_industryRows.value = data.categoryRows
_seasonalRows.value = data.seasonalRows
_priceRows.value = data.priceRows
_competitionRows.value = data.competitionRows
updateTime()
buildChartOptions()
} catch (e) {
console.error('loadMarketData failed:', e)
updateTime()
buildChartOptions()
uni.showToast({ title: mapAnalyticsError(e, { fallbackMessage: '甯傚満瓒嬪娍鏁版嵁鍔犺浇澶辫触' }), icon: 'none' })
}
}
function selectPeriod(p: string) {
selectedPeriod.value = p
customRangeEnabled.value = false
selectedStartDate.value = ''
selectedEndDate.value = ''
loadMarketData()
}
function refreshData() {
loadMarketData()
uni.showToast({ title: '宸插埛鏂?, icon: 'success' })
}
function toggleCustomRange() {
customRangeEnabled.value = !customRangeEnabled.value
}
function onDateRangeApply(range: { start: string; end: string }) {
selectedStartDate.value = range.start
selectedEndDate.value = range.end
customRangeEnabled.value = true
loadMarketData()
}
function onDateRangeClear() {
selectedStartDate.value = ''
selectedEndDate.value = ''
customRangeEnabled.value = false
loadMarketData()
}
function exportReport() {
uni.showActionSheet({
itemList: ['瀵煎嚭Excel', '瀵煎嚭PDF', '瀵煎嚭鍥剧墖'],
success: () => uni.showToast({ title: '瀵煎嚭鎴愬姛', icon: 'success' })
})
}
function updateTime() {
const now = new Date()
const hh = now.getHours().toString().padStart(2, '0')
const mm = now.getMinutes().toString().padStart(2, '0')
lastUpdateTime.value = `${hh}:${mm}`
}
function buildChartOptions() {
const trendAny = _marketTrendRows.value
const industryAny = _industryRows.value
const seasonalAny = _seasonalRows.value
const priceAny = _priceRows.value
const compAny = _competitionRows.value
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) 甯傚満鏁翠綋瓒嬪娍锛欸MV / 璁㈠崟鏁?/ 鐢ㄦ埛鏁? 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)
}
marketTrendOption.value = {
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)
}
industryCompareOption.value = {
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) 瀛h妭鎬ц秼鍔匡細鎸夋湀 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)
}
seasonalTrendOption.value = {
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)
}
priceTrendOption.value = {
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)
}
competitionOption.value = {
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] }
})
}
]
}
}
function handleMenu() {
showSidebarMenu.value = true
}
function handleSidebarUpdate(visible: boolean) {
showSidebarMenu.value = visible
}
function toggleMoreMenu() {
showMoreMenu.value = !showMoreMenu.value
}
function closeMoreMenu() {
showMoreMenu.value = false
}
function handleSearch() {
uni.showToast({ title: '鎼滅储', icon: 'none' })
}
function handleNotification() {
uni.showToast({ title: '閫氱煡', icon: 'none' })
}
function handleFullscreen() {
uni.showToast({ title: '鍏ㄥ睆', icon: 'none' })
}
function handleMobile() {
uni.showToast({ title: '绉诲姩绔?, icon: 'none' })
}
function handleDropdown() {
uni.showToast({ title: '涓嬫媺鑿滃崟', icon: 'none' })
}
function 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 removed */ 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: 960px) {
.title,
.subtitle {
max-width: 200px;
}
.topbar-right .btn-hidden {
display: none !important;
}
.more-btn {
display: flex !important;
}
}
/* 鍝嶅簲寮忥細绐勫睆鏃跺叏灞忔樉绀?*/
@media screen and (max-width: 959px) {
.page-layout {
flex-direction: column !important;
}
.main-content {
width: 100%;
}
}
</style>