Files
medical-mall/pages/mall/analytics/sales-report.uvue

869 lines
20 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">
<!-- 固定顶部导航栏 -->
<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"
/>
<!-- KPI 指标卡片 -->
<view class="kpi-grid">
<view class="kpi-card">
<text class="kpi-label">GMV成交总额</text>
<text class="kpi-value">¥{{ formatMoney(salesData.gmv) }}</text>
<text class="kpi-meta">较上期:{{ formatPct(salesData.gmv_growth) }}</text>
</view>
<view class="kpi-card">
<text class="kpi-label">订单量</text>
<text class="kpi-value">{{ formatInt(salesData.orders) }}</text>
<text class="kpi-meta">较上期:{{ formatPct(salesData.order_growth) }}</text>
</view>
<view class="kpi-card">
<text class="kpi-label">转化率</text>
<text class="kpi-value">{{ formatPct(salesData.conversion_rate) }}</text>
<text class="kpi-meta">较上期:{{ formatPct(salesData.conversion_growth) }}</text>
</view>
<view class="kpi-card">
<text class="kpi-label">客单价</text>
<text class="kpi-value">¥{{ formatMoney(salesData.avg_order_amount) }}</text>
<text class="kpi-meta">较上期:{{ formatPct(salesData.avg_order_growth) }}</text>
</view>
</view>
<!-- 销售趋势图表 -->
<view class="card card-full">
<view class="card-head">
<text class="card-title">销售趋势分析</text>
<text class="card-desc">{{ selectedPeriodText }} · 柱GMV · 线:订单数</text>
</view>
<view v-if="loading || !trend.x || trend.x.length === 0" class="chart-loading">
<text>{{ loading ? '加载中...' : '暂无数据' }}</text>
</view>
<AnalyticsComboChart
v-else
:xLabels="trend.x"
:gmv="trend.gmv"
:orders="trend.orders"
:height="320"
/>
</view>
<!-- 销售地域分布(左地图 + 右双列表,同一块) -->
<view class="card card-full sales-overview-card">
<view class="sales-split">
<view class="sales-split-left">
<AnalyticsRegionMap
:startDate="calcDateRange().startDate"
:endDate="calcDateRange().endDate"
:topMerchants="topMerchants"
:loading="loading"
/>
</view>
<view class="sales-split-right">
<view class="sales-split-list">
<view class="list-head">
<text class="list-title">商品销售排行 TOP 10</text>
<text class="list-desc">按销量排序</text>
</view>
<view v-if="loading || topProducts.length === 0" class="chart-loading chart-loading-compact">
<text>{{ loading ? '加载中...' : '暂无数据' }}</text>
</view>
<view v-else class="rank-scroll">
<view class="rank-list">
<view v-for="p in topProducts" :key="p.id" class="rank-item">
<text class="rank-no">{{ p.rank }}</text>
<text class="rank-name">{{ p.name }}</text>
<text class="rank-val">{{ p.sales }} 件</text>
</view>
</view>
</view>
</view>
<view class="sales-split-list">
<view class="list-head">
<text class="list-title">商家销售排行 TOP 10</text>
<text class="list-desc">按 GMV 排序</text>
</view>
<view v-if="loading || topMerchants.length === 0" class="chart-loading chart-loading-compact">
<text>{{ loading ? '加载中...' : '暂无数据' }}</text>
</view>
<view v-else class="rank-scroll">
<view class="rank-list">
<view v-for="m in topMerchants" :key="m.id" class="rank-item">
<text class="rank-no">{{ m.rank }}</text>
<text class="rank-name">{{ m.name }}</text>
<view class="rank-right">
<text class="rank-val">¥{{ formatMoney(m.sales) }}</text>
<text class="chip" :class="m.growth >= 0 ? 'pos' : 'neg'">
{{ m.growth >= 0 ? '+' : '' }}{{ m.growth }}%
</text>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
<!-- 留白 -->
<view style="height: 24px;"></view>
</view>
</view>
</view>
</view>
</template>
<script setup lang="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 AnalyticsDateRangePicker from '@/components/analytics/AnalyticsDateRangePicker.uvue'
import { computed, reactive, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { fetchSalesKpis, fetchSalesTrend, fetchSalesTopProducts, fetchSalesTopMerchants } from '@/services/analytics/salesReportService.uts'
import { mapAnalyticsError } from '@/services/analytics/errorMapper.uts'
import type { TimePeriod } from '@/types/analytics/common.uts'
import type { SalesTrendData, SalesData, ProductRank, MerchantRank } from '@/types/analytics/sales.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/sales-report')
const loading = ref(false)
const timePeriods = ref<Array<TimePeriod>>([
{ value: '7d', label: '7天' },
{ value: '30d', label: '30天' },
{ value: '90d', label: '90天' },
{ value: '1y', label: '1年' }
])
const salesData = reactive<SalesData>({
gmv: 0,
gmv_growth: 0,
orders: 0,
order_growth: 0,
conversion_rate: 0,
conversion_growth: 0,
avg_order_amount: 0,
avg_order_growth: 0
})
const trend = reactive<SalesTrendData>({ x: [], gmv: [], orders: [] })
const topProducts = reactive<Array<ProductRank>>([])
const topMerchants = reactive<Array<MerchantRank>>([])
const selectedPeriodText = computed((): string => {
const p = timePeriods.value.find((t) => t.value === selectedPeriod.value)
return p ? p.label : '7天'
})
onLoad(() => {
updateTime()
loadSalesData()
})
function calcDateRange() {
const now = new Date()
const endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const days = selectedPeriod.value === '7d' ? 7 : selectedPeriod.value === '30d' ? 30 : selectedPeriod.value === '90d' ? 90 : 365
const startDate = new Date(endDate.getTime() - (days - 1) * 24 * 60 * 60 * 1000)
return { startDate, endDate, days }
}
async function loadSalesData() {
loading.value = true
try {
updateTime()
const range = selectedStartDate.value && selectedEndDate.value
? { start: selectedStartDate.value, end: selectedEndDate.value }
: null
// KPI
const kpi = await fetchSalesKpis(selectedPeriod.value, range)
salesData.gmv = kpi.gmv
salesData.gmv_growth = kpi.gmv_growth
salesData.orders = kpi.orders
salesData.order_growth = kpi.order_growth
salesData.conversion_rate = kpi.conversion_rate
salesData.conversion_growth = kpi.conversion_growth
salesData.avg_order_amount = kpi.avg_order_amount
salesData.avg_order_growth = kpi.avg_order_growth
// 趋势
const t = await fetchSalesTrend(selectedPeriod.value, range)
trend.x = t.x
trend.gmv = t.gmv
trend.orders = t.orders
// TOP 商品/商家
const pList = await fetchSalesTopProducts(selectedPeriod.value, 50, range)
for (let i = 0; i < pList.length; i++) {
pList[i].rank = i + 1
}
topProducts.splice(0, topProducts.length, ...pList)
const mList = await fetchSalesTopMerchants(selectedPeriod.value, 50, range)
for (let i = 0; i < mList.length; i++) {
mList[i].rank = i + 1
}
topMerchants.splice(0, topMerchants.length, ...mList)
} catch (e) {
console.error('❌ loadSalesData failed', e)
uni.showToast({ title: mapAnalyticsError(e, { fallbackMessage: '数据加载失败' }), icon: 'none', duration: 2000 })
} finally {
loading.value = false
updateTime()
}
}
function selectPeriod(p: string) {
selectedPeriod.value = p
customRangeEnabled.value = false
selectedStartDate.value = ''
selectedEndDate.value = ''
loadSalesData()
}
function refreshData() {
loadSalesData()
uni.showToast({ title: '已刷新', icon: 'success' })
}
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 formatInt(n: number): string {
const v = isFinite(n) ? Math.round(n) : 0
if (v >= 10000) return (v / 10000).toFixed(1) + '万'
return v.toString()
}
function formatMoney(n: number): string {
const v = isFinite(n) ? n : 0
if (v >= 10000) return (v / 10000).toFixed(1) + '万'
return v.toFixed(0)
}
function formatPct(n: number): string {
const v = isFinite(n) ? n : 0
const sign = v > 0 ? '+' : ''
return `${sign}${v.toFixed(1)}%`
}
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' })
}
function toggleCustomRange() {
customRangeEnabled.value = !customRangeEnabled.value
}
function onDateRangeApply(range: { start: string; end: string }) {
selectedStartDate.value = range.start
selectedEndDate.value = range.end
customRangeEnabled.value = true
loadSalesData()
}
function onDateRangeClear() {
selectedStartDate.value = ''
selectedEndDate.value = ''
customRangeEnabled.value = false
loadSalesData()
}
</script>
<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;
position: relative;
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;
}
/* KPI 网格 */
.kpi-grid {
margin-top: 12px;
display: flex;
flex-direction: row !important;
flex-wrap: wrap;
gap: 12px;
}
.kpi-card {
background: #fff;
border-radius: 14px;
border: 1px solid rgba(0,0,0,0.06);
padding: 14px;
box-sizing: border-box;
flex: 1 1 calc(50% - 6px);
min-width: 260px;
}
.kpi-label {
font-size: 12px;
color: rgba(0,0,0,0.55);
}
.kpi-value {
margin-top: 8px;
font-size: 22px;
font-weight: 800;
color: #111;
}
.kpi-meta {
margin-top: 8px;
font-size: 12px;
color: rgba(0,0,0,0.55);
}
/* 卡片 */
.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;
}
.chart-loading {
width: 100%;
height: 320px;
display: flex;
align-items: center;
justify-content: center;
color: rgba(0,0,0,0.45);
font-size: 14px;
}
.chart-loading-compact {
height: 160px;
flex: 1;
}
.sales-overview-card {
height: 560px;
display: flex;
flex-direction: column;
}
.sales-overview-card .sales-split {
flex: 1;
min-height: 0;
display: flex;
flex-direction: row !important;
gap: 12px;
align-items: stretch;
}
.sales-split-left {
flex: 2;
min-width: 0;
}
.sales-split-right {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
.sales-split-list {
flex: 1;
min-height: 0;
border: 1px solid rgba(0,0,0,0.06);
border-radius: 14px;
padding: 12px;
box-sizing: border-box;
display: flex;
flex-direction: column;
}
.list-head {
display: flex;
flex-direction: row !important;
justify-content: space-between;
align-items: baseline;
margin-bottom: 10px;
flex: 0 0 auto;
}
.list-title {
font-size: 14px;
font-weight: 600;
color: #111;
}
.list-desc {
font-size: 12px;
color: rgba(0,0,0,0.55);
}
@media screen and (max-width: 960px) {
.sales-split {
flex-direction: column !important;
}
}
/* 排行列表 */
.rank-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding-right: 6px;
-webkit-overflow-scrolling: touch;
/* 防止滚动链把滚轮事件传给页面 */
overscroll-behavior: contain;
scroll-behavior: auto;
/* 默认隐藏滚动条Firefox */
scrollbar-width: none;
/* 默认隐藏滚动条WebKit */
-ms-overflow-style: none;
}
.rank-scroll::-webkit-scrollbar {
width: 0;
height: 0;
}
/* 鼠标悬停在方块内时显示滚动条,并允许拖动 */
.sales-split-list:hover .rank-scroll {
scrollbar-width: thin;
scrollbar-color: rgba(0,0,0,0.35) rgba(0,0,0,0.06);
}
.sales-split-list:hover .rank-scroll::-webkit-scrollbar {
width: 8px;
}
.sales-split-list:hover .rank-scroll::-webkit-scrollbar-track {
background: rgba(0,0,0,0.06);
border-radius: 999px;
}
.sales-split-list:hover .rank-scroll::-webkit-scrollbar-thumb {
background: rgba(0,0,0,0.35);
border-radius: 999px;
}
.sales-split-list:hover .rank-scroll::-webkit-scrollbar-thumb:hover {
background: rgba(0,0,0,0.5);
}
.rank-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.rank-item {
display: flex;
flex-direction: row !important;
align-items: center;
gap: 10px;
padding: 10px 0;
border-bottom: 1px solid rgba(0,0,0,0.06);
}
.rank-item:last-child {
border-bottom: none;
}
.rank-no {
width: 28px;
height: 28px;
border-radius: 999px;
background: rgba(0,0,0,0.06);
text-align: center;
line-height: 28px;
font-size: 12px;
flex: 0 0 auto;
}
.rank-name {
flex: 1;
font-size: 13px;
color: #111;
}
.rank-val {
font-size: 13px;
color: rgba(0,0,0,0.65);
}
.rank-right {
display: flex;
flex-direction: row !important;
align-items: center;
gap: 8px;
}
.chip {
padding: 3px 8px;
border-radius: 999px;
font-size: 12px;
}
.chip.pos {
background: rgba(34,197,94,0.12);
color: #16a34a;
}
.chip.neg {
background: rgba(239,68,68,0.12);
color: #dc2626;
}
/* 响应式 */
@media screen and (min-width: 960px) {
.kpi-card {
flex: 1 1 calc(25% - 9px);
min-width: 200px;
}
}
@media screen and (max-width: 960px) {
.title,
.subtitle {
max-width: 200px;
}
.topbar-right .btn-hidden {
display: none !important;
}
.more-btn {
display: flex !important;
}
}
</style>