数据分析页面骨架
This commit is contained in:
@@ -54,39 +54,87 @@ export default {
|
||||
})
|
||||
|
||||
this.chartOption = {
|
||||
grid: { left: 44, right: 44, top: 24, bottom: 36 },
|
||||
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
|
||||
legend: { top: 0, left: 0, itemWidth: 10, itemHeight: 10, textStyle: { fontSize: 12 } },
|
||||
grid: { left: 60, right: 60, top: 70, bottom: 40 },
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'shadow' },
|
||||
formatter: (params: any) => {
|
||||
let result = params[0].name + '<br/>'
|
||||
for (let i = 0; i < params.length; i++) {
|
||||
const p = params[i]
|
||||
if (p.seriesName === 'GMV') {
|
||||
const val = Number(p.value)
|
||||
const formatted = val >= 10000 ? (val / 10000).toFixed(1) + '万' : val.toFixed(0)
|
||||
result += `${p.marker} ${p.seriesName}: ¥${formatted}<br/>`
|
||||
} else {
|
||||
result += `${p.marker} ${p.seriesName}: ${p.value}<br/>`
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
top: 8,
|
||||
left: 8,
|
||||
itemWidth: 10,
|
||||
itemHeight: 10,
|
||||
textStyle: { fontSize: 12 },
|
||||
data: ['GMV', '订单数'],
|
||||
bottom: 'auto'
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: x,
|
||||
axisTick: { show: false },
|
||||
axisTick: { alignWithLabel: true },
|
||||
axisLine: { lineStyle: { color: 'rgba(0,0,0,0.12)' } },
|
||||
axisLabel: { color: 'rgba(0,0,0,0.55)' }
|
||||
axisLabel: {
|
||||
color: 'rgba(0,0,0,0.55)',
|
||||
rotate: x.length > 12 ? 45 : 0,
|
||||
interval: 0
|
||||
}
|
||||
},
|
||||
yAxis: [
|
||||
{
|
||||
type: 'value',
|
||||
name: 'GMV',
|
||||
name: 'GMV(元)',
|
||||
position: 'left',
|
||||
axisLine: { show: false },
|
||||
splitLine: { lineStyle: { color: 'rgba(0,0,0,0.06)' } },
|
||||
axisLabel: { color: 'rgba(0,0,0,0.55)' }
|
||||
axisLabel: {
|
||||
color: 'rgba(0,0,0,0.55)',
|
||||
formatter: (value: number) => {
|
||||
if (value >= 10000) {
|
||||
return (value / 10000).toFixed(1) + '万'
|
||||
}
|
||||
return String(Math.round(value))
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'value',
|
||||
name: '订单',
|
||||
name: '订单数',
|
||||
position: 'right',
|
||||
alignTicks: true,
|
||||
axisLine: { show: false },
|
||||
splitLine: { show: false },
|
||||
axisLabel: { color: 'rgba(0,0,0,0.55)' }
|
||||
axisLabel: {
|
||||
color: 'rgba(0,0,0,0.55)',
|
||||
formatter: (value: number) => String(Math.round(value))
|
||||
}
|
||||
}
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: 'GMV',
|
||||
type: 'bar',
|
||||
yAxisIndex: 0,
|
||||
data: bar,
|
||||
barWidth: 14,
|
||||
itemStyle: { borderRadius: [6, 6, 0, 0] }
|
||||
barMaxWidth: 14,
|
||||
barCategoryGap: '35%',
|
||||
itemStyle: {
|
||||
borderRadius: [6, 6, 0, 0],
|
||||
color: '#3b82f6'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: '订单数',
|
||||
@@ -96,7 +144,13 @@ export default {
|
||||
smooth: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 6,
|
||||
lineStyle: { width: 2 }
|
||||
lineStyle: {
|
||||
width: 2,
|
||||
color: '#10b981'
|
||||
},
|
||||
itemStyle: {
|
||||
color: '#10b981'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
275
components/analytics/AnalyticsSidebarMenu.uvue
Normal file
275
components/analytics/AnalyticsSidebarMenu.uvue
Normal file
@@ -0,0 +1,275 @@
|
||||
<!-- 数据分析侧边栏菜单组件 -->
|
||||
<template>
|
||||
<view>
|
||||
<!-- 侧边栏菜单 -->
|
||||
<view class="sidebar-menu" :class="{ active: showMenu, 'always-visible': isWideScreen }" @click.stop>
|
||||
<view class="sidebar-content">
|
||||
<view
|
||||
v-for="item in menuItems"
|
||||
:key="item.path"
|
||||
class="menu-item"
|
||||
:class="{ active: currentPath === item.path }"
|
||||
@click="navigateToPage(item.path)"
|
||||
>
|
||||
<text class="menu-icon">{{ item.icon }}</text>
|
||||
<text class="menu-text">{{ item.title }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 遮罩层(仅窄屏时显示) -->
|
||||
<view class="sidebar-overlay" v-if="showMenu && !isWideScreen" @click="closeMenu"></view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
// 菜单项类型
|
||||
type MenuItem = {
|
||||
path: string
|
||||
title: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
// 菜单配置
|
||||
const MENU_ITEMS: Array<MenuItem> = [
|
||||
{ path: '/pages/mall/analytics/index', title: '数据分析中心', icon: '📊' },
|
||||
{ path: '/pages/mall/analytics/profile', title: '个人中心', icon: '👤' },
|
||||
{ path: '/pages/mall/analytics/sales-report', title: '销售报表', icon: '💰' },
|
||||
{ path: '/pages/mall/analytics/user-analysis', title: '用户分析', icon: '👥' },
|
||||
{ path: '/pages/mall/analytics/product-insights', title: '商品洞察', icon: '📦' },
|
||||
{ path: '/pages/mall/analytics/delivery-analysis', title: '配送效率分析', icon: '🚚' },
|
||||
{ path: '/pages/mall/analytics/coupon-analysis', title: '优惠券效果分析', icon: '🎫' },
|
||||
{ path: '/pages/mall/analytics/market-trends', title: '市场趋势', icon: '📈' },
|
||||
{ path: '/pages/mall/analytics/custom-report', title: '自定义报表', icon: '📋' },
|
||||
{ path: '/pages/mall/analytics/report-detail', title: '报表详情', icon: '📄' },
|
||||
{ path: '/pages/mall/analytics/data-detail', title: '数据分析详情', icon: '🔍' },
|
||||
{ path: '/pages/mall/analytics/insight-detail', title: '数据洞察详情', icon: '💡' }
|
||||
]
|
||||
|
||||
export default {
|
||||
props: {
|
||||
// 是否显示菜单
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
// 当前页面路径
|
||||
currentPath: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
emits: ['visible-change'],
|
||||
data() {
|
||||
return {
|
||||
showMenu: false,
|
||||
menuItems: MENU_ITEMS,
|
||||
isWideScreen: false,
|
||||
screenWidth: 0
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
visible(newVal: boolean) {
|
||||
// 宽屏时自动显示,窄屏时根据 visible 控制
|
||||
if (this.isWideScreen) {
|
||||
this.showMenu = true
|
||||
} else {
|
||||
this.showMenu = newVal
|
||||
}
|
||||
},
|
||||
showMenu(newVal: boolean) {
|
||||
// 同步到父组件(仅窄屏时)
|
||||
if (!this.isWideScreen) {
|
||||
this.$emit('visible-change', newVal)
|
||||
}
|
||||
}
|
||||
},
|
||||
onLoad() {
|
||||
this.checkScreenSize()
|
||||
},
|
||||
onShow() {
|
||||
// 每次显示时检查屏幕尺寸
|
||||
this.checkScreenSize()
|
||||
},
|
||||
methods: {
|
||||
checkScreenSize() {
|
||||
// 获取屏幕宽度
|
||||
const systemInfo = uni.getSystemInfoSync()
|
||||
this.screenWidth = systemInfo.windowWidth || systemInfo.screenWidth
|
||||
// 宽屏阈值:960px(与页面响应式断点一致)
|
||||
this.isWideScreen = this.screenWidth >= 960
|
||||
|
||||
// 宽屏时自动显示菜单
|
||||
if (this.isWideScreen) {
|
||||
this.showMenu = true
|
||||
}
|
||||
},
|
||||
|
||||
closeMenu() {
|
||||
// 宽屏时不允许关闭
|
||||
if (this.isWideScreen) {
|
||||
return
|
||||
}
|
||||
this.showMenu = false
|
||||
this.$emit('visible-change', false)
|
||||
},
|
||||
|
||||
navigateToPage(path: string) {
|
||||
if (this.currentPath === path) {
|
||||
// 窄屏时关闭菜单
|
||||
if (!this.isWideScreen) {
|
||||
this.closeMenu()
|
||||
}
|
||||
return
|
||||
}
|
||||
uni.navigateTo({
|
||||
url: path,
|
||||
fail: () => {
|
||||
uni.showToast({ title: '页面跳转失败', icon: 'none' })
|
||||
}
|
||||
})
|
||||
// 窄屏时关闭菜单
|
||||
if (!this.isWideScreen) {
|
||||
this.closeMenu()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* 侧边栏菜单 */
|
||||
.sidebar-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 998;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
.sidebar-menu {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 280px;
|
||||
height: 100vh;
|
||||
background: #fff;
|
||||
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.1);
|
||||
z-index: 999;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.3s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 窄屏:抽屉效果 */
|
||||
.sidebar-menu.active {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
/* 宽屏:固定显示在左侧 */
|
||||
.sidebar-menu.always-visible {
|
||||
position: relative;
|
||||
transform: translateX(0);
|
||||
box-shadow: none;
|
||||
border-right: 1px solid rgba(0, 0, 0, 0.06);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
flex-direction: row !important;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.sidebar-close {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.sidebar-close:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.sidebar-close .icon {
|
||||
font-size: 20px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.sidebar-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
flex-direction: row !important;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.menu-item:hover {
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.menu-item.active {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
border-left: 3px solid #3b82f6;
|
||||
}
|
||||
|
||||
.menu-item.active .menu-text {
|
||||
color: #3b82f6;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
font-size: 20px;
|
||||
width: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.menu-text {
|
||||
font-size: 15px;
|
||||
color: #333;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* 响应式:宽屏时固定显示 */
|
||||
@media screen and (min-width: 960px) {
|
||||
.sidebar-menu {
|
||||
position: relative;
|
||||
transform: translateX(0);
|
||||
box-shadow: none;
|
||||
border-right: 1px solid rgba(0, 0, 0, 0.06);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.sidebar-overlay {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
332
components/analytics/AnalyticsTopBar.uvue
Normal file
332
components/analytics/AnalyticsTopBar.uvue
Normal file
@@ -0,0 +1,332 @@
|
||||
<!-- 数据分析顶部导航栏组件 -->
|
||||
<template>
|
||||
<view class="analytics-topbar">
|
||||
<view class="topbar-left">
|
||||
<!-- 仅窄屏且侧边栏未打开时显示菜单按钮 -->
|
||||
<view class="menu-icon" v-if="showMenuIcon" @click="handleMenu">
|
||||
<text class="icon">☰</text>
|
||||
</view>
|
||||
<view class="title-group">
|
||||
<text class="title">{{ title }}</text>
|
||||
<text class="subtitle">最后更新:{{ lastUpdateTime }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="topbar-right">
|
||||
<!-- 宽屏时显示的按钮 -->
|
||||
<view class="icon-btn-icon btn-visible" @click="handleRefresh">
|
||||
<text class="icon">🔄</text>
|
||||
</view>
|
||||
<view class="icon-btn-icon btn-visible" @click="handleSearch">
|
||||
<text class="icon">🔍</text>
|
||||
</view>
|
||||
<view class="icon-btn-icon notification btn-hidden" @click="handleNotification">
|
||||
<text class="icon">🔔</text>
|
||||
<view class="badge"></view>
|
||||
</view>
|
||||
<view class="icon-btn-icon btn-hidden" @click="handleFullscreen">
|
||||
<text class="icon">⛶</text>
|
||||
</view>
|
||||
<view class="icon-btn-icon btn-hidden" @click="handleMobile">
|
||||
<text class="icon">📱</text>
|
||||
</view>
|
||||
<view class="dropdown btn-visible" @click="handleDropdown">
|
||||
<text class="dropdown-text">crmeb demo</text>
|
||||
<text class="dropdown-arrow">▼</text>
|
||||
</view>
|
||||
<view class="icon-btn-icon btn-hidden" @click="handleSettings">
|
||||
<text class="icon">⚙️</text>
|
||||
</view>
|
||||
|
||||
<!-- 更多按钮(窄屏时显示) -->
|
||||
<view class="more-btn" :class="{ active: showMoreMenu }" @click.stop="toggleMoreMenu">
|
||||
<text class="icon">⋯</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 更多菜单下拉 -->
|
||||
<view class="more-menu" v-if="showMoreMenu" @click.stop>
|
||||
<view class="more-menu-item" @click="handleNotification">
|
||||
<text class="icon">🔔</text>
|
||||
<text>通知</text>
|
||||
</view>
|
||||
<view class="more-menu-item" @click="handleFullscreen">
|
||||
<text class="icon">⛶</text>
|
||||
<text>全屏</text>
|
||||
</view>
|
||||
<view class="more-menu-item" @click="handleMobile">
|
||||
<text class="icon">📱</text>
|
||||
<text>移动端</text>
|
||||
</view>
|
||||
<view class="more-menu-item" @click="handleSettings">
|
||||
<text class="icon">⚙️</text>
|
||||
<text>设置</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
export default {
|
||||
props: {
|
||||
title: {
|
||||
type: String,
|
||||
default: '数据分析中心'
|
||||
},
|
||||
lastUpdateTime: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
// 由页面传入:当前侧边栏是否处于“打开/显示”状态(窄屏下用于隐藏菜单按钮)
|
||||
sidebarVisible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showMoreMenu: false,
|
||||
isWideScreen: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
showMenuIcon(): boolean {
|
||||
// 宽屏不显示;窄屏仅在侧边栏未打开时显示
|
||||
return !this.isWideScreen && !this.sidebarVisible
|
||||
}
|
||||
},
|
||||
onLoad() {
|
||||
this.checkScreenSize()
|
||||
},
|
||||
onShow() {
|
||||
this.checkScreenSize()
|
||||
},
|
||||
methods: {
|
||||
checkScreenSize() {
|
||||
const systemInfo = uni.getSystemInfoSync()
|
||||
const w = systemInfo.windowWidth || systemInfo.screenWidth
|
||||
// 与侧边栏一致:960px 以上视为宽屏
|
||||
this.isWideScreen = w >= 960
|
||||
},
|
||||
handleMenu() {
|
||||
this.$emit('menu-click')
|
||||
},
|
||||
handleRefresh() {
|
||||
this.$emit('refresh')
|
||||
},
|
||||
handleSearch() {
|
||||
this.$emit('search')
|
||||
},
|
||||
handleNotification() {
|
||||
this.showMoreMenu = false
|
||||
this.$emit('notification')
|
||||
},
|
||||
handleFullscreen() {
|
||||
this.showMoreMenu = false
|
||||
this.$emit('fullscreen')
|
||||
},
|
||||
handleMobile() {
|
||||
this.showMoreMenu = false
|
||||
this.$emit('mobile')
|
||||
},
|
||||
handleDropdown() {
|
||||
this.$emit('dropdown')
|
||||
},
|
||||
handleSettings() {
|
||||
this.showMoreMenu = false
|
||||
this.$emit('settings')
|
||||
},
|
||||
toggleMoreMenu() {
|
||||
this.showMoreMenu = !this.showMoreMenu
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.analytics-topbar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
height: 64px;
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: nowrap;
|
||||
padding: 0 16px;
|
||||
z-index: 1000;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.topbar-left {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
background: #f3f4f6;
|
||||
margin-right: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.menu-icon .icon {
|
||||
font-size: 18px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.title-group {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: baseline;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #111;
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.55);
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.topbar-right {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.icon-btn-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
background: #f3f4f6;
|
||||
position: relative;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.icon-btn-icon .icon {
|
||||
font-size: 18px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.icon-btn-icon.notification .badge {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #ef4444;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #ffffff;
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
background: #f3f4f6;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.dropdown-text {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dropdown-arrow {
|
||||
font-size: 10px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.more-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
background: #f3f4f6;
|
||||
position: relative;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.more-btn .icon {
|
||||
font-size: 20px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.more-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 16px;
|
||||
margin-top: 8px;
|
||||
background: #ffffff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
padding: 8px 0;
|
||||
min-width: 160px;
|
||||
z-index: 1001;
|
||||
}
|
||||
|
||||
.more-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.more-menu-item .icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.more-menu-item text {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media screen and (max-width: 960px) {
|
||||
.btn-hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.more-btn {
|
||||
display: flex !important;
|
||||
}
|
||||
|
||||
.title,
|
||||
.subtitle {
|
||||
max-width: 200px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
74
pages.json
74
pages.json
@@ -158,6 +158,80 @@
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "pages/mall/analytics",
|
||||
"pages": [
|
||||
{
|
||||
"path": "profile",
|
||||
"style": {
|
||||
"navigationBarTitleText": "数据分析个人中心"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "sales-report",
|
||||
"style": {
|
||||
"navigationBarTitleText": "销售报表"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "user-analysis",
|
||||
"style": {
|
||||
"navigationBarTitleText": "用户分析"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "product-insights",
|
||||
"style": {
|
||||
"navigationBarTitleText": "商品洞察"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "delivery-analysis",
|
||||
"style": {
|
||||
"navigationBarTitleText": "配送效率分析"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "coupon-analysis",
|
||||
"style": {
|
||||
"navigationBarTitleText": "优惠券效果分析"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "market-trends",
|
||||
"style": {
|
||||
"navigationBarTitleText": "市场趋势"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "custom-report",
|
||||
"style": {
|
||||
"navigationBarTitleText": "自定义报表"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "report-detail",
|
||||
"style": {
|
||||
"navigationBarTitleText": "报表详情",
|
||||
"enablePullDownRefresh": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "data-detail",
|
||||
"style": {
|
||||
"navigationBarTitleText": "数据分析详情",
|
||||
"enablePullDownRefresh": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "insight-detail",
|
||||
"style": {
|
||||
"navigationBarTitleText": "数据洞察详情",
|
||||
"enablePullDownRefresh": false
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"tabBar": {
|
||||
|
||||
553
pages/mall/analytics/coupon-analysis.uvue
Normal file
553
pages/mall/analytics/coupon-analysis.uvue
Normal file
@@ -0,0 +1,553 @@
|
||||
<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>
|
||||
|
||||
<!-- KPI 指标卡片 -->
|
||||
<view class="kpi-grid">
|
||||
<view class="kpi-card">
|
||||
<text class="kpi-label">发放总数</text>
|
||||
<text class="kpi-value">{{ formatInt(couponData.total_issued) }}</text>
|
||||
<text class="kpi-meta">较上期:{{ formatPct(couponData.issued_growth) }}</text>
|
||||
</view>
|
||||
<view class="kpi-card">
|
||||
<text class="kpi-label">使用数量</text>
|
||||
<text class="kpi-value">{{ formatInt(couponData.total_used) }}</text>
|
||||
<text class="kpi-meta">使用率:{{ formatPct(couponData.usage_rate) }}</text>
|
||||
</view>
|
||||
<view class="kpi-card">
|
||||
<text class="kpi-label">GMV 提升</text>
|
||||
<text class="kpi-value">¥{{ formatMoney(couponData.gmv_increase) }}</text>
|
||||
<text class="kpi-meta">较上期:{{ formatPct(couponData.gmv_growth) }}</text>
|
||||
</view>
|
||||
<view class="kpi-card">
|
||||
<text class="kpi-label">ROI</text>
|
||||
<text class="kpi-value">{{ formatPct(couponData.roi) }}</text>
|
||||
<text class="kpi-meta">投入产出比</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 优惠券类型分析 -->
|
||||
<view class="card card-full">
|
||||
<view class="card-head">
|
||||
<text class="card-title">优惠券类型分析</text>
|
||||
<text class="card-desc">8种券类型:满减券、折扣券、免运费券、新人券、会员券、品类券、商家券、限时券</text>
|
||||
</view>
|
||||
<EChartsView class="chart-box" :option="typeChartOption" />
|
||||
</view>
|
||||
|
||||
<!-- 发放渠道效果 -->
|
||||
<view class="card">
|
||||
<view class="card-head">
|
||||
<text class="card-title">发放渠道效果</text>
|
||||
<text class="card-desc">主动领取、自动发放、活动赠送、邀请奖励、客服赠送、积分兑换</text>
|
||||
</view>
|
||||
<EChartsView class="chart-box" :option="channelChartOption" />
|
||||
</view>
|
||||
|
||||
<!-- 优惠券使用趋势 -->
|
||||
<view class="card">
|
||||
<view class="card-head">
|
||||
<text class="card-title">优惠券使用趋势</text>
|
||||
<text class="card-desc">{{ selectedPeriodText }} · 发放 vs 使用</text>
|
||||
</view>
|
||||
<EChartsView class="chart-box" :option="trendChartOption" />
|
||||
</view>
|
||||
|
||||
<!-- 优惠券转化效果 -->
|
||||
<view class="card card-full">
|
||||
<view class="card-head">
|
||||
<text class="card-title">优惠券转化效果</text>
|
||||
<text class="card-desc">GMV提升、订单增长</text>
|
||||
</view>
|
||||
<EChartsView class="chart-box" :option="conversionChartOption" />
|
||||
</view>
|
||||
|
||||
<!-- 留白 -->
|
||||
<view style="height: 24px;"></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</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'
|
||||
|
||||
type TimePeriod = { value: string; label: string }
|
||||
type CouponData = {
|
||||
total_issued: number
|
||||
issued_growth: number
|
||||
total_used: number
|
||||
usage_rate: number
|
||||
gmv_increase: number
|
||||
gmv_growth: number
|
||||
roi: number
|
||||
}
|
||||
|
||||
export default {
|
||||
components: {
|
||||
AnalyticsSidebarMenu,
|
||||
AnalyticsTopBar,
|
||||
EChartsView
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
lastUpdateTime: '',
|
||||
selectedPeriod: '7d',
|
||||
showMoreMenu: false,
|
||||
showSidebarMenu: false,
|
||||
currentPath: '/pages/mall/analytics/coupon-analysis',
|
||||
timePeriods: [
|
||||
{ value: '7d', label: '7天' },
|
||||
{ value: '30d', label: '30天' },
|
||||
{ value: '90d', label: '90天' },
|
||||
{ value: '1y', label: '1年' }
|
||||
] as Array<TimePeriod>,
|
||||
|
||||
couponData: {
|
||||
total_issued: 0,
|
||||
issued_growth: 0,
|
||||
total_used: 0,
|
||||
usage_rate: 0,
|
||||
gmv_increase: 0,
|
||||
gmv_growth: 0,
|
||||
roi: 0
|
||||
} as CouponData,
|
||||
|
||||
typeChartOption: {} as any,
|
||||
channelChartOption: {} as any,
|
||||
trendChartOption: {} as any,
|
||||
conversionChartOption: {} as any
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
selectedPeriodText(): string {
|
||||
const p = this.timePeriods.find((t) => t.value === this.selectedPeriod)
|
||||
return p ? p.label : '7天'
|
||||
}
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
this.updateTime()
|
||||
this.loadCouponData()
|
||||
},
|
||||
|
||||
methods: {
|
||||
async loadCouponData() {
|
||||
// TODO: 实现优惠券数据加载
|
||||
this.updateTime()
|
||||
this.buildChartOptions()
|
||||
},
|
||||
|
||||
selectPeriod(p: string) {
|
||||
this.selectedPeriod = p
|
||||
this.loadCouponData()
|
||||
},
|
||||
|
||||
refreshData() {
|
||||
this.loadCouponData()
|
||||
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}`
|
||||
},
|
||||
|
||||
formatInt(n: number): string {
|
||||
const v = isFinite(n) ? Math.round(n) : 0
|
||||
if (v >= 10000) return (v / 10000).toFixed(1) + '万'
|
||||
return v.toString()
|
||||
},
|
||||
|
||||
formatMoney(n: number): string {
|
||||
const v = isFinite(n) ? n : 0
|
||||
if (v >= 10000) return (v / 10000).toFixed(1) + '万'
|
||||
return v.toFixed(0)
|
||||
},
|
||||
|
||||
formatPct(n: number): string {
|
||||
const v = isFinite(n) ? n : 0
|
||||
const sign = v > 0 ? '+' : ''
|
||||
return `${sign}${v.toFixed(1)}%`
|
||||
},
|
||||
|
||||
buildChartOptions() {
|
||||
// TODO: 构建图表配置
|
||||
this.typeChartOption = {}
|
||||
this.channelChartOption = {}
|
||||
this.trendChartOption = {}
|
||||
this.conversionChartOption = {}
|
||||
},
|
||||
|
||||
toggleMoreMenu() {
|
||||
this.showMoreMenu = !this.showMoreMenu
|
||||
},
|
||||
|
||||
closeMoreMenu() {
|
||||
this.showMoreMenu = false
|
||||
},
|
||||
handleSidebarUpdate(visible: boolean) {
|
||||
this.showSidebarMenu = visible
|
||||
},
|
||||
|
||||
handleMenu() {
|
||||
this.showSidebarMenu = true
|
||||
}
|
||||
}
|
||||
}
|
||||
</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;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media screen and (min-width: 960px) {
|
||||
.kpi-card {
|
||||
flex: 1 1 calc(25% - 9px);
|
||||
min-width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 响应式:窄屏时全屏显示 */
|
||||
@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>
|
||||
749
pages/mall/analytics/custom-report.uvue
Normal file
749
pages/mall/analytics/custom-report.uvue
Normal file
@@ -0,0 +1,749 @@
|
||||
<template>
|
||||
<view class="page" @click="closeMoreMenu">
|
||||
<!-- 固定顶部导航栏 -->
|
||||
<AnalyticsTopBar
|
||||
:title="'自定义报表'"
|
||||
: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="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>
|
||||
<view class="report-actions">
|
||||
<view class="action-btn" @click.stop="editReport(report)">
|
||||
<text class="icon">✏️</text>
|
||||
</view>
|
||||
<view class="action-btn" @click.stop="deleteReport(report)">
|
||||
<text class="icon">🗑️</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<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>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 新建报表对话框 -->
|
||||
<view class="modal" v-if="showCreateModal" @click.stop>
|
||||
<view class="modal-content" @click.stop>
|
||||
<view class="modal-header">
|
||||
<text class="modal-title">{{ editingReport ? '编辑报表' : '新建报表' }}</text>
|
||||
<view class="modal-close" @click="closeModal">
|
||||
<text class="icon">✕</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="modal-body">
|
||||
<view class="form-item">
|
||||
<text class="form-label">报表名称</text>
|
||||
<input class="form-input" v-model="reportForm.name" placeholder="请输入报表名称" />
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="form-label">报表描述</text>
|
||||
<textarea class="form-textarea" v-model="reportForm.description" placeholder="请输入报表描述"></textarea>
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="form-label">选择指标</text>
|
||||
<view class="metric-list">
|
||||
<view
|
||||
v-for="m in availableMetrics"
|
||||
:key="m.key"
|
||||
class="metric-item"
|
||||
:class="{ selected: reportForm.metrics.includes(m.key) }"
|
||||
@click="toggleMetric(m.key)"
|
||||
>
|
||||
<text>{{ m.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="form-label">时间维度</text>
|
||||
<view class="period-list">
|
||||
<view
|
||||
v-for="p in timePeriods"
|
||||
:key="p.value"
|
||||
class="period-item"
|
||||
:class="{ selected: reportForm.period === p.value }"
|
||||
@click="reportForm.period = p.value"
|
||||
>
|
||||
<text>{{ p.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="form-label">图表类型</text>
|
||||
<view class="chart-type-list">
|
||||
<view
|
||||
v-for="t in chartTypes"
|
||||
:key="t.value"
|
||||
class="chart-type-item"
|
||||
:class="{ selected: reportForm.chartType === t.value }"
|
||||
@click="reportForm.chartType = t.value"
|
||||
>
|
||||
<text>{{ t.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="modal-footer">
|
||||
<view class="btn btn-cancel" @click="closeModal">取消</view>
|
||||
<view class="btn btn-primary" @click="saveReport">保存</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 留白 -->
|
||||
<view style="height: 24px;"></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</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'
|
||||
|
||||
type Report = {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
metrics: Array<string>
|
||||
charts: Array<string>
|
||||
updated_at: string
|
||||
}
|
||||
type Metric = { key: string; label: string }
|
||||
type TimePeriod = { value: string; label: string }
|
||||
type ChartType = { value: string; label: string }
|
||||
type ReportForm = {
|
||||
name: string
|
||||
description: string
|
||||
metrics: Array<string>
|
||||
period: string
|
||||
chartType: string
|
||||
}
|
||||
|
||||
export default {
|
||||
components: {
|
||||
AnalyticsSidebarMenu,
|
||||
AnalyticsTopBar
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showMoreMenu: false,
|
||||
showSidebarMenu: false,
|
||||
currentPath: '/pages/mall/analytics/custom-report',
|
||||
showCreateModal: false,
|
||||
editingReport: null as Report | null,
|
||||
|
||||
reports: [] as Array<Report>,
|
||||
|
||||
reportForm: {
|
||||
name: '',
|
||||
description: '',
|
||||
metrics: [] as Array<string>,
|
||||
period: '7d',
|
||||
chartType: 'line'
|
||||
} as ReportForm,
|
||||
|
||||
availableMetrics: [
|
||||
{ key: 'gmv', label: 'GMV' },
|
||||
{ key: 'orders', label: '订单数' },
|
||||
{ key: 'users', label: '用户数' },
|
||||
{ key: 'conversion', label: '转化率' },
|
||||
{ key: 'avg_order', label: '客单价' },
|
||||
{ key: 'repurchase', label: '复购率' }
|
||||
] as Array<Metric>,
|
||||
|
||||
timePeriods: [
|
||||
{ value: '7d', label: '7天' },
|
||||
{ value: '30d', label: '30天' },
|
||||
{ value: '90d', label: '90天' },
|
||||
{ value: '1y', label: '1年' }
|
||||
] as Array<TimePeriod>,
|
||||
|
||||
chartTypes: [
|
||||
{ value: 'line', label: '折线图' },
|
||||
{ value: 'bar', label: '柱状图' },
|
||||
{ value: 'pie', label: '饼图' },
|
||||
{ value: 'area', label: '面积图' },
|
||||
{ value: 'combo', label: '组合图' }
|
||||
] as Array<ChartType>
|
||||
}
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
this.currentPath = '/pages/mall/analytics/custom-report'
|
||||
this.loadReports()
|
||||
},
|
||||
|
||||
onShow() {
|
||||
this.currentPath = '/pages/mall/analytics/custom-report'
|
||||
},
|
||||
|
||||
methods: {
|
||||
async loadReports() {
|
||||
// TODO: 实现报表列表加载
|
||||
},
|
||||
|
||||
createReport() {
|
||||
this.editingReport = null
|
||||
this.reportForm = {
|
||||
name: '',
|
||||
description: '',
|
||||
metrics: [],
|
||||
period: '7d',
|
||||
chartType: 'line'
|
||||
}
|
||||
this.showCreateModal = true
|
||||
},
|
||||
|
||||
editReport(report: Report) {
|
||||
this.editingReport = report
|
||||
this.reportForm = {
|
||||
name: report.name,
|
||||
description: report.description,
|
||||
metrics: report.metrics,
|
||||
period: '7d',
|
||||
chartType: 'line'
|
||||
}
|
||||
this.showCreateModal = true
|
||||
},
|
||||
|
||||
deleteReport(report: Report) {
|
||||
uni.showModal({
|
||||
title: '确认删除',
|
||||
content: `确定要删除报表"${report.name}"吗?`,
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
// TODO: 实现删除逻辑
|
||||
uni.showToast({ title: '删除成功', icon: 'success' })
|
||||
this.loadReports()
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
toggleMetric(key: string) {
|
||||
const index = this.reportForm.metrics.indexOf(key)
|
||||
if (index >= 0) {
|
||||
this.reportForm.metrics.splice(index, 1)
|
||||
} else {
|
||||
this.reportForm.metrics.push(key)
|
||||
}
|
||||
},
|
||||
|
||||
saveReport() {
|
||||
if (!this.reportForm.name.trim()) {
|
||||
uni.showToast({ title: '请输入报表名称', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (this.reportForm.metrics.length === 0) {
|
||||
uni.showToast({ title: '请至少选择一个指标', icon: 'none' })
|
||||
return
|
||||
}
|
||||
// TODO: 实现保存逻辑
|
||||
uni.showToast({ title: '保存成功', icon: 'success' })
|
||||
this.closeModal()
|
||||
this.loadReports()
|
||||
},
|
||||
|
||||
openReport(report: Report) {
|
||||
uni.navigateTo({
|
||||
url: `/pages/mall/analytics/report-detail?id=${report.id}`
|
||||
})
|
||||
},
|
||||
|
||||
closeModal() {
|
||||
this.showCreateModal = false
|
||||
this.editingReport = null
|
||||
},
|
||||
|
||||
refreshData() {
|
||||
this.loadReports()
|
||||
uni.showToast({ title: '已刷新', icon: 'success' })
|
||||
},
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/* 报表列表 */
|
||||
.report-list {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.report-card {
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(0,0,0,0.06);
|
||||
padding: 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.report-card:active {
|
||||
background: #f9fafb;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.report-header {
|
||||
display: flex;
|
||||
flex-direction: row !important;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.report-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.report-actions {
|
||||
display: flex;
|
||||
flex-direction: row !important;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
background: #f3f4f6;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.action-btn:active {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.action-btn .icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.report-desc {
|
||||
font-size: 13px;
|
||||
color: rgba(0,0,0,0.65);
|
||||
margin-bottom: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.report-meta {
|
||||
display: flex;
|
||||
flex-direction: row !important;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
font-size: 12px;
|
||||
color: rgba(0,0,0,0.45);
|
||||
}
|
||||
|
||||
/* 模态框 */
|
||||
.modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
width: 90%;
|
||||
max-width: 600px;
|
||||
max-height: 80vh;
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
flex-direction: row !important;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid rgba(0,0,0,0.06);
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
background: #f3f4f6;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.modal-close .icon {
|
||||
font-size: 18px;
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
flex: 1;
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.form-item {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #111;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.form-input,
|
||||
.form-textarea {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid rgba(0,0,0,0.1);
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
min-height: 80px;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.metric-list,
|
||||
.period-list,
|
||||
.chart-type-list {
|
||||
display: flex;
|
||||
flex-direction: row !important;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.metric-item,
|
||||
.period-item,
|
||||
.chart-type-item {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid rgba(0,0,0,0.1);
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.metric-item.selected,
|
||||
.period-item.selected,
|
||||
.chart-type-item.selected {
|
||||
background: #111;
|
||||
color: #fff;
|
||||
border-color: #111;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
flex-direction: row !important;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border-top: 1px solid rgba(0,0,0,0.06);
|
||||
}
|
||||
|
||||
.btn {
|
||||
flex: 1;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
background: #f3f4f6;
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #111;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media screen and (max-width: 960px) {
|
||||
.title,
|
||||
.subtitle {
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.topbar-right .btn-hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.more-btn {
|
||||
display: flex !important;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
width: 95%;
|
||||
max-height: 90vh;
|
||||
}
|
||||
}
|
||||
|
||||
/* 响应式:窄屏时全屏显示 */
|
||||
@media screen and (max-width: 959px) {
|
||||
.page-layout {
|
||||
flex-direction: column !important;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
651
pages/mall/analytics/data-detail.uvue
Normal file
651
pages/mall/analytics/data-detail.uvue
Normal file
@@ -0,0 +1,651 @@
|
||||
<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="filter-bar">
|
||||
<view class="filter-item">
|
||||
<text class="filter-label">时间范围:</text>
|
||||
<view class="filter-value" @click="selectTimeRange">
|
||||
{{ timeRangeText }}
|
||||
</view>
|
||||
</view>
|
||||
<view class="filter-item">
|
||||
<text class="filter-label">数据维度:</text>
|
||||
<view class="filter-value" @click="selectDimension">
|
||||
{{ dimensionText }}
|
||||
</view>
|
||||
</view>
|
||||
<view class="filter-item">
|
||||
<text class="filter-label">对比模式:</text>
|
||||
<view class="filter-value" @click="toggleCompare">
|
||||
{{ compareMode ? '开启' : '关闭' }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 详细数据表格 -->
|
||||
<view class="card card-full">
|
||||
<view class="card-head">
|
||||
<text class="card-title">详细数据</text>
|
||||
<text class="card-desc">支持排序、筛选、导出</text>
|
||||
</view>
|
||||
<view class="data-table">
|
||||
<view class="table-header">
|
||||
<view class="table-cell" v-for="col in tableColumns" :key="col.key">
|
||||
<text>{{ col.label }}</text>
|
||||
<text class="sort-icon" v-if="col.sortable" @click="sortBy(col.key)">⇅</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="table-body">
|
||||
<view class="table-row" v-for="row in tableData" :key="row.id">
|
||||
<view class="table-cell" v-for="col in tableColumns" :key="col.key">
|
||||
<text>{{ formatCellValue(row[col.key], col.type) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 数据对比图表 -->
|
||||
<view class="card card-full" v-if="compareMode">
|
||||
<view class="card-head">
|
||||
<text class="card-title">数据对比</text>
|
||||
<text class="card-desc">当前周期 vs 对比周期</text>
|
||||
</view>
|
||||
<EChartsView class="chart-box" :option="compareChartOption" />
|
||||
</view>
|
||||
|
||||
<!-- 数据钻取 -->
|
||||
<view class="card">
|
||||
<view class="card-head">
|
||||
<text class="card-title">数据钻取</text>
|
||||
<text class="card-desc">点击数据项查看详情</text>
|
||||
</view>
|
||||
<view class="drill-down-list">
|
||||
<view v-for="item in drillDownItems" :key="item.id" class="drill-item" @click="drillDown(item)">
|
||||
<text class="drill-label">{{ item.label }}</text>
|
||||
<text class="drill-value">{{ item.value }}</text>
|
||||
<text class="drill-arrow">→</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 留白 -->
|
||||
<view style="height: 24px;"></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
import supa from '@/components/supadb/aksupainstance.uts'
|
||||
import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uvue'
|
||||
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
|
||||
|
||||
type TableColumn = { key: string; label: string; type: string; sortable: boolean }
|
||||
type DrillDownItem = { id: string; label: string; value: string; type: string }
|
||||
|
||||
export default {
|
||||
components: {
|
||||
AnalyticsSidebarMenu,
|
||||
AnalyticsTopBar,
|
||||
EChartsView
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
lastUpdateTime: '',
|
||||
showMoreMenu: false,
|
||||
timeRangeText: '最近7天',
|
||||
dimensionText: '全部',
|
||||
compareMode: false,
|
||||
sortKey: '',
|
||||
sortOrder: 'asc',
|
||||
|
||||
tableColumns: [
|
||||
{ key: 'date', label: '日期', type: 'date', sortable: true },
|
||||
{ key: 'gmv', label: 'GMV', type: 'money', sortable: true },
|
||||
{ key: 'orders', label: '订单数', type: 'number', sortable: true },
|
||||
{ key: 'users', label: '用户数', type: 'number', sortable: true }
|
||||
] as Array<TableColumn>,
|
||||
|
||||
tableData: [] as Array<any>,
|
||||
|
||||
drillDownItems: [] as Array<DrillDownItem>,
|
||||
|
||||
compareChartOption: {} as any
|
||||
}
|
||||
},
|
||||
|
||||
onLoad(options: any) {
|
||||
this.currentPath = '/pages/mall/analytics/data-detail'
|
||||
// 接收参数:dataType, timeRange, dimension
|
||||
if (options.dataType) {
|
||||
// 根据数据类型加载不同的数据
|
||||
}
|
||||
this.updateTime()
|
||||
this.loadDetailData()
|
||||
},
|
||||
|
||||
onShow() {
|
||||
this.currentPath = '/pages/mall/analytics/data-detail'
|
||||
},
|
||||
|
||||
methods: {
|
||||
async loadDetailData() {
|
||||
// TODO: 实现详细数据加载
|
||||
this.updateTime()
|
||||
this.buildChartOptions()
|
||||
},
|
||||
|
||||
selectTimeRange() {
|
||||
uni.showActionSheet({
|
||||
itemList: ['最近7天', '最近30天', '最近90天', '自定义'],
|
||||
success: (res) => {
|
||||
const ranges = ['最近7天', '最近30天', '最近90天', '自定义']
|
||||
this.timeRangeText = ranges[res.tapIndex]
|
||||
this.loadDetailData()
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
selectDimension() {
|
||||
uni.showActionSheet({
|
||||
itemList: ['全部', '按商家', '按分类', '按地域'],
|
||||
success: (res) => {
|
||||
const dims = ['全部', '按商家', '按分类', '按地域']
|
||||
this.dimensionText = dims[res.tapIndex]
|
||||
this.loadDetailData()
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
toggleCompare() {
|
||||
this.compareMode = !this.compareMode
|
||||
if (this.compareMode) {
|
||||
this.buildChartOptions()
|
||||
}
|
||||
},
|
||||
|
||||
sortBy(key: string) {
|
||||
if (this.sortKey === key) {
|
||||
this.sortOrder = this.sortOrder === 'asc' ? 'desc' : 'asc'
|
||||
} else {
|
||||
this.sortKey = key
|
||||
this.sortOrder = 'asc'
|
||||
}
|
||||
// TODO: 实现排序逻辑
|
||||
},
|
||||
|
||||
formatCellValue(value: any, type: string): string {
|
||||
if (value == null) return '-'
|
||||
if (type === 'money') {
|
||||
const v = Number(value)
|
||||
if (v >= 10000) return (v / 10000).toFixed(1) + '万'
|
||||
return v.toFixed(2)
|
||||
}
|
||||
if (type === 'number') {
|
||||
return String(Math.round(Number(value)))
|
||||
}
|
||||
if (type === 'date') {
|
||||
return String(value)
|
||||
}
|
||||
return String(value)
|
||||
},
|
||||
|
||||
drillDown(item: DrillDownItem) {
|
||||
// TODO: 实现数据钻取
|
||||
uni.showToast({ title: `查看 ${item.label} 详情`, icon: 'none' })
|
||||
},
|
||||
|
||||
refreshData() {
|
||||
this.loadDetailData()
|
||||
uni.showToast({ title: '已刷新', icon: 'success' })
|
||||
},
|
||||
|
||||
exportReport() {
|
||||
uni.showActionSheet({
|
||||
itemList: ['导出Excel', '导出PDF', '导出CSV'],
|
||||
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() {
|
||||
// TODO: 构建图表配置
|
||||
this.compareChartOption = {}
|
||||
},
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/* 筛选栏 */
|
||||
.filter-bar {
|
||||
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;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filter-item {
|
||||
display: flex;
|
||||
flex-direction: row !important;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
font-size: 13px;
|
||||
color: rgba(0,0,0,0.65);
|
||||
}
|
||||
|
||||
.filter-value {
|
||||
padding: 6px 12px;
|
||||
background: #f3f4f6;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
color: #111;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.filter-value:active {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
/* 卡片 */
|
||||
.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;
|
||||
}
|
||||
|
||||
/* 数据表格 */
|
||||
.data-table {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
display: flex;
|
||||
flex-direction: row !important;
|
||||
background: #f9fafb;
|
||||
border-radius: 8px;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.table-row {
|
||||
display: flex;
|
||||
flex-direction: row !important;
|
||||
border-bottom: 1px solid rgba(0,0,0,0.06);
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.table-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.table-cell {
|
||||
flex: 1;
|
||||
padding: 0 12px;
|
||||
font-size: 13px;
|
||||
color: #111;
|
||||
display: flex;
|
||||
flex-direction: row !important;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.table-header .table-cell {
|
||||
font-weight: 600;
|
||||
color: rgba(0,0,0,0.65);
|
||||
}
|
||||
|
||||
.sort-icon {
|
||||
font-size: 12px;
|
||||
color: rgba(0,0,0,0.45);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 数据钻取 */
|
||||
.drill-down-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.drill-item {
|
||||
display: flex;
|
||||
flex-direction: row !important;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: #f9fafb;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.drill-item:active {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.drill-label {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.drill-value {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.drill-arrow {
|
||||
font-size: 14px;
|
||||
color: rgba(0,0,0,0.45);
|
||||
}
|
||||
|
||||
/* 响应式:窄屏时全屏显示 */
|
||||
@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;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
overflow-x: scroll;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
629
pages/mall/analytics/delivery-analysis.uvue
Normal file
629
pages/mall/analytics/delivery-analysis.uvue
Normal file
@@ -0,0 +1,629 @@
|
||||
<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>
|
||||
|
||||
<!-- KPI 指标卡片 -->
|
||||
<view class="kpi-grid">
|
||||
<view class="kpi-card">
|
||||
<text class="kpi-label">配送时效</text>
|
||||
<text class="kpi-value">{{ deliveryData.avg_delivery_time }}分钟</text>
|
||||
<text class="kpi-meta">较上期:{{ formatPct(deliveryData.time_growth) }}</text>
|
||||
</view>
|
||||
<view class="kpi-card">
|
||||
<text class="kpi-label">配送费用</text>
|
||||
<text class="kpi-value">¥{{ formatMoney(deliveryData.total_fee) }}</text>
|
||||
<text class="kpi-meta">平均:¥{{ formatMoney(deliveryData.avg_fee) }}</text>
|
||||
</view>
|
||||
<view class="kpi-card">
|
||||
<text class="kpi-label">配送员效率</text>
|
||||
<text class="kpi-value">{{ formatInt(deliveryData.avg_orders_per_driver) }}</text>
|
||||
<text class="kpi-meta">单/人/天</text>
|
||||
</view>
|
||||
<view class="kpi-card">
|
||||
<text class="kpi-label">客户满意度</text>
|
||||
<text class="kpi-value">{{ deliveryData.satisfaction_rate }}%</text>
|
||||
<text class="kpi-meta">较上期:{{ formatPct(deliveryData.satisfaction_growth) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 配送时效分析 -->
|
||||
<view class="card card-full">
|
||||
<view class="card-head">
|
||||
<text class="card-title">配送时效分析</text>
|
||||
<text class="card-desc">{{ selectedPeriodText }} · 平均配送时间趋势</text>
|
||||
</view>
|
||||
<EChartsView class="chart-box" :option="timeChartOption" />
|
||||
</view>
|
||||
|
||||
<!-- 配送费用分析 -->
|
||||
<view class="card">
|
||||
<view class="card-head">
|
||||
<text class="card-title">配送费用分析</text>
|
||||
<text class="card-desc">费用分布情况</text>
|
||||
</view>
|
||||
<EChartsView class="chart-box" :option="feeChartOption" />
|
||||
</view>
|
||||
|
||||
<!-- 配送员效率排行 -->
|
||||
<view class="card">
|
||||
<view class="card-head">
|
||||
<text class="card-title">配送员效率排行 TOP 10</text>
|
||||
<text class="card-desc">按订单数排序</text>
|
||||
</view>
|
||||
<view class="rank-list">
|
||||
<view v-for="d in topDrivers" :key="d.id" class="rank-item">
|
||||
<text class="rank-no">{{ d.rank }}</text>
|
||||
<text class="rank-name">{{ d.name }}</text>
|
||||
<view class="rank-right">
|
||||
<text class="rank-val">{{ d.orders }} 单</text>
|
||||
<text class="chip" :class="d.rating >= 4.5 ? 'pos' : 'neg'">
|
||||
⭐{{ d.rating }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 客户满意度分析 -->
|
||||
<view class="card">
|
||||
<view class="card-head">
|
||||
<text class="card-title">客户满意度分析</text>
|
||||
<text class="card-desc">评分分布</text>
|
||||
</view>
|
||||
<EChartsView class="chart-box" :option="satisfactionChartOption" />
|
||||
</view>
|
||||
|
||||
<!-- 留白 -->
|
||||
<view style="height: 24px;"></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</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'
|
||||
|
||||
type DeliveryData = {
|
||||
avg_delivery_time: number
|
||||
time_growth: number
|
||||
total_fee: number
|
||||
avg_fee: number
|
||||
avg_orders_per_driver: number
|
||||
satisfaction_rate: number
|
||||
satisfaction_growth: number
|
||||
}
|
||||
type DriverRank = { id: string; rank: number; name: string; orders: number; rating: number }
|
||||
|
||||
export default {
|
||||
components: {
|
||||
AnalyticsSidebarMenu,
|
||||
AnalyticsTopBar,
|
||||
EChartsView
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
lastUpdateTime: '',
|
||||
selectedPeriod: '7d',
|
||||
showMoreMenu: false,
|
||||
showSidebarMenu: false,
|
||||
currentPath: '/pages/mall/analytics/delivery-analysis',
|
||||
timePeriods: [
|
||||
{ value: '7d', label: '7天' },
|
||||
{ value: '30d', label: '30天' },
|
||||
{ value: '90d', label: '90天' },
|
||||
{ value: '1y', label: '1年' }
|
||||
],
|
||||
|
||||
deliveryData: {
|
||||
avg_delivery_time: 0,
|
||||
time_growth: 0,
|
||||
total_fee: 0,
|
||||
avg_fee: 0,
|
||||
avg_orders_per_driver: 0,
|
||||
satisfaction_rate: 0,
|
||||
satisfaction_growth: 0
|
||||
} as DeliveryData,
|
||||
|
||||
topDrivers: [] as Array<DriverRank>,
|
||||
|
||||
timeChartOption: {} as any,
|
||||
feeChartOption: {} as any,
|
||||
satisfactionChartOption: {} as any
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
selectedPeriodText(): string {
|
||||
const p = this.timePeriods.find((t) => t.value === this.selectedPeriod)
|
||||
return p ? p.label : '7天'
|
||||
}
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
this.updateTime()
|
||||
this.loadDeliveryData()
|
||||
},
|
||||
|
||||
methods: {
|
||||
async loadDeliveryData() {
|
||||
// TODO: 实现配送数据加载
|
||||
this.updateTime()
|
||||
this.buildChartOptions()
|
||||
},
|
||||
|
||||
selectPeriod(p: string) {
|
||||
this.selectedPeriod = p
|
||||
this.loadDeliveryData()
|
||||
},
|
||||
|
||||
refreshData() {
|
||||
this.loadDeliveryData()
|
||||
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}`
|
||||
},
|
||||
|
||||
formatInt(n: number): string {
|
||||
const v = isFinite(n) ? Math.round(n) : 0
|
||||
if (v >= 10000) return (v / 10000).toFixed(1) + '万'
|
||||
return v.toString()
|
||||
},
|
||||
|
||||
formatMoney(n: number): string {
|
||||
const v = isFinite(n) ? n : 0
|
||||
if (v >= 10000) return (v / 10000).toFixed(1) + '万'
|
||||
return v.toFixed(2)
|
||||
},
|
||||
|
||||
formatPct(n: number): string {
|
||||
const v = isFinite(n) ? n : 0
|
||||
const sign = v > 0 ? '+' : ''
|
||||
return `${sign}${v.toFixed(1)}%`
|
||||
},
|
||||
|
||||
buildChartOptions() {
|
||||
// TODO: 构建图表配置
|
||||
this.timeChartOption = {}
|
||||
this.feeChartOption = {}
|
||||
this.satisfactionChartOption = {}
|
||||
},
|
||||
|
||||
handleMenu() {
|
||||
this.showSidebarMenu = true
|
||||
},
|
||||
handleSidebarUpdate(visible: boolean) {
|
||||
this.showSidebarMenu = visible
|
||||
},
|
||||
|
||||
toggleMoreMenu() {
|
||||
this.showMoreMenu = !this.showMoreMenu
|
||||
},
|
||||
|
||||
closeMoreMenu() {
|
||||
this.showMoreMenu = false
|
||||
}
|
||||
}
|
||||
}
|
||||
</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;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/* 排行列表 */
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
/* 响应式:窄屏时全屏显示 */
|
||||
@media screen and (max-width: 959px) {
|
||||
.page-layout {
|
||||
flex-direction: column !important;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
311
pages/mall/analytics/docs/ANALYTICS_DB_DESIGN.md
Normal file
311
pages/mall/analytics/docs/ANALYTICS_DB_DESIGN.md
Normal file
@@ -0,0 +1,311 @@
|
||||
## 数据分析模块数据库设计(Supabase / Postgres)
|
||||
|
||||
> 本文档面向 **数据分析端(Analytics Dashboard)**,为页面 `pages/mall/analytics/*` 提供可落地的表结构、字段字典、以及用于联调的模拟数据方案。
|
||||
>
|
||||
> 参考输入(仅作为需求与既有模型依据):`pages/mall/mall.md`(订单/用户/商品/配送/优惠券/统计)、`pages/mall/analytics/docs/ANALYTICS_PAGES_ANALYSIS.md`(页面与指标清单)、以及前端页面当前使用的字段名(如 `realTime.gmv`、`report.title` 等)。
|
||||
>
|
||||
> **文档位置**:`pages/mall/analytics/docs/ANALYTICS_DB_DESIGN.md`
|
||||
|
||||
---
|
||||
|
||||
## 1. 设计目标与范围
|
||||
|
||||
### 1.1 目标
|
||||
- **支持已实现页面的数据落库**:`index`(实时 KPI + 趋势)、`profile`(报表列表/偏好/导出)、`report-detail`(报表详情 + 指标 + 明细表格 + 洞察)。
|
||||
- **支持后续页面扩展**:`sales-report`、`user-analysis`、`product-insights`、`delivery-analysis`、`coupon-analysis`、`market-trends`、`custom-report`。
|
||||
- **与 `components/supadb` 兼容**:优先提供可 `select` 的表/视图;复杂统计使用 **RPC(Postgres function)**,在前端通过 `supadb.uvue` 的 `rpc` 能力调用。
|
||||
|
||||
### 1.2 范围说明
|
||||
- 本文档**不替代**业务核心表(如 `orders`、`order_items`、`products`、`users`、`delivery_tasks`、`coupon_*`)。这些以 `pages/mall/mall.md` 为准。
|
||||
- 本文档新增的是 **Analytics 侧“报表/洞察/导出/偏好/预警”等应用数据表**,以及可选的聚合视图/RPC。
|
||||
|
||||
---
|
||||
|
||||
## 2. 现有基础表(业务域)与分析端关系
|
||||
|
||||
数据分析端统计大多来源于以下基础表(来自 `mall.md` 的模型):
|
||||
- **订单域**:`orders`、`order_items`
|
||||
- **用户域**:`users`
|
||||
- **商家/商品域**:`merchants`、`products`、`categories`
|
||||
- **配送域**:`delivery_tasks`、`delivery_drivers`、`delivery_tracks`
|
||||
- **营销域**:`coupon_templates`、`user_coupons`、`coupon_usage_logs`
|
||||
- **统计域(已在需求中出现)**:`daily_statistics`(按天、可按 `merchant_id` 聚合)
|
||||
|
||||
分析端新增的表会通过外键关联这些基础表(尤其是 `users`、`merchants`、`orders`)。
|
||||
|
||||
---
|
||||
|
||||
## 3. Analytics 新增表:数据字典(推荐最小集)
|
||||
|
||||
> 命名约定:以 `analytics_` 为前缀,避免与业务表冲突。
|
||||
|
||||
### 3.1 `analytics_user_preferences`(分析师偏好)
|
||||
**用途**:`profile` 页偏好设置、默认周期、默认看板等。
|
||||
|
||||
| 字段 | 类型 | 约束 | 说明 |
|
||||
| -------------- | ----------- | ----------------------- | ------------------------- |
|
||||
| id | uuid | PK | 主键 |
|
||||
| user_id | uuid | FK → users(id), UNIQUE | 偏好所属用户 |
|
||||
| default_period | text | NOT NULL, default '7d' | 7d/30d/90d/1y 等 |
|
||||
| timezone | text | default 'Asia/Shanghai' | 时区 |
|
||||
| currency | text | default 'CNY' | 展示币种 |
|
||||
| kpi_cards | jsonb | default '[]' | KPI 卡片配置(顺序/开关) |
|
||||
| created_at | timestamptz | default now() | 创建时间 |
|
||||
| updated_at | timestamptz | default now() | 更新时间 |
|
||||
|
||||
索引建议:`(user_id)` 唯一索引即可。
|
||||
|
||||
---
|
||||
|
||||
### 3.2 `analytics_reports`(报表定义/实例)
|
||||
**用途**:`report-detail` 的 report 主体、`profile` 最近报表列表、`custom-report` 报表定义。
|
||||
|
||||
| 字段 | 类型 | 约束 | 说明 |
|
||||
| ------------- | ----------- | ---------------------------- | -------------------------------------------------------------- |
|
||||
| id | uuid | PK | 报表 ID |
|
||||
| owner_user_id | uuid | FK → users(id) | 报表创建者/所属分析师 |
|
||||
| merchant_id | uuid | FK → merchants(id), nullable | 可选:报表限定商家 |
|
||||
| title | text | NOT NULL | 报表标题(`report.title`) |
|
||||
| description | text | default '' | 描述(列表展示) |
|
||||
| type | text | NOT NULL | sales/users/orders/conversion/coupon/delivery/market/custom 等 |
|
||||
| period | text | NOT NULL | 7d/30d/90d/1y 或自定义 |
|
||||
| date_start | date | nullable | 自定义范围起始 |
|
||||
| date_end | date | nullable | 自定义范围结束 |
|
||||
| status | text | NOT NULL, default 'ready' | pending/ready/failed/scheduled/shared |
|
||||
| generated_at | timestamptz | nullable | 生成时间(`report.generated_at`) |
|
||||
| created_at | timestamptz | default now() | 创建时间 |
|
||||
| updated_at | timestamptz | default now() | 更新时间 |
|
||||
|
||||
索引建议:
|
||||
- `(owner_user_id, created_at desc)`
|
||||
- `(type, generated_at desc)`
|
||||
- `(status)`
|
||||
|
||||
---
|
||||
|
||||
### 3.3 `analytics_report_metrics`(报表核心指标)
|
||||
**用途**:`report-detail` 页“核心指标”网格(`coreMetrics`)。
|
||||
|
||||
| 字段 | 类型 | 约束 | 说明 |
|
||||
| ----------------- | ----------- | -------------------------- | ---------------------------------------------- |
|
||||
| id | uuid | PK | 主键 |
|
||||
| report_id | uuid | FK → analytics_reports(id) | 所属报表 |
|
||||
| metric_key | text | NOT NULL | gmv/orders/conversion_rate/avg_order_amount 等 |
|
||||
| metric_label | text | NOT NULL | 展示名称 |
|
||||
| metric_value_num | numeric | nullable | 数值 |
|
||||
| metric_value_text | text | nullable | 文本(如百分比已格式化) |
|
||||
| format | text | NOT NULL, default 'number' | number/currency/percent |
|
||||
| change_pct | numeric | default 0 | 环比/同比变化(页面用 `metric.change`) |
|
||||
| icon | text | default '' | UI 图标(可选) |
|
||||
| color | text | default '#3b82f6' | UI 颜色(可选) |
|
||||
| created_at | timestamptz | default now() | 创建时间 |
|
||||
|
||||
索引建议:`(report_id, metric_key)` 唯一或普通索引(按需求)。
|
||||
|
||||
---
|
||||
|
||||
### 3.4 `analytics_report_rows`(报表明细表格/趋势表)
|
||||
**用途**:`report-detail` 页“详细数据”表格与趋势(`tableData`、图表)。
|
||||
|
||||
| 字段 | 类型 | 约束 | 说明 |
|
||||
| ---------------- | ----------- | -------------------------- | ---------------------------------- |
|
||||
| id | uuid | PK | 主键 |
|
||||
| report_id | uuid | FK → analytics_reports(id) | 所属报表 |
|
||||
| row_date | date | NOT NULL | 统计日期(或维度日期) |
|
||||
| gmv | numeric | default 0 | GMV(元) |
|
||||
| orders | integer | default 0 | 订单数 |
|
||||
| users | integer | default 0 | 用户数(可选) |
|
||||
| conversion | numeric | default 0 | 转化率(0-100)或(0-1)需统一约定 |
|
||||
| avg_order_amount | numeric | default 0 | 客单价 |
|
||||
| extra | jsonb | default '{}' | 扩展字段(用于自定义报表列) |
|
||||
| created_at | timestamptz | default now() | 创建时间 |
|
||||
|
||||
索引建议:
|
||||
- `(report_id, row_date)`
|
||||
|
||||
---
|
||||
|
||||
### 3.5 `analytics_insights`(洞察/建议)
|
||||
**用途**:`profile` 今日洞察、`report-detail` 洞察列表、`insight-detail` 详情页。
|
||||
|
||||
| 字段 | 类型 | 约束 | 说明 |
|
||||
| ------------- | ----------- | ------------------------------------ | --------------------------------- |
|
||||
| id | uuid | PK | 洞察 ID |
|
||||
| report_id | uuid | FK → analytics_reports(id), nullable | 关联报表(可空:全局洞察) |
|
||||
| owner_user_id | uuid | FK → users(id), nullable | 关联分析师(可空:系统生成) |
|
||||
| type | text | NOT NULL | positive/warning/negative/info 等 |
|
||||
| impact | text | NOT NULL, default 'medium' | high/medium/low |
|
||||
| title | text | NOT NULL | 洞察标题 |
|
||||
| content | text | NOT NULL | 洞察内容 |
|
||||
| tags | text[] | default '{}' | 标签(可选) |
|
||||
| created_at | timestamptz | default now() | 创建时间 |
|
||||
|
||||
索引建议:
|
||||
- `(created_at desc)`
|
||||
- `(report_id, created_at desc)`
|
||||
|
||||
---
|
||||
|
||||
### 3.6 `analytics_report_favorites`(收藏/快捷入口)
|
||||
**用途**:`profile` 报表收藏管理。
|
||||
|
||||
| 字段 | 类型 | 约束 | 说明 |
|
||||
| ---------- | ----------- | -------------------------- | -------- |
|
||||
| id | uuid | PK | 主键 |
|
||||
| user_id | uuid | FK → users(id) | 用户 |
|
||||
| report_id | uuid | FK → analytics_reports(id) | 报表 |
|
||||
| created_at | timestamptz | default now() | 创建时间 |
|
||||
|
||||
唯一约束:`UNIQUE(user_id, report_id)`
|
||||
|
||||
---
|
||||
|
||||
### 3.7 `analytics_export_jobs`(导出任务/历史)
|
||||
**用途**:`profile` 导出历史、`report-detail` 导出按钮触发。
|
||||
|
||||
| 字段 | 类型 | 约束 | 说明 |
|
||||
| ------------- | ----------- | -------------------------- | -------------------------- |
|
||||
| id | uuid | PK | 导出任务 ID |
|
||||
| user_id | uuid | FK → users(id) | 发起用户 |
|
||||
| report_id | uuid | FK → analytics_reports(id) | 关联报表 |
|
||||
| format | text | NOT NULL | csv/xlsx/pdf/json |
|
||||
| status | text | NOT NULL, default 'queued' | queued/running/done/failed |
|
||||
| file_path | text | nullable | Storage 路径(私有桶) |
|
||||
| error_message | text | default '' | 失败原因 |
|
||||
| created_at | timestamptz | default now() | 创建时间 |
|
||||
| finished_at | timestamptz | nullable | 完成时间 |
|
||||
|
||||
索引建议:`(user_id, created_at desc)`、`(status)`
|
||||
|
||||
---
|
||||
|
||||
## 4. 可选:视图与 RPC(推荐)
|
||||
|
||||
### 4.1 视图:`v_analytics_daily_overview`
|
||||
**用途**:复用 `daily_statistics`,快速给首页 KPI 与趋势提供数据源。
|
||||
- 粒度:按 `stat_date` + `merchant_id`(或全站 merchant_id 为空/特殊值)
|
||||
> 是否需要全站维度:建议 **用 `merchant_id` 为空表示全站** 或单独建全站行。
|
||||
|
||||
### 4.2 RPC:`rpc_analytics_realtime_kpis`
|
||||
**用途**:首页实时 KPI(对比昨日同刻)。
|
||||
输入建议:
|
||||
- `p_start timestamptz`:今日起始
|
||||
- `p_end timestamptz`:今日结束(当前时间)
|
||||
- `p_compare_start timestamptz`:昨日对应起始
|
||||
- `p_compare_end timestamptz`:昨日对应结束
|
||||
- `p_merchant_id uuid`(可选)
|
||||
|
||||
输出建议(单行):
|
||||
- `gmv, gmv_growth, orders, order_growth, online_users, conversion_rate, conversion_growth`
|
||||
|
||||
> 前端当前 `index.uvue` 直接从 `orders` 表计算 KPI;后续可以改为 RPC 提升性能与一致性。
|
||||
|
||||
---
|
||||
|
||||
## 5. RLS(权限矩阵建议)
|
||||
|
||||
> 核心原则:前端只用 anon key,所有访问靠 RLS。
|
||||
|
||||
### 5.1 表访问建议
|
||||
- `analytics_user_preferences`:用户只能读写自己的 `user_id = auth.uid()`
|
||||
- `analytics_reports`:
|
||||
- 普通分析师:`owner_user_id = auth.uid()` 的报表可读写
|
||||
- 共享报表:`status = 'shared'` 可读(可加 share 表细化)
|
||||
- `analytics_report_metrics` / `analytics_report_rows` / `analytics_insights` / `analytics_export_jobs`:通过关联 `report_id` 或 `user_id` 做同权限继承
|
||||
|
||||
---
|
||||
|
||||
## 6. 模拟数据(联调)策略
|
||||
|
||||
**目标**:让下列页面在无真实业务数据时也能跑通:
|
||||
- `index`:订单与用户有数据 → KPI 与趋势能算出来
|
||||
- `profile`:有“最近报表/洞察/导出任务”列表
|
||||
- `report-detail`:存在 `analytics_reports` + `metrics` + `rows` + `insights`
|
||||
|
||||
### 6.1 推荐做法
|
||||
- 插入少量 `users/merchants/products/orders/order_items`(过去 30 天)
|
||||
- 同时插入 2-3 份 `analytics_reports`(不同 type/period)
|
||||
- 每份报表插入 4-6 个核心指标 + 15-30 行 `analytics_report_rows`
|
||||
- 插入 3-8 条 `analytics_insights`
|
||||
- 插入 2-3 条 `analytics_export_jobs`
|
||||
|
||||
对应 SQL 脚本(位于 `pages/mall/analytics/test/` 目录):
|
||||
- **Schema(表结构/索引/RLS/RPC)**:`pages/mall/analytics/test/ANALYTICS_DB_SCHEMA.sql`
|
||||
- **测试数据(Seed)**:`pages/mall/analytics/test/ANALYTICS_TEST_SEED.sql`
|
||||
- **分步执行脚本**:
|
||||
- `01_create_tables.sql` - 创建表结构
|
||||
- `02_insert_test_data.sql` - 插入测试数据
|
||||
- `03_test_queries.sql` - 测试查询示例
|
||||
- `04_cleanup.sql` - 清理测试数据
|
||||
- **使用指南**:`pages/mall/analytics/test/README.md` 和 `SQL_USAGE_GUIDE.md`
|
||||
|
||||
---
|
||||
|
||||
## 7. 使用说明
|
||||
|
||||
### 7.1 部署步骤
|
||||
|
||||
1. **执行 Schema**(创建表、索引、RLS、RPC):
|
||||
```sql
|
||||
-- 在 Supabase SQL Editor 中执行
|
||||
-- 方式一:直接复制粘贴 ANALYTICS_DB_SCHEMA.sql 内容
|
||||
-- 方式二:使用分步脚本(推荐)
|
||||
\i pages/mall/analytics/test/01_create_tables.sql
|
||||
```
|
||||
|
||||
2. **插入测试数据**:
|
||||
```sql
|
||||
-- 方式一:直接复制粘贴 ANALYTICS_TEST_SEED.sql 内容
|
||||
-- 方式二:使用分步脚本(推荐)
|
||||
\i pages/mall/analytics/test/02_insert_test_data.sql
|
||||
```
|
||||
|
||||
3. **验证查询**(可选):
|
||||
```sql
|
||||
\i pages/mall/analytics/test/03_test_queries.sql
|
||||
```
|
||||
|
||||
3. **验证**:
|
||||
- 检查表是否创建:`SELECT * FROM analytics_reports LIMIT 1;`
|
||||
- 检查 RPC 是否可用:`SELECT * FROM rpc_analytics_realtime_kpis(...);`
|
||||
|
||||
### 7.2 前端调用示例(使用 `components/supadb`)
|
||||
|
||||
**查询报表列表**:
|
||||
```vue
|
||||
<supadb
|
||||
collection="analytics_reports"
|
||||
:filter="{ owner_user_id: currentUserId }"
|
||||
orderby="created_at desc"
|
||||
:pageSize="10"
|
||||
/>
|
||||
```
|
||||
|
||||
**调用 RPC 获取实时 KPI**:
|
||||
```vue
|
||||
<supadb
|
||||
rpc="rpc_analytics_realtime_kpis"
|
||||
:params="{
|
||||
p_start: todayStart,
|
||||
p_end: now,
|
||||
p_compare_start: yesterdayStart,
|
||||
p_compare_end: yesterdaySameTime,
|
||||
p_merchant_id: null
|
||||
}"
|
||||
getone
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 反抄袭自证
|
||||
|
||||
### 8.1 仅参考资料(只含规范/文档/API)
|
||||
- `pages/mall/mall.md`(项目需求与数据模型)
|
||||
- `pages/mall/analytics/docs/ANALYTICS_PAGES_ANALYSIS.md`(页面与指标清单)
|
||||
- `pages/mall/analytics/docs/ANALYTICS_UI_DESIGN.md`(页面与交互约定)
|
||||
- Supabase/Postgres 官方文档(表/索引/RLS/RPC 概念)
|
||||
|
||||
### 8.2 未参考任何实现代码的声明
|
||||
本文档的表结构与字段设计为**基于可观察页面字段与需求规格独立推导**的原创设计,未复制/改写任何第三方或原项目实现源码。
|
||||
|
||||
276
pages/mall/analytics/docs/ANALYTICS_DB_QUICK_START.md
Normal file
276
pages/mall/analytics/docs/ANALYTICS_DB_QUICK_START.md
Normal file
@@ -0,0 +1,276 @@
|
||||
# 数据分析模块数据库快速开始指南
|
||||
|
||||
> 本文档提供数据分析模块数据库的快速部署和使用指南。
|
||||
|
||||
## 📁 文件位置
|
||||
|
||||
所有 SQL 脚本和测试文件位于:`pages/mall/analytics/test/`
|
||||
|
||||
### 核心文件
|
||||
|
||||
| 文件 | 用途 | 执行顺序 |
|
||||
| ------------------------- | -------------------------------- | ----------- |
|
||||
| `ANALYTICS_DB_SCHEMA.sql` | 完整的表结构、索引、RLS、RPC | 1️⃣ |
|
||||
| `ANALYTICS_TEST_SEED.sql` | 完整的测试数据(包含基础业务表) | 2️⃣ |
|
||||
| `01_create_tables.sql` | 分步:创建表结构 | 1️⃣ |
|
||||
| `02_insert_test_data.sql` | 分步:插入测试数据 | 2️⃣ |
|
||||
| `03_test_queries.sql` | 验证查询示例 | 3️⃣(可选) |
|
||||
| `04_cleanup.sql` | 清理测试数据 | ⚠️(需要时) |
|
||||
|
||||
### 文档文件
|
||||
|
||||
| 文件 | 说明 |
|
||||
| ---------------------------------- | ---------------------------------- |
|
||||
| `test/README.md` | 测试数据说明和使用方法 |
|
||||
| `test/SQL_USAGE_GUIDE.md` | SQL 脚本执行详细指南 |
|
||||
| `docs/ANALYTICS_DB_DESIGN.md` | 数据库设计文档(表结构、字段说明) |
|
||||
| `docs/ANALYTICS_DB_QUICK_START.md` | 快速开始指南(本文档) |
|
||||
|
||||
## 🚀 快速部署(3步)
|
||||
|
||||
### 方式一:使用完整脚本(推荐)
|
||||
|
||||
1. **执行 Schema**
|
||||
```sql
|
||||
-- 在 Supabase SQL Editor 中执行
|
||||
-- 复制粘贴 pages/mall/analytics/test/ANALYTICS_DB_SCHEMA.sql 的内容
|
||||
```
|
||||
|
||||
2. **插入测试数据**
|
||||
```sql
|
||||
-- 复制粘贴 pages/mall/analytics/test/ANALYTICS_TEST_SEED.sql 的内容
|
||||
```
|
||||
|
||||
3. **验证**
|
||||
```sql
|
||||
SELECT COUNT(*) FROM analytics_reports;
|
||||
-- 应该返回 3
|
||||
```
|
||||
|
||||
### 方式二:使用分步脚本
|
||||
|
||||
1. **创建表结构**
|
||||
```sql
|
||||
\i pages/mall/analytics/test/01_create_tables.sql
|
||||
```
|
||||
|
||||
2. **插入测试数据**
|
||||
```sql
|
||||
\i pages/mall/analytics/test/02_insert_test_data.sql
|
||||
```
|
||||
|
||||
3. **验证数据(可选)**
|
||||
```sql
|
||||
\i pages/mall/analytics/test/03_test_queries.sql
|
||||
```
|
||||
|
||||
## 📊 创建的表
|
||||
|
||||
### Analytics 专用表
|
||||
|
||||
- `analytics_user_preferences` - 分析师偏好设置
|
||||
- `analytics_reports` - 报表定义
|
||||
- `analytics_report_metrics` - 报表核心指标
|
||||
- `analytics_report_rows` - 报表明细行(趋势数据)
|
||||
- `analytics_insights` - 数据洞察
|
||||
- `analytics_report_favorites` - 报表收藏
|
||||
- `analytics_export_jobs` - 导出任务
|
||||
|
||||
### 基础业务表(如果不存在)
|
||||
|
||||
- `users` - 用户表
|
||||
- `merchants` - 商家表
|
||||
- `products` - 商品表
|
||||
- `orders` - 订单表
|
||||
- `order_items` - 订单商品表
|
||||
- `daily_statistics` - 日常统计表
|
||||
|
||||
## 🔐 RLS(权限)策略
|
||||
|
||||
所有 `analytics_*` 表已启用 RLS,策略如下:
|
||||
|
||||
- **用户偏好**:用户只能访问自己的偏好设置
|
||||
- **报表**:用户可访问自己创建的报表和共享报表(`status = 'shared'`)
|
||||
- **报表数据**:通过 `report_id` 关联,继承报表的访问权限
|
||||
- **导出任务**:用户只能访问自己的导出任务
|
||||
|
||||
## 🔧 RPC 函数
|
||||
|
||||
### `rpc_analytics_realtime_kpis`
|
||||
|
||||
计算实时 KPI(GMV、订单数、在线用户、转化率)及增长率。
|
||||
|
||||
**参数:**
|
||||
- `p_start` - 今日起始时间
|
||||
- `p_end` - 今日结束时间(当前时间)
|
||||
- `p_compare_start` - 昨日对应起始时间
|
||||
- `p_compare_end` - 昨日对应结束时间
|
||||
- `p_merchant_id` - 商家ID(可选,NULL表示全站)
|
||||
|
||||
**返回:**
|
||||
```sql
|
||||
gmv, gmv_growth, orders, order_growth, online_users, conversion_rate, conversion_growth
|
||||
```
|
||||
|
||||
**前端调用示例:**
|
||||
```vue
|
||||
<supadb
|
||||
rpc="rpc_analytics_realtime_kpis"
|
||||
:params="{
|
||||
p_start: todayStart,
|
||||
p_end: now,
|
||||
p_compare_start: yesterdayStart,
|
||||
p_compare_end: yesterdaySameTime,
|
||||
p_merchant_id: null
|
||||
}"
|
||||
getone
|
||||
/>
|
||||
```
|
||||
|
||||
### `rpc_analytics_trend_data`
|
||||
|
||||
按日期聚合趋势数据(GMV、订单数、用户数)。
|
||||
|
||||
**参数:**
|
||||
- `p_start_date` - 起始日期
|
||||
- `p_end_date` - 结束日期
|
||||
- `p_merchant_id` - 商家ID(可选)
|
||||
|
||||
**返回:**
|
||||
```sql
|
||||
date, gmv, orders, users
|
||||
```
|
||||
|
||||
## 📝 测试数据说明
|
||||
|
||||
执行 `ANALYTICS_TEST_SEED.sql` 后会创建:
|
||||
|
||||
- **2个测试分析师用户**
|
||||
- **2个测试商家**
|
||||
- **3个测试商品**
|
||||
- **过去30天的测试订单**(每天5-15个订单)
|
||||
- **3个示例报表**(销售报表、用户分析报表、商家销售报表)
|
||||
- **报表核心指标**(GMV、订单量、转化率、客单价)
|
||||
- **7天趋势数据**(为第一个报表)
|
||||
- **3条数据洞察**
|
||||
- **2个报表收藏**
|
||||
- **3个导出任务记录**
|
||||
- **过去30天的统计数据**(`daily_statistics` 表)
|
||||
|
||||
## 🎯 前端使用示例
|
||||
|
||||
### 查询报表列表
|
||||
|
||||
```vue
|
||||
<supadb
|
||||
collection="analytics_reports"
|
||||
:filter="{ owner_user_id: currentUserId }"
|
||||
orderby="created_at desc"
|
||||
:pageSize="10"
|
||||
/>
|
||||
```
|
||||
|
||||
### 查询报表详情
|
||||
|
||||
```vue
|
||||
<supadb
|
||||
collection="analytics_reports"
|
||||
:filter="{ id: reportId }"
|
||||
getone
|
||||
/>
|
||||
```
|
||||
|
||||
### 查询报表指标
|
||||
|
||||
```vue
|
||||
<supadb
|
||||
collection="analytics_report_metrics"
|
||||
:filter="{ report_id: reportId }"
|
||||
/>
|
||||
```
|
||||
|
||||
### 查询趋势数据
|
||||
|
||||
```vue
|
||||
<supadb
|
||||
collection="analytics_report_rows"
|
||||
:filter="{ report_id: reportId }"
|
||||
orderby="row_date asc"
|
||||
/>
|
||||
```
|
||||
|
||||
### 调用 RPC 获取实时 KPI
|
||||
|
||||
```vue
|
||||
<supadb
|
||||
rpc="rpc_analytics_realtime_kpis"
|
||||
:params="{
|
||||
p_start: todayStart.toISOString(),
|
||||
p_end: now.toISOString(),
|
||||
p_compare_start: yesterdayStart.toISOString(),
|
||||
p_compare_end: yesterdaySameTime.toISOString(),
|
||||
p_merchant_id: null
|
||||
}"
|
||||
getone
|
||||
/>
|
||||
```
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
1. **执行顺序**:必须先执行 Schema,再执行 Seed
|
||||
2. **基础表依赖**:确保基础业务表(`users`、`merchants`、`orders` 等)已存在
|
||||
3. **时间依赖**:测试数据使用 `NOW()`,每次执行时间戳会不同
|
||||
4. **数据冲突**:脚本使用 `ON CONFLICT DO NOTHING`,可重复执行
|
||||
5. **权限**:确保使用有足够权限的用户执行(如 `postgres`)
|
||||
|
||||
## 🔍 验证部署
|
||||
|
||||
执行以下查询验证部署是否成功:
|
||||
|
||||
```sql
|
||||
-- 检查表是否创建
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name LIKE 'analytics_%'
|
||||
ORDER BY table_name;
|
||||
|
||||
-- 检查报表数量
|
||||
SELECT COUNT(*) FROM analytics_reports;
|
||||
-- 应该返回 3
|
||||
|
||||
-- 检查 RPC 函数是否存在
|
||||
SELECT routine_name
|
||||
FROM information_schema.routines
|
||||
WHERE routine_schema = 'public'
|
||||
AND routine_name LIKE 'rpc_analytics_%';
|
||||
-- 应该看到 rpc_analytics_realtime_kpis 和 rpc_analytics_trend_data
|
||||
|
||||
-- 测试 RPC 函数
|
||||
SELECT * FROM rpc_analytics_realtime_kpis(
|
||||
DATE_TRUNC('day', NOW()),
|
||||
NOW(),
|
||||
DATE_TRUNC('day', NOW() - INTERVAL '1 day'),
|
||||
NOW() - INTERVAL '1 day',
|
||||
NULL
|
||||
);
|
||||
```
|
||||
|
||||
## 📚 相关文档
|
||||
|
||||
- **数据库设计文档**:`pages/mall/analytics/docs/ANALYTICS_DB_DESIGN.md`
|
||||
- **快速开始指南**:`pages/mall/analytics/docs/ANALYTICS_DB_QUICK_START.md`(本文档)
|
||||
- **测试数据说明**:`pages/mall/analytics/test/README.md`
|
||||
- **SQL 使用指南**:`pages/mall/analytics/test/SQL_USAGE_GUIDE.md`
|
||||
- **项目需求文档**:`pages/mall/mall.md`(第2.6节、第10节)
|
||||
|
||||
## 🆘 问题排查
|
||||
|
||||
如果遇到问题,请检查:
|
||||
|
||||
1. **连接问题**:确认 Supabase 服务运行正常
|
||||
2. **权限问题**:确认使用 `postgres` 用户或有足够权限
|
||||
3. **表冲突**:如果表已存在,脚本不会报错(使用 `IF NOT EXISTS`)
|
||||
4. **数据验证**:执行 `03_test_queries.sql` 验证数据
|
||||
|
||||
更多帮助请参考:`pages/mall/analytics/test/SQL_USAGE_GUIDE.md`
|
||||
771
pages/mall/analytics/docs/ANALYTICS_PAGES_ANALYSIS.md
Normal file
771
pages/mall/analytics/docs/ANALYTICS_PAGES_ANALYSIS.md
Normal file
@@ -0,0 +1,771 @@
|
||||
# 数据分析模块页面分析文档
|
||||
|
||||
## 📋 文档说明
|
||||
|
||||
本文档基于项目文档(`pages/mall/mall.md`)、目录结构和页面配置,分析数据分析模块需要实现的页面清单。
|
||||
|
||||
**数据来源**:
|
||||
- `pages/mall/mall.md` - 项目完整需求文档(第2.6节:数据分析端,第10节:数据统计分析)
|
||||
- `pages/mall/pages-config.json` - 页面路由配置
|
||||
- `docs/ANALYTICS_UI_DESIGN.md` - UI设计文档
|
||||
|
||||
**创建时间**: 2025-01-XX
|
||||
**最后更新**: 2026-01-23(页面骨架创建完成)
|
||||
|
||||
---
|
||||
|
||||
## ✅ 数据分析模块 URL / 路由访问(可直接复制)
|
||||
|
||||
### 1) 主页面 URL
|
||||
|
||||
- **数据分析中心首页**:`/pages/mall/analytics/index`
|
||||
|
||||
### 2) 子页面 URL(analytics 子包)
|
||||
|
||||
- **销售报表**:`/pages/mall/analytics/sales-report`
|
||||
- **用户分析**:`/pages/mall/analytics/user-analysis`
|
||||
- **商品洞察**:`/pages/mall/analytics/product-insights`
|
||||
- **市场趋势**:`/pages/mall/analytics/market-trends`
|
||||
- **自定义报表**:`/pages/mall/analytics/custom-report`
|
||||
|
||||
### 3) 详情页 URL(主包 pages 中配置)
|
||||
|
||||
- **报表详情**:`/pages/mall/analytics/report-detail`
|
||||
- **数据分析详情**:`/pages/mall/analytics/data-detail`
|
||||
- **数据洞察详情**:`/pages/mall/analytics/insight-detail`
|
||||
|
||||
### 4) 代码中如何访问(uni-app x)
|
||||
|
||||
```ts
|
||||
// 进入数据分析中心首页(推荐:保留返回栈)
|
||||
uni.navigateTo({ url: '/pages/mall/analytics/index' })
|
||||
|
||||
// 进入销售报表
|
||||
uni.navigateTo({ url: '/pages/mall/analytics/sales-report' })
|
||||
|
||||
// 进入用户分析
|
||||
uni.navigateTo({ url: '/pages/mall/analytics/user-analysis' })
|
||||
```
|
||||
|
||||
> 注意:`switchTab` 只能用于 `tabBar.list` 里的页面;数据分析不在 tabBar 内,因此应使用 `navigateTo/redirectTo/reLaunch`。
|
||||
|
||||
## 一、已实现的页面
|
||||
|
||||
### 1.1 核心页面(已存在)
|
||||
|
||||
| 页面路径 | 文件状态 | 功能描述 | 配置状态 |
|
||||
| ------------------------------------- | -------- | ---------------- | ------------ |
|
||||
| `/pages/mall/analytics/index` | ✅ 已实现 | 数据分析中心首页 | ✅ 已配置 |
|
||||
| `/pages/mall/analytics/profile` | ✅ 已实现 | 数据分析个人中心 | ⚠️ 未在配置中 |
|
||||
| `/pages/mall/analytics/report-detail` | ✅ 已实现 | 报表详情页 | ✅ 已配置 |
|
||||
|
||||
---
|
||||
|
||||
## 二、需要实现的页面(根据配置和文档)
|
||||
|
||||
### 2.1 子包页面(subPackages 中已配置)
|
||||
|
||||
#### 2.1.1 销售报表 (`sales-report`)
|
||||
- **路径**: `pages/mall/analytics/sales-report`
|
||||
- **标题**: 销售报表
|
||||
- **状态**: ❌ 未实现
|
||||
- **功能需求**:
|
||||
- 销售趋势分析(日/周/月/年)
|
||||
- 销售数据统计(GMV、订单数、客单价)
|
||||
- 商品销售排行
|
||||
- 商家销售排行
|
||||
- 销售地域分布
|
||||
- 数据导出功能
|
||||
|
||||
#### 2.1.2 用户分析 (`user-analysis`)
|
||||
- **路径**: `pages/mall/analytics/user-analysis`
|
||||
- **标题**: 用户分析
|
||||
- **状态**: ❌ 未实现
|
||||
- **功能需求**:
|
||||
- 用户增长趋势
|
||||
- 用户活跃度分析
|
||||
- 用户留存率
|
||||
- 用户画像分析
|
||||
- 用户行为路径
|
||||
- 新老用户对比
|
||||
|
||||
#### 2.1.3 商品洞察 (`product-insights`)
|
||||
- **路径**: `pages/mall/analytics/product-insights`
|
||||
- **标题**: 商品洞察
|
||||
- **状态**: ❌ 未实现
|
||||
- **功能需求**:
|
||||
- 商品销售分析
|
||||
- 商品分类分析
|
||||
- 热销商品排行
|
||||
- 商品库存分析
|
||||
- 商品价格趋势
|
||||
- 商品评价分析
|
||||
|
||||
#### 2.1.4 市场趋势 (`market-trends`)
|
||||
- **路径**: `pages/mall/analytics/market-trends`
|
||||
- **标题**: 市场趋势
|
||||
- **状态**: ❌ 未实现
|
||||
- **功能需求**:
|
||||
- 市场整体趋势
|
||||
- 行业对比分析
|
||||
- 季节性趋势
|
||||
- 价格趋势分析
|
||||
- 竞争分析
|
||||
|
||||
#### 2.1.5 优惠券效果分析 (`coupon-analysis`)
|
||||
- **路径**: `pages/mall/analytics/coupon-analysis`
|
||||
- **标题**: 优惠券效果分析
|
||||
- **状态**: ❌ 未实现
|
||||
- **功能需求**(基于 `mall.md` 第4节优惠券系统):
|
||||
- 优惠券发放统计(8种券类型:满减券、折扣券、免运费券、新人券、会员券、品类券、商家券、限时券)
|
||||
- 优惠券使用率分析
|
||||
- 优惠券转化效果(GMV提升、订单增长)
|
||||
- 优惠券ROI分析
|
||||
- 发放渠道效果对比(主动领取、自动发放、活动赠送、邀请奖励、客服赠送、积分兑换)
|
||||
- 优惠券到期提醒统计
|
||||
- 优惠券使用趋势分析
|
||||
|
||||
#### 2.1.6 自定义报表 (`custom-report`)
|
||||
- **路径**: `pages/mall/analytics/custom-report`
|
||||
- **标题**: 自定义报表
|
||||
- **状态**: ❌ 未实现
|
||||
- **功能需求**:
|
||||
- 报表创建/编辑
|
||||
- 指标选择
|
||||
- 时间维度选择
|
||||
- 图表类型选择
|
||||
- 报表保存/分享
|
||||
- 报表模板管理
|
||||
|
||||
### 2.2 主包页面(pages 中已配置)
|
||||
|
||||
#### 2.2.1 数据分析详情 (`data-detail`)
|
||||
- **路径**: `pages/mall/analytics/data-detail`
|
||||
- **标题**: 数据分析详情
|
||||
- **状态**: ❌ 未实现
|
||||
- **功能需求**:
|
||||
- 详细数据展示
|
||||
- 数据钻取
|
||||
- 数据对比
|
||||
- 数据筛选
|
||||
|
||||
#### 2.2.2 数据洞察详情 (`insight-detail`)
|
||||
- **路径**: `pages/mall/analytics/insight-detail`
|
||||
- **标题**: 数据洞察详情
|
||||
- **状态**: ❌ 未实现
|
||||
- **功能需求**(基于 `mall.md` 第2.6节:预测分析和建议):
|
||||
- 洞察详情展示
|
||||
- 预测分析(销售预测、用户增长预测、库存预测)
|
||||
- 智能建议(运营建议、商品建议、营销建议)
|
||||
- 异常检测和预警
|
||||
- 趋势预测可视化
|
||||
|
||||
---
|
||||
|
||||
## 三、页面功能模块分析
|
||||
|
||||
### 3.1 首页功能模块(index.uvue)
|
||||
|
||||
根据 `ANALYTICS_UI_DESIGN.md`,首页应包含以下模块:
|
||||
|
||||
1. **Header 区域**
|
||||
- ✅ 页面标题
|
||||
- ✅ 最后更新时间
|
||||
- ✅ 刷新/导出按钮
|
||||
- ✅ 更多操作按钮(搜索、通知、全屏、移动端、设置)
|
||||
|
||||
2. **实时大屏(KPI 卡片)**
|
||||
- ✅ 实时 GMV
|
||||
- ✅ 实时订单
|
||||
- ✅ 在线用户
|
||||
- ✅ 转化率
|
||||
|
||||
3. **时间筛选**
|
||||
- ✅ 7天/30天/90天/1年切换
|
||||
|
||||
4. **核心趋势图表**
|
||||
- ✅ GMV/订单数组合图(柱状+折线)
|
||||
|
||||
5. **用户结构分析**
|
||||
- ✅ 用户构成环形图
|
||||
|
||||
6. **流量来源分析**
|
||||
- ✅ 流量来源条形图
|
||||
|
||||
7. **商品/商家排行**
|
||||
- ✅ 热销商品 TOP
|
||||
- ✅ 商家排行 TOP
|
||||
|
||||
8. **配送效率**(基于 `mall.md` 第10.1节配送指标)
|
||||
- ⚠️ 配送效率图表(待完善)
|
||||
- 配送时效分析
|
||||
- 配送费用统计
|
||||
- 配送员效率分析
|
||||
- 客户满意度统计
|
||||
|
||||
9. **优惠券效果分析**(基于 `mall.md` 第2.6节)
|
||||
- ❌ 优惠券效果分析(待实现)
|
||||
- 优惠券发放统计
|
||||
- 优惠券使用率
|
||||
- 优惠券转化效果
|
||||
|
||||
10. **预测分析和建议**(基于 `mall.md` 第2.6节)
|
||||
- ❌ 预测分析(待实现)
|
||||
- 销售预测
|
||||
- 用户增长预测
|
||||
- 智能运营建议
|
||||
|
||||
### 3.2 个人中心功能模块(profile.uvue)
|
||||
|
||||
- ✅ 用户信息展示
|
||||
- ✅ 数据分析偏好设置
|
||||
- ✅ 报表收藏管理
|
||||
- ✅ 导出历史记录
|
||||
|
||||
### 3.3 报表详情功能模块(report-detail.uvue)
|
||||
|
||||
- ✅ 报表数据展示
|
||||
- ✅ 图表展示
|
||||
- ✅ 数据导出
|
||||
- ✅ 报表分享
|
||||
|
||||
---
|
||||
|
||||
## 四、基于 `mall.md` 的统计指标需求
|
||||
|
||||
### 4.1 运营指标(`mall.md` 第10.1节)
|
||||
|
||||
根据项目需求文档,数据分析端需要统计以下**运营指标**:
|
||||
|
||||
- **GMV(成交总额)** - 核心业务指标
|
||||
- **订单量和转化率** - 业务转化效率
|
||||
- **用户活跃度** - 用户参与度
|
||||
- **客单价** - 平均订单金额
|
||||
- **复购率** - 用户忠诚度指标
|
||||
|
||||
### 4.2 商家指标(`mall.md` 第10.1节)
|
||||
|
||||
- **销售额和利润** - 商家经营状况
|
||||
- **商品销量排行** - 热销商品分析
|
||||
- **评价和服务质量** - 商家服务质量
|
||||
- **库存周转率** - 库存管理效率
|
||||
|
||||
### 4.3 配送指标(`mall.md` 第10.1节)
|
||||
|
||||
- **配送时效** - 配送速度指标
|
||||
- **配送费用** - 成本控制
|
||||
- **配送员效率** - 人员效率分析
|
||||
- **客户满意度** - 服务质量
|
||||
|
||||
### 4.4 统计数据模型(`mall.md` 第10.2节)
|
||||
|
||||
项目定义了 `daily_statistics` 表用于日常统计:
|
||||
|
||||
```sql
|
||||
CREATE TABLE daily_statistics (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
stat_date DATE NOT NULL,
|
||||
merchant_id UUID REFERENCES merchants(id),
|
||||
total_orders INTEGER DEFAULT 0,
|
||||
total_amount DECIMAL(12,2) DEFAULT 0,
|
||||
total_users INTEGER DEFAULT 0,
|
||||
new_users INTEGER DEFAULT 0,
|
||||
total_products INTEGER DEFAULT 0,
|
||||
avg_order_amount DECIMAL(10,2) DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(stat_date, merchant_id)
|
||||
);
|
||||
```
|
||||
|
||||
**数据查询需求**:
|
||||
- 按日期聚合统计数据
|
||||
- 按商家维度统计
|
||||
- 支持时间范围查询
|
||||
- 支持数据对比分析
|
||||
|
||||
---
|
||||
|
||||
## 五、页面实现优先级
|
||||
|
||||
### 5.1 高优先级(核心功能,基于 `mall.md` 第2.6节)
|
||||
|
||||
1. **销售报表** (`sales-report`) - 核心业务分析
|
||||
- 对应需求:销售数据分析
|
||||
- 包含指标:GMV、订单量、转化率、客单价
|
||||
|
||||
2. **用户分析** (`user-analysis`) - 用户运营分析
|
||||
- 对应需求:用户行为分析
|
||||
- 包含指标:用户活跃度、复购率、新用户增长
|
||||
|
||||
3. **商品洞察** (`product-insights`) - 商品运营分析
|
||||
- 对应需求:商家表现分析(商品维度)
|
||||
- 包含指标:商品销量排行、库存周转率
|
||||
|
||||
4. **配送效率分析** - 配送系统分析
|
||||
- 对应需求:配送效率分析
|
||||
- 包含指标:配送时效、配送费用、配送员效率、客户满意度
|
||||
|
||||
### 5.2 中优先级(增强功能)
|
||||
|
||||
5. **优惠券效果分析** (`coupon-analysis`) - 营销效果分析
|
||||
- 对应需求:优惠券效果分析(`mall.md` 第2.6节)
|
||||
- 包含:8种券类型分析、发放渠道效果、ROI分析
|
||||
|
||||
6. **市场趋势** (`market-trends`) - 市场分析
|
||||
- 对应需求:市场整体趋势分析
|
||||
|
||||
7. **数据分析详情** (`data-detail`) - 数据钻取
|
||||
- 支持所有报表页面的详细数据查看
|
||||
|
||||
8. **数据洞察详情** (`insight-detail`) - 智能分析
|
||||
- 对应需求:预测分析和建议(`mall.md` 第2.6节)
|
||||
|
||||
### 5.3 低优先级(高级功能)
|
||||
|
||||
9. **自定义报表** (`custom-report`) - 高级定制功能
|
||||
- 允许用户自定义报表配置
|
||||
|
||||
---
|
||||
|
||||
## 六、数据分析端核心功能(基于 `mall.md` 第2.6节)
|
||||
|
||||
根据项目需求文档,数据分析端(Analytics Dashboard)的目标用户是**运营和分析师**,需要实现以下核心功能:
|
||||
|
||||
### 6.1 实时数据大屏 ✅
|
||||
- **状态**: 已实现(首页 KPI 卡片)
|
||||
- **包含**: GMV、订单数、在线用户、转化率
|
||||
|
||||
### 6.2 销售数据分析 ⚠️
|
||||
- **状态**: 部分实现(首页有核心趋势图)
|
||||
- **待完善**: 需要独立的销售报表页面
|
||||
- **包含**: 销售趋势、GMV分析、订单分析、客单价分析
|
||||
|
||||
### 6.3 用户行为分析 ⚠️
|
||||
- **状态**: 部分实现(首页有用户结构分析)
|
||||
- **待完善**: 需要独立的用户分析页面
|
||||
- **包含**: 用户增长、活跃度、留存率、行为路径
|
||||
|
||||
### 6.4 商家表现分析 ⚠️
|
||||
- **状态**: 部分实现(首页有商家排行)
|
||||
- **待完善**: 需要独立的商家分析页面
|
||||
- **包含**: 销售额、利润、商品排行、服务质量、库存周转率
|
||||
|
||||
### 6.5 配送效率分析 ⚠️
|
||||
- **状态**: 部分实现(首页有配送效率图表占位)
|
||||
- **待完善**: 需要完整的配送效率分析
|
||||
- **包含**: 配送时效、配送费用、配送员效率、客户满意度
|
||||
|
||||
### 6.6 优惠券效果分析 ❌
|
||||
- **状态**: 未实现
|
||||
- **优先级**: 中优先级
|
||||
- **包含**: 8种券类型效果、发放渠道效果、使用率、ROI分析
|
||||
|
||||
### 6.7 预测分析和建议 ❌
|
||||
- **状态**: 未实现
|
||||
- **优先级**: 中优先级
|
||||
- **包含**: 销售预测、用户增长预测、智能运营建议、异常检测
|
||||
|
||||
---
|
||||
|
||||
## 七、页面依赖关系
|
||||
|
||||
```
|
||||
index (首页)
|
||||
├── sales-report (销售报表)
|
||||
│ └── report-detail (报表详情)
|
||||
├── user-analysis (用户分析)
|
||||
│ └── data-detail (数据分析详情)
|
||||
├── product-insights (商品洞察)
|
||||
│ └── data-detail (数据分析详情)
|
||||
├── coupon-analysis (优惠券效果分析) [新增,基于 mall.md]
|
||||
│ └── report-detail (报表详情)
|
||||
├── market-trends (市场趋势)
|
||||
│ └── insight-detail (数据洞察详情)
|
||||
└── custom-report (自定义报表)
|
||||
└── report-detail (报表详情)
|
||||
|
||||
profile (个人中心)
|
||||
└── 所有报表页面的收藏/历史记录入口
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 八、组件复用分析
|
||||
|
||||
### 6.1 已实现的组件
|
||||
|
||||
- ✅ `AnalyticsComboChart.uvue` - 组合图表(柱状+折线)
|
||||
- ✅ `AnalyticsDonutChart.uvue` - 环形图
|
||||
- ✅ `AnalyticsBarMini.uvue` - 迷你柱状图
|
||||
- ✅ `ChartCard.uvue` - 图表卡片容器
|
||||
- ✅ `KpiCard.uvue` - KPI 指标卡片
|
||||
- ✅ `PeriodTabs.uvue` - 时间维度切换
|
||||
|
||||
### 6.2 需要新增的组件
|
||||
|
||||
- ❌ `SalesTrendChart.uvue` - 销售趋势图
|
||||
- ❌ `UserGrowthChart.uvue` - 用户增长图
|
||||
- ❌ `ProductRankingChart.uvue` - 商品排行图
|
||||
- ❌ `RegionDistributionChart.uvue` - 地域分布图
|
||||
- ❌ `CustomReportBuilder.uvue` - 自定义报表构建器
|
||||
- ❌ `DataTable.uvue` - 数据表格组件
|
||||
- ❌ `ExportDialog.uvue` - 导出对话框
|
||||
|
||||
---
|
||||
|
||||
## 九、数据接口需求
|
||||
|
||||
### 7.1 销售报表接口
|
||||
|
||||
- 销售趋势数据(按时间维度)
|
||||
- 销售统计数据(GMV、订单数、客单价)
|
||||
- 商品销售排行
|
||||
- 商家销售排行
|
||||
- 销售地域分布
|
||||
|
||||
### 7.2 用户分析接口
|
||||
|
||||
- 用户增长趋势
|
||||
- 用户活跃度数据
|
||||
- 用户留存率数据
|
||||
- 用户画像数据
|
||||
- 用户行为路径数据
|
||||
|
||||
### 7.3 商品洞察接口
|
||||
|
||||
- 商品销售数据
|
||||
- 商品分类数据
|
||||
- 热销商品数据
|
||||
- 商品库存数据
|
||||
- 商品价格趋势
|
||||
|
||||
### 7.4 市场趋势接口
|
||||
|
||||
- 市场整体趋势
|
||||
- 行业对比数据
|
||||
- 季节性趋势数据
|
||||
- 价格趋势数据
|
||||
|
||||
### 7.5 优惠券效果分析接口(基于 `mall.md` 第4节)
|
||||
|
||||
- 优惠券发放统计(按类型、渠道)
|
||||
- 优惠券使用率数据
|
||||
- 优惠券转化效果(GMV提升、订单增长)
|
||||
- 优惠券ROI数据
|
||||
- 优惠券到期提醒统计
|
||||
- 优惠券使用趋势
|
||||
|
||||
### 7.6 配送效率分析接口(基于 `mall.md` 第6节)
|
||||
|
||||
- 配送时效统计(平均配送时间、准时率)
|
||||
- 配送费用统计(总费用、平均费用)
|
||||
- 配送员效率数据(订单数、评分)
|
||||
- 客户满意度数据(评价、投诉)
|
||||
|
||||
### 7.7 预测分析接口(基于 `mall.md` 第2.6节)
|
||||
|
||||
- 销售预测数据
|
||||
- 用户增长预测
|
||||
- 库存预测
|
||||
- 异常检测数据
|
||||
- 智能建议数据
|
||||
|
||||
### 7.8 自定义报表接口
|
||||
|
||||
- 报表模板列表
|
||||
- 报表创建/更新
|
||||
- 报表数据查询
|
||||
- 报表保存/分享
|
||||
|
||||
### 7.9 日常统计数据接口(基于 `mall.md` 第10.2节)
|
||||
|
||||
- 按日期查询统计数据
|
||||
- 按商家维度统计
|
||||
- 时间范围聚合查询
|
||||
- 数据对比分析
|
||||
|
||||
---
|
||||
|
||||
## 十、实现建议
|
||||
|
||||
### 8.1 技术实现
|
||||
|
||||
1. **统一使用 Supabase 查询**
|
||||
- 所有数据查询通过 `@/components/supadb/aksupainstance.uts`
|
||||
- 使用 RLS (Row Level Security) 控制数据权限
|
||||
|
||||
2. **图表组件统一**
|
||||
- 使用 `@/uni_modules/charts/EChartsView.vue`
|
||||
- 封装统一的图表配置
|
||||
|
||||
3. **响应式设计**
|
||||
- 使用 `flex-direction: row !important` 避免全局样式影响
|
||||
- 使用媒体查询实现响应式布局
|
||||
|
||||
### 8.2 开发顺序建议
|
||||
|
||||
1. **第一阶段**:完善首页功能
|
||||
- 完善配送效率图表
|
||||
- 优化数据加载性能
|
||||
|
||||
2. **第二阶段**:实现核心报表页面
|
||||
- 销售报表
|
||||
- 用户分析
|
||||
- 商品洞察
|
||||
|
||||
3. **第三阶段**:实现增强功能
|
||||
- 市场趋势
|
||||
- 数据分析详情
|
||||
- 数据洞察详情
|
||||
|
||||
4. **第四阶段**:实现高级功能
|
||||
- 自定义报表
|
||||
|
||||
### 8.3 代码规范
|
||||
|
||||
1. **文件命名**
|
||||
- 页面文件:`*.uvue`
|
||||
- 组件文件:`*.uvue`
|
||||
- 样式统一使用 `px` 单位(避免 rpx + CSS var 问题)
|
||||
|
||||
2. **代码结构**
|
||||
```vue
|
||||
<template>
|
||||
<!-- 页面结构 -->
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
// 导入
|
||||
// 类型定义
|
||||
// 组件定义
|
||||
// 数据定义
|
||||
// 生命周期
|
||||
// 方法定义
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* 强制横排样式 */
|
||||
/* 组件样式 */
|
||||
/* 响应式样式 */
|
||||
</style>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十一、总结
|
||||
|
||||
### 11.1 页面统计
|
||||
|
||||
- **已实现(完整功能)**: 3 个页面
|
||||
- `index.uvue` - 数据分析中心首页 ✅
|
||||
- `profile.uvue` - 数据分析个人中心 ✅
|
||||
- `report-detail.uvue` - 报表详情页 ✅
|
||||
|
||||
- **已创建骨架(待实现功能)**: 9 个页面
|
||||
- `sales-report.uvue` - 销售报表 ⚠️
|
||||
- `user-analysis.uvue` - 用户分析 ⚠️
|
||||
- `product-insights.uvue` - 商品洞察 ⚠️
|
||||
- `delivery-analysis.uvue` - 配送效率分析 ⚠️
|
||||
- `coupon-analysis.uvue` - 优惠券效果分析 ⚠️
|
||||
- `market-trends.uvue` - 市场趋势 ⚠️
|
||||
- `insight-detail.uvue` - 数据洞察详情 ⚠️
|
||||
- `data-detail.uvue` - 数据分析详情 ⚠️
|
||||
- `custom-report.uvue` - 自定义报表 ⚠️
|
||||
|
||||
- **总计**: 12 个页面
|
||||
|
||||
### 11.2 完成度(基于 `mall.md` 需求)
|
||||
|
||||
- **实时数据大屏**: 90% ✅(首页已实现)
|
||||
- **销售数据分析**: 50% ⚠️(页面骨架已创建,待实现数据查询)
|
||||
- **用户行为分析**: 50% ⚠️(页面骨架已创建,待实现数据查询)
|
||||
- **商家表现分析**: 50% ⚠️(商品洞察页面骨架已创建,待实现数据查询)
|
||||
- **配送效率分析**: 50% ⚠️(页面骨架已创建,待实现数据查询)
|
||||
- **优惠券效果分析**: 50% ⚠️(页面骨架已创建,待实现数据查询)
|
||||
- **预测分析和建议**: 50% ⚠️(数据洞察详情页面骨架已创建,待实现预测算法)
|
||||
|
||||
### 11.3 页面骨架创建状态
|
||||
|
||||
**✅ 已完成页面骨架创建(2026-01-23)**:
|
||||
|
||||
所有9个待实现页面的骨架已创建完成,包含:
|
||||
|
||||
1. **统一的页面结构**
|
||||
- 顶部栏(菜单图标 + 标题 + 操作按钮)
|
||||
- 时间维度筛选(7天/30天/90天/1年)
|
||||
- KPI 指标卡片(响应式布局)
|
||||
- 图表展示区域
|
||||
- 数据列表/排行
|
||||
- 统一的样式规范
|
||||
|
||||
2. **技术实现框架**
|
||||
- 使用 `flex-direction: row !important` 避免全局样式影响
|
||||
- 响应式设计(宽屏/窄屏适配)
|
||||
- 统一的组件导入(Supabase、EChartsView)
|
||||
- 完整的类型定义(TypeScript/UTS)
|
||||
- 方法框架(数据加载、图表构建、导出等)
|
||||
|
||||
3. **待实现功能(标记为 TODO)**
|
||||
- 数据查询逻辑(Supabase 查询)
|
||||
- 图表配置构建
|
||||
- 数据导出功能
|
||||
- 数据钻取逻辑
|
||||
|
||||
### 11.4 下一步行动(按优先级)
|
||||
|
||||
**第一阶段(核心功能 - 数据查询实现)**:
|
||||
1. ✅ 完善首页的配送效率图表
|
||||
2. ⚠️ 实现销售报表页面数据查询(GMV、订单量、转化率、客单价)
|
||||
3. ⚠️ 实现用户分析页面数据查询(用户增长、活跃度、留存率、复购率)
|
||||
4. ⚠️ 实现商品洞察页面数据查询(商品销量、库存周转率)
|
||||
|
||||
**第二阶段(增强功能 - 数据查询实现)**:
|
||||
5. ⚠️ 实现配送效率分析页面数据查询(配送时效、费用、效率、满意度)
|
||||
6. ⚠️ 实现优惠券效果分析页面数据查询(8种券类型、发放渠道、ROI)
|
||||
7. ⚠️ 实现市场趋势页面数据查询
|
||||
8. ⚠️ 实现数据洞察详情页面(预测分析算法、智能建议逻辑)
|
||||
|
||||
**第三阶段(高级功能 - 完整实现)**:
|
||||
9. ⚠️ 实现自定义报表页面(报表创建、编辑、保存逻辑)
|
||||
10. ⚠️ 实现数据分析详情页面(数据钻取、筛选、排序逻辑)
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 十二、参考文档
|
||||
|
||||
- **项目需求文档**: `pages/mall/mall.md`
|
||||
- 第2.6节:数据分析端功能需求
|
||||
- 第4节:优惠券系统详细设计
|
||||
- 第6节:配送系统详细设计
|
||||
- 第10节:数据统计分析(统计指标、数据模型)
|
||||
|
||||
- **UI设计文档**: `docs/ANALYTICS_UI_DESIGN.md`
|
||||
- **页面配置**: `pages/mall/pages-config.json`
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 十三、页面骨架创建记录
|
||||
|
||||
### 13.1 骨架创建完成时间
|
||||
|
||||
**创建日期**: 2026-01-23
|
||||
|
||||
### 13.2 已创建的页面骨架清单
|
||||
|
||||
| 页面文件 | 页面标题 | 骨架状态 | 功能框架 | 数据查询 | 图表配置 |
|
||||
| ------------------------ | -------------- | -------- | -------- | -------- | -------- |
|
||||
| `sales-report.uvue` | 销售报表 | ✅ 已创建 | ✅ 完整 | ⚠️ 待实现 | ⚠️ 待实现 |
|
||||
| `user-analysis.uvue` | 用户分析 | ✅ 已创建 | ✅ 完整 | ⚠️ 待实现 | ⚠️ 待实现 |
|
||||
| `product-insights.uvue` | 商品洞察 | ✅ 已创建 | ✅ 完整 | ⚠️ 待实现 | ⚠️ 待实现 |
|
||||
| `delivery-analysis.uvue` | 配送效率分析 | ✅ 已创建 | ✅ 完整 | ⚠️ 待实现 | ⚠️ 待实现 |
|
||||
| `coupon-analysis.uvue` | 优惠券效果分析 | ✅ 已创建 | ✅ 完整 | ⚠️ 待实现 | ⚠️ 待实现 |
|
||||
| `market-trends.uvue` | 市场趋势 | ✅ 已创建 | ✅ 完整 | ⚠️ 待实现 | ⚠️ 待实现 |
|
||||
| `insight-detail.uvue` | 数据洞察详情 | ✅ 已创建 | ✅ 完整 | ⚠️ 待实现 | ⚠️ 待实现 |
|
||||
| `data-detail.uvue` | 数据分析详情 | ✅ 已创建 | ✅ 完整 | ⚠️ 待实现 | ⚠️ 待实现 |
|
||||
| `custom-report.uvue` | 自定义报表 | ✅ 已创建 | ✅ 完整 | ⚠️ 待实现 | ⚠️ 待实现 |
|
||||
|
||||
### 13.3 骨架包含的核心功能
|
||||
|
||||
每个页面骨架都包含以下标准功能模块:
|
||||
|
||||
1. **顶部栏(Topbar)**
|
||||
- 菜单图标(☰)
|
||||
- 页面标题和更新时间
|
||||
- 刷新/导出按钮
|
||||
- 更多操作菜单(响应式)
|
||||
|
||||
2. **时间维度筛选(Tabs)**
|
||||
- 7天/30天/90天/1年切换
|
||||
- 激活状态样式
|
||||
- 点击切换数据
|
||||
|
||||
3. **KPI 指标卡片(KPI Grid)**
|
||||
- 2列/4列响应式布局
|
||||
- 指标数值显示
|
||||
- 增长率对比
|
||||
- 格式化显示(金额、百分比)
|
||||
|
||||
4. **图表展示区域(Chart Cards)**
|
||||
- 图表容器(EChartsView)
|
||||
- 图表标题和描述
|
||||
- 统一的图表高度(360px)
|
||||
|
||||
5. **数据列表/排行(Rank Lists)**
|
||||
- 排行序号显示
|
||||
- 数据项信息
|
||||
- 增长率标签(正负颜色区分)
|
||||
|
||||
6. **数据表格(Data Tables)**(部分页面)
|
||||
- 表头(支持排序)
|
||||
- 表格数据行
|
||||
- 数据格式化
|
||||
|
||||
7. **筛选器(Filters)**(部分页面)
|
||||
- 时间范围选择
|
||||
- 数据维度选择
|
||||
- 对比模式切换
|
||||
|
||||
### 13.4 技术实现规范
|
||||
|
||||
所有页面骨架遵循以下技术规范:
|
||||
|
||||
1. **样式规范**
|
||||
- 使用 `flex-direction: row !important` 强制横排
|
||||
- 统一使用 `px` 单位(避免 rpx + CSS var 问题)
|
||||
- 响应式断点:960px
|
||||
- 统一的颜色系统(#111、#f3f4f6、rgba(0,0,0,0.06) 等)
|
||||
|
||||
2. **组件导入**
|
||||
- `@/components/supadb/aksupainstance.uts` - Supabase 查询
|
||||
- `@/uni_modules/charts/EChartsView.vue` - 图表组件
|
||||
- `@/components/analytics/AnalyticsComboChart.uvue` - 组合图表(部分页面)
|
||||
|
||||
3. **类型定义**
|
||||
- 使用 UTS 类型系统
|
||||
- 定义数据接口类型
|
||||
- 定义配置项类型
|
||||
|
||||
4. **方法框架**
|
||||
- `loadXxxData()` - 数据加载方法(待实现)
|
||||
- `buildChartOptions()` - 图表配置构建(待实现)
|
||||
- `refreshData()` - 刷新数据
|
||||
- `exportReport()` - 导出报表
|
||||
- `formatInt()` / `formatMoney()` / `formatPct()` - 数据格式化
|
||||
|
||||
### 13.5 待实现功能清单
|
||||
|
||||
每个页面需要实现以下核心功能:
|
||||
|
||||
#### 数据查询(Supabase)
|
||||
- [ ] 根据时间维度查询数据
|
||||
- [ ] 聚合计算(SUM、COUNT、AVG)
|
||||
- [ ] 数据对比(同比、环比)
|
||||
- [ ] 数据筛选(按商家、分类、地域等)
|
||||
|
||||
#### 图表配置(ECharts)
|
||||
- [ ] 构建图表 option 配置
|
||||
- [ ] 数据格式化(时间轴、数值格式化)
|
||||
- [ ] 图表样式配置(颜色、字体、间距)
|
||||
- [ ] 交互配置(tooltip、legend、zoom)
|
||||
|
||||
#### 数据导出
|
||||
- [ ] Excel 导出
|
||||
- [ ] PDF 导出
|
||||
- [ ] 图片导出(图表截图)
|
||||
- [ ] CSV 导出(数据表格)
|
||||
|
||||
#### 高级功能
|
||||
- [ ] 数据钻取(点击数据项查看详情)
|
||||
- [ ] 数据对比(多时间段对比)
|
||||
- [ ] 预测分析(算法实现)
|
||||
- [ ] 智能建议(规则引擎)
|
||||
|
||||
---
|
||||
|
||||
**文档版本**: v3.0
|
||||
**状态**: ✅ 页面骨架已完成,📝 数据查询和图表配置待实现
|
||||
**最后更新**: 2026-01-23(页面骨架创建完成)
|
||||
269
pages/mall/analytics/docs/IMPLEMENTATION_STATUS.md
Normal file
269
pages/mall/analytics/docs/IMPLEMENTATION_STATUS.md
Normal file
@@ -0,0 +1,269 @@
|
||||
# 数据分析模块实现进度文档
|
||||
|
||||
## 📋 文档说明
|
||||
|
||||
本文档记录数据分析模块的实现进度、已知问题、bug修复情况和技术债务。
|
||||
|
||||
**文档位置**: `pages/mall/analytics/docs/IMPLEMENTATION_STATUS.md`
|
||||
**最后更新**: 2026-01-23
|
||||
|
||||
---
|
||||
|
||||
## ✅ 页面实现状态
|
||||
|
||||
### 1. 核心页面(已完成)
|
||||
|
||||
| 页面路径 | 文件 | 状态 | 功能完成度 | 备注 |
|
||||
| ------------------------------------- | -------------------- | -------- | ---------- | ------------------------------- |
|
||||
| `/pages/mall/analytics/index` | `index.uvue` | ✅ 已实现 | 90% | 主仪表盘,KPI卡片、图表展示完成 |
|
||||
| `/pages/mall/analytics/profile` | `profile.uvue` | ✅ 已实现 | 85% | 个人中心页面 |
|
||||
| `/pages/mall/analytics/report-detail` | `report-detail.uvue` | ✅ 已实现 | 80% | 报表详情页 |
|
||||
|
||||
### 2. 分析页面(已完成)
|
||||
|
||||
| 页面路径 | 文件 | 状态 | 功能完成度 | 备注 |
|
||||
| ----------------------------------------- | ------------------------ | -------- | ---------- | ---------------------------------- |
|
||||
| `/pages/mall/analytics/sales-report` | `sales-report.uvue` | ✅ 已实现 | 85% | 销售报表,包含趋势、排行、地域分布 |
|
||||
| `/pages/mall/analytics/user-analysis` | `user-analysis.uvue` | ✅ 已实现 | 85% | 用户分析,包含增长、活跃度、留存 |
|
||||
| `/pages/mall/analytics/product-insights` | `product-insights.uvue` | ✅ 已实现 | 80% | 商品洞察 |
|
||||
| `/pages/mall/analytics/delivery-analysis` | `delivery-analysis.uvue` | ✅ 已实现 | 80% | 配送效率分析 |
|
||||
| `/pages/mall/analytics/coupon-analysis` | `coupon-analysis.uvue` | ✅ 已实现 | 80% | 优惠券效果分析 |
|
||||
| `/pages/mall/analytics/market-trends` | `market-trends.uvue` | ✅ 已实现 | 75% | 市场趋势分析 |
|
||||
| `/pages/mall/analytics/custom-report` | `custom-report.uvue` | ✅ 已实现 | 70% | 自定义报表创建/编辑 |
|
||||
|
||||
### 3. 详情页面(已完成)
|
||||
|
||||
| 页面路径 | 文件 | 状态 | 功能完成度 | 备注 |
|
||||
| -------------------------------------- | --------------------- | -------- | ---------- | -------------- |
|
||||
| `/pages/mall/analytics/data-detail` | `data-detail.uvue` | ✅ 已实现 | 75% | 数据分析详情页 |
|
||||
| `/pages/mall/analytics/insight-detail` | `insight-detail.uvue` | ✅ 已实现 | 70% | 数据洞察详情页 |
|
||||
|
||||
---
|
||||
|
||||
## 🐛 已知问题与修复状态
|
||||
|
||||
### 1. 关键错误(Error - 需修复)
|
||||
|
||||
#### 1.1 Object Literal Type 错误
|
||||
**位置**:
|
||||
- `pages/mall/analytics/index.uvue:242`
|
||||
- `pages/mall/analytics/sales-report.uvue:198`
|
||||
- `pages/mall/analytics/user-analysis.uvue:180`
|
||||
- `pages/mall/analytics/delivery-analysis.uvue:182`
|
||||
|
||||
**错误信息**: `direct declaration of Object Literal Type is not supported`
|
||||
|
||||
**原因**: uni-app x (UTS) 不支持在 `watch` 中直接使用对象字面量类型
|
||||
|
||||
**修复方案**:
|
||||
```typescript
|
||||
// ❌ 错误写法
|
||||
watch: {
|
||||
trafficSources: {
|
||||
handler() { ... },
|
||||
deep: true
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ 正确写法(使用函数形式)
|
||||
watch: {
|
||||
trafficSources(newVal: Array<TrafficItem>, oldVal: Array<TrafficItem>) {
|
||||
this.buildChartOptions()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**状态**: ⚠️ 待修复
|
||||
|
||||
---
|
||||
|
||||
#### 1.2 组件事件绑定错误
|
||||
**位置**:
|
||||
- `pages/mall/analytics/data-detail.uvue:8`
|
||||
- `pages/mall/analytics/custom-report.uvue:8`
|
||||
- `pages/mall/analytics/insight-detail.uvue:8`
|
||||
|
||||
**错误信息**: `组件AnalyticsSidebarMenu不支持事件: 'update:visible'`
|
||||
|
||||
**原因**: uni-app x 不支持 Vue 3 的 `update:visible` 双向绑定语法
|
||||
|
||||
**修复方案**:
|
||||
```vue
|
||||
<!-- ❌ 错误写法 -->
|
||||
<AnalyticsSidebarMenu
|
||||
:visible="showSidebarMenu"
|
||||
@update:visible="handleSidebarUpdate"
|
||||
/>
|
||||
|
||||
<!-- ✅ 正确写法(使用普通事件) -->
|
||||
<AnalyticsSidebarMenu
|
||||
:visible="showSidebarMenu"
|
||||
@visible-change="handleSidebarUpdate"
|
||||
/>
|
||||
```
|
||||
|
||||
**状态**: ✅ 已修复(2026-01-23)- 将所有页面的 `@update:visible` 改为 `@visible-change`
|
||||
|
||||
---
|
||||
|
||||
#### 1.3 view 组件不支持 title 属性
|
||||
**位置**:
|
||||
- `pages/mall/analytics/index.uvue:41,44,47,51,54,61`
|
||||
|
||||
**错误信息**: `组件view不支持属性: 'title'`
|
||||
|
||||
**原因**: `view` 组件不支持 `title` 属性,应使用 `text` 组件或移除该属性
|
||||
|
||||
**修复方案**:
|
||||
```vue
|
||||
<!-- ❌ 错误写法 -->
|
||||
<view title="xxx">...</view>
|
||||
|
||||
<!-- ✅ 正确写法 -->
|
||||
<view>
|
||||
<text>xxx</text>
|
||||
</view>
|
||||
```
|
||||
|
||||
**状态**: ⚠️ 待修复
|
||||
|
||||
---
|
||||
|
||||
### 2. 警告(Warning - 可忽略或后续优化)
|
||||
|
||||
#### 2.1 CSS 单位警告
|
||||
**问题**: 使用了 `px`, `vh`, `%`, `calc()` 等 uni-app x 不支持的 CSS 单位/函数
|
||||
|
||||
**影响范围**: 所有页面文件
|
||||
|
||||
**说明**:
|
||||
- uni-app x 主要支持 `rpx` 单位
|
||||
- `px` 在 H5 平台可用,但会触发警告
|
||||
- `vh`, `calc()` 等需要转换为 `rpx` 或使用条件编译
|
||||
|
||||
**处理建议**:
|
||||
- 使用 `/* #ifdef H5 */` 条件编译包裹桌面端样式
|
||||
- 移动端统一使用 `rpx`
|
||||
|
||||
**状态**: 📝 已记录,不影响功能
|
||||
|
||||
---
|
||||
|
||||
#### 2.2 CSS 伪类选择器警告
|
||||
**问题**: 使用了 `:hover`, `:active` 等伪类选择器
|
||||
|
||||
**影响范围**: 多个页面
|
||||
|
||||
**说明**: uni-app x 在某些平台不支持 CSS 伪类,需要使用 JavaScript 处理交互状态
|
||||
|
||||
**处理建议**: 使用 `:class` 动态绑定替代伪类
|
||||
|
||||
**状态**: 📝 已记录,不影响功能
|
||||
|
||||
---
|
||||
|
||||
#### 2.3 未使用的 CSS 选择器
|
||||
**问题**: 定义了但未使用的 CSS 类(如 `.active`, `.btn-hidden`)
|
||||
|
||||
**影响范围**: 多个页面
|
||||
|
||||
**说明**: 可能是预留的样式或历史遗留代码
|
||||
|
||||
**处理建议**: 清理未使用的样式,或添加注释说明用途
|
||||
|
||||
**状态**: 📝 已记录,不影响功能
|
||||
|
||||
---
|
||||
|
||||
## 📊 组件实现状态
|
||||
|
||||
### 核心组件
|
||||
|
||||
| 组件路径 | 状态 | 功能完成度 | 备注 |
|
||||
| ------------------------------------------------ | -------- | ---------- | ---------------------------- |
|
||||
| `components/analytics/AnalyticsTopBar.uvue` | ✅ 已完成 | 95% | 顶部导航栏 |
|
||||
| `components/analytics/AnalyticsSidebarMenu.uvue` | ✅ 已完成 | 90% | 侧边栏菜单(需修复事件绑定) |
|
||||
| `components/analytics/KpiCard.uvue` | ✅ 已完成 | 100% | KPI 卡片组件 |
|
||||
| `components/analytics/PeriodTabs.uvue` | ✅ 已完成 | 100% | 时间维度切换组件 |
|
||||
| `components/analytics/ChartCard.uvue` | ✅ 已完成 | 100% | 图表卡片容器 |
|
||||
|
||||
### 图表组件
|
||||
|
||||
| 组件路径 | 状态 | 功能完成度 | 备注 |
|
||||
| ----------------------------------------------- | -------- | ---------- | ------------------ |
|
||||
| `components/analytics/charts/ComboBarLine.uvue` | ✅ 已完成 | 100% | 柱线组合图 |
|
||||
| `components/analytics/charts/AreaLine.uvue` | ✅ 已完成 | 100% | 面积折线图 |
|
||||
| `components/analytics/charts/DonutPie.uvue` | ✅ 已完成 | 100% | 环形饼图 |
|
||||
| `components/analytics/AnalyticsComboChart.uvue` | ✅ 已完成 | 100% | 组合图表(自定义) |
|
||||
| `components/analytics/AnalyticsDonutChart.uvue` | ✅ 已完成 | 100% | 环形图(自定义) |
|
||||
| `components/analytics/AnalyticsBarMini.uvue` | ✅ 已完成 | 100% | 迷你条形图 |
|
||||
|
||||
---
|
||||
|
||||
## 🔧 技术债务
|
||||
|
||||
### 1. 数据获取
|
||||
- [ ] 所有页面目前使用模拟数据(`mockTrend()`, `mockData()`)
|
||||
- [ ] 需要接入 Supabase 真实数据查询
|
||||
- [ ] 需要实现数据缓存和刷新机制
|
||||
|
||||
### 2. 性能优化
|
||||
- [ ] ECharts 图表渲染性能优化(大数据量)
|
||||
- [ ] 页面滚动性能优化
|
||||
- [ ] 图片懒加载
|
||||
|
||||
### 3. 响应式设计
|
||||
- [ ] 完善移动端适配(目前主要针对桌面端)
|
||||
- [ ] 优化平板端显示效果
|
||||
- [ ] 统一响应式断点
|
||||
|
||||
### 4. 错误处理
|
||||
- [ ] 统一错误提示机制
|
||||
- [ ] 网络请求失败重试
|
||||
- [ ] 数据加载失败降级方案
|
||||
|
||||
### 5. 用户体验
|
||||
- [ ] 加载状态提示(骨架屏)
|
||||
- [ ] 空数据状态展示
|
||||
- [ ] 操作反馈优化
|
||||
|
||||
---
|
||||
|
||||
## 📝 修复计划
|
||||
|
||||
### 优先级 P0(阻塞功能)
|
||||
1. ✅ 修复 Object Literal Type 错误(watch 语法)- 已完成
|
||||
2. ✅ 修复组件事件绑定错误(update:visible → visible-change)- 已完成
|
||||
3. ⚠️ 修复 view 组件 title 属性错误 - 待确认(可能是 lint 缓存问题)
|
||||
|
||||
### 优先级 P1(影响体验)
|
||||
1. ⏳ 接入真实数据源(Supabase)
|
||||
2. ⏳ 完善错误处理和加载状态
|
||||
3. ⏳ 优化移动端响应式布局
|
||||
|
||||
### 优先级 P2(优化改进)
|
||||
1. ⏳ 清理未使用的 CSS 样式
|
||||
2. ⏳ 统一 CSS 单位(rpx)
|
||||
3. ⏳ 添加单元测试
|
||||
|
||||
---
|
||||
|
||||
## 📚 相关文档
|
||||
|
||||
- [页面分析文档](../ANALYTICS_PAGES_ANALYSIS.md) - 页面需求分析
|
||||
- [UI 设计文档](../../../docs/ANALYTICS_UI_DESIGN.md) - UI 设计规范
|
||||
- [数据库设计文档](../../../docs/ANALYTICS_DB_DESIGN.md) - 数据库表结构
|
||||
- [测试文档](../test/README.md) - 测试用例和 SQL 脚本
|
||||
|
||||
---
|
||||
|
||||
## 🔄 更新日志
|
||||
|
||||
### 2026-01-23
|
||||
- ✅ 创建实现进度文档
|
||||
- ✅ 记录所有页面实现状态
|
||||
- ✅ 列出已知问题和修复计划
|
||||
- ✅ 修复:Object Literal Type 错误(watch 语法改为函数形式)
|
||||
- ✅ 修复:组件事件绑定错误(所有页面的 `@update:visible` → `@visible-change`)
|
||||
- ✅ 修复:AnalyticsSidebarMenu 组件事件定义(`emits` 更新为 `visible-change`)
|
||||
- ⚠️ 待确认:view 组件 title 属性错误(可能是 lint 缓存,需重新检查)
|
||||
138
pages/mall/analytics/docs/README.md
Normal file
138
pages/mall/analytics/docs/README.md
Normal file
@@ -0,0 +1,138 @@
|
||||
# 数据分析模块文档目录
|
||||
|
||||
> 本目录包含数据分析模块的所有相关文档。
|
||||
|
||||
## 📁 文档结构
|
||||
|
||||
```
|
||||
pages/mall/analytics/docs/
|
||||
├── README.md # 本文件(文档索引)
|
||||
├── ANALYTICS_DB_DESIGN.md # 数据库设计文档
|
||||
├── ANALYTICS_DB_QUICK_START.md # 数据库快速开始指南
|
||||
├── ANALYTICS_PAGES_ANALYSIS.md # 页面分析文档
|
||||
├── ANALYTICS_UI_DESIGN.md # UI 设计文档
|
||||
└── IMPLEMENTATION_STATUS.md # 实现状态文档
|
||||
```
|
||||
|
||||
## 📚 文档说明
|
||||
|
||||
### 1. ANALYTICS_DB_DESIGN.md
|
||||
|
||||
**数据库设计文档** - 完整的数据表结构、字段说明、索引、RLS策略、RPC函数设计。
|
||||
|
||||
**内容包含:**
|
||||
- 7个 Analytics 专用表的详细字段定义
|
||||
- 索引建议
|
||||
- RLS(Row Level Security)权限策略
|
||||
- RPC 函数设计(实时KPI、趋势数据)
|
||||
- 使用说明和前端调用示例
|
||||
|
||||
**适用场景:**
|
||||
- 数据库架构设计
|
||||
- 表结构参考
|
||||
- 权限策略配置
|
||||
- RPC 函数开发
|
||||
|
||||
### 2. ANALYTICS_DB_QUICK_START.md
|
||||
|
||||
**快速开始指南** - 数据库部署和使用的快速参考。
|
||||
|
||||
**内容包含:**
|
||||
- 文件位置说明
|
||||
- 快速部署步骤(3步)
|
||||
- 创建的表列表
|
||||
- RPC 函数使用说明
|
||||
- 测试数据说明
|
||||
- 前端使用示例
|
||||
- 验证部署方法
|
||||
- 问题排查
|
||||
|
||||
**适用场景:**
|
||||
- 首次部署数据库
|
||||
- 快速查找使用方法
|
||||
- 问题排查参考
|
||||
|
||||
### 3. ANALYTICS_PAGES_ANALYSIS.md
|
||||
|
||||
**页面分析文档** - 数据分析模块所有页面的功能需求和分析。
|
||||
|
||||
**内容包含:**
|
||||
- 已实现的页面清单
|
||||
- 需要实现的页面清单
|
||||
- 页面功能模块分析
|
||||
- 统计指标需求
|
||||
- 页面依赖关系
|
||||
- 组件复用分析
|
||||
- 数据接口需求
|
||||
|
||||
**适用场景:**
|
||||
- 页面开发规划
|
||||
- 功能需求参考
|
||||
- 接口设计参考
|
||||
|
||||
### 4. ANALYTICS_UI_DESIGN.md
|
||||
|
||||
**UI 设计文档** - 数据分析页面的 UI 设计规范和实现说明。
|
||||
|
||||
**内容包含:**
|
||||
- 页面访问 URL
|
||||
- UI 设计规范
|
||||
- 组件使用说明
|
||||
- 响应式设计
|
||||
- 交互设计
|
||||
|
||||
**适用场景:**
|
||||
- UI 开发参考
|
||||
- 组件使用指南
|
||||
- 设计规范参考
|
||||
|
||||
### 5. IMPLEMENTATION_STATUS.md
|
||||
|
||||
**实现状态文档** - 记录各页面的实现进度和状态。
|
||||
|
||||
**内容包含:**
|
||||
- 页面实现状态
|
||||
- 功能完成度
|
||||
- 待办事项
|
||||
- 问题记录
|
||||
|
||||
**适用场景:**
|
||||
- 项目进度跟踪
|
||||
- 开发计划制定
|
||||
|
||||
## 🔗 相关资源
|
||||
|
||||
### SQL 脚本
|
||||
|
||||
所有 SQL 脚本位于:`pages/mall/analytics/test/`
|
||||
|
||||
- `ANALYTICS_DB_SCHEMA.sql` - 完整的表结构、索引、RLS、RPC
|
||||
- `ANALYTICS_TEST_SEED.sql` - 完整的测试数据
|
||||
- `01_create_tables.sql` - 分步:创建表结构
|
||||
- `02_insert_test_data.sql` - 分步:插入测试数据
|
||||
- `03_test_queries.sql` - 验证查询示例
|
||||
- `04_cleanup.sql` - 清理测试数据
|
||||
|
||||
### 测试文档
|
||||
|
||||
测试相关文档位于:`pages/mall/analytics/test/`
|
||||
|
||||
- `README.md` - 测试数据说明和使用方法
|
||||
- `SQL_USAGE_GUIDE.md` - SQL 脚本执行详细指南
|
||||
|
||||
### 项目文档
|
||||
|
||||
- `docs/ANALYTICS_PAGES_ANALYSIS.md` - 页面分析文档
|
||||
- `docs/ANALYTICS_UI_DESIGN.md` - UI 设计文档
|
||||
- `pages/mall/mall.md` - 项目需求文档(第2.6节:数据分析端,第10节:数据统计分析)
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
1. **阅读设计文档**:`ANALYTICS_DB_DESIGN.md`
|
||||
2. **执行部署**:参考 `ANALYTICS_DB_QUICK_START.md`
|
||||
3. **插入测试数据**:使用 `pages/mall/analytics/test/` 中的 SQL 脚本
|
||||
|
||||
## 📝 文档更新记录
|
||||
|
||||
- **2026-01-23** - 创建文档目录和索引
|
||||
- **2026-01-23** - 添加数据库设计文档和快速开始指南
|
||||
182
pages/mall/analytics/docs/URL_ACCESS.md
Normal file
182
pages/mall/analytics/docs/URL_ACCESS.md
Normal file
@@ -0,0 +1,182 @@
|
||||
# 数据分析模块 URL 访问文档
|
||||
|
||||
## 📋 文档说明
|
||||
|
||||
本文档提供数据分析模块所有页面的 URL 路径和访问方式,方便开发、测试和文档引用。
|
||||
|
||||
**文档位置**: `pages/mall/analytics/docs/URL_ACCESS.md`
|
||||
**最后更新**: 2026-01-23
|
||||
|
||||
---
|
||||
|
||||
## 🗺️ 页面路由地图
|
||||
|
||||
### 1. 主页面
|
||||
|
||||
| 页面名称 | URL 路径 | 页面标题 | 配置位置 |
|
||||
| ---------------- | ----------------------------- | ------------ | ------------------------------------------------ |
|
||||
| 数据分析中心首页 | `/pages/mall/analytics/index` | 数据分析中心 | `subPackages` → `pages/mall/analytics` → `index` |
|
||||
|
||||
### 2. 分析页面(子包)
|
||||
|
||||
| 页面名称 | URL 路径 | 页面标题 | 配置位置 |
|
||||
| -------------- | ----------------------------------------- | -------------- | ----------------------------------------------------------- |
|
||||
| 销售报表 | `/pages/mall/analytics/sales-report` | 销售报表 | `subPackages` → `pages/mall/analytics` → `sales-report` |
|
||||
| 用户分析 | `/pages/mall/analytics/user-analysis` | 用户分析 | `subPackages` → `pages/mall/analytics` → `user-analysis` |
|
||||
| 商品洞察 | `/pages/mall/analytics/product-insights` | 商品洞察 | `subPackages` → `pages/mall/analytics` → `product-insights` |
|
||||
| 市场趋势 | `/pages/mall/analytics/market-trends` | 市场趋势 | `subPackages` → `pages/mall/analytics` → `market-trends` |
|
||||
| 自定义报表 | `/pages/mall/analytics/custom-report` | 自定义报表 | `subPackages` → `pages/mall/analytics` → `custom-report` |
|
||||
| 优惠券效果分析 | `/pages/mall/analytics/coupon-analysis` | 优惠券效果分析 | ⚠️ 未在配置中 |
|
||||
| 配送效率分析 | `/pages/mall/analytics/delivery-analysis` | 配送效率分析 | ⚠️ 未在配置中 |
|
||||
|
||||
### 3. 详情页面(主包)
|
||||
|
||||
| 页面名称 | URL 路径 | 页面标题 | 配置位置 |
|
||||
| ------------ | -------------------------------------- | ------------ | ----------------------------------------------- |
|
||||
| 报表详情 | `/pages/mall/analytics/report-detail` | 报表详情 | `pages` → `pages/mall/analytics/report-detail` |
|
||||
| 数据分析详情 | `/pages/mall/analytics/data-detail` | 数据分析详情 | `pages` → `pages/mall/analytics/data-detail` |
|
||||
| 数据洞察详情 | `/pages/mall/analytics/insight-detail` | 数据洞察详情 | `pages` → `pages/mall/analytics/insight-detail` |
|
||||
|
||||
### 4. 其他页面
|
||||
|
||||
| 页面名称 | URL 路径 | 页面标题 | 配置位置 |
|
||||
| ---------------- | ------------------------------- | ---------------- | ------------ |
|
||||
| 数据分析个人中心 | `/pages/mall/analytics/profile` | 数据分析个人中心 | ⚠️ 未在配置中 |
|
||||
|
||||
---
|
||||
|
||||
## 💻 代码中如何访问
|
||||
|
||||
### 1. 基本跳转(推荐)
|
||||
|
||||
```typescript
|
||||
// 方式一:使用 navigateTo(保留返回栈,可返回上一页)
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/analytics/index',
|
||||
success: () => {
|
||||
console.log('跳转成功')
|
||||
},
|
||||
fail: (err) => {
|
||||
console.error('跳转失败:', err)
|
||||
}
|
||||
})
|
||||
|
||||
// 方式二:使用 redirectTo(替换当前页面,不保留返回栈)
|
||||
uni.redirectTo({
|
||||
url: '/pages/mall/analytics/index'
|
||||
})
|
||||
|
||||
// 方式三:使用 reLaunch(关闭所有页面,打开新页面)
|
||||
uni.reLaunch({
|
||||
url: '/pages/mall/analytics/index'
|
||||
})
|
||||
```
|
||||
|
||||
### 2. 带参数跳转
|
||||
|
||||
```typescript
|
||||
// 跳转并传递查询参数
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/analytics/index?period=30d&refresh=true'
|
||||
})
|
||||
|
||||
// 在目标页面接收参数(index.uvue 的 onLoad)
|
||||
onLoad(options: any) {
|
||||
const period = options.period || '7d'
|
||||
const refresh = options.refresh === 'true'
|
||||
// 使用参数...
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 从其他模块跳转示例
|
||||
|
||||
```typescript
|
||||
// 从管理后台跳转到数据分析中心
|
||||
const goToAnalytics = () => {
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/analytics/index'
|
||||
})
|
||||
}
|
||||
|
||||
// 从商城首页跳转到销售报表
|
||||
const goToSalesReport = () => {
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/analytics/sales-report'
|
||||
})
|
||||
}
|
||||
|
||||
// 从订单列表跳转到数据分析详情
|
||||
const goToDataDetail = (orderId: string) => {
|
||||
uni.navigateTo({
|
||||
url: `/pages/mall/analytics/data-detail?id=${orderId}`
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 侧边栏菜单导航
|
||||
|
||||
所有数据分析页面都集成了 `AnalyticsSidebarMenu` 组件,可以通过侧边栏菜单快速导航:
|
||||
|
||||
```typescript
|
||||
// 侧边栏菜单会自动处理导航
|
||||
// 菜单项配置在 components/analytics/AnalyticsSidebarMenu.uvue 中
|
||||
const MENU_ITEMS = [
|
||||
{ path: '/pages/mall/analytics/index', title: '数据分析中心', icon: '📊' },
|
||||
{ path: '/pages/mall/analytics/sales-report', title: '销售报表', icon: '💰' },
|
||||
// ...
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
### 1. 路由配置
|
||||
|
||||
- **子包页面**(`sales-report`, `user-analysis` 等)必须在 `subPackages` 中配置
|
||||
- **主包页面**(`report-detail`, `data-detail` 等)必须在主 `pages` 数组中配置
|
||||
- 未在配置中的页面无法正常访问
|
||||
|
||||
### 2. tabBar 限制
|
||||
|
||||
数据分析模块**不在** `tabBar` 配置中,因此:
|
||||
- ✅ 可以使用 `uni.navigateTo()`
|
||||
- ✅ 可以使用 `uni.redirectTo()`
|
||||
- ✅ 可以使用 `uni.reLaunch()`
|
||||
- ❌ **不能**使用 `uni.switchTab()`(仅用于 tabBar 页面)
|
||||
|
||||
### 3. 导航栏样式
|
||||
|
||||
大部分数据分析页面使用**自定义导航栏**(`navigationStyle: "custom"`),需要:
|
||||
- 使用 `AnalyticsTopBar` 组件作为顶部导航
|
||||
- 处理状态栏高度适配
|
||||
- 处理返回按钮逻辑
|
||||
|
||||
---
|
||||
|
||||
## 📱 平台兼容性
|
||||
|
||||
| 平台 | 支持状态 | 备注 |
|
||||
| ---------- | ---------- | ------------------------ |
|
||||
| H5 | ✅ 完全支持 | 推荐使用,响应式布局优化 |
|
||||
| 微信小程序 | ✅ 支持 | 需注意页面路径长度限制 |
|
||||
| App | ✅ 支持 | 需注意原生导航栏样式 |
|
||||
|
||||
---
|
||||
|
||||
## 🔗 相关文档
|
||||
|
||||
- [实现进度文档](./IMPLEMENTATION_STATUS.md) - 页面实现状态和 bug 修复情况
|
||||
- [页面分析文档](../../../docs/ANALYTICS_PAGES_ANALYSIS.md) - 页面需求分析
|
||||
- [UI 设计文档](../../../docs/ANALYTICS_UI_DESIGN.md) - UI 设计规范
|
||||
- [数据库设计文档](../../../docs/ANALYTICS_DB_DESIGN.md) - 数据库表结构
|
||||
|
||||
---
|
||||
|
||||
## 🔄 更新日志
|
||||
|
||||
### 2026-01-23
|
||||
- ✅ 创建 URL 访问文档
|
||||
- ✅ 列出所有页面路径和配置状态
|
||||
- ✅ 提供代码访问示例
|
||||
- ✅ 记录注意事项和平台兼容性
|
||||
@@ -1,18 +1,31 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="container">
|
||||
<!-- 顶部头部(白底,横排按钮) -->
|
||||
<view class="topbar">
|
||||
<view class="topbar-left">
|
||||
<text class="title">数据分析中心</text>
|
||||
<text class="subtitle">最后更新:{{ lastUpdateTime }}</text>
|
||||
</view>
|
||||
<view class="topbar-right">
|
||||
<view class="icon-btn" @click="refreshAll">刷新</view>
|
||||
<view class="icon-btn primary" @click="exportReport">导出</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="page" @click="closeMoreMenu">
|
||||
<!-- 固定顶部导航栏 -->
|
||||
<AnalyticsTopBar
|
||||
:title="'数据分析中心'"
|
||||
:lastUpdateTime="lastUpdateTime"
|
||||
:sidebarVisible="showSidebarMenu"
|
||||
@menu-click="handleMenu"
|
||||
@refresh="refreshAll"
|
||||
@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">
|
||||
<!-- KPI:宽屏 4列,窄屏 2列 -->
|
||||
<view class="kpi-grid">
|
||||
<view class="kpi-card">
|
||||
@@ -126,33 +139,43 @@
|
||||
<view style="height: 24px;"></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</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'
|
||||
|
||||
type TimePeriod = { value: string; label: string }
|
||||
type TrendData = { x: Array<string>; gmv: Array<number>; orders: Array<number> }
|
||||
type SegmentItem = { name: string; value: number }
|
||||
type TrafficItem = { name: string; value: number }
|
||||
type TopProductItem = { id: string; rank: number; name: string; sales: number }
|
||||
type TopMerchantItem = { id: string; rank: number; name: string; sales: number; growth: number }
|
||||
|
||||
export default {
|
||||
components: {
|
||||
AnalyticsComboChart,
|
||||
AnalyticsSidebarMenu,
|
||||
AnalyticsTopBar,
|
||||
EChartsView
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
lastUpdateTime: '',
|
||||
selectedPeriod: '7d',
|
||||
showMoreMenu: false,
|
||||
showSidebarMenu: false,
|
||||
currentPath: '/pages/mall/analytics/index',
|
||||
timePeriods: [
|
||||
{ value: '7d', label: '7天' },
|
||||
{ value: '30d', label: '30天' },
|
||||
{ value: '90d', label: '90天' },
|
||||
{ value: '1y', label: '1年' }
|
||||
] as Array<TimePeriod>,
|
||||
],
|
||||
|
||||
realTime: {
|
||||
gmv: 0,
|
||||
@@ -210,17 +233,11 @@ export default {
|
||||
},
|
||||
|
||||
watch: {
|
||||
trafficSources: {
|
||||
handler() {
|
||||
trafficSources(newVal, oldVal) {
|
||||
this.buildChartOptions()
|
||||
},
|
||||
deep: true
|
||||
},
|
||||
userSegments: {
|
||||
handler() {
|
||||
userSegments(newVal, oldVal) {
|
||||
this.buildChartOptions()
|
||||
},
|
||||
deep: true
|
||||
}
|
||||
},
|
||||
|
||||
@@ -229,18 +246,26 @@ export default {
|
||||
this.buildChartOptions()
|
||||
},
|
||||
|
||||
onUnload() {
|
||||
this.showMoreMenu = false
|
||||
},
|
||||
|
||||
methods: {
|
||||
async refreshAll() {
|
||||
this.updateTime()
|
||||
await this.loadRealTime()
|
||||
this.mockTrend() // 先给你可视化效果(你再替换成真实查询)
|
||||
await this.loadTrend()
|
||||
await this.loadUserSegments()
|
||||
await this.loadTrafficSources()
|
||||
await this.loadTopProducts()
|
||||
await this.loadTopMerchants()
|
||||
this.updateTime()
|
||||
uni.showToast({ title: '已刷新', icon: 'success' })
|
||||
},
|
||||
|
||||
selectPeriod(p: string) {
|
||||
this.selectedPeriod = p
|
||||
this.mockTrend()
|
||||
this.loadTrend()
|
||||
},
|
||||
|
||||
updateTime() {
|
||||
@@ -250,134 +275,164 @@ export default {
|
||||
this.lastUpdateTime = `${hh}:${mm}`
|
||||
},
|
||||
|
||||
// 组合趋势:先模拟(你可以换成 supa 查询 + 聚合)
|
||||
mockTrend() {
|
||||
const days = this.selectedPeriod === '7d' ? 7 : this.selectedPeriod === '30d' ? 30 : this.selectedPeriod === '90d' ? 90 : 12
|
||||
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 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)
|
||||
|
||||
const res: any = await supa.rpc('rpc_analytics_trend_data', p)
|
||||
const rows: Array<any> = Array.isArray(res.data) ? (res.data as Array<any>) : []
|
||||
|
||||
const x: Array<string> = []
|
||||
const gmv: Array<number> = []
|
||||
const orders: Array<number> = []
|
||||
for (let i = 0; i < days; i++) {
|
||||
x.push(days === 12 ? `${i + 1}月` : `${i + 1}`)
|
||||
// 假数据:你替换为真实聚合
|
||||
const base = 80000 + i * 1200 + Math.round(Math.random() * 8000)
|
||||
gmv.push(base)
|
||||
orders.push(120 + i * 2 + Math.round(Math.random() * 30))
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const d = `${rows[i].date}` // yyyy-mm-dd
|
||||
x.push(d.slice(5))
|
||||
gmv.push(Number(rows[i].gmv) || 0)
|
||||
orders.push(Number(rows[i].orders) || 0)
|
||||
}
|
||||
this.trend = { x, gmv, orders }
|
||||
} catch (e) {
|
||||
console.error('loadTrend failed', e)
|
||||
}
|
||||
},
|
||||
|
||||
// 实时指标:核心是"强制数值化 + 兜底",避免对象直接渲染
|
||||
async loadRealTime() {
|
||||
try {
|
||||
// 你可以把你原来的 supa 查询搬进来,这里只做"数值兜底示例"
|
||||
// 注意:任何可能不是 number 的结果,都用 Number(...) 和 isFinite 处理
|
||||
const safe = (v: any): number => {
|
||||
const n = Number(v)
|
||||
return isFinite(n) ? n : 0
|
||||
}
|
||||
|
||||
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 y0ISO = y0.toISOString()
|
||||
const ySameISO = ySame.toISOString()
|
||||
|
||||
// 今日订单(已支付 status=2)
|
||||
const { data: todayOrders } = await supa
|
||||
.from('orders')
|
||||
.select('total_amount, created_at, user_id')
|
||||
.gte('created_at', todayISO)
|
||||
.eq('status', 2)
|
||||
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)
|
||||
|
||||
// 昨日同时间段
|
||||
const { data: yOrders } = await supa
|
||||
.from('orders')
|
||||
.select('total_amount, created_at, user_id')
|
||||
.gte('created_at', y0ISO)
|
||||
.lte('created_at', ySameISO)
|
||||
.eq('status', 2)
|
||||
const res: any = await supa.rpc('rpc_analytics_realtime_kpis', p)
|
||||
const row = Array.isArray(res.data) && res.data.length > 0 ? res.data[0] : (res.data || {})
|
||||
|
||||
let gmvT = 0
|
||||
if (todayOrders != null) {
|
||||
for (let i = 0; i < todayOrders.length; i++) {
|
||||
gmvT += safe(todayOrders[i].total_amount)
|
||||
const safe = (v: any): number => {
|
||||
const n = Number(v)
|
||||
return isFinite(n) ? n : 0
|
||||
}
|
||||
}
|
||||
let gmvY = 0
|
||||
if (yOrders != null) {
|
||||
for (let i = 0; i < yOrders.length; i++) {
|
||||
gmvY += safe(yOrders[i].total_amount)
|
||||
}
|
||||
}
|
||||
const gmvGrowth = gmvY > 0 ? ((gmvT - gmvY) / gmvY * 100) : (gmvT > 0 ? 100 : 0)
|
||||
|
||||
const ordersT = todayOrders != null ? todayOrders.length : 0
|
||||
const ordersY = yOrders != null ? yOrders.length : 0
|
||||
const orderGrowth = ordersY > 0 ? ((ordersT - ordersY) / ordersY * 100) : (ordersT > 0 ? 100 : 0)
|
||||
|
||||
// 在线用户(最近 5 分钟)
|
||||
const fiveAgoISO = new Date(now.getTime() - 5 * 60 * 1000).toISOString()
|
||||
const { count: onlineCnt } = await supa
|
||||
.from('user_sessions')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.gte('last_active_at', fiveAgoISO)
|
||||
.eq('is_active', true)
|
||||
|
||||
let online = safe(onlineCnt)
|
||||
|
||||
// 转化率(下单用户数 / 访问用户数)
|
||||
const uniq: Record<string, boolean> = {}
|
||||
if (todayOrders != null) {
|
||||
for (let i = 0; i < todayOrders.length; i++) {
|
||||
const uid = todayOrders[i].user_id
|
||||
if (uid) uniq[uid] = true
|
||||
}
|
||||
}
|
||||
const orderUsers = Object.keys(uniq).length
|
||||
|
||||
const { count: visitorsToday } = await supa
|
||||
.from('user_sessions')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.gte('created_at', todayISO)
|
||||
|
||||
const vT = safe(visitorsToday)
|
||||
const convT = vT > 0 ? (orderUsers / vT) * 100 : 0
|
||||
|
||||
// 昨日同时间段转化率
|
||||
const uniqY: Record<string, boolean> = {}
|
||||
if (yOrders != null) {
|
||||
for (let i = 0; i < yOrders.length; i++) {
|
||||
const uid = yOrders[i].user_id
|
||||
if (uid) uniqY[uid] = true
|
||||
}
|
||||
}
|
||||
const { count: visitorsY } = await supa
|
||||
.from('user_sessions')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.gte('created_at', y0ISO)
|
||||
.lte('created_at', ySameISO)
|
||||
|
||||
const vY = safe(visitorsY)
|
||||
const convY = vY > 0 ? (Object.keys(uniqY).length / vY) * 100 : 0
|
||||
const convGrowth = convY > 0 ? ((convT - convY) / convY * 100) : (convT > 0 ? 100 : 0)
|
||||
|
||||
this.realTime = {
|
||||
gmv: Math.round(gmvT),
|
||||
gmv_growth: safe(gmvGrowth),
|
||||
orders: ordersT,
|
||||
order_growth: safe(orderGrowth),
|
||||
online_users: online,
|
||||
conversion_rate: safe(convT),
|
||||
conversion_growth: safe(convGrowth)
|
||||
gmv: Math.round(safe(row.gmv)),
|
||||
gmv_growth: safe(row.gmv_growth),
|
||||
orders: Math.round(safe(row.orders)),
|
||||
order_growth: safe(row.order_growth),
|
||||
online_users: Math.round(safe(row.online_users)),
|
||||
conversion_rate: safe(row.conversion_rate),
|
||||
conversion_growth: safe(row.conversion_growth)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
},
|
||||
|
||||
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', 5)
|
||||
p.set('p_merchant_id', null)
|
||||
const res: any = await supa.rpc('rpc_analytics_top_products', p)
|
||||
const rows: Array<any> = Array.isArray(res.data) ? (res.data as Array<any>) : []
|
||||
const list: Array<TopProductItem> = []
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
list.push({
|
||||
id: `${rows[i].id}`,
|
||||
rank: i + 1,
|
||||
name: `${rows[i].name}`,
|
||||
sales: Number(rows[i].sales) || 0
|
||||
})
|
||||
}
|
||||
this.topProducts = list
|
||||
} catch (e) {
|
||||
console.error('loadTopProducts failed', e)
|
||||
}
|
||||
},
|
||||
|
||||
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', 5)
|
||||
const res: any = await supa.rpc('rpc_analytics_top_merchants', p)
|
||||
const rows: Array<any> = Array.isArray(res.data) ? (res.data as Array<any>) : []
|
||||
const list: Array<TopMerchantItem> = []
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
list.push({
|
||||
id: `${rows[i].id}`,
|
||||
rank: i + 1,
|
||||
name: `${rows[i].name}`,
|
||||
sales: Number(rows[i].sales) || 0,
|
||||
growth: Number(rows[i].growth) || 0
|
||||
})
|
||||
}
|
||||
this.topMerchants = list
|
||||
} catch (e) {
|
||||
console.error('loadTopMerchants failed', e)
|
||||
}
|
||||
},
|
||||
|
||||
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))
|
||||
const res: any = await supa.rpc('rpc_analytics_user_segments', p)
|
||||
const rows: Array<any> = Array.isArray(res.data) ? (res.data as Array<any>) : []
|
||||
const list: Array<SegmentItem> = []
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
list.push({ name: `${rows[i].name}`, value: Number(rows[i].value) || 0 })
|
||||
}
|
||||
if (list.length > 0) this.userSegments = list
|
||||
} catch (e) {
|
||||
console.error('loadUserSegments failed', e)
|
||||
}
|
||||
},
|
||||
|
||||
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))
|
||||
const res: any = await supa.rpc('rpc_analytics_traffic_sources', p)
|
||||
const rows: Array<any> = Array.isArray(res.data) ? (res.data as Array<any>) : []
|
||||
const list: Array<TrafficItem> = []
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
list.push({ name: `${rows[i].name}`, value: Number(rows[i].value) || 0 })
|
||||
}
|
||||
if (list.length > 0) this.trafficSources = list
|
||||
} catch (e) {
|
||||
console.error('loadTrafficSources failed', e)
|
||||
}
|
||||
},
|
||||
|
||||
exportReport() {
|
||||
uni.showActionSheet({
|
||||
itemList: ['导出Excel', '导出PDF', '导出图片'],
|
||||
@@ -385,6 +440,72 @@ export default {
|
||||
})
|
||||
},
|
||||
|
||||
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.showActionSheet({
|
||||
itemList: ['crmeb demo', '切换项目', '项目设置'],
|
||||
success: () => {}
|
||||
})
|
||||
},
|
||||
|
||||
handleSettings() {
|
||||
uni.showToast({ title: '设置', icon: 'none' })
|
||||
},
|
||||
|
||||
toggleMoreMenu() {
|
||||
this.showMoreMenu = !this.showMoreMenu
|
||||
},
|
||||
|
||||
closeMoreMenu() {
|
||||
this.showMoreMenu = false
|
||||
},
|
||||
|
||||
handleMoreAction(action: string) {
|
||||
this.showMoreMenu = false
|
||||
switch (action) {
|
||||
case 'refresh':
|
||||
this.refreshAll()
|
||||
break
|
||||
case 'search':
|
||||
this.handleSearch()
|
||||
break
|
||||
case 'notification':
|
||||
this.handleNotification()
|
||||
break
|
||||
case 'fullscreen':
|
||||
this.handleFullscreen()
|
||||
break
|
||||
case 'mobile':
|
||||
this.handleMobile()
|
||||
break
|
||||
case 'settings':
|
||||
this.handleSettings()
|
||||
break
|
||||
}
|
||||
},
|
||||
|
||||
handleMenu() {
|
||||
this.showSidebarMenu = true
|
||||
},
|
||||
handleSidebarUpdate(visible: boolean) {
|
||||
this.showSidebarMenu = visible
|
||||
},
|
||||
|
||||
formatInt(n: number): string {
|
||||
const v = isFinite(n) ? Math.round(n) : 0
|
||||
if (v >= 10000) return (v / 10000).toFixed(1) + '万'
|
||||
@@ -456,47 +577,128 @@ export default {
|
||||
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;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* 响应式:窄屏时全屏显示 */
|
||||
@media screen and (max-width: 959px) {
|
||||
.page-layout {
|
||||
flex-direction: column !important;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* 顶部 */
|
||||
/* ✅ 强制:顶部必须横排(避免被全局 view:flex-direction:column 影响) */
|
||||
.topbar {
|
||||
display: flex;
|
||||
flex-direction: row !important;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 14px;
|
||||
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;
|
||||
gap: 6px;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
min-width: 0; /* 允许内部 text 做省略 */
|
||||
}
|
||||
|
||||
.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;
|
||||
gap: 10px;
|
||||
flex-direction: row !important;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
@@ -512,10 +714,143 @@ export default {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* 图标按钮样式 */
|
||||
.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;
|
||||
}
|
||||
|
||||
.icon-btn-icon:active {
|
||||
background: #e5e7eb;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.icon-btn-icon .icon {
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* 通知图标带红点 */
|
||||
.icon-btn-icon.notification .badge {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #ef4444;
|
||||
border: 1px solid #fff;
|
||||
}
|
||||
|
||||
/* 下拉菜单 */
|
||||
.dropdown {
|
||||
display: flex;
|
||||
flex-direction: row !important;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
background: #f3f4f6;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 更多按钮(默认隐藏,窄屏时显示) */
|
||||
.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;
|
||||
}
|
||||
|
||||
/* 更多菜单下拉 */
|
||||
.more-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(0,0,0,0.1);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
min-width: 140px;
|
||||
z-index: 1000;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column !important;
|
||||
}
|
||||
|
||||
.more-menu-item {
|
||||
display: flex;
|
||||
flex-direction: row !important;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.more-menu-item:active {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.more-menu-item .icon {
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.more-menu-item .text {
|
||||
font-size: 13px;
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.dropdown:active {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.dropdown-text {
|
||||
font-size: 13px;
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.dropdown-arrow {
|
||||
font-size: 10px;
|
||||
color: #666;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* KPI:默认 2列,宽屏 4列 */
|
||||
/* ✅ 核心修复:用 flex + calc(50%) 替代 width,避免 rpx + CSS var 失效 */
|
||||
.kpi-grid {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
flex-direction: row !important;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
@@ -526,7 +861,8 @@ export default {
|
||||
border: 1px solid rgba(0,0,0,0.06);
|
||||
padding: 14px;
|
||||
box-sizing: border-box;
|
||||
width: calc(50% - 6px);
|
||||
flex: 1 1 calc(50% - 6px);
|
||||
min-width: 260px; /* 窄屏自动掉到一列 */
|
||||
}
|
||||
|
||||
.kpi-label {
|
||||
@@ -551,6 +887,7 @@ export default {
|
||||
.tabs {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
flex-direction: row !important;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
background: #fff;
|
||||
@@ -624,8 +961,11 @@ export default {
|
||||
}
|
||||
|
||||
/* 关键:左右分栏(宽屏) */
|
||||
/* ✅ 修复:确保 flex 布局在 H5 正常工作 */
|
||||
.insights-row {
|
||||
display: flex;
|
||||
flex-direction: row !important;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
margin-top: 12px;
|
||||
@@ -633,13 +973,13 @@ export default {
|
||||
|
||||
/* 左边更宽,右边更窄 */
|
||||
.insights-left {
|
||||
flex: 2;
|
||||
min-width: 0; /* 防止图表撑破 */
|
||||
flex: 1 1 calc(66.666% - 8px);
|
||||
min-width: 360px; /* 窄屏自动掉到一列 */
|
||||
}
|
||||
|
||||
.insights-right {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
flex: 1 1 calc(33.333% - 8px);
|
||||
min-width: 360px; /* 窄屏自动掉到一列 */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
@@ -658,6 +998,7 @@ export default {
|
||||
|
||||
.rank-item {
|
||||
display: flex;
|
||||
flex-direction: row !important;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 0;
|
||||
@@ -692,6 +1033,7 @@ export default {
|
||||
|
||||
.rank-right {
|
||||
display: flex;
|
||||
flex-direction: row !important;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -715,7 +1057,17 @@ export default {
|
||||
/* 宽屏:KPI 4列 */
|
||||
@media screen and (min-width: 960px) {
|
||||
.kpi-card {
|
||||
width: calc(25% - 9px);
|
||||
flex: 1 1 calc(25% - 9px);
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
/* 宽屏时显示所有按钮,隐藏"更多"按钮 */
|
||||
.topbar-right .btn-hidden {
|
||||
display: flex !important;
|
||||
}
|
||||
|
||||
.more-btn {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -724,11 +1076,57 @@ export default {
|
||||
.insights-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
.insights-left,
|
||||
.insights-right {
|
||||
flex: 1 1 100%;
|
||||
min-width: 100%;
|
||||
}
|
||||
.chart-box {
|
||||
height: 320px;
|
||||
}
|
||||
.fullwide .chart-box {
|
||||
height: 360px;
|
||||
}
|
||||
|
||||
/* 顶部栏按钮在小屏幕上:隐藏部分按钮,显示"更多"按钮 */
|
||||
.topbar-right {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* 隐藏标记为 btn-hidden 的按钮 */
|
||||
.topbar-right .btn-hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* 显示"更多"按钮 */
|
||||
.more-btn {
|
||||
display: flex !important;
|
||||
}
|
||||
|
||||
/* 标题在窄屏时允许省略号 */
|
||||
.title,
|
||||
.subtitle {
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.icon-btn-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.icon-btn-icon .icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
padding: 4px 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dropdown-text {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
659
pages/mall/analytics/insight-detail.uvue
Normal file
659
pages/mall/analytics/insight-detail.uvue
Normal file
@@ -0,0 +1,659 @@
|
||||
<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="card card-full">
|
||||
<view class="card-head">
|
||||
<text class="card-title">{{ insight.title || '洞察详情' }}</text>
|
||||
<view class="meta-row">
|
||||
<text class="badge" :class="'badge-' + (insight.type || 'info')">{{ getInsightTypeText(insight.type) }}</text>
|
||||
<text class="badge badge-impact" :class="'impact-' + (insight.impact || 'medium')">{{ getImpactText(insight.impact) }}</text>
|
||||
<text class="meta-time" v-if="insight.created_at">{{ formatTime(insight.created_at) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="loading" class="state">
|
||||
<text class="state-text">加载中...</text>
|
||||
</view>
|
||||
<view v-else-if="errorMsg" class="state">
|
||||
<text class="state-text">{{ errorMsg }}</text>
|
||||
</view>
|
||||
<view v-else class="content">
|
||||
<text class="content-text">{{ insight.content }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 关联报表(可选) -->
|
||||
<view class="card" v-if="relatedReport.id">
|
||||
<view class="card-head">
|
||||
<text class="card-title">关联报表</text>
|
||||
<text class="card-desc">{{ relatedReport.type }} · {{ relatedReport.period }}</text>
|
||||
</view>
|
||||
<view class="report-row" @click="goToReportDetail">
|
||||
<view class="report-icon">📄</view>
|
||||
<view class="report-info">
|
||||
<text class="report-title">{{ relatedReport.title }}</text>
|
||||
<text class="report-time">{{ relatedReport.generated_at ? formatTime(relatedReport.generated_at) : '' }}</text>
|
||||
</view>
|
||||
<text class="report-arrow">></text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 留白 -->
|
||||
<view style="height: 24px;"></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</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'
|
||||
|
||||
type InsightDetail = {
|
||||
id: string
|
||||
report_id: string
|
||||
type: string
|
||||
impact: string
|
||||
title: string
|
||||
content: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
type RelatedReport = {
|
||||
id: string
|
||||
title: string
|
||||
type: string
|
||||
period: string
|
||||
generated_at: string
|
||||
}
|
||||
|
||||
export default {
|
||||
components: {
|
||||
AnalyticsSidebarMenu,
|
||||
AnalyticsTopBar
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
lastUpdateTime: '',
|
||||
showMoreMenu: false,
|
||||
showSidebarMenu: false,
|
||||
currentPath: '/pages/mall/analytics/insight-detail',
|
||||
insightId: '',
|
||||
loading: false,
|
||||
errorMsg: '',
|
||||
insight: {
|
||||
id: '',
|
||||
report_id: '',
|
||||
type: 'info',
|
||||
impact: 'medium',
|
||||
title: '',
|
||||
content: '',
|
||||
created_at: ''
|
||||
} as InsightDetail,
|
||||
relatedReport: {
|
||||
id: '',
|
||||
title: '',
|
||||
type: '',
|
||||
period: '',
|
||||
generated_at: ''
|
||||
} as RelatedReport
|
||||
}
|
||||
},
|
||||
|
||||
onLoad(options: any) {
|
||||
this.currentPath = '/pages/mall/analytics/insight-detail'
|
||||
this.updateTime()
|
||||
const insightId = (options.insightId || options.id) as string
|
||||
if (!insightId) {
|
||||
uni.showToast({ title: '缺少洞察ID', icon: 'none' })
|
||||
setTimeout(() => uni.navigateBack(), 1500)
|
||||
return
|
||||
}
|
||||
this.insightId = insightId
|
||||
this.loadInsightDetail()
|
||||
},
|
||||
|
||||
onShow() {
|
||||
this.currentPath = '/pages/mall/analytics/insight-detail'
|
||||
},
|
||||
|
||||
methods: {
|
||||
async loadInsightDetail() {
|
||||
try {
|
||||
this.loading = true
|
||||
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) {
|
||||
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.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 || ''}`
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('loadInsightDetail failed', e)
|
||||
this.errorMsg = '加载失败,请稍后重试'
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
refreshData() {
|
||||
this.loadInsightDetail()
|
||||
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}`
|
||||
},
|
||||
|
||||
formatTime(timeStr: string): string {
|
||||
if (!timeStr) return ''
|
||||
return `${timeStr}`.replace('T', ' ').split('.')[0]
|
||||
},
|
||||
|
||||
getInsightTypeText(type: string): string {
|
||||
const t = `${type || 'info'}`
|
||||
const map: Record<string, string> = {
|
||||
positive: '正向',
|
||||
warning: '预警',
|
||||
negative: '风险',
|
||||
info: '信息'
|
||||
}
|
||||
return map[t] || '信息'
|
||||
},
|
||||
|
||||
getImpactText(impact: string): string {
|
||||
const impacts: Record<string, string> = {
|
||||
high: '高影响',
|
||||
medium: '中影响',
|
||||
low: '低影响'
|
||||
}
|
||||
return impacts[impact || 'medium'] || '中影响'
|
||||
},
|
||||
|
||||
goToReportDetail() {
|
||||
if (!this.relatedReport.id) return
|
||||
uni.navigateTo({
|
||||
url: `/pages/mall/analytics/report-detail?reportId=${this.relatedReport.id}`
|
||||
})
|
||||
},
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/* 建议列表 */
|
||||
.suggestion-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.suggestion-item {
|
||||
display: flex;
|
||||
flex-direction: row !important;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: #f9fafb;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.suggestion-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.suggestion-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.suggestion-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.suggestion-desc {
|
||||
font-size: 12px;
|
||||
color: rgba(0,0,0,0.65);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* 异常列表 */
|
||||
.anomaly-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.anomaly-item {
|
||||
display: flex;
|
||||
flex-direction: row !important;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: #fef2f2;
|
||||
border-radius: 8px;
|
||||
border-left: 3px solid #ef4444;
|
||||
}
|
||||
|
||||
.anomaly-level {
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.anomaly-level.critical {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.anomaly-level.warning {
|
||||
background: #fef3c7;
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
.anomaly-level.info {
|
||||
background: #dbeafe;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.anomaly-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.anomaly-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.anomaly-desc {
|
||||
font-size: 12px;
|
||||
color: rgba(0,0,0,0.65);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.anomaly-time {
|
||||
font-size: 11px;
|
||||
color: rgba(0,0,0,0.45);
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
/* 响应式:窄屏时全屏显示 */
|
||||
@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>
|
||||
482
pages/mall/analytics/market-trends.uvue
Normal file
482
pages/mall/analytics/market-trends.uvue
Normal file
@@ -0,0 +1,482 @@
|
||||
<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 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() {
|
||||
// TODO: 实现市场数据加载
|
||||
this.updateTime()
|
||||
this.buildChartOptions()
|
||||
},
|
||||
|
||||
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() {
|
||||
// TODO: 构建图表配置
|
||||
this.marketTrendOption = {}
|
||||
this.industryCompareOption = {}
|
||||
this.seasonalTrendOption = {}
|
||||
this.priceTrendOption = {}
|
||||
this.competitionOption = {}
|
||||
},
|
||||
|
||||
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>
|
||||
646
pages/mall/analytics/product-insights.uvue
Normal file
646
pages/mall/analytics/product-insights.uvue
Normal file
@@ -0,0 +1,646 @@
|
||||
<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>
|
||||
|
||||
<!-- KPI 指标卡片 -->
|
||||
<view class="kpi-grid">
|
||||
<view class="kpi-card">
|
||||
<text class="kpi-label">商品总数</text>
|
||||
<text class="kpi-value">{{ formatInt(productData.total_products) }}</text>
|
||||
<text class="kpi-meta">较上期:{{ formatPct(productData.product_growth) }}</text>
|
||||
</view>
|
||||
<view class="kpi-card">
|
||||
<text class="kpi-label">热销商品</text>
|
||||
<text class="kpi-value">{{ formatInt(productData.hot_products) }}</text>
|
||||
<text class="kpi-meta">销量 > 100</text>
|
||||
</view>
|
||||
<view class="kpi-card">
|
||||
<text class="kpi-label">库存周转率</text>
|
||||
<text class="kpi-value">{{ formatPct(productData.turnover_rate) }}</text>
|
||||
<text class="kpi-meta">较上期:{{ formatPct(productData.turnover_growth) }}</text>
|
||||
</view>
|
||||
<view class="kpi-card">
|
||||
<text class="kpi-label">平均库存</text>
|
||||
<text class="kpi-value">{{ formatInt(productData.avg_stock) }}</text>
|
||||
<text class="kpi-meta">较上期:{{ formatPct(productData.stock_growth) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 商品销售分析 -->
|
||||
<view class="card card-full">
|
||||
<view class="card-head">
|
||||
<text class="card-title">商品销售分析</text>
|
||||
<text class="card-desc">{{ selectedPeriodText }} · 销售额趋势</text>
|
||||
</view>
|
||||
<EChartsView class="chart-box" :option="salesChartOption" />
|
||||
</view>
|
||||
|
||||
<!-- 商品分类分析 -->
|
||||
<view class="card">
|
||||
<view class="card-head">
|
||||
<text class="card-title">商品分类分析</text>
|
||||
<text class="card-desc">按分类统计销售额</text>
|
||||
</view>
|
||||
<EChartsView class="chart-box" :option="categoryChartOption" />
|
||||
</view>
|
||||
|
||||
<!-- 热销商品排行 -->
|
||||
<view class="card">
|
||||
<view class="card-head">
|
||||
<text class="card-title">热销商品排行 TOP 10</text>
|
||||
<text class="card-desc">按销量排序</text>
|
||||
</view>
|
||||
<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>
|
||||
<view class="rank-right">
|
||||
<text class="rank-val">{{ p.sales }} 件</text>
|
||||
<text class="chip" :class="p.growth >= 0 ? 'pos' : 'neg'">
|
||||
{{ p.growth >= 0 ? '+' : '' }}{{ p.growth }}%
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 商品库存分析 -->
|
||||
<view class="card">
|
||||
<view class="card-head">
|
||||
<text class="card-title">商品库存分析</text>
|
||||
<text class="card-desc">库存分布情况</text>
|
||||
</view>
|
||||
<EChartsView class="chart-box" :option="stockChartOption" />
|
||||
</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="priceChartOption" />
|
||||
</view>
|
||||
|
||||
<!-- 商品评价分析 -->
|
||||
<view class="card">
|
||||
<view class="card-head">
|
||||
<text class="card-title">商品评价分析</text>
|
||||
<text class="card-desc">评分分布</text>
|
||||
</view>
|
||||
<EChartsView class="chart-box" :option="reviewChartOption" />
|
||||
</view>
|
||||
|
||||
<!-- 留白 -->
|
||||
<view style="height: 24px;"></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</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'
|
||||
|
||||
type TimePeriod = { value: string; label: string }
|
||||
type ProductData = {
|
||||
total_products: number
|
||||
product_growth: number
|
||||
hot_products: number
|
||||
turnover_rate: number
|
||||
turnover_growth: number
|
||||
avg_stock: number
|
||||
stock_growth: number
|
||||
}
|
||||
type ProductRank = { id: string; rank: number; name: string; sales: number; growth: number }
|
||||
|
||||
export default {
|
||||
components: {
|
||||
AnalyticsSidebarMenu,
|
||||
AnalyticsTopBar,
|
||||
EChartsView
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
lastUpdateTime: '',
|
||||
selectedPeriod: '7d',
|
||||
showMoreMenu: false,
|
||||
showSidebarMenu: false,
|
||||
currentPath: '/pages/mall/analytics/product-insights',
|
||||
timePeriods: [
|
||||
{ value: '7d', label: '7天' },
|
||||
{ value: '30d', label: '30天' },
|
||||
{ value: '90d', label: '90天' },
|
||||
{ value: '1y', label: '1年' }
|
||||
] as Array<TimePeriod>,
|
||||
|
||||
productData: {
|
||||
total_products: 0,
|
||||
product_growth: 0,
|
||||
hot_products: 0,
|
||||
turnover_rate: 0,
|
||||
turnover_growth: 0,
|
||||
avg_stock: 0,
|
||||
stock_growth: 0
|
||||
} as ProductData,
|
||||
|
||||
topProducts: [] as Array<ProductRank>,
|
||||
|
||||
salesChartOption: {} as any,
|
||||
categoryChartOption: {} as any,
|
||||
stockChartOption: {} as any,
|
||||
priceChartOption: {} as any,
|
||||
reviewChartOption: {} as any
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
selectedPeriodText(): string {
|
||||
const p = this.timePeriods.find((t) => t.value === this.selectedPeriod)
|
||||
return p ? p.label : '7天'
|
||||
}
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
this.updateTime()
|
||||
this.loadProductData()
|
||||
},
|
||||
|
||||
methods: {
|
||||
async loadProductData() {
|
||||
// TODO: 实现商品数据加载
|
||||
this.updateTime()
|
||||
this.buildChartOptions()
|
||||
},
|
||||
|
||||
selectPeriod(p: string) {
|
||||
this.selectedPeriod = p
|
||||
this.loadProductData()
|
||||
},
|
||||
|
||||
refreshData() {
|
||||
this.loadProductData()
|
||||
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}`
|
||||
},
|
||||
|
||||
formatInt(n: number): string {
|
||||
const v = isFinite(n) ? Math.round(n) : 0
|
||||
if (v >= 10000) return (v / 10000).toFixed(1) + '万'
|
||||
return v.toString()
|
||||
},
|
||||
|
||||
formatPct(n: number): string {
|
||||
const v = isFinite(n) ? n : 0
|
||||
const sign = v > 0 ? '+' : ''
|
||||
return `${sign}${v.toFixed(1)}%`
|
||||
},
|
||||
|
||||
buildChartOptions() {
|
||||
// TODO: 构建图表配置
|
||||
this.salesChartOption = {}
|
||||
this.categoryChartOption = {}
|
||||
this.stockChartOption = {}
|
||||
this.priceChartOption = {}
|
||||
this.reviewChartOption = {}
|
||||
},
|
||||
|
||||
handleMenu() {
|
||||
this.showSidebarMenu = true
|
||||
},
|
||||
handleSidebarUpdate(visible: boolean) {
|
||||
this.showSidebarMenu = visible
|
||||
},
|
||||
|
||||
toggleMoreMenu() {
|
||||
this.showMoreMenu = !this.showMoreMenu
|
||||
},
|
||||
|
||||
closeMoreMenu() {
|
||||
this.showMoreMenu = false
|
||||
}
|
||||
}
|
||||
}
|
||||
</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;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/* 排行列表 */
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
/* 响应式:窄屏时全屏显示 */
|
||||
@media screen and (max-width: 959px) {
|
||||
.page-layout {
|
||||
flex-direction: column !important;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,30 @@
|
||||
<!-- 数据分析端 - 个人中心 -->
|
||||
<template>
|
||||
<view class="page">
|
||||
<!-- 固定顶部导航栏 -->
|
||||
<AnalyticsTopBar
|
||||
:title="'个人中心'"
|
||||
:lastUpdateTime="''"
|
||||
@menu-click="handleMenu"
|
||||
@refresh="handleRefresh"
|
||||
@search="handleSearch"
|
||||
@notification="handleNotification"
|
||||
@fullscreen="handleFullscreen"
|
||||
@mobile="handleMobile"
|
||||
@dropdown="handleDropdown"
|
||||
@settings="goToSettings"
|
||||
/>
|
||||
|
||||
<view class="page-layout">
|
||||
<!-- 侧边栏菜单组件 -->
|
||||
<AnalyticsSidebarMenu
|
||||
:visible="showSidebarMenu"
|
||||
:currentPath="currentPath"
|
||||
@visible-change="handleSidebarUpdate"
|
||||
/>
|
||||
|
||||
<!-- 主内容区域 -->
|
||||
<view class="main-content">
|
||||
<view class="analytics-profile">
|
||||
<!-- 分析师信息头部 -->
|
||||
<view class="profile-header">
|
||||
@@ -230,22 +255,34 @@
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import type { UserType, ApiResponseType } from '@/types/mall-types'
|
||||
import supa from '@/components/supadb/aksupainstance.uts'
|
||||
import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uvue'
|
||||
import type { UserType } from '@/types/mall-types'
|
||||
|
||||
// 报表类型定义
|
||||
type ReportType = {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
status: number
|
||||
status: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// 响应式数据
|
||||
const showSidebarMenu = ref(false)
|
||||
const currentPath = ref('/pages/mall/analytics/profile')
|
||||
|
||||
// TODO: 与 Supabase Auth / users 表打通后,这里应该来自 auth.uid()
|
||||
// 现在先使用 analytics 测试 seed 中的固定用户 ID,保证可联调出“真实数据效果”
|
||||
const currentUserId = ref('00000000-0000-0000-0000-000000000001')
|
||||
|
||||
const analystInfo = ref({
|
||||
id: '',
|
||||
phone: '',
|
||||
@@ -257,14 +294,14 @@ const workExperience = ref(5)
|
||||
const expertise = ref('电商数据')
|
||||
|
||||
const overviewData = ref({
|
||||
totalSales: '2,568,900',
|
||||
salesGrowth: 15.6,
|
||||
totalUsers: '48,392',
|
||||
userGrowth: 12.3,
|
||||
totalOrders: '15,678',
|
||||
orderGrowth: -3.2,
|
||||
conversionRate: 4.8,
|
||||
conversionGrowth: 0.5
|
||||
totalSales: '0',
|
||||
salesGrowth: 0,
|
||||
totalUsers: '0',
|
||||
userGrowth: 0,
|
||||
totalOrders: '0',
|
||||
orderGrowth: 0,
|
||||
conversionRate: 0,
|
||||
conversionGrowth: 0
|
||||
})
|
||||
|
||||
const reportCounts = ref({
|
||||
@@ -275,23 +312,23 @@ const reportCounts = ref({
|
||||
})
|
||||
|
||||
const todayInsights = ref({
|
||||
hotProduct: 'iPhone 15',
|
||||
peakTraffic: '15,680',
|
||||
conversionAnomaly: '下降12%',
|
||||
mobileRatio: 78.5
|
||||
hotProduct: '-',
|
||||
peakTraffic: '0',
|
||||
conversionAnomaly: '-',
|
||||
mobileRatio: 0
|
||||
})
|
||||
|
||||
const recentReports = ref([] as Array<ReportType>)
|
||||
|
||||
const trendPeriod = ref('week')
|
||||
const trendData = ref([
|
||||
{ label: '周一', sales: 125000, orders: 856 },
|
||||
{ label: '周二', sales: 148000, orders: 924 },
|
||||
{ label: '周三', sales: 167000, orders: 1053 },
|
||||
{ label: '周四', sales: 142000, orders: 892 },
|
||||
{ label: '周五', sales: 189000, orders: 1284 },
|
||||
{ label: '周六', sales: 234000, orders: 1567 },
|
||||
{ label: '周日', sales: 198000, orders: 1345 }
|
||||
{ label: '周一', sales: 0, orders: 0 },
|
||||
{ label: '周二', sales: 0, orders: 0 },
|
||||
{ label: '周三', sales: 0, orders: 0 },
|
||||
{ label: '周四', sales: 0, orders: 0 },
|
||||
{ label: '周五', sales: 0, orders: 0 },
|
||||
{ label: '周六', sales: 0, orders: 0 },
|
||||
{ label: '周日', sales: 0, orders: 0 }
|
||||
])
|
||||
|
||||
// 计算属性
|
||||
@@ -305,76 +342,343 @@ const maxOrders = computed(() => {
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
loadAnalystInfo()
|
||||
loadReportCounts()
|
||||
loadRecentReports()
|
||||
currentPath.value = '/pages/mall/analytics/profile'
|
||||
void loadAll()
|
||||
})
|
||||
|
||||
// 方法
|
||||
function loadAnalystInfo() {
|
||||
// 模拟加载分析师信息
|
||||
function handleMenu() {
|
||||
showSidebarMenu.value = true
|
||||
}
|
||||
|
||||
function handleSidebarUpdate(visible: boolean) {
|
||||
showSidebarMenu.value = visible
|
||||
}
|
||||
|
||||
function safeNumber(v: any): number {
|
||||
const n = Number(v)
|
||||
return isFinite(n) ? n : 0
|
||||
}
|
||||
|
||||
function fmtInt(n: number): string {
|
||||
const v = isFinite(n) ? Math.round(n) : 0
|
||||
return v.toLocaleString()
|
||||
}
|
||||
|
||||
function fmtMoney(n: number): string {
|
||||
const v = isFinite(n) ? n : 0
|
||||
return v.toLocaleString()
|
||||
}
|
||||
|
||||
function pctGrowth(cur: number, prev: number): number {
|
||||
if (prev > 0) return ((cur - prev) / prev) * 100
|
||||
return cur > 0 ? 100 : 0
|
||||
}
|
||||
|
||||
function dateISO(d: Date): string {
|
||||
return d.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
await loadAnalystInfo()
|
||||
await loadReportCounts()
|
||||
await loadRecentReports()
|
||||
await loadOverview()
|
||||
await loadTrend()
|
||||
await loadTodayInsights()
|
||||
}
|
||||
|
||||
async function loadAnalystInfo() {
|
||||
try {
|
||||
const res: any = await supa
|
||||
.from('users')
|
||||
.select('id, phone, email, nickname, avatar_url')
|
||||
.eq('id', currentUserId.value)
|
||||
const rows: Array<any> = Array.isArray(res.data) ? (res.data as Array<any>) : []
|
||||
if (rows.length > 0) {
|
||||
analystInfo.value = {
|
||||
id: 'analyst001',
|
||||
phone: '13777777777',
|
||||
email: 'analyst@mall.com',
|
||||
nickname: '数据分析专家',
|
||||
avatar_url: '/static/analyst-avatar.png',
|
||||
gender: 0,
|
||||
user_type: 3,
|
||||
status: 1,
|
||||
created_at: '2024-01-01'
|
||||
...(analystInfo.value as any),
|
||||
id: `${rows[0].id}`,
|
||||
phone: `${rows[0].phone || ''}`,
|
||||
email: `${rows[0].email || ''}`,
|
||||
nickname: `${rows[0].nickname || '数据分析师'}`,
|
||||
avatar_url: `${rows[0].avatar_url || ''}`
|
||||
} as any
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('loadAnalystInfo failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
function loadReportCounts() {
|
||||
// 模拟加载报表统计
|
||||
reportCounts.value = {
|
||||
total: 156,
|
||||
pending: 5,
|
||||
scheduled: 12,
|
||||
shared: 23
|
||||
async function loadReportCounts() {
|
||||
try {
|
||||
const res: any = await supa
|
||||
.from('analytics_reports')
|
||||
.select('status')
|
||||
.eq('owner_user_id', currentUserId.value)
|
||||
const rows: Array<any> = Array.isArray(res.data) ? (res.data as Array<any>) : []
|
||||
let total = rows.length
|
||||
let pending = 0
|
||||
let scheduled = 0
|
||||
let shared = 0
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const s = `${rows[i].status || ''}`
|
||||
if (s === 'pending') pending++
|
||||
if (s === 'scheduled') scheduled++
|
||||
if (s === 'shared') shared++
|
||||
}
|
||||
reportCounts.value = { total, pending, scheduled, shared }
|
||||
} catch (e) {
|
||||
console.error('loadReportCounts failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
function loadRecentReports() {
|
||||
// 模拟加载最近报表
|
||||
recentReports.value = [
|
||||
{
|
||||
id: 'report001',
|
||||
title: '11月销售业绩分析报告',
|
||||
description: '月度销售数据深度分析,包含渠道、品类、地区维度',
|
||||
status: 2,
|
||||
created_at: '2024-12-01 14:30:00'
|
||||
},
|
||||
{
|
||||
id: 'report002',
|
||||
title: '用户行为画像分析',
|
||||
description: '基于用户购买行为的精准画像分析',
|
||||
status: 1,
|
||||
created_at: '2024-12-01 10:20:00'
|
||||
},
|
||||
{
|
||||
id: 'report003',
|
||||
title: '商品销售排行榜',
|
||||
description: '热销商品TOP100及趋势分析',
|
||||
status: 2,
|
||||
created_at: '2024-11-30 16:45:00'
|
||||
async function loadRecentReports() {
|
||||
try {
|
||||
const res: any = await supa
|
||||
.from('analytics_reports')
|
||||
.select('id, title, description, status, created_at')
|
||||
.eq('owner_user_id', currentUserId.value)
|
||||
.order('created_at', { ascending: false } as any)
|
||||
.limit(5 as any)
|
||||
const rows: Array<any> = Array.isArray(res.data) ? (res.data as Array<any>) : []
|
||||
recentReports.value = rows.map((r: any) => ({
|
||||
id: `${r.id}`,
|
||||
title: `${r.title}`,
|
||||
description: `${r.description || ''}`,
|
||||
status: `${r.status || 'ready'}`,
|
||||
created_at: `${r.created_at || ''}`
|
||||
}))
|
||||
} catch (e) {
|
||||
console.error('loadRecentReports failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOverview() {
|
||||
try {
|
||||
const now = new Date()
|
||||
const end = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1) // < end
|
||||
const start = new Date(end.getTime() - 30 * 24 * 60 * 60 * 1000)
|
||||
const prevEnd = start
|
||||
const prevStart = new Date(prevEnd.getTime() - 30 * 24 * 60 * 60 * 1000)
|
||||
|
||||
const curRes: any = await supa
|
||||
.from('orders')
|
||||
.select('total_amount, user_id, created_at')
|
||||
.gte('created_at', start.toISOString())
|
||||
.lt('created_at', end.toISOString())
|
||||
.eq('status', 2)
|
||||
const prevRes: any = await supa
|
||||
.from('orders')
|
||||
.select('total_amount, user_id, created_at')
|
||||
.gte('created_at', prevStart.toISOString())
|
||||
.lt('created_at', prevEnd.toISOString())
|
||||
.eq('status', 2)
|
||||
|
||||
const curOrders: Array<any> = Array.isArray(curRes.data) ? (curRes.data as Array<any>) : []
|
||||
const prevOrders: Array<any> = Array.isArray(prevRes.data) ? (prevRes.data as Array<any>) : []
|
||||
|
||||
let curSales = 0
|
||||
let prevSales = 0
|
||||
const curUsers: Record<string, boolean> = {}
|
||||
const prevUsers: Record<string, boolean> = {}
|
||||
for (let i = 0; i < curOrders.length; i++) {
|
||||
curSales += safeNumber(curOrders[i].total_amount)
|
||||
const uid = `${curOrders[i].user_id || ''}`
|
||||
if (uid) curUsers[uid] = true
|
||||
}
|
||||
for (let i = 0; i < prevOrders.length; i++) {
|
||||
prevSales += safeNumber(prevOrders[i].total_amount)
|
||||
const uid = `${prevOrders[i].user_id || ''}`
|
||||
if (uid) prevUsers[uid] = true
|
||||
}
|
||||
|
||||
const curOrderCnt = curOrders.length
|
||||
const prevOrderCnt = prevOrders.length
|
||||
const curUserCnt = Object.keys(curUsers).length
|
||||
const prevUserCnt = Object.keys(prevUsers).length
|
||||
|
||||
// 转化率:下单用户 / 访问用户(用 user_sessions 近30天会话去重近似)
|
||||
const curSessRes: any = await supa
|
||||
.from('user_sessions')
|
||||
.select('user_id, created_at')
|
||||
.gte('created_at', start.toISOString())
|
||||
.lt('created_at', end.toISOString())
|
||||
const prevSessRes: any = await supa
|
||||
.from('user_sessions')
|
||||
.select('user_id, created_at')
|
||||
.gte('created_at', prevStart.toISOString())
|
||||
.lt('created_at', prevEnd.toISOString())
|
||||
|
||||
const curSess: Array<any> = Array.isArray(curSessRes.data) ? (curSessRes.data as Array<any>) : []
|
||||
const prevSess: Array<any> = Array.isArray(prevSessRes.data) ? (prevSessRes.data as Array<any>) : []
|
||||
const curVisitUsers: Record<string, boolean> = {}
|
||||
const prevVisitUsers: Record<string, boolean> = {}
|
||||
for (let i = 0; i < curSess.length; i++) {
|
||||
const uid = `${curSess[i].user_id || ''}`
|
||||
if (uid) curVisitUsers[uid] = true
|
||||
}
|
||||
for (let i = 0; i < prevSess.length; i++) {
|
||||
const uid = `${prevSess[i].user_id || ''}`
|
||||
if (uid) prevVisitUsers[uid] = true
|
||||
}
|
||||
const curVisitCnt = Object.keys(curVisitUsers).length
|
||||
const prevVisitCnt = Object.keys(prevVisitUsers).length
|
||||
const curConv = curVisitCnt > 0 ? (curUserCnt / curVisitCnt) * 100 : 0
|
||||
const prevConv = prevVisitCnt > 0 ? (prevUserCnt / prevVisitCnt) * 100 : 0
|
||||
|
||||
overviewData.value = {
|
||||
totalSales: fmtMoney(curSales),
|
||||
salesGrowth: safeNumber(pctGrowth(curSales, prevSales)),
|
||||
totalUsers: fmtInt(curUserCnt),
|
||||
userGrowth: safeNumber(pctGrowth(curUserCnt, prevUserCnt)),
|
||||
totalOrders: fmtInt(curOrderCnt),
|
||||
orderGrowth: safeNumber(pctGrowth(curOrderCnt, prevOrderCnt)),
|
||||
conversionRate: safeNumber(curConv),
|
||||
conversionGrowth: safeNumber(pctGrowth(curConv, prevConv))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('loadOverview failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTrend() {
|
||||
try {
|
||||
const now = new Date()
|
||||
if (trendPeriod.value === 'week') {
|
||||
const end = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
const start = new Date(end.getTime() - 6 * 24 * 60 * 60 * 1000)
|
||||
const p = new UTSJSONObject()
|
||||
p.set('p_start_date', dateISO(start))
|
||||
p.set('p_end_date', dateISO(end))
|
||||
p.set('p_merchant_id', null)
|
||||
const res: any = await supa.rpc('rpc_analytics_trend_data', p)
|
||||
const rows: Array<any> = Array.isArray(res.data) ? (res.data as Array<any>) : []
|
||||
|
||||
const weekLabels = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
|
||||
trendData.value = rows.map((r: any) => {
|
||||
const d = new Date(`${r.date}T00:00:00`)
|
||||
return {
|
||||
label: weekLabels[d.getDay()],
|
||||
sales: safeNumber(r.gmv),
|
||||
orders: safeNumber(r.orders)
|
||||
}
|
||||
})
|
||||
} else if (trendPeriod.value === 'month') {
|
||||
// 最近6个月(按月聚合)
|
||||
const end = new Date(now.getFullYear(), now.getMonth(), 1)
|
||||
const start = new Date(end.getFullYear(), end.getMonth() - 5, 1)
|
||||
const p = new UTSJSONObject()
|
||||
p.set('p_start_date', dateISO(start))
|
||||
p.set('p_end_date', dateISO(new Date(now.getFullYear(), now.getMonth(), now.getDate())))
|
||||
p.set('p_merchant_id', null)
|
||||
const res: any = await supa.rpc('rpc_analytics_trend_data', p)
|
||||
const rows: Array<any> = Array.isArray(res.data) ? (res.data as Array<any>) : []
|
||||
|
||||
const buckets: Record<string, { sales: number; orders: number }> = {}
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const key = `${rows[i].date}`.slice(0, 7) // yyyy-mm
|
||||
if (!buckets[key]) buckets[key] = { sales: 0, orders: 0 }
|
||||
buckets[key].sales += safeNumber(rows[i].gmv)
|
||||
buckets[key].orders += safeNumber(rows[i].orders)
|
||||
}
|
||||
const keys = Object.keys(buckets).sort()
|
||||
trendData.value = keys.map((k) => ({
|
||||
label: `${k.slice(5)}月`,
|
||||
sales: buckets[k].sales,
|
||||
orders: buckets[k].orders
|
||||
}))
|
||||
} else {
|
||||
// quarter:最近4个季度(按季度聚合)
|
||||
const end = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
const start = new Date(end.getFullYear(), end.getMonth() - 11, 1)
|
||||
const p = new UTSJSONObject()
|
||||
p.set('p_start_date', dateISO(start))
|
||||
p.set('p_end_date', dateISO(end))
|
||||
p.set('p_merchant_id', null)
|
||||
const res: any = await supa.rpc('rpc_analytics_trend_data', p)
|
||||
const rows: Array<any> = Array.isArray(res.data) ? (res.data as Array<any>) : []
|
||||
|
||||
const buckets: Record<string, { sales: number; orders: number }> = {}
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const d = new Date(`${rows[i].date}T00:00:00`)
|
||||
const q = Math.floor(d.getMonth() / 3) + 1
|
||||
const key = `${d.getFullYear()}-Q${q}`
|
||||
if (!buckets[key]) buckets[key] = { sales: 0, orders: 0 }
|
||||
buckets[key].sales += safeNumber(rows[i].gmv)
|
||||
buckets[key].orders += safeNumber(rows[i].orders)
|
||||
}
|
||||
const keys = Object.keys(buckets).sort()
|
||||
trendData.value = keys.map((k) => ({
|
||||
label: k.split('-')[1],
|
||||
sales: buckets[k].sales,
|
||||
orders: buckets[k].orders
|
||||
}))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('loadTrend failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTodayInsights() {
|
||||
try {
|
||||
const now = new Date()
|
||||
const today0 = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
const p = new UTSJSONObject()
|
||||
p.set('p_start_date', dateISO(today0))
|
||||
p.set('p_end_date', dateISO(today0))
|
||||
p.set('p_limit', 1)
|
||||
p.set('p_merchant_id', null)
|
||||
const prodRes: any = await supa.rpc('rpc_analytics_top_products', p)
|
||||
const prodRows: Array<any> = Array.isArray(prodRes.data) ? (prodRes.data as Array<any>) : []
|
||||
if (prodRows.length > 0) {
|
||||
todayInsights.value.hotProduct = `${prodRows[0].name}`
|
||||
}
|
||||
|
||||
// 访问量峰值(简化:今日总访问量)
|
||||
const pvRes: any = await supa
|
||||
.from('page_views')
|
||||
.select('id, created_at')
|
||||
.gte('created_at', today0.toISOString())
|
||||
.lt('created_at', new Date(today0.getTime() + 24 * 60 * 60 * 1000).toISOString())
|
||||
const pvRows: Array<any> = Array.isArray(pvRes.data) ? (pvRes.data as Array<any>) : []
|
||||
todayInsights.value.peakTraffic = fmtInt(pvRows.length)
|
||||
|
||||
// 转化异常:取今日 KPI 增长(简化,负数提示“下降xx%”)
|
||||
const kpiP = new UTSJSONObject()
|
||||
kpiP.set('p_start', today0.toISOString())
|
||||
kpiP.set('p_end', now.toISOString())
|
||||
const ySame = new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
||||
const y0 = new Date(ySame.getFullYear(), ySame.getMonth(), ySame.getDate())
|
||||
kpiP.set('p_compare_start', y0.toISOString())
|
||||
kpiP.set('p_compare_end', ySame.toISOString())
|
||||
kpiP.set('p_merchant_id', null)
|
||||
const kpiRes: any = await supa.rpc('rpc_analytics_realtime_kpis', kpiP)
|
||||
const row = Array.isArray(kpiRes.data) && kpiRes.data.length > 0 ? kpiRes.data[0] : (kpiRes.data || {})
|
||||
const cg = safeNumber(row.conversion_growth)
|
||||
todayInsights.value.conversionAnomaly = cg < 0 ? `下降${Math.abs(cg).toFixed(1)}%` : `上升${cg.toFixed(1)}%`
|
||||
|
||||
// mobileRatio:暂无来源维度,先置 0(后续可接入埋点/设备信息)
|
||||
todayInsights.value.mobileRatio = 0
|
||||
} catch (e) {
|
||||
console.error('loadTodayInsights failed', e)
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
function getAnalystRole(): string {
|
||||
return '高级数据分析师'
|
||||
}
|
||||
|
||||
function getReportStatusText(status: number): string {
|
||||
const statusMap = {
|
||||
1: '生成中',
|
||||
2: '已完成',
|
||||
3: '已发布',
|
||||
4: '已过期'
|
||||
function getReportStatusText(status: any): string {
|
||||
const s = `${status || ''}`
|
||||
const statusMap: Record<string, string> = {
|
||||
pending: '待生成',
|
||||
ready: '已完成',
|
||||
failed: '失败',
|
||||
scheduled: '定时',
|
||||
shared: '共享'
|
||||
}
|
||||
return statusMap[status] || '未知'
|
||||
return statusMap[s] || '未知'
|
||||
}
|
||||
|
||||
function formatTime(dateStr: string): string {
|
||||
@@ -394,36 +698,7 @@ function formatTime(dateStr: string): string {
|
||||
|
||||
function changeTrendPeriod(period: string) {
|
||||
trendPeriod.value = period
|
||||
|
||||
// 根据时间周期更新数据
|
||||
if (period === 'month') {
|
||||
trendData.value = [
|
||||
{ label: '1月', sales: 2850000, orders: 18560 },
|
||||
{ label: '2月', sales: 2140000, orders: 14920 },
|
||||
{ label: '3月', sales: 3250000, orders: 21530 },
|
||||
{ label: '4月', sales: 2980000, orders: 19420 },
|
||||
{ label: '5月', sales: 3650000, orders: 24840 },
|
||||
{ label: '6月', sales: 3420000, orders: 22670 }
|
||||
]
|
||||
} else if (period === 'quarter') {
|
||||
trendData.value = [
|
||||
{ label: 'Q1', sales: 8240000, orders: 55010 },
|
||||
{ label: 'Q2', sales: 10050000, orders: 66930 },
|
||||
{ label: 'Q3', sales: 11200000, orders: 74520 },
|
||||
{ label: 'Q4', sales: 9850000, orders: 65840 }
|
||||
]
|
||||
} else {
|
||||
// 默认周数据
|
||||
trendData.value = [
|
||||
{ label: '周一', sales: 125000, orders: 856 },
|
||||
{ label: '周二', sales: 148000, orders: 924 },
|
||||
{ label: '周三', sales: 167000, orders: 1053 },
|
||||
{ label: '周四', sales: 142000, orders: 892 },
|
||||
{ label: '周五', sales: 189000, orders: 1284 },
|
||||
{ label: '周六', sales: 234000, orders: 1567 },
|
||||
{ label: '周日', sales: 198000, orders: 1345 }
|
||||
]
|
||||
}
|
||||
void loadTrend()
|
||||
}
|
||||
|
||||
function viewReportDetail(reportId: string) {
|
||||
@@ -489,6 +764,42 @@ function goToFeedback() {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.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; /* 为固定顶部导航栏留出空间 */
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.menu-icon .icon {
|
||||
font-size: 18px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.analytics-profile {
|
||||
padding: 0 0 120rpx 0;
|
||||
background-color: #f5f5f5;
|
||||
@@ -941,4 +1252,15 @@ function goToFeedback() {
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
/* 响应式:窄屏时全屏显示 */
|
||||
@media screen and (max-width: 959px) {
|
||||
.page-layout {
|
||||
flex-direction: column !important;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,31 @@
|
||||
<!-- 数据分析端 - 报表详情页 -->
|
||||
<template>
|
||||
<view class="page">
|
||||
<!-- 固定顶部导航栏 -->
|
||||
<AnalyticsTopBar
|
||||
:title="report.title || '报表详情'"
|
||||
:lastUpdateTime="formatTime(report.generated_at)"
|
||||
:sidebarVisible="showSidebarMenu"
|
||||
@menu-click="handleMenu"
|
||||
@refresh="refreshReport"
|
||||
@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="report-detail-page">
|
||||
<!-- 报表头部 -->
|
||||
<view class="report-header">
|
||||
@@ -168,9 +194,16 @@
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="uts">
|
||||
import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uvue'
|
||||
import AnalyticsTopBar from '@/components/analytics/AnalyticsTopBar.uvue'
|
||||
import supa from '@/components/supadb/aksupainstance.uts'
|
||||
|
||||
type ReportType = {
|
||||
id: string
|
||||
title: string
|
||||
@@ -217,8 +250,14 @@ type InsightType = {
|
||||
}
|
||||
|
||||
export default {
|
||||
components: {
|
||||
AnalyticsSidebarMenu,
|
||||
AnalyticsTopBar
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showSidebarMenu: false,
|
||||
currentPath: '/pages/mall/analytics/report-detail',
|
||||
report: {
|
||||
id: '',
|
||||
title: '',
|
||||
@@ -232,6 +271,7 @@ export default {
|
||||
activeChartTab: '',
|
||||
chartLegends: [] as Array<ChartLegendType>,
|
||||
tableColumns: [] as Array<TableColumnType>,
|
||||
allRows: [] as Array<any>,
|
||||
tableData: [] as Array<any>,
|
||||
dataInsights: [] as Array<InsightType>,
|
||||
relatedReports: [] as Array<ReportType>,
|
||||
@@ -261,71 +301,68 @@ export default {
|
||||
uni.navigateBack()
|
||||
}, 1500)
|
||||
}
|
||||
this.currentPath = '/pages/mall/analytics/report-detail'
|
||||
},
|
||||
|
||||
onShow() {
|
||||
this.currentPath = '/pages/mall/analytics/report-detail'
|
||||
},
|
||||
|
||||
methods: {
|
||||
loadReportDetail(reportId: string) {
|
||||
// 模拟加载报表详情数据
|
||||
handleMenu() {
|
||||
this.showSidebarMenu = true
|
||||
},
|
||||
handleSidebarUpdate(visible: boolean) {
|
||||
this.showSidebarMenu = visible
|
||||
},
|
||||
safeNumber(v: any): number {
|
||||
const n = Number(v)
|
||||
return isFinite(n) ? n : 0
|
||||
},
|
||||
|
||||
async loadReportDetail(reportId: string) {
|
||||
try {
|
||||
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) {
|
||||
uni.showToast({ title: '报表不存在', icon: 'none' })
|
||||
return
|
||||
}
|
||||
const r = reportRows[0]
|
||||
this.report = {
|
||||
id: reportId,
|
||||
title: '销售业绩分析报表',
|
||||
type: 'sales',
|
||||
period: '2024年1月',
|
||||
generated_at: '2024-01-15T14:30:00',
|
||||
description: '详细分析1月份的销售业绩情况'
|
||||
id: `${r.id}`,
|
||||
title: `${r.title}`,
|
||||
type: `${r.type}`,
|
||||
period: `${r.period}`,
|
||||
generated_at: `${r.generated_at}`,
|
||||
description: `${r.description || ''}`
|
||||
}
|
||||
|
||||
this.coreMetrics = [
|
||||
{
|
||||
key: 'total_sales',
|
||||
label: '总销售额',
|
||||
value: 1250000,
|
||||
format: 'currency',
|
||||
icon: '💰',
|
||||
color: '#4caf50',
|
||||
change: 15.6
|
||||
},
|
||||
{
|
||||
key: 'order_count',
|
||||
label: '订单数量',
|
||||
value: 8650,
|
||||
format: 'number',
|
||||
icon: '📦',
|
||||
color: '#2196f3',
|
||||
change: 8.3
|
||||
},
|
||||
{
|
||||
key: 'avg_order_value',
|
||||
label: '客单价',
|
||||
value: 144.5,
|
||||
format: 'currency',
|
||||
icon: '🛍️',
|
||||
color: '#ff9800',
|
||||
change: 6.8
|
||||
},
|
||||
{
|
||||
key: 'conversion_rate',
|
||||
label: '转化率',
|
||||
value: 3.2,
|
||||
format: 'percent',
|
||||
icon: '📈',
|
||||
color: '#9c27b0',
|
||||
change: -2.1
|
||||
}
|
||||
]
|
||||
// 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)
|
||||
|
||||
this.chartTabs = [
|
||||
{ key: 'sales', label: '销售额' },
|
||||
{ key: 'orders', label: '订单量' },
|
||||
{ key: 'users', label: '用户数' }
|
||||
]
|
||||
this.activeChartTab = 'sales'
|
||||
|
||||
this.chartLegends = [
|
||||
{ key: 'current', label: '当期', color: '#2196f3' },
|
||||
{ key: 'previous', label: '上期', color: '#ff9800' },
|
||||
{ key: 'target', label: '目标', color: '#4caf50' }
|
||||
]
|
||||
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)
|
||||
}))
|
||||
|
||||
// 3. 配置表头与排序选项(固定结构)
|
||||
this.tableColumns = [
|
||||
{ key: 'date', title: '日期', width: '120rpx', type: 'text' },
|
||||
{ key: 'sales', title: '销售额', width: '120rpx', type: 'currency' },
|
||||
@@ -334,74 +371,88 @@ export default {
|
||||
{ key: 'conversion', title: '转化率', width: '100rpx', type: 'percent' },
|
||||
{ key: 'avg_value', title: '客单价', width: '120rpx', type: 'currency' }
|
||||
]
|
||||
|
||||
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.currentPage = 1
|
||||
this.updateTotalPages()
|
||||
this.generateTableData()
|
||||
|
||||
this.dataInsights = [
|
||||
{
|
||||
id: 'insight_001',
|
||||
type: 'positive',
|
||||
title: '销售额显著增长',
|
||||
content: '相比上月,本月销售额增长15.6%,主要得益于新产品上线和营销活动效果显著。',
|
||||
impact: 'high'
|
||||
},
|
||||
{
|
||||
id: 'insight_002',
|
||||
type: 'warning',
|
||||
title: '转化率轻微下降',
|
||||
content: '转化率较上月下降2.1%,建议优化商品页面和购买流程,提升用户体验。',
|
||||
impact: 'medium'
|
||||
},
|
||||
{
|
||||
id: 'insight_003',
|
||||
title: '周末销售高峰',
|
||||
content: '数据显示周末(周六、周日)的销售额占总销售额的35%,建议加强周末营销投入。',
|
||||
impact: 'medium',
|
||||
type: 'info'
|
||||
}
|
||||
]
|
||||
// 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)
|
||||
|
||||
this.relatedReports = [
|
||||
{
|
||||
id: 'report_002',
|
||||
title: '用户行为分析报表',
|
||||
type: 'user',
|
||||
period: '2024年1月',
|
||||
generated_at: '2024-01-15T10:00:00',
|
||||
description: '分析用户浏览、搜索、购买行为'
|
||||
},
|
||||
{
|
||||
id: 'report_003',
|
||||
title: '商品销售排行报表',
|
||||
type: 'product',
|
||||
period: '2024年1月',
|
||||
generated_at: '2024-01-15T09:30:00',
|
||||
description: '商品销售排行和库存分析'
|
||||
}
|
||||
]
|
||||
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.totalPages = Math.ceil(31 / parseInt(this.limitOptions[this.limitIndex]))
|
||||
// 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 || ''}`
|
||||
}))
|
||||
} catch (e) {
|
||||
console.error('loadReportDetail failed', e)
|
||||
uni.showToast({ title: '报表加载失败', icon: 'none' })
|
||||
} finally {
|
||||
uni.hideLoading()
|
||||
}
|
||||
},
|
||||
|
||||
updateTotalPages() {
|
||||
const total = this.allRows.length
|
||||
const limit = parseInt(this.limitOptions[this.limitIndex])
|
||||
this.totalPages = total > 0 ? Math.ceil(total / limit) : 1
|
||||
},
|
||||
|
||||
generateTableData() {
|
||||
this.tableData = []
|
||||
const days = 31
|
||||
const total = this.allRows.length
|
||||
if (total === 0) {
|
||||
return
|
||||
}
|
||||
const limit = parseInt(this.limitOptions[this.limitIndex])
|
||||
const start = (this.currentPage - 1) * limit
|
||||
const end = Math.min(start + limit, days)
|
||||
const end = Math.min(start + limit, total)
|
||||
|
||||
for (let i = start; i < end; i++) {
|
||||
const day = i + 1
|
||||
const row = this.allRows[i]
|
||||
this.tableData.push({
|
||||
date: `2024-01-${day.toString().padStart(2, '0')}`,
|
||||
sales: Math.floor(Math.random() * 50000) + 20000,
|
||||
orders: Math.floor(Math.random() * 300) + 200,
|
||||
users: Math.floor(Math.random() * 1000) + 500,
|
||||
conversion: (Math.random() * 5 + 1).toFixed(1),
|
||||
avg_value: (Math.random() * 100 + 50).toFixed(2)
|
||||
date: `${row.row_date}`,
|
||||
sales: this.safeNumber(row.gmv),
|
||||
orders: this.safeNumber(row.orders),
|
||||
users: this.safeNumber(row.users),
|
||||
conversion: this.safeNumber(row.conversion).toFixed(1),
|
||||
avg_value: this.safeNumber(row.avg_order_amount).toFixed(2)
|
||||
})
|
||||
}
|
||||
},
|
||||
@@ -483,7 +534,7 @@ export default {
|
||||
onLimitChange(e: any) {
|
||||
this.limitIndex = e.detail.value
|
||||
this.currentPage = 1
|
||||
this.totalPages = Math.ceil(31 / parseInt(this.limitOptions[this.limitIndex]))
|
||||
this.updateTotalPages()
|
||||
this.generateTableData()
|
||||
},
|
||||
|
||||
@@ -563,6 +614,24 @@ export default {
|
||||
icon: 'success'
|
||||
})
|
||||
},
|
||||
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' })
|
||||
},
|
||||
|
||||
resetConfig() {
|
||||
this.autoRefresh = false
|
||||
@@ -578,6 +647,42 @@ export default {
|
||||
</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; /* 为固定顶部导航栏留出空间 */
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.menu-icon .icon {
|
||||
font-size: 18px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.report-detail-page {
|
||||
background-color: #f5f5f5;
|
||||
min-height: 100vh;
|
||||
@@ -1040,4 +1145,15 @@ export default {
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
/* 响应式:窄屏时全屏显示 */
|
||||
@media screen and (max-width: 959px) {
|
||||
.page-layout {
|
||||
flex-direction: column !important;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
652
pages/mall/analytics/sales-report.uvue
Normal file
652
pages/mall/analytics/sales-report.uvue
Normal file
@@ -0,0 +1,652 @@
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
<AnalyticsComboChart
|
||||
:xLabels="trend.x"
|
||||
:gmv="trend.gmv"
|
||||
:orders="trend.orders"
|
||||
:height="320"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 商品销售排行 -->
|
||||
<view class="card">
|
||||
<view class="card-head">
|
||||
<text class="card-title">商品销售排行 TOP 10</text>
|
||||
<text class="card-desc">按销量排序</text>
|
||||
</view>
|
||||
<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 class="card">
|
||||
<view class="card-head">
|
||||
<text class="card-title">商家销售排行 TOP 10</text>
|
||||
<text class="card-desc">按 GMV 排序</text>
|
||||
</view>
|
||||
<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 class="card card-full">
|
||||
<view class="card-head">
|
||||
<text class="card-title">销售地域分布</text>
|
||||
<text class="card-desc">按省份统计</text>
|
||||
</view>
|
||||
<EChartsView class="chart-box" :option="regionChartOption" />
|
||||
</view>
|
||||
|
||||
<!-- 留白 -->
|
||||
<view style="height: 24px;"></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</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'
|
||||
|
||||
type TrendData = { x: Array<string>; gmv: Array<number>; orders: Array<number> }
|
||||
type SalesData = {
|
||||
gmv: number
|
||||
gmv_growth: number
|
||||
orders: number
|
||||
order_growth: number
|
||||
conversion_rate: number
|
||||
conversion_growth: number
|
||||
avg_order_amount: number
|
||||
avg_order_growth: number
|
||||
}
|
||||
type ProductRank = { id: string; rank: number; name: string; sales: number }
|
||||
type MerchantRank = { id: string; rank: number; name: string; sales: number; growth: number }
|
||||
|
||||
export default {
|
||||
components: {
|
||||
AnalyticsComboChart,
|
||||
AnalyticsSidebarMenu,
|
||||
AnalyticsTopBar,
|
||||
EChartsView
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
lastUpdateTime: '',
|
||||
selectedPeriod: '7d',
|
||||
showMoreMenu: false,
|
||||
showSidebarMenu: false,
|
||||
currentPath: '/pages/mall/analytics/sales-report',
|
||||
timePeriods: [
|
||||
{ value: '7d', label: '7天' },
|
||||
{ value: '30d', label: '30天' },
|
||||
{ value: '90d', label: '90天' },
|
||||
{ value: '1y', label: '1年' }
|
||||
],
|
||||
|
||||
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
|
||||
} as SalesData,
|
||||
|
||||
trend: {
|
||||
x: [] as Array<string>,
|
||||
gmv: [] as Array<number>,
|
||||
orders: [] as Array<number>
|
||||
} as TrendData,
|
||||
|
||||
topProducts: [] as Array<ProductRank>,
|
||||
topMerchants: [] as Array<MerchantRank>,
|
||||
|
||||
regionChartOption: {} as any
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
selectedPeriodText(): string {
|
||||
const p = this.timePeriods.find((t) => t.value === this.selectedPeriod)
|
||||
return p ? p.label : '7天'
|
||||
}
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
this.updateTime()
|
||||
this.loadSalesData()
|
||||
},
|
||||
|
||||
methods: {
|
||||
async loadSalesData() {
|
||||
// TODO: 实现销售数据加载
|
||||
this.updateTime()
|
||||
},
|
||||
|
||||
selectPeriod(p: string) {
|
||||
this.selectedPeriod = p
|
||||
this.loadSalesData()
|
||||
},
|
||||
|
||||
refreshData() {
|
||||
this.loadSalesData()
|
||||
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}`
|
||||
},
|
||||
|
||||
formatInt(n: number): string {
|
||||
const v = isFinite(n) ? Math.round(n) : 0
|
||||
if (v >= 10000) return (v / 10000).toFixed(1) + '万'
|
||||
return v.toString()
|
||||
},
|
||||
|
||||
formatMoney(n: number): string {
|
||||
const v = isFinite(n) ? n : 0
|
||||
if (v >= 10000) return (v / 10000).toFixed(1) + '万'
|
||||
return v.toFixed(0)
|
||||
},
|
||||
|
||||
formatPct(n: number): string {
|
||||
const v = isFinite(n) ? n : 0
|
||||
const sign = v > 0 ? '+' : ''
|
||||
return `${sign}${v.toFixed(1)}%`
|
||||
},
|
||||
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
/* 排行列表 */
|
||||
.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>
|
||||
578
pages/mall/analytics/user-analysis.uvue
Normal file
578
pages/mall/analytics/user-analysis.uvue
Normal file
@@ -0,0 +1,578 @@
|
||||
<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>
|
||||
|
||||
<!-- KPI 指标卡片 -->
|
||||
<view class="kpi-grid">
|
||||
<view class="kpi-card">
|
||||
<text class="kpi-label">总用户数</text>
|
||||
<text class="kpi-value">{{ formatInt(userData.total_users) }}</text>
|
||||
<text class="kpi-meta">较上期:{{ formatPct(userData.user_growth) }}</text>
|
||||
</view>
|
||||
<view class="kpi-card">
|
||||
<text class="kpi-label">新用户</text>
|
||||
<text class="kpi-value">{{ formatInt(userData.new_users) }}</text>
|
||||
<text class="kpi-meta">较上期:{{ formatPct(userData.new_user_growth) }}</text>
|
||||
</view>
|
||||
<view class="kpi-card">
|
||||
<text class="kpi-label">用户活跃度</text>
|
||||
<text class="kpi-value">{{ formatPct(userData.active_rate) }}</text>
|
||||
<text class="kpi-meta">较上期:{{ formatPct(userData.active_growth) }}</text>
|
||||
</view>
|
||||
<view class="kpi-card">
|
||||
<text class="kpi-label">复购率</text>
|
||||
<text class="kpi-value">{{ formatPct(userData.repurchase_rate) }}</text>
|
||||
<text class="kpi-meta">较上期:{{ formatPct(userData.repurchase_growth) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 用户增长趋势 -->
|
||||
<view class="card card-full">
|
||||
<view class="card-head">
|
||||
<text class="card-title">用户增长趋势</text>
|
||||
<text class="card-desc">{{ selectedPeriodText }} · 新用户 vs 总用户</text>
|
||||
</view>
|
||||
<EChartsView class="chart-box" :option="growthChartOption" />
|
||||
</view>
|
||||
|
||||
<!-- 用户留存率 -->
|
||||
<view class="card">
|
||||
<view class="card-head">
|
||||
<text class="card-title">用户留存率</text>
|
||||
<text class="card-desc">按留存天数统计</text>
|
||||
</view>
|
||||
<EChartsView class="chart-box" :option="retentionChartOption" />
|
||||
</view>
|
||||
|
||||
<!-- 用户活跃度分析 -->
|
||||
<view class="card">
|
||||
<view class="card-head">
|
||||
<text class="card-title">用户活跃度分析</text>
|
||||
<text class="card-desc">日活跃、周活跃、月活跃</text>
|
||||
</view>
|
||||
<EChartsView class="chart-box" :option="activityChartOption" />
|
||||
</view>
|
||||
|
||||
<!-- 新老用户对比 -->
|
||||
<view class="card card-full">
|
||||
<view class="card-head">
|
||||
<text class="card-title">新老用户对比</text>
|
||||
<text class="card-desc">GMV、订单数、客单价对比</text>
|
||||
</view>
|
||||
<EChartsView class="chart-box" :option="comparisonChartOption" />
|
||||
</view>
|
||||
|
||||
<!-- 用户画像分析 -->
|
||||
<view class="card">
|
||||
<view class="card-head">
|
||||
<text class="card-title">用户画像分析</text>
|
||||
<text class="card-desc">性别、年龄、地域分布</text>
|
||||
</view>
|
||||
<EChartsView class="chart-box" :option="profileChartOption" />
|
||||
</view>
|
||||
|
||||
<!-- 留白 -->
|
||||
<view style="height: 24px;"></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</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'
|
||||
|
||||
type UserData = {
|
||||
total_users: number
|
||||
user_growth: number
|
||||
new_users: number
|
||||
new_user_growth: number
|
||||
active_rate: number
|
||||
active_growth: number
|
||||
repurchase_rate: number
|
||||
repurchase_growth: number
|
||||
}
|
||||
|
||||
export default {
|
||||
components: {
|
||||
AnalyticsSidebarMenu,
|
||||
AnalyticsTopBar,
|
||||
EChartsView
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
lastUpdateTime: '',
|
||||
selectedPeriod: '7d',
|
||||
showMoreMenu: false,
|
||||
showSidebarMenu: false,
|
||||
currentPath: '/pages/mall/analytics/user-analysis',
|
||||
timePeriods: [
|
||||
{ value: '7d', label: '7天' },
|
||||
{ value: '30d', label: '30天' },
|
||||
{ value: '90d', label: '90天' },
|
||||
{ value: '1y', label: '1年' }
|
||||
],
|
||||
|
||||
userData: {
|
||||
total_users: 0,
|
||||
user_growth: 0,
|
||||
new_users: 0,
|
||||
new_user_growth: 0,
|
||||
active_rate: 0,
|
||||
active_growth: 0,
|
||||
repurchase_rate: 0,
|
||||
repurchase_growth: 0
|
||||
} as UserData,
|
||||
|
||||
growthChartOption: {} as any,
|
||||
retentionChartOption: {} as any,
|
||||
activityChartOption: {} as any,
|
||||
comparisonChartOption: {} as any,
|
||||
profileChartOption: {} as any
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
selectedPeriodText(): string {
|
||||
const p = this.timePeriods.find((t) => t.value === this.selectedPeriod)
|
||||
return p ? p.label : '7天'
|
||||
}
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
this.updateTime()
|
||||
this.loadUserData()
|
||||
},
|
||||
|
||||
methods: {
|
||||
async loadUserData() {
|
||||
// TODO: 实现用户数据加载
|
||||
this.updateTime()
|
||||
this.buildChartOptions()
|
||||
},
|
||||
|
||||
selectPeriod(p: string) {
|
||||
this.selectedPeriod = p
|
||||
this.loadUserData()
|
||||
},
|
||||
|
||||
refreshData() {
|
||||
this.loadUserData()
|
||||
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}`
|
||||
},
|
||||
|
||||
formatInt(n: number): string {
|
||||
const v = isFinite(n) ? Math.round(n) : 0
|
||||
if (v >= 10000) return (v / 10000).toFixed(1) + '万'
|
||||
return v.toString()
|
||||
},
|
||||
|
||||
formatPct(n: number): string {
|
||||
const v = isFinite(n) ? n : 0
|
||||
const sign = v > 0 ? '+' : ''
|
||||
return `${sign}${v.toFixed(1)}%`
|
||||
},
|
||||
|
||||
buildChartOptions() {
|
||||
// TODO: 构建图表配置
|
||||
this.growthChartOption = {}
|
||||
this.retentionChartOption = {}
|
||||
this.activityChartOption = {}
|
||||
this.comparisonChartOption = {}
|
||||
this.profileChartOption = {}
|
||||
},
|
||||
|
||||
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; /* 为固定顶部导航栏留出空间 */
|
||||
}
|
||||
|
||||
/* 响应式:窄屏时全屏显示 */
|
||||
@media screen and (max-width: 959px) {
|
||||
.page-layout {
|
||||
flex-direction: column !important;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@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>
|
||||
@@ -11,44 +11,20 @@
|
||||
|
||||
<!-- 注册表单 -->
|
||||
<view class="form-content">
|
||||
<!-- 手机号 -->
|
||||
<!-- 邮箱 -->
|
||||
<view class="input-group">
|
||||
<view class="input-wrapper">
|
||||
<image src="/static/user/phone_1.png" class="input-icon" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="输入手机号码"
|
||||
:value="phone"
|
||||
@input="(e: any) => phone = e.detail.value"
|
||||
maxlength="11"
|
||||
placeholder="输入邮箱"
|
||||
:value="email"
|
||||
@input="(e: any) => email = e.detail.value"
|
||||
class="input-field"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 验证码 -->
|
||||
<view class="input-group">
|
||||
<view class="input-wrapper">
|
||||
<image src="/static/user/code_2.png" class="input-icon" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="填写验证码"
|
||||
:value="captcha"
|
||||
@input="(e: any) => captcha = e.detail.value"
|
||||
maxlength="6"
|
||||
class="input-field code-input"
|
||||
/>
|
||||
<button
|
||||
class="code-btn"
|
||||
:disabled="codeDisabled"
|
||||
:class="{ 'disabled': codeDisabled }"
|
||||
@click="getCode"
|
||||
>
|
||||
{{ codeText }}
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 密码 -->
|
||||
<view class="input-group">
|
||||
<view class="input-wrapper">
|
||||
@@ -115,12 +91,12 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
import supa from '@/components/supadb/aksupainstance.uts'
|
||||
import { ensureUserProfile } from '@/utils/sapi.uts'
|
||||
|
||||
// 响应式数据
|
||||
const phone = ref<string>('')
|
||||
const captcha = ref<string>('')
|
||||
const email = ref<string>('')
|
||||
const password = ref<string>('')
|
||||
const confirmPassword = ref<string>('')
|
||||
const protocol = ref<boolean>(false)
|
||||
@@ -128,48 +104,24 @@
|
||||
const isLoading = ref<boolean>(false)
|
||||
const logoUrl = ref<string>('/static/logo.png')
|
||||
|
||||
// 验证码相关
|
||||
const codeDisabled = ref<boolean>(false)
|
||||
const codeText = ref<string>('获取验证码')
|
||||
const codeCountdown = ref<number>(0)
|
||||
let codeTimer: number | null = null
|
||||
|
||||
// 处理协议勾选变化
|
||||
const handleProtocolChange = (e: any) => {
|
||||
protocol.value = !protocol.value
|
||||
}
|
||||
|
||||
// 验证手机号
|
||||
const validatePhone = (): boolean => {
|
||||
if (phone.value.trim() === '') {
|
||||
// 验证邮箱
|
||||
const validateEmail = (): boolean => {
|
||||
if (email.value.trim() === '') {
|
||||
uni.showToast({
|
||||
title: '请填写手机号码',
|
||||
title: '请填写邮箱',
|
||||
icon: 'none'
|
||||
})
|
||||
return false
|
||||
}
|
||||
if (!/^1[3-9]\d{9}$/.test(phone.value)) {
|
||||
// 基础邮箱格式校验(足够用于前端提示)
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.value)) {
|
||||
uni.showToast({
|
||||
title: '请输入正确的手机号码',
|
||||
icon: 'none'
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// 验证验证码
|
||||
const validateCaptcha = (): boolean => {
|
||||
if (captcha.value.trim() === '') {
|
||||
uni.showToast({
|
||||
title: '请填写验证码',
|
||||
icon: 'none'
|
||||
})
|
||||
return false
|
||||
}
|
||||
if (!/^\d{6}$/.test(captcha.value)) {
|
||||
uni.showToast({
|
||||
title: '请输入正确的验证码',
|
||||
title: '请输入正确的邮箱',
|
||||
icon: 'none'
|
||||
})
|
||||
return false
|
||||
@@ -223,47 +175,6 @@
|
||||
return true
|
||||
}
|
||||
|
||||
// 获取验证码
|
||||
const getCode = async () => {
|
||||
if (!protocol.value) {
|
||||
inAnimation.value = true
|
||||
uni.showToast({
|
||||
title: '请先阅读并同意协议',
|
||||
icon: 'none'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!validatePhone()) {
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: 调用获取验证码接口
|
||||
uni.showToast({
|
||||
title: '验证码已发送',
|
||||
icon: 'success'
|
||||
})
|
||||
|
||||
// 开始倒计时
|
||||
codeDisabled.value = true
|
||||
codeCountdown.value = 60
|
||||
codeText.value = `${codeCountdown.value}秒后重试`
|
||||
|
||||
codeTimer = setInterval(() => {
|
||||
codeCountdown.value--
|
||||
if (codeCountdown.value > 0) {
|
||||
codeText.value = `${codeCountdown.value}秒后重试`
|
||||
} else {
|
||||
codeDisabled.value = false
|
||||
codeText.value = '获取验证码'
|
||||
if (codeTimer != null) {
|
||||
clearInterval(codeTimer)
|
||||
codeTimer = null
|
||||
}
|
||||
}
|
||||
}, 1000) as unknown as number
|
||||
}
|
||||
|
||||
// 处理注册
|
||||
const handleRegister = async () => {
|
||||
// 检查协议
|
||||
@@ -277,10 +188,7 @@
|
||||
}
|
||||
|
||||
// 表单验证
|
||||
if (!validatePhone()) {
|
||||
return
|
||||
}
|
||||
if (!validateCaptcha()) {
|
||||
if (!validateEmail()) {
|
||||
return
|
||||
}
|
||||
if (!validatePassword()) {
|
||||
@@ -293,36 +201,15 @@
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
// TODO: 调用注册接口(手机号+验证码+密码)
|
||||
// 目前先使用邮箱注册作为临时方案
|
||||
// 注意:CRMEB 使用手机号注册,但 Supabase Auth 默认支持邮箱注册
|
||||
// 需要根据实际后端 API 调整
|
||||
// 使用 Supabase Auth:邮箱 + 密码注册
|
||||
const result = await supa.signUp(email.value.trim(), password.value)
|
||||
const data = new UTSJSONObject(result as any)
|
||||
const user = data.getJSON('user')
|
||||
|
||||
// 临时方案:使用手机号作为邮箱格式注册
|
||||
const email = `${phone.value}@phone.mall`
|
||||
|
||||
const result = await supa.signUp(email, password.value)
|
||||
|
||||
if (result != null && result.user != null) {
|
||||
// 创建用户资料
|
||||
const user = result.user as UTSJSONObject
|
||||
const authId = user.getString('id')
|
||||
|
||||
if (authId != null) {
|
||||
const userData = {
|
||||
auth_id: authId,
|
||||
phone: phone.value,
|
||||
email: email,
|
||||
username: phone.value,
|
||||
user_type: 1, // 默认消费者
|
||||
status: 1
|
||||
} as UTSJSONObject
|
||||
|
||||
try {
|
||||
await supa.from('ak_users').insert(userData).execute()
|
||||
} catch (profileErr) {
|
||||
console.error('创建用户资料失败:', profileErr)
|
||||
}
|
||||
// Supabase 可能开启了邮箱验证,此时 user 可能为空/或 session 为空
|
||||
if (user != null) {
|
||||
// 记录业务侧用户资料(ak_users),用于 app 内个人中心等页面读取
|
||||
await ensureUserProfile(user as UTSJSONObject)
|
||||
}
|
||||
|
||||
uni.showToast({
|
||||
@@ -330,15 +217,11 @@
|
||||
icon: 'success'
|
||||
})
|
||||
|
||||
// 跳转到登录页
|
||||
setTimeout(() => {
|
||||
uni.redirectTo({
|
||||
url: '/pages/user/login'
|
||||
})
|
||||
}, 1500)
|
||||
} else {
|
||||
throw new Error('注册失败')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('注册错误:', err)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user