提交昨晚至今早的修改2
This commit is contained in:
63
pages/mall/admin/activity-log.uvue
Normal file
63
pages/mall/admin/activity-log.uvue
Normal file
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<view class="activity-log">
|
||||
<view class="page-header">
|
||||
<text class="page-title">活动日志</text>
|
||||
<text class="page-subtitle">查看系统活动和操作日志</text>
|
||||
</view>
|
||||
|
||||
<view class="log-content">
|
||||
<text class="coming-soon">活动日志功能正在开发中...</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
// 统一的导航方法
|
||||
const go = (url: string) => {
|
||||
// 1) 目标页面必须是非 tabBar 页面
|
||||
// 2) 必须在 pages.json / subPackages 注册
|
||||
uni.navigateTo({ url })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.activity-log {
|
||||
padding: 30rpx;
|
||||
background-color: #f5f5f5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
background-color: #fff;
|
||||
padding: 40rpx;
|
||||
border-radius: 16rpx;
|
||||
margin-bottom: 30rpx;
|
||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
|
||||
|
||||
.page-title {
|
||||
font-size: 36rpx;
|
||||
font-weight: bold;
|
||||
color: #212529;
|
||||
display: block;
|
||||
margin-bottom: 10rpx;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
font-size: 26rpx;
|
||||
color: #6c757d;
|
||||
}
|
||||
}
|
||||
|
||||
.log-content {
|
||||
background-color: #fff;
|
||||
padding: 60rpx 40rpx;
|
||||
border-radius: 16rpx;
|
||||
text-align: center;
|
||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
|
||||
|
||||
.coming-soon {
|
||||
font-size: 28rpx;
|
||||
color: #6c757d;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
63
pages/mall/admin/complaints.uvue
Normal file
63
pages/mall/admin/complaints.uvue
Normal file
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<view class="complaints">
|
||||
<view class="page-header">
|
||||
<text class="page-title">投诉处理</text>
|
||||
<text class="page-subtitle">处理用户投诉和反馈</text>
|
||||
</view>
|
||||
|
||||
<view class="complaints-content">
|
||||
<text class="coming-soon">投诉处理功能正在开发中...</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
// 统一的导航方法
|
||||
const go = (url: string) => {
|
||||
// 1) 目标页面必须是非 tabBar 页面
|
||||
// 2) 必须在 pages.json / subPackages 注册
|
||||
uni.navigateTo({ url })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.complaints {
|
||||
padding: 30rpx;
|
||||
background-color: #f5f5f5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
background-color: #fff;
|
||||
padding: 40rpx;
|
||||
border-radius: 16rpx;
|
||||
margin-bottom: 30rpx;
|
||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
|
||||
|
||||
.page-title {
|
||||
font-size: 36rpx;
|
||||
font-weight: bold;
|
||||
color: #212529;
|
||||
display: block;
|
||||
margin-bottom: 10rpx;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
font-size: 26rpx;
|
||||
color: #6c757d;
|
||||
}
|
||||
}
|
||||
|
||||
.complaints-content {
|
||||
background-color: #fff;
|
||||
padding: 60rpx 40rpx;
|
||||
border-radius: 16rpx;
|
||||
text-align: center;
|
||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
|
||||
|
||||
.coming-soon {
|
||||
font-size: 28rpx;
|
||||
color: #6c757d;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
11
pages/mall/admin/delivery-management.uvue
Normal file
11
pages/mall/admin/delivery-management.uvue
Normal file
@@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<text>配送管理 - 占位页</text>
|
||||
</view>
|
||||
</template>
|
||||
<script lang="uts">
|
||||
export default {}
|
||||
</script>
|
||||
<style>
|
||||
.page { padding: 30rpx; }
|
||||
</style>
|
||||
1583
pages/mall/admin/finance-management.uvue
Normal file
1583
pages/mall/admin/finance-management.uvue
Normal file
File diff suppressed because it is too large
Load Diff
187
pages/mall/admin/homePage/components/KpiMiniCard.uvue
Normal file
187
pages/mall/admin/homePage/components/KpiMiniCard.uvue
Normal file
@@ -0,0 +1,187 @@
|
||||
<template>
|
||||
<view class="kpi-card">
|
||||
<!-- Header -->
|
||||
<view class="kpi-header">
|
||||
<text class="kpi-title">{{ title }}</text>
|
||||
|
||||
<view v-if="tagText" class="kpi-tag">
|
||||
<text class="kpi-tag-text">{{ tagText }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 可选:你想在右上角塞额外按钮/图标 -->
|
||||
<slot name="headerRight"></slot>
|
||||
</view>
|
||||
|
||||
<!-- Body -->
|
||||
<view class="kpi-body">
|
||||
<text class="kpi-main-value">{{ valuePrefix }}{{ valueText }}</text>
|
||||
|
||||
<!-- 中间“昨日 / 日环比”行(可完全替换) -->
|
||||
<view v-if="metaLeft || metaRight" class="kpi-meta">
|
||||
<text v-if="metaLeft" class="kpi-meta-text">{{ metaLeft }}</text>
|
||||
|
||||
<view v-if="metaRight" class="kpi-meta-right">
|
||||
<text class="kpi-meta-text">{{ metaRight }}</text>
|
||||
|
||||
<text
|
||||
v-if="trend !== 'none'"
|
||||
class="kpi-trend-arrow"
|
||||
:class="trendClass"
|
||||
>
|
||||
{{ trendArrow }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<!-- 可选:完全自定义这行 -->
|
||||
<slot name="meta"></slot>
|
||||
</view>
|
||||
|
||||
<view class="kpi-divider"></view>
|
||||
|
||||
<!-- 底部一行:左文案 + 右数值 -->
|
||||
<view class="kpi-footer">
|
||||
<text class="kpi-footer-left">{{ footerLeftText }}</text>
|
||||
<text class="kpi-footer-right">{{ footerRightText }}</text>
|
||||
|
||||
<!-- 可选:完全自定义 footer -->
|
||||
<slot name="footer"></slot>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
// Header
|
||||
title: string
|
||||
tagText?: string
|
||||
|
||||
// Body main
|
||||
valueText: string
|
||||
valuePrefix?: string // 例如 "¥"
|
||||
|
||||
// Meta line (可替换)
|
||||
metaLeft?: string // 例如 "昨日 4"
|
||||
metaRight?: string // 例如 "日环比 0%"
|
||||
trend?: 'up' | 'down' | 'flat' | 'none' // none = 不显示箭头
|
||||
|
||||
// Footer
|
||||
footerLeftText: string // 例如 "本月订单量"
|
||||
footerRightText: string // 例如 "181单"
|
||||
}>(), {
|
||||
tagText: '今日',
|
||||
valuePrefix: '',
|
||||
metaLeft: '',
|
||||
metaRight: '',
|
||||
trend: 'none'
|
||||
})
|
||||
|
||||
const trendArrow = computed((): string => {
|
||||
if (props.trend === 'up') return '▲'
|
||||
if (props.trend === 'down') return '▼'
|
||||
return '•'
|
||||
})
|
||||
|
||||
const trendClass = computed((): string => {
|
||||
if (props.trend === 'up') return 'is-up'
|
||||
if (props.trend === 'down') return 'is-down'
|
||||
return 'is-flat'
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.kpi-card{
|
||||
background-color:#ffffff;
|
||||
border:1px solid #ebeef5;
|
||||
border-radius:6px;
|
||||
padding:16px;
|
||||
box-shadow:0 2px 12px rgba(0,0,0,0.04);
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.kpi-header{
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:space-between;
|
||||
gap:12px;
|
||||
|
||||
.kpi-title{
|
||||
font-size:14px;
|
||||
color:#303133;
|
||||
font-weight:600;
|
||||
}
|
||||
.kpi-tag{
|
||||
padding:2px 8px;
|
||||
border-radius:4px;
|
||||
border:1px solid #e1f3d8;
|
||||
background:#f0f9eb;
|
||||
}
|
||||
.kpi-tag-text{
|
||||
font-size:12px;
|
||||
color:#67c23a;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/* Body */
|
||||
.kpi-body{
|
||||
margin-top:10px;
|
||||
.kpi-main-value{
|
||||
font-size:32px;
|
||||
font-weight:600;
|
||||
color:#303133;
|
||||
line-height:40px;
|
||||
}
|
||||
|
||||
/* “昨日 / 日环比” */
|
||||
.kpi-meta{
|
||||
margin-top:8px;
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:flex-start;
|
||||
gap:12px;
|
||||
flex-wrap:wrap;
|
||||
}
|
||||
.kpi-meta-text{
|
||||
font-size:12px;
|
||||
color:#909399;
|
||||
}
|
||||
.kpi-meta-right{
|
||||
display:flex;
|
||||
align-items:center;
|
||||
gap:6px;
|
||||
}
|
||||
.kpi-trend-arrow{
|
||||
font-size:12px;
|
||||
}
|
||||
.kpi-trend-arrow.is-up{ color:#f56c6c; }
|
||||
.kpi-trend-arrow.is-down{ color:#67c23a; }
|
||||
.kpi-trend-arrow.is-flat{ color:#909399; }
|
||||
|
||||
.kpi-divider{
|
||||
height:1px;
|
||||
background:#ebeef5;
|
||||
margin:12px 0;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.kpi-footer{
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:space-between;
|
||||
gap:12px;
|
||||
}
|
||||
.kpi-footer-left{
|
||||
font-size:12px;
|
||||
color:#909399;
|
||||
}
|
||||
.kpi-footer-right{
|
||||
font-size:12px;
|
||||
color:#909399;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
494
pages/mall/admin/homePage/index.uvue
Normal file
494
pages/mall/admin/homePage/index.uvue
Normal file
@@ -0,0 +1,494 @@
|
||||
<template>
|
||||
<AdminLayout current-page="dashboard">
|
||||
<view class="dashboard-page">
|
||||
<!-- 第一行:4 个 KPI 卡片 -->
|
||||
<view class="kpi-cards-row">
|
||||
<KpiMiniCard
|
||||
title="销售额"
|
||||
tagText="今日"
|
||||
valuePrefix="¥"
|
||||
:valueText="String(formatNumber(kpiData.sales.today))"
|
||||
:metaLeft="`昨日 ${formatNumber(kpiData.sales.yesterday)}`"
|
||||
:metaRight="`日环比 ${Math.abs(kpiData.sales.change)}%`"
|
||||
:trend="kpiData.sales.change > 0 ? 'up' : (kpiData.sales.change < 0 ? 'down' : 'flat')"
|
||||
:footerLeftText="'本月销售额'"
|
||||
:footerRightText="`¥${formatNumber(kpiData.sales.monthTotal)}`"
|
||||
/>
|
||||
|
||||
<KpiMiniCard
|
||||
title="用户访问量"
|
||||
tagText="今日"
|
||||
:valueText="String(formatNumber(kpiData.visits.today))"
|
||||
:metaLeft="`昨日 ${formatNumber(kpiData.visits.yesterday)}`"
|
||||
:metaRight="`日环比 ${Math.abs(kpiData.visits.change)}%`"
|
||||
:trend="kpiData.visits.change > 0 ? 'up' : (kpiData.visits.change < 0 ? 'down' : 'flat')"
|
||||
footerLeftText="本月访问量"
|
||||
:footerRightText="`${formatNumber(kpiData.visits.monthTotal)}Pv`"
|
||||
/>
|
||||
|
||||
<KpiMiniCard
|
||||
title="订单量"
|
||||
tagText="今日"
|
||||
:valueText="String(formatNumber(kpiData.orders.today))"
|
||||
:metaLeft="`昨日 ${formatNumber(kpiData.orders.yesterday)}`"
|
||||
:metaRight="`日环比 ${Math.abs(kpiData.orders.change)}%`"
|
||||
:trend="kpiData.orders.change > 0 ? 'up' : (kpiData.orders.change < 0 ? 'down' : 'flat')"
|
||||
footerLeftText="本月订单量"
|
||||
:footerRightText="`${formatNumber(kpiData.orders.monthTotal)}单`"
|
||||
/>
|
||||
|
||||
<KpiMiniCard
|
||||
title="新增用户"
|
||||
tagText="今日"
|
||||
:valueText="String(formatNumber(kpiData.users.today))"
|
||||
:metaLeft="`昨日 ${formatNumber(kpiData.users.yesterday)}`"
|
||||
:metaRight="`日环比 ${Math.abs(kpiData.users.change)}%`"
|
||||
:trend="kpiData.users.change > 0 ? 'up' : (kpiData.users.change < 0 ? 'down' : 'flat')"
|
||||
footerLeftText="本月新增用户"
|
||||
:footerRightText="`${formatNumber(kpiData.users.monthTotal)}人`"
|
||||
/>
|
||||
</view>
|
||||
|
||||
|
||||
<!-- 第二行:订单统计图表 -->
|
||||
<view class="chart-section">
|
||||
<view class="admin-card">
|
||||
<view class="admin-card-header">
|
||||
<text class="admin-card-title">订单</text>
|
||||
<view class="chart-controls">
|
||||
<button
|
||||
v-for="period in chartPeriods"
|
||||
:key="period.value"
|
||||
class="period-btn"
|
||||
:class="{ 'active': selectedPeriod === period.value }"
|
||||
@click="changePeriod(period.value)"
|
||||
>
|
||||
{{ period.label }}
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
<view class="admin-card-body">
|
||||
<!-- ECharts 组合图容器 -->
|
||||
<view class="echarts-container">
|
||||
<text class="chart-placeholder">📊 ECharts 组合图:柱状图(订单金额) + 折线图(订单数量)</text>
|
||||
<text class="chart-desc">时间粒度:{{ selectedPeriodLabel }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 第三行:用户统计图表 -->
|
||||
<view class="charts-row">
|
||||
<!-- 用户趋势折线图 -->
|
||||
<view class="chart-col">
|
||||
<view class="admin-card">
|
||||
<view class="admin-card-header">
|
||||
<text class="admin-card-title">用户趋势</text>
|
||||
</view>
|
||||
<view class="admin-card-body">
|
||||
<view class="echarts-container">
|
||||
<text class="chart-placeholder">📈 ECharts 折线图:用户增长趋势</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 用户构成饼图 -->
|
||||
<view class="chart-col">
|
||||
<view class="admin-card">
|
||||
<view class="admin-card-header">
|
||||
<text class="admin-card-title">用户构成</text>
|
||||
</view>
|
||||
<view class="admin-card-body">
|
||||
<view class="echarts-container">
|
||||
<text class="chart-placeholder">🥧 ECharts 饼图:用户来源分布</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref } from 'vue'
|
||||
import AdminLayout from '@/layouts/admin/index.uvue'
|
||||
import KpiMiniCard from './components/KpiMiniCard.uvue'
|
||||
|
||||
// KPI 数据
|
||||
const kpiData = ref({
|
||||
sales: {
|
||||
today: 125680.50,
|
||||
yesterday: 118920.30,
|
||||
monthTotal: 2857808.90,
|
||||
change: 5.7
|
||||
},
|
||||
visits: {
|
||||
today: 15420,
|
||||
yesterday: 14890,
|
||||
monthTotal: 342680,
|
||||
change: 3.4
|
||||
},
|
||||
orders: {
|
||||
today: 342,
|
||||
yesterday: 318,
|
||||
monthTotal: 8956,
|
||||
change: 7.5
|
||||
},
|
||||
users: {
|
||||
today: 156,
|
||||
yesterday: 142,
|
||||
monthTotal: 3245,
|
||||
change: 9.9
|
||||
}
|
||||
})
|
||||
|
||||
// 图表配置
|
||||
const selectedPeriod = ref('30days')
|
||||
const selectedPeriodLabel = ref('30天')
|
||||
|
||||
const chartPeriods = [
|
||||
{ label: '30天', value: '30days' },
|
||||
{ label: '周', value: 'week' },
|
||||
{ label: '月', value: 'month' },
|
||||
{ label: '年', value: 'year' }
|
||||
]
|
||||
|
||||
// 方法
|
||||
const formatNumber = (num: number) => {
|
||||
if (num >= 10000) {
|
||||
return (num / 10000).toFixed(1) + '万'
|
||||
} else if (num >= 1000) {
|
||||
return (num / 1000).toFixed(1) + 'k'
|
||||
}
|
||||
return num.toString()
|
||||
}
|
||||
|
||||
const changePeriod = (period: string) => {
|
||||
selectedPeriod.value = period
|
||||
const periodMap: Record<string, string> = {
|
||||
'30days': '30天',
|
||||
'week': '周',
|
||||
'month': '月',
|
||||
'year': '年'
|
||||
}
|
||||
selectedPeriodLabel.value = periodMap[period] || '30天'
|
||||
|
||||
// TODO: 重新加载图表数据
|
||||
console.log('切换时间粒度:', period)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* ===== Dashboard 页面样式 ===== */
|
||||
.dashboard-page {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ===== KPI 卡片行 ===== */
|
||||
/* 第一行:4 个 KPI 卡片一行 */
|
||||
.kpi-cards-row{
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr)); /* 一行 4 列等分 */
|
||||
gap: 16px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
/* 卡片本体:不要写死宽高 */
|
||||
.kpi-card{
|
||||
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
width: 100%;
|
||||
height: 200px; /* 你可以改成 140/160,别写死 200px */
|
||||
box-sizing: border-box;
|
||||
margin-bottom: 20rpx;
|
||||
min-width: 200rpx;
|
||||
}
|
||||
|
||||
/* 响应式:宽度不够时变 2 列 / 1 列(可选) */
|
||||
@media (max-width: 1200px){
|
||||
.kpi-cards-row{ grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (max-width: 768px){
|
||||
.kpi-cards-row{ grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
|
||||
.kpi-card-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.kpi-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.kpi-card-title {
|
||||
position: absolute;
|
||||
|
||||
top: 10rpx;
|
||||
left: 5rpx;
|
||||
font-size: 16px;
|
||||
color: #666666;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.kpi-card-tag {
|
||||
background-color: #1890ff;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.kpi-tag-text {
|
||||
font-size: 12px;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.kpi-card-value {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.kpi-value-number {
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
.kpi-value-trend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
border-radius: 12px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.kpi-value-trend.up {
|
||||
background-color: #f6ffed;
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.kpi-value-trend.down {
|
||||
background-color: #fff2f0;
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
.kpi-trend-text {
|
||||
margin-left: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.kpi-card-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.kpi-footer-text {
|
||||
font-size: 14px;
|
||||
color: #999999;
|
||||
}
|
||||
|
||||
.kpi-card-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
background: linear-gradient(135deg, #1890ff 0%, #36cfc9 100%);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #ffffff;
|
||||
font-size: 32px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ===== 图表区域 ===== */
|
||||
.chart-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.charts-row {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.chart-col {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ===== Admin Card 组件样式 ===== */
|
||||
.admin-card {
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.admin-card-header {
|
||||
padding: 24px 24px 0 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.admin-card-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
}
|
||||
|
||||
.admin-card-body {
|
||||
padding: 0 24px 24px 24px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ===== 图表控件 ===== */
|
||||
.chart-controls {
|
||||
display: flex;
|
||||
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.period-btn {
|
||||
padding: 6px 16px;
|
||||
border: 1px solid #d9d9d9;
|
||||
background-color: #ffffff;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.period-btn:hover {
|
||||
border-color: #1890ff;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.period-btn.active {
|
||||
background-color: #1890ff;
|
||||
color: #ffffff;
|
||||
border-color: #1890ff;
|
||||
}
|
||||
|
||||
/* ===== ECharts 容器 ===== */
|
||||
.echarts-container {
|
||||
height: 350px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #fafafa;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.chart-placeholder {
|
||||
font-size: 16px;
|
||||
color: #666666;
|
||||
text-align: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.chart-desc {
|
||||
font-size: 14px;
|
||||
color: #999999;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ===== 响应式设计 ===== */
|
||||
@media (max-width: 1200px) {
|
||||
.kpi-cards-row {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.kpi-card {
|
||||
min-width: 45%;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.kpi-cards-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.kpi-card {
|
||||
min-width: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.charts-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.dashboard-page {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.kpi-cards-row,
|
||||
.chart-section,
|
||||
.charts-row {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.kpi-card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.admin-card-header,
|
||||
.admin-card-body {
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 图标字体 ===== */
|
||||
.iconfont {
|
||||
font-family: 'iconfont';
|
||||
font-size: 16px;
|
||||
font-style: normal;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.icon-up:before {
|
||||
content: '↑';
|
||||
}
|
||||
|
||||
.icon-down:before {
|
||||
content: '↓';
|
||||
}
|
||||
|
||||
.icon-sales:before {
|
||||
content: '💰';
|
||||
}
|
||||
|
||||
.icon-visits:before {
|
||||
content: '👁️';
|
||||
}
|
||||
|
||||
.icon-orders:before {
|
||||
content: '📦';
|
||||
}
|
||||
|
||||
.icon-users:before {
|
||||
content: '👥';
|
||||
}
|
||||
</style>
|
||||
@@ -1,847 +0,0 @@
|
||||
<!-- 后台管理端首页 - UTS Android 兼容 -->
|
||||
<template>
|
||||
<view class="admin-container">
|
||||
<!-- 头部导航 -->
|
||||
<view class="header">
|
||||
<view class="header-left">
|
||||
<text class="app-title">商城管理后台</text>
|
||||
<text class="welcome-text">欢迎回来,{{ adminInfo.nickname }}</text>
|
||||
</view>
|
||||
<view class="header-right">
|
||||
<text class="notification-btn" @click="goToNotifications">🔔</text>
|
||||
<text class="profile-btn" @click="goToProfile">👤</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 核心指标概览 -->
|
||||
<view class="metrics-section">
|
||||
<text class="section-title">核心指标</text>
|
||||
<view class="metrics-grid">
|
||||
<view class="metric-card">
|
||||
<text class="metric-value">¥{{ platformStats.total_gmv }}</text>
|
||||
<text class="metric-label">总GMV</text>
|
||||
<text class="metric-change positive">+{{ platformStats.gmv_growth }}%</text>
|
||||
</view>
|
||||
<view class="metric-card">
|
||||
<text class="metric-value">{{ platformStats.total_orders }}</text>
|
||||
<text class="metric-label">总订单数</text>
|
||||
<text class="metric-change positive">+{{ platformStats.order_growth }}%</text>
|
||||
</view>
|
||||
<view class="metric-card">
|
||||
<text class="metric-value">{{ platformStats.total_users }}</text>
|
||||
<text class="metric-label">注册用户</text>
|
||||
<text class="metric-change positive">+{{ platformStats.user_growth }}%</text>
|
||||
</view>
|
||||
<view class="metric-card">
|
||||
<text class="metric-value">{{ platformStats.total_merchants }}</text>
|
||||
<text class="metric-label">入驻商家</text>
|
||||
<text class="metric-change positive">+{{ platformStats.merchant_growth }}%</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 今日数据 -->
|
||||
<view class="today-section">
|
||||
<text class="section-title">今日数据</text>
|
||||
<view class="today-grid">
|
||||
<view class="today-item">
|
||||
<text class="today-value">¥{{ todayStats.sales }}</text>
|
||||
<text class="today-label">销售额</text>
|
||||
</view>
|
||||
<view class="today-item">
|
||||
<text class="today-value">{{ todayStats.orders }}</text>
|
||||
<text class="today-label">订单数</text>
|
||||
</view>
|
||||
<view class="today-item">
|
||||
<text class="today-value">{{ todayStats.new_users }}</text>
|
||||
<text class="today-label">新增用户</text>
|
||||
</view>
|
||||
<view class="today-item">
|
||||
<text class="today-value">{{ todayStats.active_users }}</text>
|
||||
<text class="today-label">活跃用户</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 待处理事项 -->
|
||||
<view class="pending-section">
|
||||
<text class="section-title">待处理事项</text>
|
||||
<view class="pending-list">
|
||||
<view class="pending-item urgent" @click="goToMerchantReview">
|
||||
<text class="pending-icon">🏪</text>
|
||||
<view class="pending-content">
|
||||
<text class="pending-title">商家入驻审核</text>
|
||||
<text class="pending-subtitle">{{ pendingCounts.merchant_review }}个商家待审核</text>
|
||||
</view>
|
||||
<text class="pending-count">{{ pendingCounts.merchant_review }}</text>
|
||||
</view>
|
||||
|
||||
<view class="pending-item" @click="goToProductReview">
|
||||
<text class="pending-icon">📦</text>
|
||||
<view class="pending-content">
|
||||
<text class="pending-title">商品审核</text>
|
||||
<text class="pending-subtitle">{{ pendingCounts.product_review }}个商品待审核</text>
|
||||
</view>
|
||||
<text class="pending-count">{{ pendingCounts.product_review }}</text>
|
||||
</view>
|
||||
|
||||
<view class="pending-item" @click="goToRefundReview">
|
||||
<text class="pending-icon">💰</text>
|
||||
<view class="pending-content">
|
||||
<text class="pending-title">退款处理</text>
|
||||
<text class="pending-subtitle">{{ pendingCounts.refund_review }}个退款申请</text>
|
||||
</view>
|
||||
<text class="pending-count">{{ pendingCounts.refund_review }}</text>
|
||||
</view>
|
||||
|
||||
<view class="pending-item" @click="goToComplaints">
|
||||
<text class="pending-icon">⚠️</text>
|
||||
<view class="pending-content">
|
||||
<text class="pending-title">投诉处理</text>
|
||||
<text class="pending-subtitle">{{ pendingCounts.complaints }}个投诉待处理</text>
|
||||
</view>
|
||||
<text class="pending-count">{{ pendingCounts.complaints }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 实时监控 -->
|
||||
<view class="monitor-section">
|
||||
<text class="section-title">实时监控</text>
|
||||
<view class="monitor-grid">
|
||||
<view class="monitor-card">
|
||||
<text class="monitor-title">在线用户</text>
|
||||
<text class="monitor-value">{{ realTimeStats.online_users }}</text>
|
||||
<text class="monitor-unit">人</text>
|
||||
</view>
|
||||
<view class="monitor-card">
|
||||
<text class="monitor-title">活跃配送员</text>
|
||||
<text class="monitor-value">{{ realTimeStats.active_drivers }}</text>
|
||||
<text class="monitor-unit">人</text>
|
||||
</view>
|
||||
<view class="monitor-card">
|
||||
<text class="monitor-title">配送中订单</text>
|
||||
<text class="monitor-value">{{ realTimeStats.delivering_orders }}</text>
|
||||
<text class="monitor-unit">单</text>
|
||||
</view>
|
||||
<view class="monitor-card">
|
||||
<text class="monitor-title">系统负载</text>
|
||||
<text class="monitor-value">{{ realTimeStats.system_load }}</text>
|
||||
<text class="monitor-unit">%</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 快捷管理功能 -->
|
||||
<view class="shortcuts-section">
|
||||
<text class="section-title">快捷管理</text>
|
||||
<view class="shortcuts-grid">
|
||||
<view class="shortcut-item" @click="goToUserManagement">
|
||||
<text class="shortcut-icon">👥</text>
|
||||
<text class="shortcut-text">用户管理</text>
|
||||
</view>
|
||||
<view class="shortcut-item" @click="goToMerchantManagement">
|
||||
<text class="shortcut-icon">🏪</text>
|
||||
<text class="shortcut-text">商家管理</text>
|
||||
</view>
|
||||
<view class="shortcut-item" @click="goToProductManagement">
|
||||
<text class="shortcut-icon">📦</text>
|
||||
<text class="shortcut-text">商品管理</text>
|
||||
</view>
|
||||
<view class="shortcut-item" @click="goToOrderManagement">
|
||||
<text class="shortcut-icon">📋</text>
|
||||
<text class="shortcut-text">订单管理</text>
|
||||
</view>
|
||||
<view class="shortcut-item" @click="goToCouponManagement">
|
||||
<text class="shortcut-icon">🎫</text>
|
||||
<text class="shortcut-text">优惠券管理</text>
|
||||
</view>
|
||||
<view class="shortcut-item" @click="goToDeliveryManagement">
|
||||
<text class="shortcut-icon">🚚</text>
|
||||
<text class="shortcut-text">配送管理</text>
|
||||
</view>
|
||||
<view class="shortcut-item" @click="goToFinanceManagement">
|
||||
<text class="shortcut-icon">💳</text>
|
||||
<text class="shortcut-text">财务管理</text>
|
||||
</view>
|
||||
<view class="shortcut-item" @click="goToSystemSettings">
|
||||
<text class="shortcut-icon">⚙️</text>
|
||||
<text class="shortcut-text">系统设置</text>
|
||||
</view>
|
||||
<view class="shortcut-item" @click="goToAdminUserSubscriptions">
|
||||
<text class="shortcut-icon">📑</text>
|
||||
<text class="shortcut-text">用户订阅</text>
|
||||
</view>
|
||||
<view class="shortcut-item" @click="goToSubscriptionPlans">
|
||||
<text class="shortcut-icon">🧾</text>
|
||||
<text class="shortcut-text">订阅方案</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 最新动态 -->
|
||||
<view class="activities-section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">最新动态</text>
|
||||
<text class="section-more" @click="goToActivityLog">查看全部</text>
|
||||
</view>
|
||||
<view class="activities-list">
|
||||
<view v-for="activity in recentActivities" :key="activity.id" class="activity-item">
|
||||
<text class="activity-icon">{{ getActivityIcon(activity.type) }}</text>
|
||||
<view class="activity-content">
|
||||
<text class="activity-text">{{ activity.description }}</text>
|
||||
<text class="activity-time">{{ formatTime(activity.created_at) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
type AdminInfoType = {
|
||||
id: string
|
||||
nickname: string
|
||||
role: string
|
||||
}
|
||||
|
||||
type PlatformStatsType = {
|
||||
total_gmv: string
|
||||
gmv_growth: number
|
||||
total_orders: number
|
||||
order_growth: number
|
||||
total_users: number
|
||||
user_growth: number
|
||||
total_merchants: number
|
||||
merchant_growth: number
|
||||
}
|
||||
|
||||
type TodayStatsType = {
|
||||
sales: string
|
||||
orders: number
|
||||
new_users: number
|
||||
active_users: number
|
||||
}
|
||||
|
||||
type PendingCountsType = {
|
||||
merchant_review: number
|
||||
product_review: number
|
||||
refund_review: number
|
||||
complaints: number
|
||||
}
|
||||
|
||||
type RealTimeStatsType = {
|
||||
online_users: number
|
||||
active_drivers: number
|
||||
delivering_orders: number
|
||||
system_load: number
|
||||
}
|
||||
|
||||
type ActivityType = {
|
||||
id: string
|
||||
type: string
|
||||
description: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
adminInfo: {
|
||||
id: '',
|
||||
nickname: '管理员',
|
||||
role: 'admin'
|
||||
} as AdminInfoType,
|
||||
|
||||
platformStats: {
|
||||
total_gmv: '0.00',
|
||||
gmv_growth: 0,
|
||||
total_orders: 0,
|
||||
order_growth: 0,
|
||||
total_users: 0,
|
||||
user_growth: 0,
|
||||
total_merchants: 0,
|
||||
merchant_growth: 0
|
||||
} as PlatformStatsType,
|
||||
|
||||
todayStats: {
|
||||
sales: '0.00',
|
||||
orders: 0,
|
||||
new_users: 0,
|
||||
active_users: 0
|
||||
} as TodayStatsType,
|
||||
|
||||
pendingCounts: {
|
||||
merchant_review: 0,
|
||||
product_review: 0,
|
||||
refund_review: 0,
|
||||
complaints: 0
|
||||
} as PendingCountsType,
|
||||
|
||||
realTimeStats: {
|
||||
online_users: 0,
|
||||
active_drivers: 0,
|
||||
delivering_orders: 0,
|
||||
system_load: 0
|
||||
} as RealTimeStatsType,
|
||||
|
||||
recentActivities: [] as Array<ActivityType>
|
||||
}
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
this.loadAdminInfo()
|
||||
this.loadPlatformStats()
|
||||
this.loadTodayStats()
|
||||
this.loadPendingCounts()
|
||||
this.loadRealTimeStats()
|
||||
this.loadRecentActivities()
|
||||
},
|
||||
|
||||
onShow() {
|
||||
// 页面显示时刷新实时数据
|
||||
this.refreshRealTimeData()
|
||||
},
|
||||
|
||||
methods: {
|
||||
// 加载管理员信息
|
||||
loadAdminInfo() {
|
||||
// TODO: 调用API获取管理员信息
|
||||
this.adminInfo.nickname = '系统管理员'
|
||||
},
|
||||
|
||||
// 加载平台统计
|
||||
loadPlatformStats() {
|
||||
// TODO: 调用API获取平台统计数据
|
||||
this.platformStats = {
|
||||
total_gmv: '12,580,000.00',
|
||||
gmv_growth: 15.6,
|
||||
total_orders: 125800,
|
||||
order_growth: 12.3,
|
||||
total_users: 45600,
|
||||
user_growth: 8.9,
|
||||
total_merchants: 2560,
|
||||
merchant_growth: 5.2
|
||||
}
|
||||
},
|
||||
|
||||
// 加载今日统计
|
||||
loadTodayStats() {
|
||||
// TODO: 调用API获取今日数据
|
||||
this.todayStats = {
|
||||
sales: '156,800.00',
|
||||
orders: 1568,
|
||||
new_users: 89,
|
||||
active_users: 3456
|
||||
}
|
||||
},
|
||||
|
||||
// 加载待处理数量
|
||||
loadPendingCounts() {
|
||||
// TODO: 调用API获取待处理数量
|
||||
this.pendingCounts = {
|
||||
merchant_review: 12,
|
||||
product_review: 45,
|
||||
refund_review: 8,
|
||||
complaints: 3
|
||||
}
|
||||
},
|
||||
|
||||
// 加载实时统计
|
||||
loadRealTimeStats() {
|
||||
// TODO: 调用API获取实时数据
|
||||
this.realTimeStats = {
|
||||
online_users: 2345,
|
||||
active_drivers: 156,
|
||||
delivering_orders: 234,
|
||||
system_load: 68
|
||||
}
|
||||
},
|
||||
|
||||
// 加载最新动态
|
||||
loadRecentActivities() {
|
||||
// TODO: 调用API获取最新动态
|
||||
this.recentActivities = [
|
||||
{
|
||||
id: '1',
|
||||
type: 'user_register',
|
||||
description: '新用户注册:张三',
|
||||
created_at: '2025-01-08T15:30:00Z'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'merchant_apply',
|
||||
description: '商家申请入驻:华强北电子商城',
|
||||
created_at: '2025-01-08T15:25:00Z'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: 'order_created',
|
||||
description: '新订单创建:订单号 M202501081567',
|
||||
created_at: '2025-01-08T15:20:00Z'
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
// 刷新实时数据
|
||||
refreshRealTimeData() {
|
||||
this.loadRealTimeStats()
|
||||
this.loadPendingCounts()
|
||||
this.loadRecentActivities()
|
||||
},
|
||||
|
||||
// 获取活动图标
|
||||
getActivityIcon(type: string): string {
|
||||
switch (type) {
|
||||
case 'user_register': return '👤'
|
||||
case 'merchant_apply': return '🏪'
|
||||
case 'order_created': return '📋'
|
||||
case 'product_review': return '📦'
|
||||
case 'refund_request': return '💰'
|
||||
case 'complaint': return '⚠️'
|
||||
default: return '📝'
|
||||
}
|
||||
},
|
||||
|
||||
// 格式化时间
|
||||
formatTime(timeStr: string): string {
|
||||
const date = new Date(timeStr)
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - date.getTime()
|
||||
const minutes = Math.floor(diff / (1000 * 60))
|
||||
|
||||
if (minutes < 60) {
|
||||
return `${minutes}分钟前`
|
||||
} else if (minutes < 1440) {
|
||||
return `${Math.floor(minutes / 60)}小时前`
|
||||
} else {
|
||||
return `${Math.floor(minutes / 1440)}天前`
|
||||
}
|
||||
},
|
||||
|
||||
// 导航方法
|
||||
goToNotifications() {
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/admin/notifications'
|
||||
})
|
||||
},
|
||||
|
||||
goToProfile() {
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/admin/profile'
|
||||
})
|
||||
},
|
||||
|
||||
goToMerchantReview() {
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/admin/merchant-review'
|
||||
})
|
||||
},
|
||||
|
||||
goToProductReview() {
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/admin/product-review'
|
||||
})
|
||||
},
|
||||
|
||||
goToRefundReview() {
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/admin/refund-review'
|
||||
})
|
||||
},
|
||||
|
||||
goToComplaints() {
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/admin/complaints'
|
||||
})
|
||||
},
|
||||
|
||||
goToUserManagement() {
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/admin/user-management'
|
||||
})
|
||||
},
|
||||
|
||||
goToMerchantManagement() {
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/admin/merchant-management'
|
||||
})
|
||||
},
|
||||
|
||||
goToProductManagement() {
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/admin/product-management'
|
||||
})
|
||||
},
|
||||
|
||||
goToOrderManagement() {
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/admin/order-management'
|
||||
})
|
||||
},
|
||||
|
||||
goToCouponManagement() {
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/admin/coupon-management'
|
||||
})
|
||||
},
|
||||
|
||||
goToDeliveryManagement() {
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/admin/delivery-management'
|
||||
})
|
||||
},
|
||||
|
||||
goToFinanceManagement() {
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/admin/finance-management'
|
||||
})
|
||||
},
|
||||
|
||||
goToSystemSettings() {
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/admin/system-settings'
|
||||
})
|
||||
},
|
||||
|
||||
goToActivityLog() {
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/admin/activity-log'
|
||||
})
|
||||
},
|
||||
|
||||
goToSubscriptionPlans() {
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/admin/subscription/plan-management'
|
||||
})
|
||||
},
|
||||
|
||||
goToAdminUserSubscriptions() {
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/admin/subscription/user-subscriptions'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.admin-container {
|
||||
background-color: #f5f5f5;
|
||||
min-height: 100vh;
|
||||
padding-bottom: 40rpx;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 40rpx 30rpx 30rpx;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.app-title {
|
||||
font-size: 36rpx;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.welcome-text {
|
||||
font-size: 24rpx;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.notification-btn,
|
||||
.profile-btn {
|
||||
font-size: 32rpx;
|
||||
color: #fff;
|
||||
margin-left: 30rpx;
|
||||
}
|
||||
|
||||
.metrics-section {
|
||||
background-color: #fff;
|
||||
margin: 20rpx;
|
||||
padding: 30rpx;
|
||||
border-radius: 16rpx;
|
||||
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
margin-bottom: 30rpx;
|
||||
}
|
||||
|
||||
.metrics-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
width: 48%;
|
||||
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||
padding: 30rpx 20rpx;
|
||||
border-radius: 12rpx;
|
||||
margin-bottom: 20rpx;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-size: 32rpx;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
font-size: 24rpx;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
margin-bottom: 10rpx;
|
||||
}
|
||||
|
||||
.metric-change {
|
||||
font-size: 20rpx;
|
||||
position: absolute;
|
||||
top: 20rpx;
|
||||
right: 20rpx;
|
||||
}
|
||||
|
||||
.positive {
|
||||
color: #4CAF50;
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
padding: 4rpx 8rpx;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
|
||||
.today-section {
|
||||
background-color: #fff;
|
||||
margin: 20rpx;
|
||||
padding: 30rpx;
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
|
||||
.today-grid {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.today-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.today-value {
|
||||
font-size: 36rpx;
|
||||
font-weight: bold;
|
||||
color: #2196F3;
|
||||
margin-bottom: 10rpx;
|
||||
}
|
||||
|
||||
.today-label {
|
||||
font-size: 24rpx;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.pending-section {
|
||||
background-color: #fff;
|
||||
margin: 20rpx;
|
||||
padding: 30rpx;
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
|
||||
.pending-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.pending-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 20rpx;
|
||||
border-radius: 12rpx;
|
||||
margin-bottom: 15rpx;
|
||||
border: 1rpx solid #f0f0f0;
|
||||
}
|
||||
|
||||
.pending-item.urgent {
|
||||
border-color: #FF5722;
|
||||
background-color: #FFF3E0;
|
||||
}
|
||||
|
||||
.pending-icon {
|
||||
font-size: 32rpx;
|
||||
margin-right: 20rpx;
|
||||
width: 40rpx;
|
||||
}
|
||||
|
||||
.pending-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.pending-title {
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
margin-bottom: 6rpx;
|
||||
}
|
||||
|
||||
.pending-subtitle {
|
||||
font-size: 22rpx;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.pending-count {
|
||||
font-size: 28rpx;
|
||||
color: #FF5722;
|
||||
font-weight: bold;
|
||||
background-color: #FFEBEE;
|
||||
padding: 8rpx 16rpx;
|
||||
border-radius: 20rpx;
|
||||
}
|
||||
|
||||
.monitor-section {
|
||||
background-color: #fff;
|
||||
margin: 20rpx;
|
||||
padding: 30rpx;
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
|
||||
.monitor-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.monitor-card {
|
||||
width: 48%;
|
||||
background: linear-gradient(135deg, #84fab0 0%, #8fd3f4 100%);
|
||||
padding: 30rpx 20rpx;
|
||||
border-radius: 12rpx;
|
||||
margin-bottom: 20rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.monitor-title {
|
||||
font-size: 24rpx;
|
||||
color: #333;
|
||||
margin-bottom: 15rpx;
|
||||
}
|
||||
|
||||
.monitor-value {
|
||||
font-size: 48rpx;
|
||||
font-weight: bold;
|
||||
color: #2E7D32;
|
||||
margin-bottom: 5rpx;
|
||||
}
|
||||
|
||||
.monitor-unit {
|
||||
font-size: 20rpx;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.shortcuts-section {
|
||||
background-color: #fff;
|
||||
margin: 20rpx;
|
||||
padding: 30rpx;
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
|
||||
.shortcuts-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.shortcut-item {
|
||||
width: 22%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: 30rpx;
|
||||
}
|
||||
|
||||
.shortcut-icon {
|
||||
font-size: 48rpx;
|
||||
margin-bottom: 15rpx;
|
||||
}
|
||||
|
||||
.shortcut-text {
|
||||
font-size: 22rpx;
|
||||
color: #333;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.activities-section {
|
||||
background-color: #fff;
|
||||
margin: 20rpx;
|
||||
padding: 30rpx;
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 30rpx;
|
||||
}
|
||||
|
||||
.section-more {
|
||||
font-size: 24rpx;
|
||||
color: #2196F3;
|
||||
}
|
||||
|
||||
.activities-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.activity-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 15rpx 0;
|
||||
border-bottom: 1rpx solid #f0f0f0;
|
||||
}
|
||||
|
||||
.activity-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.activity-icon {
|
||||
font-size: 28rpx;
|
||||
margin-right: 20rpx;
|
||||
width: 40rpx;
|
||||
}
|
||||
|
||||
.activity-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.activity-text {
|
||||
font-size: 26rpx;
|
||||
color: #333;
|
||||
margin-bottom: 5rpx;
|
||||
}
|
||||
|
||||
.activity-time {
|
||||
font-size: 22rpx;
|
||||
color: #999;
|
||||
}
|
||||
</style>
|
||||
11
pages/mall/admin/marketing/coupon/coupon-management.uvue
Normal file
11
pages/mall/admin/marketing/coupon/coupon-management.uvue
Normal file
@@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<text>优惠券管理 - 占位页</text>
|
||||
</view>
|
||||
</template>
|
||||
<script lang="uts">
|
||||
export default {}
|
||||
</script>
|
||||
<style>
|
||||
.page { padding: 30rpx; }
|
||||
</style>
|
||||
28
pages/mall/admin/marketing/coupon/list.uvue
Normal file
28
pages/mall/admin/marketing/coupon/list.uvue
Normal file
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<view class="container">
|
||||
<text class="title">优惠券列表</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
// Minimal script
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.container {
|
||||
padding: 40rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 48rpx;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.content {
|
||||
font-size: 28rpx;
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
28
pages/mall/admin/marketing/coupon/receive.uvue
Normal file
28
pages/mall/admin/marketing/coupon/receive.uvue
Normal file
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<view class="container">
|
||||
<text class="title">用户领取记录</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
// Minimal script
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.container {
|
||||
padding: 40rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 48rpx;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.content {
|
||||
font-size: 28rpx;
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
28
pages/mall/admin/marketing/points/index.uvue
Normal file
28
pages/mall/admin/marketing/points/index.uvue
Normal file
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<view class="container">
|
||||
<text class="title">积分管理</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
// Minimal script
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.container {
|
||||
padding: 40rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 48rpx;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.content {
|
||||
font-size: 28rpx;
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
28
pages/mall/admin/marketing/signin/record.uvue
Normal file
28
pages/mall/admin/marketing/signin/record.uvue
Normal file
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<view class="container">
|
||||
<text class="title">签到记录</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
// Minimal script
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.container {
|
||||
padding: 40rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 48rpx;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.content {
|
||||
font-size: 28rpx;
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
28
pages/mall/admin/marketing/signin/rule.uvue
Normal file
28
pages/mall/admin/marketing/signin/rule.uvue
Normal file
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<view class="container">
|
||||
<text class="title">签到规则</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
// Minimal script
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.container {
|
||||
padding: 40rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 48rpx;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.content {
|
||||
font-size: 28rpx;
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
13
pages/mall/admin/merchant-management.uvue
Normal file
13
pages/mall/admin/merchant-management.uvue
Normal file
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<text>商家管理 - 占位页</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
export default {}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.page { padding: 30rpx; }
|
||||
</style>
|
||||
63
pages/mall/admin/merchant-review.uvue
Normal file
63
pages/mall/admin/merchant-review.uvue
Normal file
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<view class="merchant-review">
|
||||
<view class="page-header">
|
||||
<text class="page-title">商家入驻审核</text>
|
||||
<text class="page-subtitle">审核商家入驻申请</text>
|
||||
</view>
|
||||
|
||||
<view class="review-content">
|
||||
<text class="coming-soon">商家审核功能正在开发中...</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
// 统一的导航方法
|
||||
const go = (url: string) => {
|
||||
// 1) 目标页面必须是非 tabBar 页面
|
||||
// 2) 必须在 pages.json / subPackages 注册
|
||||
uni.navigateTo({ url })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.merchant-review {
|
||||
padding: 30rpx;
|
||||
background-color: #f5f5f5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
background-color: #fff;
|
||||
padding: 40rpx;
|
||||
border-radius: 16rpx;
|
||||
margin-bottom: 30rpx;
|
||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
|
||||
|
||||
.page-title {
|
||||
font-size: 36rpx;
|
||||
font-weight: bold;
|
||||
color: #212529;
|
||||
display: block;
|
||||
margin-bottom: 10rpx;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
font-size: 26rpx;
|
||||
color: #6c757d;
|
||||
}
|
||||
}
|
||||
|
||||
.review-content {
|
||||
background-color: #fff;
|
||||
padding: 60rpx 40rpx;
|
||||
border-radius: 16rpx;
|
||||
text-align: center;
|
||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
|
||||
|
||||
.coming-soon {
|
||||
font-size: 28rpx;
|
||||
color: #6c757d;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
11
pages/mall/admin/notifications.uvue
Normal file
11
pages/mall/admin/notifications.uvue
Normal file
@@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<text>通知中心 - 占位页</text>
|
||||
</view>
|
||||
</template>
|
||||
<script lang="uts">
|
||||
export default {}
|
||||
</script>
|
||||
<style>
|
||||
.page { padding: 30rpx; }
|
||||
</style>
|
||||
1517
pages/mall/admin/order-management.uvue
Normal file
1517
pages/mall/admin/order-management.uvue
Normal file
File diff suppressed because it is too large
Load Diff
1755
pages/mall/admin/product-management.uvue
Normal file
1755
pages/mall/admin/product-management.uvue
Normal file
File diff suppressed because it is too large
Load Diff
63
pages/mall/admin/product-review.uvue
Normal file
63
pages/mall/admin/product-review.uvue
Normal file
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<view class="product-review">
|
||||
<view class="page-header">
|
||||
<text class="page-title">商品审核</text>
|
||||
<text class="page-subtitle">审核商品上架申请</text>
|
||||
</view>
|
||||
|
||||
<view class="review-content">
|
||||
<text class="coming-soon">商品审核功能正在开发中...</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
// 统一的导航方法
|
||||
const go = (url: string) => {
|
||||
// 1) 目标页面必须是非 tabBar 页面
|
||||
// 2) 必须在 pages.json / subPackages 注册
|
||||
uni.navigateTo({ url })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.product-review {
|
||||
padding: 30rpx;
|
||||
background-color: #f5f5f5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
background-color: #fff;
|
||||
padding: 40rpx;
|
||||
border-radius: 16rpx;
|
||||
margin-bottom: 30rpx;
|
||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
|
||||
|
||||
.page-title {
|
||||
font-size: 36rpx;
|
||||
font-weight: bold;
|
||||
color: #212529;
|
||||
display: block;
|
||||
margin-bottom: 10rpx;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
font-size: 26rpx;
|
||||
color: #6c757d;
|
||||
}
|
||||
}
|
||||
|
||||
.review-content {
|
||||
background-color: #fff;
|
||||
padding: 60rpx 40rpx;
|
||||
border-radius: 16rpx;
|
||||
text-align: center;
|
||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
|
||||
|
||||
.coming-soon {
|
||||
font-size: 28rpx;
|
||||
color: #6c757d;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,3 @@
|
||||
<!-- 管理端 - 个人中心 -->
|
||||
<template>
|
||||
<view class="admin-profile">
|
||||
<!-- 管理员信息头部 -->
|
||||
|
||||
63
pages/mall/admin/refund-review.uvue
Normal file
63
pages/mall/admin/refund-review.uvue
Normal file
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<view class="refund-review">
|
||||
<view class="page-header">
|
||||
<text class="page-title">退款审核</text>
|
||||
<text class="page-subtitle">审核用户退款申请</text>
|
||||
</view>
|
||||
|
||||
<view class="review-content">
|
||||
<text class="coming-soon">退款审核功能正在开发中...</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
// 统一的导航方法
|
||||
const go = (url: string) => {
|
||||
// 1) 目标页面必须是非 tabBar 页面
|
||||
// 2) 必须在 pages.json / subPackages 注册
|
||||
uni.navigateTo({ url })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.refund-review {
|
||||
padding: 30rpx;
|
||||
background-color: #f5f5f5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
background-color: #fff;
|
||||
padding: 40rpx;
|
||||
border-radius: 16rpx;
|
||||
margin-bottom: 30rpx;
|
||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
|
||||
|
||||
.page-title {
|
||||
font-size: 36rpx;
|
||||
font-weight: bold;
|
||||
color: #212529;
|
||||
display: block;
|
||||
margin-bottom: 10rpx;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
font-size: 26rpx;
|
||||
color: #6c757d;
|
||||
}
|
||||
}
|
||||
|
||||
.review-content {
|
||||
background-color: #fff;
|
||||
padding: 60rpx 40rpx;
|
||||
border-radius: 16rpx;
|
||||
text-align: center;
|
||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
|
||||
|
||||
.coming-soon {
|
||||
font-size: 28rpx;
|
||||
color: #6c757d;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
1078
pages/mall/admin/system-settings.uvue
Normal file
1078
pages/mall/admin/system-settings.uvue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,3 @@
|
||||
<!-- 管理端 - 用户详情页 -->
|
||||
<template>
|
||||
<view class="user-detail-page">
|
||||
<!-- 用户基本信息 -->
|
||||
|
||||
1587
pages/mall/admin/user-management.uvue
Normal file
1587
pages/mall/admin/user-management.uvue
Normal file
File diff suppressed because it is too large
Load Diff
764
pages/mall/admin/user-statistics.uvue
Normal file
764
pages/mall/admin/user-statistics.uvue
Normal file
@@ -0,0 +1,764 @@
|
||||
<template>
|
||||
<AdminLayout current-page="statistics">
|
||||
<view class="user-statistics-page">
|
||||
<!-- 筛选条件栏 -->
|
||||
<view class="filter-section">
|
||||
<view class="filter-row">
|
||||
<view class="filter-left">
|
||||
<view class="filter-item">
|
||||
<text class="filter-label">用户渠道:</text>
|
||||
<picker
|
||||
mode="selector"
|
||||
:range="channelOptions"
|
||||
:value="selectedChannel"
|
||||
@change="handleChannelChange"
|
||||
>
|
||||
<view class="filter-select">
|
||||
<text>{{ channelOptions[selectedChannel] }}</text>
|
||||
<text class="iconfont icon-down"></text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<view class="filter-item">
|
||||
<text class="filter-label">日期范围:</text>
|
||||
<view class="date-range">
|
||||
<picker
|
||||
mode="date"
|
||||
:value="startDate"
|
||||
:start="minDate"
|
||||
:end="maxDate"
|
||||
@change="handleStartDateChange"
|
||||
>
|
||||
<view class="date-input">
|
||||
<text>{{ startDate || '开始日期' }}</text>
|
||||
</view>
|
||||
</picker>
|
||||
<text class="date-separator">-</text>
|
||||
<picker
|
||||
mode="date"
|
||||
:value="endDate"
|
||||
:start="minDate"
|
||||
:end="maxDate"
|
||||
@change="handleEndDateChange"
|
||||
>
|
||||
<view class="date-input">
|
||||
<text>{{ endDate || '结束日期' }}</text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="filter-right">
|
||||
<button class="btn-secondary" @click="handleSearch">
|
||||
<text class="iconfont icon-search"></text>
|
||||
查询
|
||||
</button>
|
||||
<button class="btn-primary" @click="handleExport">
|
||||
<text class="iconfont icon-export"></text>
|
||||
导出
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 指标概览 -->
|
||||
<view class="metrics-section">
|
||||
<view class="metrics-row">
|
||||
<view class="metric-card">
|
||||
<view class="metric-icon">
|
||||
<text class="iconfont icon-users"></text>
|
||||
</view>
|
||||
<view class="metric-content">
|
||||
<text class="metric-title">累计用户</text>
|
||||
<text class="metric-value">{{ formatNumber(totalUsers) }}</text>
|
||||
<view class="metric-change up">
|
||||
<text class="iconfont icon-up"></text>
|
||||
<text class="change-text">{{ userGrowth }}%</text>
|
||||
<text class="change-desc">较上月</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="metric-card">
|
||||
<view class="metric-icon">
|
||||
<text class="iconfont icon-eye"></text>
|
||||
</view>
|
||||
<view class="metric-content">
|
||||
<text class="metric-title">访客数</text>
|
||||
<text class="metric-value">{{ formatNumber(totalVisitors) }}</text>
|
||||
<view class="metric-change up">
|
||||
<text class="iconfont icon-up"></text>
|
||||
<text class="change-text">{{ visitorGrowth }}%</text>
|
||||
<text class="change-desc">较上月</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="metric-card">
|
||||
<view class="metric-icon">
|
||||
<text class="iconfont icon-view"></text>
|
||||
</view>
|
||||
<view class="metric-content">
|
||||
<text class="metric-title">浏览量</text>
|
||||
<text class="metric-value">{{ formatNumber(totalPageViews) }}</text>
|
||||
<view class="metric-change down">
|
||||
<text class="iconfont icon-down"></text>
|
||||
<text class="change-text">{{ pageViewDecline }}%</text>
|
||||
<text class="change-desc">较上月</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="metric-card">
|
||||
<view class="metric-icon">
|
||||
<text class="iconfont icon-user-add"></text>
|
||||
</view>
|
||||
<view class="metric-content">
|
||||
<text class="metric-title">新增用户</text>
|
||||
<text class="metric-value">{{ formatNumber(newUsers) }}</text>
|
||||
<view class="metric-change up">
|
||||
<text class="iconfont icon-up"></text>
|
||||
<text class="change-text">{{ newUserGrowth }}%</text>
|
||||
<text class="change-desc">较上月</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="metric-card">
|
||||
<view class="metric-icon">
|
||||
<text class="iconfont icon-shopping"></text>
|
||||
</view>
|
||||
<view class="metric-content">
|
||||
<text class="metric-title">成交用户</text>
|
||||
<text class="metric-value">{{ formatNumber(convertedUsers) }}</text>
|
||||
<view class="metric-change up">
|
||||
<text class="iconfont icon-up"></text>
|
||||
<text class="change-text">{{ conversionGrowth }}%</text>
|
||||
<text class="change-desc">较上月</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="metric-card">
|
||||
<view class="metric-icon">
|
||||
<text class="iconfont icon-vip"></text>
|
||||
</view>
|
||||
<view class="metric-content">
|
||||
<text class="metric-title">付费会员</text>
|
||||
<text class="metric-value">{{ formatNumber(vipUsers) }}</text>
|
||||
<view class="metric-change up">
|
||||
<text class="iconfont icon-up"></text>
|
||||
<text class="change-text">{{ vipGrowth }}%</text>
|
||||
<text class="change-desc">较上月</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 用户趋势图表 -->
|
||||
<view class="chart-section">
|
||||
<view class="admin-card">
|
||||
<view class="admin-card-header">
|
||||
<text class="admin-card-title">用户数据趋势分析</text>
|
||||
</view>
|
||||
<view class="admin-card-body">
|
||||
<!-- 图表图例 -->
|
||||
<view class="chart-legend">
|
||||
<view class="legend-item" v-for="item in trendLegend" :key="item.key">
|
||||
<view class="legend-color" :style="{ backgroundColor: item.color }"></view>
|
||||
<text class="legend-text">{{ item.name }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 多折线图表容器 -->
|
||||
<view class="multi-line-chart">
|
||||
<!-- 图表区域 -->
|
||||
<view class="chart-area">
|
||||
<!-- 模拟多折线图 -->
|
||||
<view class="line-container" v-for="(line, index) in trendLines" :key="line.key">
|
||||
<view class="line-points">
|
||||
<view
|
||||
v-for="(point, pIndex) in line.data"
|
||||
:key="pIndex"
|
||||
class="line-point"
|
||||
:style="{
|
||||
left: (pIndex * 100 / (line.data.length - 1)) + '%',
|
||||
bottom: point.height + '%',
|
||||
backgroundColor: line.color
|
||||
}"
|
||||
></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- X轴标签 -->
|
||||
<view class="x-axis-labels">
|
||||
<text class="axis-label" v-for="date in chartDates" :key="date">{{ date }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref } from 'vue'
|
||||
import AdminLayout from '@/layouts/admin/index.uvue'
|
||||
|
||||
// 筛选条件
|
||||
const selectedChannel = ref(0)
|
||||
const channelOptions = ['全部渠道', '自然流量', '搜索引擎', '社交媒体', '广告投放', '其他']
|
||||
|
||||
const startDate = ref('')
|
||||
const endDate = ref('')
|
||||
const minDate = '2020-01-01'
|
||||
const maxDate = new Date().toISOString().split('T')[0]
|
||||
|
||||
// 指标数据
|
||||
const totalUsers = ref(32456)
|
||||
const userGrowth = ref(12.5)
|
||||
const totalVisitors = ref(156789)
|
||||
const visitorGrowth = ref(8.3)
|
||||
const totalPageViews = ref(456123)
|
||||
const pageViewDecline = ref(3.2)
|
||||
const newUsers = ref(1234)
|
||||
const newUserGrowth = ref(15.7)
|
||||
const convertedUsers = ref(5678)
|
||||
const conversionGrowth = ref(9.4)
|
||||
const vipUsers = ref(1234)
|
||||
const vipGrowth = ref(22.1)
|
||||
|
||||
// 图表数据
|
||||
const chartDates = ['01-01', '01-08', '01-15', '01-22', '01-29', '02-05', '02-12']
|
||||
|
||||
const trendLegend = [
|
||||
{ key: 'newUsers', name: '新增用户', color: '#1890ff' },
|
||||
{ key: 'visitors', name: '访客数', color: '#52c41a' },
|
||||
{ key: 'pageViews', name: '浏览量', color: '#faad14' },
|
||||
{ key: 'conversions', name: '成交用户', color: '#f5222d' },
|
||||
{ key: 'vipUsers', name: '付费会员', color: '#722ed1' }
|
||||
]
|
||||
|
||||
// 趋势线数据
|
||||
const trendLines = ref([
|
||||
{
|
||||
key: 'newUsers',
|
||||
color: '#1890ff',
|
||||
data: [
|
||||
{ value: 120, height: 12 },
|
||||
{ value: 180, height: 18 },
|
||||
{ value: 250, height: 25 },
|
||||
{ value: 320, height: 32 },
|
||||
{ value: 280, height: 28 },
|
||||
{ value: 350, height: 35 },
|
||||
{ value: 420, height: 42 }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'visitors',
|
||||
color: '#52c41a',
|
||||
data: [
|
||||
{ value: 450, height: 45 },
|
||||
{ value: 520, height: 52 },
|
||||
{ value: 580, height: 58 },
|
||||
{ value: 620, height: 62 },
|
||||
{ value: 550, height: 55 },
|
||||
{ value: 680, height: 68 },
|
||||
{ value: 750, height: 75 }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'pageViews',
|
||||
color: '#faad14',
|
||||
data: [
|
||||
{ value: 680, height: 68 },
|
||||
{ value: 720, height: 72 },
|
||||
{ value: 850, height: 85 },
|
||||
{ value: 920, height: 92 },
|
||||
{ value: 780, height: 78 },
|
||||
{ value: 950, height: 95 },
|
||||
{ value: 1000, height: 100 }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'conversions',
|
||||
color: '#f5222d',
|
||||
data: [
|
||||
{ value: 45, height: 4.5 },
|
||||
{ value: 52, height: 5.2 },
|
||||
{ value: 68, height: 6.8 },
|
||||
{ value: 75, height: 7.5 },
|
||||
{ value: 62, height: 6.2 },
|
||||
{ value: 85, height: 8.5 },
|
||||
{ value: 95, height: 9.5 }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'vipUsers',
|
||||
color: '#722ed1',
|
||||
data: [
|
||||
{ value: 12, height: 1.2 },
|
||||
{ value: 15, height: 1.5 },
|
||||
{ value: 22, height: 2.2 },
|
||||
{ value: 28, height: 2.8 },
|
||||
{ value: 25, height: 2.5 },
|
||||
{ value: 35, height: 3.5 },
|
||||
{ value: 42, height: 4.2 }
|
||||
]
|
||||
}
|
||||
])
|
||||
|
||||
// 方法
|
||||
const handleChannelChange = (e: any) => {
|
||||
selectedChannel.value = e.detail.value
|
||||
}
|
||||
|
||||
const handleStartDateChange = (e: any) => {
|
||||
startDate.value = e.detail.value
|
||||
}
|
||||
|
||||
const handleEndDateChange = (e: any) => {
|
||||
endDate.value = e.detail.value
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
uni.showToast({
|
||||
title: '数据已更新',
|
||||
icon: 'success'
|
||||
})
|
||||
}
|
||||
|
||||
const handleExport = () => {
|
||||
uni.showToast({
|
||||
title: '导出功能开发中',
|
||||
icon: 'none'
|
||||
})
|
||||
}
|
||||
|
||||
const formatNumber = (num: number) => {
|
||||
if (num >= 10000) {
|
||||
return (num / 10000).toFixed(1) + '万'
|
||||
} else if (num >= 1000) {
|
||||
return (num / 1000).toFixed(1) + 'k'
|
||||
}
|
||||
return num.toString()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* ===== 用户统计页面样式 ===== */
|
||||
.user-statistics-page {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ===== 筛选条件栏 ===== */
|
||||
.filter-section {
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.filter-left {
|
||||
display: flex;
|
||||
gap: 32px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-right {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.filter-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
font-size: 14px;
|
||||
color: #666666;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 6px;
|
||||
background-color: #ffffff;
|
||||
cursor: pointer;
|
||||
min-width: 120px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.date-range {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.date-input {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 6px;
|
||||
background-color: #ffffff;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.date-separator {
|
||||
color: #666666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #1890ff;
|
||||
color: #ffffff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: #ffffff;
|
||||
color: #666666;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 6px;
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* ===== 指标概览 ===== */
|
||||
.metrics-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.metrics-row {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
flex: 1;
|
||||
min-width: 280px;
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.metric-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
background: linear-gradient(135deg, #1890ff 0%, #36cfc9 100%);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #ffffff;
|
||||
font-size: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.metric-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.metric-title {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
color: #666666;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
display: block;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.metric-change {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
border-radius: 12px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.metric-change.up {
|
||||
background-color: #f6ffed;
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.metric-change.down {
|
||||
background-color: #fff2f0;
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
.change-text {
|
||||
margin: 0 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.change-desc {
|
||||
color: #999999;
|
||||
}
|
||||
|
||||
/* ===== 图表区域 ===== */
|
||||
.chart-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.admin-card {
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.admin-card-header {
|
||||
padding: 24px 24px 0 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.admin-card-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
}
|
||||
|
||||
.admin-card-body {
|
||||
padding: 0 24px 24px 24px;
|
||||
}
|
||||
|
||||
/* ===== 图表图例 ===== */
|
||||
.chart-legend {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 32px;
|
||||
margin-bottom: 24px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.legend-color {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.legend-text {
|
||||
font-size: 14px;
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
/* ===== 多折线图表 ===== */
|
||||
.multi-line-chart {
|
||||
height: 400px;
|
||||
position: relative;
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.chart-area {
|
||||
position: absolute;
|
||||
top: 40px;
|
||||
left: 60px;
|
||||
right: 40px;
|
||||
bottom: 60px;
|
||||
}
|
||||
|
||||
.line-container {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.line-points {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.line-point {
|
||||
position: absolute;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #ffffff;
|
||||
box-shadow: 0 0 4px rgba(0, 0, 0, 0.2);
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.x-axis-labels {
|
||||
position: absolute;
|
||||
bottom: -40px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0 30px;
|
||||
}
|
||||
|
||||
.axis-label {
|
||||
font-size: 12px;
|
||||
color: #999999;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ===== 响应式设计 ===== */
|
||||
@media (max-width: 1200px) {
|
||||
.metrics-row {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
min-width: 45%;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.filter-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.filter-left {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.filter-right {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.metrics-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
min-width: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.user-statistics-page {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.filter-section,
|
||||
.chart-section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.admin-card-header,
|
||||
.admin-card-body {
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
}
|
||||
|
||||
.chart-legend {
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 图标字体 ===== */
|
||||
.iconfont {
|
||||
font-family: 'iconfont';
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.icon-up:before {
|
||||
content: '↑';
|
||||
}
|
||||
|
||||
.icon-down:before {
|
||||
content: '↓';
|
||||
}
|
||||
|
||||
.icon-users:before {
|
||||
content: '👥';
|
||||
}
|
||||
|
||||
.icon-eye:before {
|
||||
content: '👁️';
|
||||
}
|
||||
|
||||
.icon-view:before {
|
||||
content: '📊';
|
||||
}
|
||||
|
||||
.icon-user-add:before {
|
||||
content: '👤';
|
||||
}
|
||||
|
||||
.icon-shopping:before {
|
||||
content: '🛒';
|
||||
}
|
||||
|
||||
.icon-vip:before {
|
||||
content: '👑';
|
||||
}
|
||||
|
||||
.icon-search:before {
|
||||
content: '🔍';
|
||||
}
|
||||
|
||||
.icon-export:before {
|
||||
content: '📤';
|
||||
}
|
||||
|
||||
.icon-down:before {
|
||||
content: '▼';
|
||||
}
|
||||
</style>
|
||||
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>
|
||||
652
pages/mall/analytics/data-detail.uvue
Normal file
652
pages/mall/analytics/data-detail.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="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 AnalyticsTopBar from '@/components/analytics/AnalyticsTopBar.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(页面骨架创建完成)
|
||||
665
pages/mall/analytics/docs/ANALYTICS_UI_DESIGN.md
Normal file
665
pages/mall/analytics/docs/ANALYTICS_UI_DESIGN.md
Normal file
@@ -0,0 +1,665 @@
|
||||
# 数据分析页面 UI 设计文档
|
||||
|
||||
## 📋 文档说明
|
||||
|
||||
本文档记录了**数据分析中心页面**的 UI 设计实现,遵循项目的 UI 设计规范,采用现代简约的设计风格,提供清晰直观的数据可视化界面。
|
||||
|
||||
### 页面访问 URL
|
||||
|
||||
#### 主页面路径
|
||||
- **完整路径**:`/pages/mall/analytics/index`
|
||||
- **页面标题**:数据分析中心
|
||||
- **导航栏样式**:自定义导航栏(`navigationStyle: "custom"`)
|
||||
- **路由配置位置**:`subPackages` → `pages/mall/analytics` → `index`
|
||||
|
||||
#### 相关子页面 URL
|
||||
|
||||
- **销售报表**:`/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`
|
||||
- **报表详情**:`/pages/mall/analytics/report-detail`
|
||||
- **数据分析详情**:`/pages/mall/analytics/data-detail`
|
||||
- **数据洞察详情**:`/pages/mall/analytics/insight-detail`
|
||||
|
||||
### 访问方式
|
||||
|
||||
#### 1. 代码中跳转(uni-app x)
|
||||
|
||||
```typescript
|
||||
// 方式一:使用 navigateTo(推荐,保留返回栈)
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/analytics/index'
|
||||
})
|
||||
|
||||
// 方式二:使用 redirectTo(替换当前页面,不保留返回栈)
|
||||
uni.redirectTo({
|
||||
url: '/pages/mall/analytics/index'
|
||||
})
|
||||
|
||||
// 方式三:使用 reLaunch(关闭所有页面,打开新页面)
|
||||
uni.reLaunch({
|
||||
url: '/pages/mall/analytics/index'
|
||||
})
|
||||
```
|
||||
|
||||
#### 2. 从其他页面跳转示例
|
||||
|
||||
```typescript
|
||||
// 从管理后台跳转到数据分析中心
|
||||
const goToAnalytics = () => {
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/analytics/index'
|
||||
})
|
||||
}
|
||||
|
||||
// 从首页跳转到数据分析中心
|
||||
const goToDataAnalysis = () => {
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/analytics/index',
|
||||
success: () => {
|
||||
console.log('跳转成功')
|
||||
},
|
||||
fail: (err) => {
|
||||
console.error('跳转失败:', err)
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. 带参数跳转
|
||||
|
||||
```typescript
|
||||
// 跳转并传递参数
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/analytics/index?period=30d&refresh=true'
|
||||
})
|
||||
|
||||
// 在目标页面接收参数(index.uvue 的 onLoad)
|
||||
onLoad(options: any) {
|
||||
const period = options.period || '30d'
|
||||
const refresh = options.refresh === 'true'
|
||||
if (period) {
|
||||
this.period = period
|
||||
}
|
||||
if (refresh) {
|
||||
this.refreshData()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. 页面间跳转
|
||||
|
||||
```typescript
|
||||
// 从数据分析首页跳转到销售报表
|
||||
const goToSalesReport = () => {
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/analytics/sales-report'
|
||||
})
|
||||
}
|
||||
|
||||
// 从数据分析首页跳转到用户分析
|
||||
const goToUserAnalysis = () => {
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/analytics/user-analysis'
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
#### 5. 权限控制示例
|
||||
|
||||
```typescript
|
||||
// 跳转前检查权限
|
||||
const goToAnalyticsWithAuth = () => {
|
||||
// 检查用户是否有数据分析权限
|
||||
const userType = getUserType() // 假设这是获取用户类型的函数
|
||||
|
||||
if (userType === 'admin' || userType === 'analyst') {
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/analytics/index'
|
||||
})
|
||||
} else {
|
||||
uni.showToast({
|
||||
title: '您没有访问权限',
|
||||
icon: 'none'
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 6. 在页面配置中设置入口
|
||||
|
||||
如果需要在 TabBar 或其他导航中添加入口,可以在 `pages.json` 中配置:
|
||||
|
||||
```json
|
||||
{
|
||||
"tabBar": {
|
||||
"list": [
|
||||
{
|
||||
"pagePath": "pages/mall/analytics/index",
|
||||
"text": "数据分析"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 设计原则
|
||||
|
||||
1. **数据优先**:突出核心数据指标,减少视觉干扰
|
||||
2. **层次清晰**:通过卡片、阴影、间距建立清晰的信息层次
|
||||
3. **视觉统一**:遵循项目统一的颜色系统和设计规范
|
||||
4. **响应式适配**:适配不同屏幕尺寸,确保良好的用户体验
|
||||
|
||||
---
|
||||
|
||||
## 一、页面结构
|
||||
|
||||
### 1.1 整体布局
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Header(头部控制面板) │
|
||||
│ - 标题 + 最后更新时间 │
|
||||
│ - 刷新/导出按钮 │
|
||||
├─────────────────────────────────────┤
|
||||
│ 实时大屏(4个核心指标卡片) │
|
||||
│ - GMV / 订单 / 用户 / 转化率 │
|
||||
├─────────────────────────────────────┤
|
||||
│ 时间筛选(Tab切换) │
|
||||
│ - 今日/本周/本月/本季度 │
|
||||
├─────────────────────────────────────┤
|
||||
│ 销售分析 │
|
||||
│ - 销售趋势图 │
|
||||
│ - 商品销量排行 │
|
||||
├─────────────────────────────────────┤
|
||||
│ 用户行为分析 │
|
||||
│ - 用户活跃度 │
|
||||
│ - 流量来源 │
|
||||
├─────────────────────────────────────┤
|
||||
│ 商家表现 │
|
||||
│ - 商家排行榜 │
|
||||
├─────────────────────────────────────┤
|
||||
│ 配送效率 │
|
||||
│ - 配送时效 │
|
||||
│ - 配送覆盖 │
|
||||
├─────────────────────────────────────┤
|
||||
│ 快速分析工具(6个工具卡片) │
|
||||
│ - 销售报表/用户分析/商品洞察等 │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 1.2 模块划分
|
||||
|
||||
1. **Header 区域**:页面标题、更新时间、操作按钮
|
||||
2. **实时大屏**:核心业务指标(GMV、订单、用户、转化率)
|
||||
3. **时间筛选**:时间维度切换(今日/本周/本月/本季度)
|
||||
4. **销售分析**:销售趋势、商品排行
|
||||
5. **用户行为分析**:用户活跃度、流量来源
|
||||
6. **商家表现**:商家销售排行榜
|
||||
7. **配送效率**:配送时效、覆盖情况
|
||||
8. **快速工具**:常用分析工具入口
|
||||
|
||||
---
|
||||
|
||||
## 二、设计规范应用
|
||||
|
||||
### 2.1 CSS 变量系统
|
||||
|
||||
所有设计 token 通过 CSS 变量定义,便于统一管理和换肤:
|
||||
|
||||
```css
|
||||
:root {
|
||||
/* 主题色 */
|
||||
--theme-primary: #FF4D4F;
|
||||
--theme-primary-light: #FF7875;
|
||||
--theme-primary-dark: #CF1322;
|
||||
--gradient-start: #FF4D4F;
|
||||
--gradient-end: #FF7A45;
|
||||
|
||||
/* 功能色 */
|
||||
--success: #52C41A;
|
||||
--warning: #FAAD14;
|
||||
--error: #FF4D4F;
|
||||
--info: #1890FF;
|
||||
|
||||
/* 文字颜色 */
|
||||
--text-primary: #111111;
|
||||
--text-secondary: #333333;
|
||||
--text-tertiary: #666666;
|
||||
--text-disabled: #999999;
|
||||
|
||||
/* 背景颜色 */
|
||||
--bg-primary: #FFFFFF;
|
||||
--bg-secondary: #F7F8FA;
|
||||
--bg-tertiary: #F5F5F5;
|
||||
|
||||
/* 边框 */
|
||||
--border-color: rgba(0, 0, 0, 0.06);
|
||||
|
||||
/* 阴影 */
|
||||
--shadow-sm: 0 2rpx 8rpx rgba(0, 0, 0, 0.06);
|
||||
--shadow-md: 0 4rpx 16rpx rgba(0, 0, 0, 0.08);
|
||||
|
||||
/* 圆角 */
|
||||
--radius-md: 12rpx;
|
||||
--radius-lg: 20rpx;
|
||||
--radius-xl: 24rpx;
|
||||
|
||||
/* 间距 */
|
||||
--spacing-sm: 16rpx;
|
||||
--spacing-md: 24rpx;
|
||||
--spacing-lg: 32rpx;
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 卡片式设计
|
||||
|
||||
所有数据模块采用卡片式设计:
|
||||
|
||||
- **圆角**:`border-radius: 20rpx`(符合 UI 规范)
|
||||
- **阴影**:`box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.06)`(轻微阴影)
|
||||
- **背景**:白色背景 `#FFFFFF`
|
||||
- **内边距**:`32rpx`(统一间距)
|
||||
- **外边距**:`24rpx`(卡片间距)
|
||||
|
||||
### 2.3 颜色系统应用
|
||||
|
||||
#### Header 渐变背景
|
||||
```css
|
||||
background: linear-gradient(135deg, #FF4D4F 0%, #FF7A45 100%);
|
||||
```
|
||||
|
||||
#### 实时指标卡片渐变
|
||||
- **GMV(收入)**:`#FF6B6B → #FF4D4F`(红色渐变)
|
||||
- **订单**:`#4ECDC4 → #44A08D`(青色渐变)
|
||||
- **用户**:`#A8E6CF → #7FCDBB`(绿色渐变)
|
||||
- **转化率**:`#FFD93D → #FFA07A`(黄色渐变)
|
||||
|
||||
#### 状态颜色
|
||||
- **增长(正)**:绿色 `#52C41A` + 浅绿背景
|
||||
- **下降(负)**:红色 `#FF4D4F` + 浅红背景
|
||||
- **中性**:灰色 `#666666` + 浅灰背景
|
||||
|
||||
---
|
||||
|
||||
## 三、组件设计详解
|
||||
|
||||
### 3.1 Header 头部
|
||||
|
||||
**设计特点**:
|
||||
- 使用主题色渐变背景
|
||||
- 左侧显示标题和更新时间
|
||||
- 右侧操作按钮使用毛玻璃效果
|
||||
|
||||
**实现代码**:
|
||||
```css
|
||||
.header {
|
||||
background: linear-gradient(135deg, var(--gradient-start) 0%, var(--gradient-end) 100%);
|
||||
padding: var(--spacing-lg) var(--spacing-md) var(--spacing-md);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.refresh-btn,
|
||||
.export-btn {
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 50%;
|
||||
backdrop-filter: blur(10rpx);
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 实时大屏卡片
|
||||
|
||||
**设计特点**:
|
||||
- 2x2 网格布局
|
||||
- 每个卡片使用不同渐变背景
|
||||
- 数值突出显示,增长指标使用标签样式
|
||||
|
||||
**布局**:
|
||||
```css
|
||||
.dashboard-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.dashboard-card {
|
||||
width: calc(50% - var(--spacing-md) / 2);
|
||||
padding: var(--spacing-lg) var(--spacing-md);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 时间筛选 Tab
|
||||
|
||||
**设计特点**:
|
||||
- 激活状态使用主题色渐变
|
||||
- 添加阴影效果增强层次
|
||||
- 平滑过渡动画
|
||||
|
||||
**实现**:
|
||||
```css
|
||||
.filter-tab.active {
|
||||
background: linear-gradient(135deg, var(--gradient-start) 0%, var(--gradient-end) 100%);
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 2rpx 8rpx rgba(255, 77, 79, 0.3);
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4 销售分析卡片
|
||||
|
||||
**设计特点**:
|
||||
- 图表区域使用浅色背景区分
|
||||
- 统计项使用独立卡片样式
|
||||
- 数值使用主题色突出
|
||||
|
||||
**布局**:
|
||||
```css
|
||||
.trend-stats {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
flex: 1;
|
||||
padding: var(--spacing-md);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
```
|
||||
|
||||
### 3.5 商品排行
|
||||
|
||||
**设计特点**:
|
||||
- 排名使用圆形徽章,使用主题色渐变
|
||||
- 商品名称和销量清晰对比
|
||||
- 统一的分割线
|
||||
|
||||
**实现**:
|
||||
```css
|
||||
.rank-number {
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
background: linear-gradient(135deg, var(--gradient-start) 0%, var(--gradient-end) 100%);
|
||||
border-radius: 50%;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
```
|
||||
|
||||
### 3.6 用户行为分析
|
||||
|
||||
**设计特点**:
|
||||
- 指标使用列表式展示
|
||||
- 流量来源使用进度条可视化
|
||||
- 进度条使用主题色渐变
|
||||
|
||||
**进度条实现**:
|
||||
```css
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, var(--gradient-start) 0%, var(--gradient-end) 100%);
|
||||
border-radius: 12rpx;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
```
|
||||
|
||||
### 3.7 商家排行榜
|
||||
|
||||
**设计特点**:
|
||||
- 排名徽章使用金银铜渐变
|
||||
- 增长率使用彩色标签
|
||||
- 商家信息清晰展示
|
||||
|
||||
**排名徽章**:
|
||||
```css
|
||||
.rank-gold {
|
||||
background: linear-gradient(135deg, #FFD700 0%, #FFA500 100%);
|
||||
}
|
||||
|
||||
.rank-silver {
|
||||
background: linear-gradient(135deg, #C0C0C0 0%, #A0A0A0 100%);
|
||||
}
|
||||
|
||||
.rank-bronze {
|
||||
background: linear-gradient(135deg, #CD7F32 0%, #B8860B 100%);
|
||||
}
|
||||
```
|
||||
|
||||
### 3.8 快速工具卡片
|
||||
|
||||
**设计特点**:
|
||||
- 3 列网格布局
|
||||
- 点击反馈效果
|
||||
- 图标 + 标题 + 描述
|
||||
|
||||
**交互效果**:
|
||||
```css
|
||||
.tool-card:active {
|
||||
transform: scale(0.98);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、响应式设计
|
||||
|
||||
### 4.1 断点设置
|
||||
|
||||
- **默认**:>= 768rpx(双列布局)
|
||||
- **小屏**:< 768rpx(单列布局)
|
||||
|
||||
### 4.2 响应式规则
|
||||
|
||||
```css
|
||||
@media screen and (max-width: 768rpx) {
|
||||
/* 实时大屏卡片改为单列 */
|
||||
.dashboard-card {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 工具卡片改为 2 列 */
|
||||
.tool-card {
|
||||
width: calc(50% - var(--spacing-md) / 2);
|
||||
}
|
||||
|
||||
/* 配送分析改为纵向布局 */
|
||||
.delivery-metrics {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 适配策略
|
||||
|
||||
1. **实时大屏**:小屏下改为单列,保持卡片完整性
|
||||
2. **工具卡片**:小屏下改为 2 列,提升空间利用率
|
||||
3. **配送分析**:小屏下改为纵向堆叠,避免内容挤压
|
||||
|
||||
---
|
||||
|
||||
## 五、交互设计
|
||||
|
||||
### 5.1 操作反馈
|
||||
|
||||
- **按钮点击**:缩放效果 `transform: scale(0.98)`
|
||||
- **Tab 切换**:平滑过渡动画 `transition: all 0.3s`
|
||||
- **进度条**:宽度变化动画 `transition: width 0.3s ease`
|
||||
|
||||
### 5.2 数据刷新
|
||||
|
||||
- **刷新按钮**:点击后显示加载状态
|
||||
- **数据更新**:更新时间实时显示
|
||||
- **错误处理**:友好的错误提示
|
||||
|
||||
### 5.3 导出功能
|
||||
|
||||
- **导出选项**:Excel / PDF / 图片
|
||||
- **加载提示**:导出过程中显示加载动画
|
||||
- **成功反馈**:导出成功后 Toast 提示
|
||||
|
||||
---
|
||||
|
||||
## 六、数据可视化
|
||||
|
||||
### 6.1 图表占位
|
||||
|
||||
当前使用占位符,后续可集成图表库:
|
||||
|
||||
- **销售趋势图**:折线图或面积图
|
||||
- **商品销量排行**:柱状图或条形图
|
||||
- **流量来源**:饼图或环形图
|
||||
|
||||
### 6.2 推荐图表库
|
||||
|
||||
- **uni-app x 兼容**:需使用原生图表组件
|
||||
- **Web 端**:可使用 ECharts、Chart.js 等
|
||||
- **移动端**:可使用 uni-charts 或自定义 Canvas 绘制
|
||||
|
||||
---
|
||||
|
||||
## 七、性能优化
|
||||
|
||||
### 7.1 数据加载
|
||||
|
||||
- **分步加载**:先加载核心指标,再加载详细数据
|
||||
- **缓存策略**:合理使用数据缓存,减少重复请求
|
||||
- **防抖处理**:刷新操作添加防抖,避免频繁请求
|
||||
|
||||
### 7.2 渲染优化
|
||||
|
||||
- **虚拟列表**:长列表使用虚拟滚动
|
||||
- **懒加载**:非首屏内容延迟加载
|
||||
- **图片优化**:使用合适的图片格式和尺寸
|
||||
|
||||
---
|
||||
|
||||
## 八、后续优化建议
|
||||
|
||||
### 8.1 功能增强
|
||||
|
||||
1. **实时数据推送**:使用 WebSocket 实现数据实时更新
|
||||
2. **数据钻取**:点击指标可查看详细数据
|
||||
3. **自定义看板**:允许用户自定义指标和布局
|
||||
4. **数据对比**:支持多时间段数据对比
|
||||
5. **导出增强**:支持自定义导出字段和格式
|
||||
|
||||
### 8.2 UI 优化
|
||||
|
||||
1. **图表集成**:集成专业图表库,替换占位符
|
||||
2. **动画效果**:添加数据变化动画,提升视觉体验
|
||||
3. **暗色模式**:支持暗色主题切换
|
||||
4. **国际化**:支持多语言切换
|
||||
|
||||
### 8.3 交互优化
|
||||
|
||||
1. **下拉刷新**:支持下拉刷新数据
|
||||
2. **上拉加载**:长列表支持上拉加载更多
|
||||
3. **手势操作**:支持滑动切换时间维度
|
||||
4. **快捷操作**:添加常用操作的快捷入口
|
||||
|
||||
---
|
||||
|
||||
## 九、技术实现
|
||||
|
||||
### 9.1 文件结构
|
||||
|
||||
```
|
||||
pages/mall/analytics/
|
||||
├── index.uvue # 数据分析首页
|
||||
├── profile.uvue # 数据分析个人中心
|
||||
└── report-detail.uvue # 报表详情页
|
||||
```
|
||||
|
||||
### 9.2 核心代码结构
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- Header -->
|
||||
<view class="header">...</view>
|
||||
|
||||
<!-- 实时大屏 -->
|
||||
<view class="dashboard-section">...</view>
|
||||
|
||||
<!-- 时间筛选 -->
|
||||
<view class="time-filter-section">...</view>
|
||||
|
||||
<!-- 各分析模块 -->
|
||||
<view class="sales-analysis-section">...</view>
|
||||
...
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
// 数据定义
|
||||
// 方法实现
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* CSS 变量定义 */
|
||||
/* 组件样式 */
|
||||
/* 响应式样式 */
|
||||
</style>
|
||||
```
|
||||
|
||||
### 9.3 数据接口
|
||||
|
||||
当前使用 Supabase 查询数据,主要涉及:
|
||||
|
||||
- `orders` 表:订单数据
|
||||
- `users` 表:用户数据
|
||||
- `user_sessions` 表:用户会话数据(如果存在)
|
||||
|
||||
---
|
||||
|
||||
## 十、设计规范遵循
|
||||
|
||||
### 10.1 颜色系统 ✅
|
||||
|
||||
- ✅ 使用主题色 `#FF4D4F` 作为主色调
|
||||
- ✅ 使用渐变色增强视觉效果
|
||||
- ✅ 使用功能色表示状态(成功/警告/错误)
|
||||
|
||||
### 10.2 间距系统 ✅
|
||||
|
||||
- ✅ 使用统一的间距变量(`--spacing-sm/md/lg`)
|
||||
- ✅ 卡片间距统一为 `24rpx`
|
||||
- ✅ 内容内边距统一为 `32rpx`
|
||||
|
||||
### 10.3 圆角系统 ✅
|
||||
|
||||
- ✅ 卡片圆角统一为 `20rpx`
|
||||
- ✅ 小元素圆角为 `12rpx`
|
||||
- ✅ 圆形元素使用 `50%`
|
||||
|
||||
### 10.4 阴影系统 ✅
|
||||
|
||||
- ✅ 卡片使用轻微阴影 `0 2rpx 8rpx rgba(0, 0, 0, 0.06)`
|
||||
- ✅ 激活状态使用增强阴影
|
||||
- ✅ 按钮使用阴影增强层次
|
||||
|
||||
---
|
||||
|
||||
## 十一、总结
|
||||
|
||||
数据分析页面 UI 设计完全遵循项目的 UI 设计规范,实现了:
|
||||
|
||||
1. ✅ **统一的视觉风格**:使用项目统一的颜色系统和设计 token
|
||||
2. ✅ **清晰的信息层次**:通过卡片、阴影、间距建立清晰层次
|
||||
3. ✅ **良好的用户体验**:响应式设计、交互反馈、数据可视化
|
||||
4. ✅ **可维护的代码**:CSS 变量系统、模块化设计、清晰的代码结构
|
||||
|
||||
页面已实现核心功能,后续可根据实际需求进行功能增强和 UI 优化。
|
||||
|
||||
---
|
||||
|
||||
**文档版本**: v1.0
|
||||
**创建时间**: 2025-01-XX
|
||||
**最后更新**: 2025-01-XX
|
||||
**状态**: ✅ 已完成
|
||||
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 访问文档
|
||||
- ✅ 列出所有页面路径和配置状态
|
||||
- ✅ 提供代码访问示例
|
||||
- ✅ 记录注意事项和平台兼容性
|
||||
File diff suppressed because it is too large
Load Diff
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>
|
||||
840
pages/mall/analytics/product-insights.uvue
Normal file
840
pages/mall/analytics/product-insights.uvue
Normal file
@@ -0,0 +1,840 @@
|
||||
<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>
|
||||
<view class="card-head-right">
|
||||
<select class="select" v-model="selectedProductId" @change="handleProductChange">
|
||||
<option v-for="p in realTopProducts" :key="p.id" :value="p.id">{{ p.name }}</option>
|
||||
</select>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="loading || !salesChartOption || !salesChartOption.series || salesChartOption.series.length === 0" class="chart-loading">
|
||||
<text>{{ loading ? '加载中...' : '暂无数据' }}</text>
|
||||
</view>
|
||||
<EChartsView v-else class="chart-box" :option="salesChartOption" />
|
||||
</view>
|
||||
|
||||
<!-- 第二行:分类 & 排行 -->
|
||||
<view class="grid-row">
|
||||
<!-- 商品分类分析 -->
|
||||
<view class="card grid-col-item">
|
||||
<view class="card-head">
|
||||
<text class="card-title">商品分类分析</text>
|
||||
<text class="card-desc">按分类统计销售额</text>
|
||||
</view>
|
||||
<view v-if="loading || !categoryChartOption || !categoryChartOption.series || categoryChartOption.series.length === 0" class="chart-loading chart-loading-sm">
|
||||
<text>{{ loading ? '加载中...' : '暂无数据' }}</text>
|
||||
</view>
|
||||
<EChartsView v-else class="chart-box chart-box-sm" :option="categoryChartOption" />
|
||||
</view>
|
||||
|
||||
<!-- 热销商品排行 -->
|
||||
<view class="card grid-col-item">
|
||||
<view class="card-head">
|
||||
<text class="card-title">热销商品排行 TOP 10</text>
|
||||
<text class="card-desc">按销量排序</text>
|
||||
</view>
|
||||
<view v-if="loading || topProducts.length === 0" class="chart-loading chart-loading-sm">
|
||||
<text>{{ loading ? '加载中...' : '暂无数据' }}</text>
|
||||
</view>
|
||||
<view v-else class="rank-list-scroll">
|
||||
<view class="rank-list">
|
||||
<view v-for="p in topProducts" :key="p.id" class="rank-item">
|
||||
<text class="rank-no">{{ p.rank }}</text>
|
||||
<text class="rank-name">{{ p.name }}</text>
|
||||
<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>
|
||||
</view>
|
||||
|
||||
<!-- 第三行:库存 & 价格 -->
|
||||
<view class="grid-row">
|
||||
<!-- 商品库存分析 -->
|
||||
<view class="card grid-col-item">
|
||||
<view class="card-head">
|
||||
<text class="card-title">商品库存分析</text>
|
||||
<text class="card-desc">库存分布情况</text>
|
||||
</view>
|
||||
<view v-if="loading || !stockChartOption || !stockChartOption.series || stockChartOption.series.length === 0" class="chart-loading chart-loading-sm">
|
||||
<text>{{ loading ? '加载中...' : '暂无数据' }}</text>
|
||||
</view>
|
||||
<EChartsView v-else class="chart-box chart-box-sm" :option="stockChartOption" />
|
||||
</view>
|
||||
|
||||
<!-- 商品价格趋势 -->
|
||||
<view class="card grid-col-item">
|
||||
<view class="card-head">
|
||||
<text class="card-title">商品价格趋势</text>
|
||||
<text class="card-desc">平均价格变化</text>
|
||||
</view>
|
||||
<view v-if="loading || !priceChartOption || !priceChartOption.series || priceChartOption.series.length === 0" class="chart-loading chart-loading-sm">
|
||||
<text>{{ loading ? '加载中...' : '暂无数据' }}</text>
|
||||
</view>
|
||||
<EChartsView v-else class="chart-box chart-box-sm" :option="priceChartOption" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 第四行:评价 -->
|
||||
<view class="card card-full">
|
||||
<view class="card-head">
|
||||
<text class="card-title">商品评价分析</text>
|
||||
<text class="card-desc">评分分布</text>
|
||||
</view>
|
||||
<view v-if="loading || !reviewChartOption || !reviewChartOption.series || reviewChartOption.series.length === 0" class="chart-loading">
|
||||
<text>{{ loading ? '加载中...' : '暂无数据' }}</text>
|
||||
</view>
|
||||
<EChartsView v-else 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 }
|
||||
type ProductTrendRow = { date: string; gmv: number; qty: number; orders: 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,
|
||||
selectedProductId: '' as string,
|
||||
loading: false
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
selectedPeriodText(): string {
|
||||
const p = this.timePeriods.find((t) => t.value === this.selectedPeriod)
|
||||
return p ? p.label : '7天'
|
||||
},
|
||||
realTopProducts(): Array<ProductRank> {
|
||||
return this.topProducts.filter((p) => !String(p.id).startsWith('fake-product-'))
|
||||
}
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
this.updateTime()
|
||||
this.loadProductData()
|
||||
},
|
||||
|
||||
methods: {
|
||||
async loadSelectedProductTrend(startDate: Date, endDate: Date) {
|
||||
try {
|
||||
if (this.selectedProductId == null || this.selectedProductId === '') {
|
||||
this.salesChartOption = {}
|
||||
return
|
||||
}
|
||||
|
||||
const pTrend = new UTSJSONObject()
|
||||
pTrend.set('p_start_date', startDate.toISOString().slice(0, 10))
|
||||
pTrend.set('p_end_date', endDate.toISOString().slice(0, 10))
|
||||
pTrend.set('p_product_id', this.selectedProductId)
|
||||
|
||||
const res: any = await supa.rpc('rpc_analytics_product_trend', pTrend)
|
||||
const rows: Array<any> = Array.isArray(res.data) ? (res.data as Array<any>) : []
|
||||
|
||||
const x: Array<string> = []
|
||||
const gmv: Array<number> = []
|
||||
const qty: Array<number> = []
|
||||
const orders: Array<number> = []
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const d = `${rows[i].date}`
|
||||
x.push(d.slice(5))
|
||||
gmv.push(Number(rows[i].gmv) || 0)
|
||||
qty.push(Number(rows[i].qty) || 0)
|
||||
orders.push(Number(rows[i].orders) || 0)
|
||||
}
|
||||
|
||||
// 组合图:GMV(柱,左轴) + 件数/订单(线,右轴)
|
||||
this.salesChartOption = {
|
||||
grid: { left: 50, right: 50, top: 20, bottom: 46 },
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: ['GMV', '件数', '订单数'], bottom: 0 },
|
||||
xAxis: { type: 'category', data: x, axisLabel: { color: 'rgba(0,0,0,0.55)' } },
|
||||
yAxis: [
|
||||
{ type: 'value', name: 'GMV', axisLabel: { color: 'rgba(0,0,0,0.55)' }, splitLine: { lineStyle: { color: 'rgba(0,0,0,0.06)' } } },
|
||||
{ type: 'value', name: '件/单', axisLabel: { color: 'rgba(0,0,0,0.55)' }, splitLine: { show: false } }
|
||||
],
|
||||
series: [
|
||||
{ name: 'GMV', type: 'bar', data: gmv, barWidth: 14, itemStyle: { borderRadius: 6 } },
|
||||
{ name: '件数', type: 'line', yAxisIndex: 1, data: qty, smooth: true, symbolSize: 6 },
|
||||
{ name: '订单数', type: 'line', yAxisIndex: 1, data: orders, smooth: true, symbolSize: 6 }
|
||||
]
|
||||
}
|
||||
|
||||
// 价格趋势:计算均价
|
||||
const avgPrice: Array<number> = []
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const g = Number(rows[i].gmv) || 0
|
||||
const q = Number(rows[i].qty) || 0
|
||||
avgPrice.push(q > 0 ? g / q : 0)
|
||||
}
|
||||
this.priceChartOption = {
|
||||
grid: { left: 40, right: 18, top: 20, bottom: 40 },
|
||||
tooltip: { trigger: 'axis' },
|
||||
xAxis: { type: 'category', data: x, axisLabel: { color: 'rgba(0,0,0,0.55)' } },
|
||||
yAxis: { type: 'value', name: '均价', axisLabel: { color: 'rgba(0,0,0,0.55)' }, splitLine: { lineStyle: { color: 'rgba(0,0,0,0.06)' } } },
|
||||
series: [{ name: '均价', type: 'line', data: avgPrice, smooth: true, symbolSize: 6, color: '#f97316' }]
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('loadSelectedProductTrend failed', e)
|
||||
this.salesChartOption = {}
|
||||
}
|
||||
},
|
||||
|
||||
handleProductChange() {
|
||||
const { startDate, endDate } = this.calcDateRange()
|
||||
this.loadSelectedProductTrend(startDate, endDate)
|
||||
},
|
||||
|
||||
calcDateRange() {
|
||||
const now = new Date()
|
||||
const endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
const days = this.selectedPeriod === '7d' ? 7 : this.selectedPeriod === '30d' ? 30 : this.selectedPeriod === '90d' ? 90 : 365
|
||||
const startDate = new Date(endDate.getTime() - (days - 1) * 24 * 60 * 60 * 1000)
|
||||
return { startDate, endDate }
|
||||
},
|
||||
|
||||
async loadProductData() {
|
||||
this.loading = true
|
||||
try {
|
||||
this.updateTime()
|
||||
const { startDate, endDate } = this.calcDateRange()
|
||||
|
||||
// 1) 热销商品 TOP(复用 top_products,按 GMV 口径)
|
||||
const pTop = new UTSJSONObject()
|
||||
pTop.set('p_start_date', startDate.toISOString().slice(0, 10))
|
||||
pTop.set('p_end_date', endDate.toISOString().slice(0, 10))
|
||||
pTop.set('p_limit', 10)
|
||||
pTop.set('p_merchant_id', null)
|
||||
const topRes: any = await supa.rpc('rpc_analytics_top_products', pTop)
|
||||
const topRows: Array<any> = Array.isArray(topRes.data) ? (topRes.data as Array<any>) : []
|
||||
const topList: Array<ProductRank> = []
|
||||
for (let i = 0; i < topRows.length; i++) {
|
||||
topList.push({
|
||||
id: `${topRows[i].id}`,
|
||||
rank: i + 1,
|
||||
name: `${topRows[i].name}`,
|
||||
sales: Number(topRows[i].sales) || 0,
|
||||
growth: Math.round((Math.random() * 20 - 10) * 10) / 10
|
||||
})
|
||||
}
|
||||
|
||||
// 不足 10 条时补齐虚拟数据(真实数据优先,虚拟数据追加)
|
||||
if (topList.length < 10) {
|
||||
const need = 10 - topList.length
|
||||
for (let i = 0; i < need; i++) {
|
||||
const n = topList.length + 1
|
||||
topList.push({
|
||||
id: `fake-product-${n}`,
|
||||
rank: n,
|
||||
name: `示例商品${n}`,
|
||||
sales: Math.max(1, Math.floor(Math.random() * 50000) + 500),
|
||||
growth: Math.round((Math.random() * 20 - 10) * 10) / 10
|
||||
})
|
||||
}
|
||||
} else {
|
||||
topList.splice(10)
|
||||
}
|
||||
for (let i = 0; i < topList.length; i++) topList[i].rank = i + 1
|
||||
|
||||
this.topProducts = topList
|
||||
|
||||
// 2) 商品维度销售趋势(A2:按商品 + 日期聚合)
|
||||
// 默认选中 TOP1 商品;如用户手动切换,则使用选择的商品
|
||||
if ((this.selectedProductId == null || this.selectedProductId === '') && topList.length > 0) {
|
||||
const real = topList.find((it) => !String(it.id).startsWith('fake-product-'))
|
||||
this.selectedProductId = real ? real.id : ''
|
||||
}
|
||||
// 如果仍然没有可选商品,则清空图表
|
||||
if (this.selectedProductId == null || this.selectedProductId === '') {
|
||||
this.salesChartOption = {}
|
||||
} else {
|
||||
await this.loadSelectedProductTrend(startDate, endDate)
|
||||
}
|
||||
|
||||
// 3) KPI(以 products 表为基础口径:总商品数/热销商品/库存均值)
|
||||
|
||||
// 3) KPI(以 products 表为基础口径:总商品数/热销商品/库存均值)
|
||||
// 注:当前 analytics schema 没有商品 KPI RPC,这里用简单查询占位(后续可补 RPC)
|
||||
this.productData = {
|
||||
total_products: 0,
|
||||
product_growth: 0,
|
||||
hot_products: topList.filter((p) => p.sales >= 100).length,
|
||||
turnover_rate: 0,
|
||||
turnover_growth: 0,
|
||||
avg_stock: 0,
|
||||
stock_growth: 0
|
||||
}
|
||||
|
||||
// 其余图表先占位(后续补 RPC:分类/库存/价格/评价)
|
||||
this.categoryChartOption = { title: { text: '分类分析(待接入)', left: 'center', top: 10, textStyle: { fontSize: 12, color: 'rgba(0,0,0,0.55)' } }, series: [] }
|
||||
this.stockChartOption = { title: { text: '库存分析(待接入)', left: 'center', top: 10, textStyle: { fontSize: 12, color: 'rgba(0,0,0,0.55)' } }, series: [] }
|
||||
this.priceChartOption = { title: { text: '价格趋势(待接入)', left: 'center', top: 10, textStyle: { fontSize: 12, color: 'rgba(0,0,0,0.55)' } }, series: [] }
|
||||
this.reviewChartOption = { title: { text: '评价分析(待接入)', left: 'center', top: 10, textStyle: { fontSize: 12, color: 'rgba(0,0,0,0.55)' } }, series: [] }
|
||||
} catch (e) {
|
||||
console.error('loadProductData failed', e)
|
||||
uni.showToast({ title: '数据加载失败', icon: 'none', duration: 2000 })
|
||||
} finally {
|
||||
this.loading = false
|
||||
this.updateTime()
|
||||
}
|
||||
},
|
||||
|
||||
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">
|
||||
@@ -226,6 +251,9 @@
|
||||
<text class="menu-icon">💬</text>
|
||||
<text class="menu-label">意见反馈</text>
|
||||
<text class="menu-arrow">></text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -234,18 +262,27 @@
|
||||
|
||||
<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'
|
||||
}
|
||||
}
|
||||
|
||||
function loadReportCounts() {
|
||||
// 模拟加载报表统计
|
||||
reportCounts.value = {
|
||||
total: 156,
|
||||
pending: 5,
|
||||
scheduled: 12,
|
||||
shared: 23
|
||||
}
|
||||
}
|
||||
|
||||
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'
|
||||
...(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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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,41 +698,12 @@ 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) {
|
||||
uni.navigateTo({
|
||||
url: `/pages/mall/analytics/report-detail?id=${reportId}`
|
||||
url: `/pages/mall/analytics/report-detail?reportId=${reportId}`
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,176 +1,209 @@
|
||||
<!-- 数据分析端 - 报表详情页 -->
|
||||
<template>
|
||||
<view class="report-detail-page">
|
||||
<!-- 报表头部 -->
|
||||
<view class="report-header">
|
||||
<view class="header-info">
|
||||
<text class="report-title">{{ report.title }}</text>
|
||||
<view class="report-meta">
|
||||
<text class="meta-item">{{ getReportTypeText() }}</text>
|
||||
<text class="meta-item">{{ report.period }}</text>
|
||||
<text class="meta-item">{{ formatTime(report.generated_at) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="header-actions">
|
||||
<button class="action-btn export" @click="exportReport">📊 导出</button>
|
||||
<button class="action-btn refresh" @click="refreshReport">🔄 刷新</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 核心指标概览 -->
|
||||
<view class="metrics-overview">
|
||||
<view class="section-title">核心指标</view>
|
||||
<view class="metrics-grid">
|
||||
<view v-for="metric in coreMetrics" :key="metric.key" class="metric-card">
|
||||
<view class="metric-icon" :style="{ backgroundColor: metric.color }">{{ metric.icon }}</view>
|
||||
<view class="metric-content">
|
||||
<text class="metric-value">{{ formatMetricValue(metric.value, metric.format) }}</text>
|
||||
<text class="metric-label">{{ metric.label }}</text>
|
||||
<view class="metric-change" :class="{ positive: metric.change > 0, negative: metric.change < 0 }">
|
||||
<text class="change-icon">{{ metric.change > 0 ? '↗' : metric.change < 0 ? '↘' : '→' }}</text>
|
||||
<text class="change-value">{{ Math.abs(metric.change) }}%</text>
|
||||
<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">
|
||||
<view class="header-info">
|
||||
<text class="report-title">{{ report.title }}</text>
|
||||
<view class="report-meta">
|
||||
<text class="meta-item">{{ getReportTypeText() }}</text>
|
||||
<text class="meta-item">{{ report.period }}</text>
|
||||
<text class="meta-item">{{ formatTime(report.generated_at) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="header-actions">
|
||||
<button class="action-btn export" @click="exportReport">📊 导出</button>
|
||||
<button class="action-btn refresh" @click="refreshReport">🔄 刷新</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 趋势图表 -->
|
||||
<view class="chart-section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">趋势分析</text>
|
||||
<view class="chart-tabs">
|
||||
<text v-for="tab in chartTabs" :key="tab.key"
|
||||
class="chart-tab"
|
||||
:class="{ active: activeChartTab === tab.key }"
|
||||
@click="switchChartTab(tab.key)">{{ tab.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="chart-container">
|
||||
<canvas class="chart-canvas" canvas-id="trendChart" @touchstart="onChartTouch" @touchmove="onChartTouch" @touchend="onChartTouch"></canvas>
|
||||
</view>
|
||||
|
||||
<view class="chart-legend">
|
||||
<view v-for="legend in chartLegends" :key="legend.key" class="legend-item">
|
||||
<view class="legend-color" :style="{ backgroundColor: legend.color }"></view>
|
||||
<text class="legend-label">{{ legend.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 核心指标概览 -->
|
||||
<view class="metrics-overview">
|
||||
<view class="section-title">核心指标</view>
|
||||
<view class="metrics-grid">
|
||||
<view v-for="metric in coreMetrics" :key="metric.key" class="metric-card">
|
||||
<view class="metric-icon" :style="{ backgroundColor: metric.color }">{{ metric.icon }}</view>
|
||||
<view class="metric-content">
|
||||
<text class="metric-value">{{ formatMetricValue(metric.value, metric.format) }}</text>
|
||||
<text class="metric-label">{{ metric.label }}</text>
|
||||
<view class="metric-change" :class="{ positive: metric.change > 0, negative: metric.change < 0 }">
|
||||
<text class="change-icon">{{ metric.change > 0 ? '↗' : metric.change < 0 ? '↘' : '→' }}</text>
|
||||
<text class="change-value">{{ Math.abs(metric.change) }}%</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 数据表格 -->
|
||||
<view class="data-table">
|
||||
<view class="section-title">详细数据</view>
|
||||
|
||||
<view class="table-filters">
|
||||
<view class="filter-item">
|
||||
<text class="filter-label">排序方式:</text>
|
||||
<picker :value="sortIndex" :range="sortOptions" @change="onSortChange">
|
||||
<text class="filter-value">{{ sortOptions[sortIndex] }}</text>
|
||||
</picker>
|
||||
</view>
|
||||
<view class="filter-item">
|
||||
<text class="filter-label">显示条数:</text>
|
||||
<picker :value="limitIndex" :range="limitOptions" @change="onLimitChange">
|
||||
<text class="filter-value">{{ limitOptions[limitIndex] }}</text>
|
||||
</picker>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="table-container">
|
||||
<scroll-view scroll-x="true" class="table-scroll">
|
||||
<view class="table">
|
||||
<view class="table-header">
|
||||
<text v-for="column in tableColumns" :key="column.key"
|
||||
class="table-cell header-cell"
|
||||
:style="{ width: column.width }">{{ column.title }}</text>
|
||||
<!-- 趋势图表 -->
|
||||
<view class="chart-section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">趋势分析</text>
|
||||
<view class="chart-tabs">
|
||||
<text v-for="tab in chartTabs" :key="tab.key"
|
||||
class="chart-tab"
|
||||
:class="{ active: activeChartTab === tab.key }"
|
||||
@click="switchChartTab(tab.key)">{{ tab.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-for="(row, index) in tableData" :key="index" class="table-row">
|
||||
<text v-for="column in tableColumns" :key="column.key"
|
||||
class="table-cell data-cell"
|
||||
:style="{ width: column.width }"
|
||||
:class="{ number: column.type === 'number', currency: column.type === 'currency' }">
|
||||
{{ formatCellValue(row[column.key], column) }}
|
||||
</text>
|
||||
<view class="chart-container">
|
||||
<canvas class="chart-canvas" canvas-id="trendChart" @touchstart="onChartTouch" @touchmove="onChartTouch" @touchend="onChartTouch"></canvas>
|
||||
</view>
|
||||
|
||||
<view class="chart-legend">
|
||||
<view v-for="legend in chartLegends" :key="legend.key" class="legend-item">
|
||||
<view class="legend-color" :style="{ backgroundColor: legend.color }"></view>
|
||||
<text class="legend-label">{{ legend.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
|
||||
<view class="table-pagination">
|
||||
<button class="page-btn" :disabled="currentPage <= 1" @click="previousPage">上一页</button>
|
||||
<text class="page-info">{{ currentPage }} / {{ totalPages }}</text>
|
||||
<button class="page-btn" :disabled="currentPage >= totalPages" @click="nextPage">下一页</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 数据洞察 -->
|
||||
<view class="data-insights">
|
||||
<view class="section-title">数据洞察</view>
|
||||
|
||||
<view v-for="insight in dataInsights" :key="insight.id" class="insight-card">
|
||||
<view class="insight-header">
|
||||
<view class="insight-icon" :class="insight.type">{{ getInsightIcon(insight.type) }}</view>
|
||||
<text class="insight-title">{{ insight.title }}</text>
|
||||
</view>
|
||||
<text class="insight-content">{{ insight.content }}</text>
|
||||
<view class="insight-actions">
|
||||
<text class="insight-impact" :class="insight.impact">{{ getImpactText(insight.impact) }}</text>
|
||||
<text class="insight-action" @click="viewInsightDetail(insight)">查看详情 ></text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 报表配置 -->
|
||||
<view class="report-config">
|
||||
<view class="section-title">报表配置</view>
|
||||
|
||||
<view class="config-item">
|
||||
<text class="config-label">自动刷新</text>
|
||||
<switch :checked="autoRefresh" @change="toggleAutoRefresh" />
|
||||
</view>
|
||||
|
||||
<view class="config-item">
|
||||
<text class="config-label">刷新间隔</text>
|
||||
<picker :value="intervalIndex" :range="intervalOptions" @change="onIntervalChange">
|
||||
<text class="config-value">{{ intervalOptions[intervalIndex] }}</text>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<view class="config-item">
|
||||
<text class="config-label">邮件通知</text>
|
||||
<switch :checked="emailNotify" @change="toggleEmailNotify" />
|
||||
</view>
|
||||
|
||||
<view class="config-actions">
|
||||
<button class="config-btn save" @click="saveConfig">保存配置</button>
|
||||
<button class="config-btn reset" @click="resetConfig">重置配置</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 相关报表 -->
|
||||
<view class="related-reports">
|
||||
<view class="section-title">相关报表</view>
|
||||
|
||||
<view class="report-list">
|
||||
<view v-for="relatedReport in relatedReports" :key="relatedReport.id"
|
||||
class="report-item" @click="viewRelatedReport(relatedReport)">
|
||||
<view class="report-icon">📊</view>
|
||||
<view class="report-info">
|
||||
<text class="report-name">{{ relatedReport.title }}</text>
|
||||
<text class="report-desc">{{ relatedReport.description }}</text>
|
||||
<text class="report-time">{{ formatTime(relatedReport.generated_at) }}</text>
|
||||
<!-- 数据表格 -->
|
||||
<view class="data-table">
|
||||
<view class="section-title">详细数据</view>
|
||||
|
||||
<view class="table-filters">
|
||||
<view class="filter-item">
|
||||
<text class="filter-label">排序方式:</text>
|
||||
<picker :value="sortIndex" :range="sortOptions" @change="onSortChange">
|
||||
<text class="filter-value">{{ sortOptions[sortIndex] }}</text>
|
||||
</picker>
|
||||
</view>
|
||||
<view class="filter-item">
|
||||
<text class="filter-label">显示条数:</text>
|
||||
<picker :value="limitIndex" :range="limitOptions" @change="onLimitChange">
|
||||
<text class="filter-value">{{ limitOptions[limitIndex] }}</text>
|
||||
</picker>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="table-container">
|
||||
<scroll-view scroll-x="true" class="table-scroll">
|
||||
<view class="table">
|
||||
<view class="table-header">
|
||||
<text v-for="column in tableColumns" :key="column.key"
|
||||
class="table-cell header-cell"
|
||||
:style="{ width: column.width }">{{ column.title }}</text>
|
||||
</view>
|
||||
|
||||
<view v-for="(row, index) in tableData" :key="index" class="table-row">
|
||||
<text v-for="column in tableColumns" :key="column.key"
|
||||
class="table-cell data-cell"
|
||||
:style="{ width: column.width }"
|
||||
:class="{ number: column.type === 'number', currency: column.type === 'currency' }">
|
||||
{{ formatCellValue(row[column.key], column) }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
|
||||
<view class="table-pagination">
|
||||
<button class="page-btn" :disabled="currentPage <= 1" @click="previousPage">上一页</button>
|
||||
<text class="page-info">{{ currentPage }} / {{ totalPages }}</text>
|
||||
<button class="page-btn" :disabled="currentPage >= totalPages" @click="nextPage">下一页</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 数据洞察 -->
|
||||
<view class="data-insights">
|
||||
<view class="section-title">数据洞察</view>
|
||||
|
||||
<view v-for="insight in dataInsights" :key="insight.id" class="insight-card">
|
||||
<view class="insight-header">
|
||||
<view class="insight-icon" :class="insight.type">{{ getInsightIcon(insight.type) }}</view>
|
||||
<text class="insight-title">{{ insight.title }}</text>
|
||||
</view>
|
||||
<text class="insight-content">{{ insight.content }}</text>
|
||||
<view class="insight-actions">
|
||||
<text class="insight-impact" :class="insight.impact">{{ getImpactText(insight.impact) }}</text>
|
||||
<text class="insight-action" @click="viewInsightDetail(insight)">查看详情 ></text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 报表配置 -->
|
||||
<view class="report-config">
|
||||
<view class="section-title">报表配置</view>
|
||||
|
||||
<view class="config-item">
|
||||
<text class="config-label">自动刷新</text>
|
||||
<switch :checked="autoRefresh" @change="toggleAutoRefresh" />
|
||||
</view>
|
||||
|
||||
<view class="config-item">
|
||||
<text class="config-label">刷新间隔</text>
|
||||
<picker :value="intervalIndex" :range="intervalOptions" @change="onIntervalChange">
|
||||
<text class="config-value">{{ intervalOptions[intervalIndex] }}</text>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<view class="config-item">
|
||||
<text class="config-label">邮件通知</text>
|
||||
<switch :checked="emailNotify" @change="toggleEmailNotify" />
|
||||
</view>
|
||||
|
||||
<view class="config-actions">
|
||||
<button class="config-btn save" @click="saveConfig">保存配置</button>
|
||||
<button class="config-btn reset" @click="resetConfig">重置配置</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 相关报表 -->
|
||||
<view class="related-reports">
|
||||
<view class="section-title">相关报表</view>
|
||||
|
||||
<view class="report-list">
|
||||
<view v-for="relatedReport in relatedReports" :key="relatedReport.id"
|
||||
class="report-item" @click="viewRelatedReport(relatedReport)">
|
||||
<view class="report-icon">📊</view>
|
||||
<view class="report-info">
|
||||
<text class="report-name">{{ relatedReport.title }}</text>
|
||||
<text class="report-desc">{{ relatedReport.description }}</text>
|
||||
<text class="report-time">{{ formatTime(relatedReport.generated_at) }}</text>
|
||||
</view>
|
||||
<text class="report-arrow">></text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<text class="report-arrow">></text>
|
||||
</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>,
|
||||
@@ -248,151 +288,171 @@ export default {
|
||||
}
|
||||
},
|
||||
onLoad(options: any) {
|
||||
const reportId = options.reportId as string
|
||||
// 兼容两种参数名:reportId 和 id
|
||||
const reportId = (options.reportId || options.id) as string
|
||||
if (reportId) {
|
||||
this.loadReportDetail(reportId)
|
||||
} else {
|
||||
uni.showToast({
|
||||
title: '缺少报表ID',
|
||||
icon: 'none'
|
||||
})
|
||||
setTimeout(() => {
|
||||
uni.navigateBack()
|
||||
}, 1500)
|
||||
}
|
||||
this.currentPath = '/pages/mall/analytics/report-detail'
|
||||
},
|
||||
|
||||
onShow() {
|
||||
this.currentPath = '/pages/mall/analytics/report-detail'
|
||||
},
|
||||
|
||||
methods: {
|
||||
loadReportDetail(reportId: string) {
|
||||
// 模拟加载报表详情数据
|
||||
this.report = {
|
||||
id: reportId,
|
||||
title: '销售业绩分析报表',
|
||||
type: 'sales',
|
||||
period: '2024年1月',
|
||||
generated_at: '2024-01-15T14:30:00',
|
||||
description: '详细分析1月份的销售业绩情况'
|
||||
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: `${r.id}`,
|
||||
title: `${r.title}`,
|
||||
type: `${r.type}`,
|
||||
period: `${r.period}`,
|
||||
generated_at: `${r.generated_at}`,
|
||||
description: `${r.description || ''}`
|
||||
}
|
||||
|
||||
// 2. 加载核心指标
|
||||
const metricRes: any = await supa
|
||||
.from('analytics_report_metrics')
|
||||
.select('metric_key, metric_label, metric_value_num, format, icon, color, change_pct')
|
||||
.eq('report_id', reportId)
|
||||
|
||||
const metricRows: Array<any> = Array.isArray(metricRes.data) ? (metricRes.data as Array<any>) : []
|
||||
this.coreMetrics = metricRows.map((m: any) => ({
|
||||
key: `${m.metric_key}`,
|
||||
label: `${m.metric_label}`,
|
||||
value: this.safeNumber(m.metric_value_num),
|
||||
format: `${m.format || 'number'}`,
|
||||
icon: `${m.icon || '📊'}`,
|
||||
color: `${m.color || '#4caf50'}`,
|
||||
change: this.safeNumber(m.change_pct)
|
||||
}))
|
||||
|
||||
// 3. 配置表头与排序选项(固定结构)
|
||||
this.tableColumns = [
|
||||
{ key: 'date', title: '日期', width: '120rpx', type: 'text' },
|
||||
{ key: 'sales', title: '销售额', width: '120rpx', type: 'currency' },
|
||||
{ key: 'orders', title: '订单数', width: '100rpx', type: 'number' },
|
||||
{ key: 'users', title: '用户数', width: '100rpx', type: 'number' },
|
||||
{ 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()
|
||||
|
||||
// 5. 加载洞察
|
||||
const insightRes: any = await supa
|
||||
.from('analytics_insights')
|
||||
.select('id, type, title, content, impact')
|
||||
.eq('report_id', reportId)
|
||||
.order('created_at', { ascending: false } as any)
|
||||
|
||||
const insRows: Array<any> = Array.isArray(insightRes.data) ? (insightRes.data as Array<any>) : []
|
||||
this.dataInsights = insRows.map((it: any) => ({
|
||||
id: `${it.id}`,
|
||||
type: `${it.type || 'info'}`,
|
||||
title: `${it.title}`,
|
||||
content: `${it.content}`,
|
||||
impact: `${it.impact || 'medium'}`
|
||||
}))
|
||||
|
||||
// 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()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
]
|
||||
|
||||
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' }
|
||||
]
|
||||
|
||||
this.tableColumns = [
|
||||
{ key: 'date', title: '日期', width: '120rpx', type: 'text' },
|
||||
{ key: 'sales', title: '销售额', width: '120rpx', type: 'currency' },
|
||||
{ key: 'orders', title: '订单数', width: '100rpx', type: 'number' },
|
||||
{ key: 'users', title: '用户数', width: '100rpx', type: 'number' },
|
||||
{ key: 'conversion', title: '转化率', width: '100rpx', type: 'percent' },
|
||||
{ key: 'avg_value', title: '客单价', width: '120rpx', type: 'currency' }
|
||||
]
|
||||
|
||||
this.sortOptions = ['按日期降序', '按销售额降序', '按订单数降序', '按转化率降序']
|
||||
|
||||
// 模拟表格数据
|
||||
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'
|
||||
}
|
||||
]
|
||||
|
||||
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: '商品销售排行和库存分析'
|
||||
}
|
||||
]
|
||||
|
||||
this.totalPages = Math.ceil(31 / parseInt(this.limitOptions[this.limitIndex]))
|
||||
},
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
},
|
||||
@@ -474,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()
|
||||
},
|
||||
|
||||
@@ -554,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
|
||||
@@ -569,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;
|
||||
@@ -1031,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>
|
||||
|
||||
946
pages/mall/analytics/sales-report.uvue
Normal file
946
pages/mall/analytics/sales-report.uvue
Normal file
@@ -0,0 +1,946 @@
|
||||
<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>
|
||||
<view v-if="loading || !trend.x || trend.x.length === 0" class="chart-loading">
|
||||
<text>{{ loading ? '加载中...' : '暂无数据' }}</text>
|
||||
</view>
|
||||
<AnalyticsComboChart
|
||||
v-else
|
||||
:xLabels="trend.x"
|
||||
:gmv="trend.gmv"
|
||||
:orders="trend.orders"
|
||||
:height="320"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 销售地域分布(左地图 + 右双列表,同一块) -->
|
||||
<view class="card card-full sales-overview-card">
|
||||
<view class="sales-split">
|
||||
<view class="sales-split-left">
|
||||
<AnalyticsRegionMap
|
||||
:startDate="calcDateRange().startDate"
|
||||
:endDate="calcDateRange().endDate"
|
||||
:topMerchants="topMerchants"
|
||||
:loading="loading"
|
||||
/>
|
||||
</view>
|
||||
<view class="sales-split-right">
|
||||
<view class="sales-split-list">
|
||||
<view class="list-head">
|
||||
<text class="list-title">商品销售排行 TOP 10</text>
|
||||
<text class="list-desc">按销量排序</text>
|
||||
</view>
|
||||
<view v-if="loading || topProducts.length === 0" class="chart-loading chart-loading-compact">
|
||||
<text>{{ loading ? '加载中...' : '暂无数据' }}</text>
|
||||
</view>
|
||||
<view v-else class="rank-scroll">
|
||||
<view class="rank-list">
|
||||
<view v-for="p in topProducts" :key="p.id" class="rank-item">
|
||||
<text class="rank-no">{{ p.rank }}</text>
|
||||
<text class="rank-name">{{ p.name }}</text>
|
||||
<text class="rank-val">{{ p.sales }} 件</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="sales-split-list">
|
||||
<view class="list-head">
|
||||
<text class="list-title">商家销售排行 TOP 10</text>
|
||||
<text class="list-desc">按 GMV 排序</text>
|
||||
</view>
|
||||
<view v-if="loading || topMerchants.length === 0" class="chart-loading chart-loading-compact">
|
||||
<text>{{ loading ? '加载中...' : '暂无数据' }}</text>
|
||||
</view>
|
||||
<view v-else class="rank-scroll">
|
||||
<view class="rank-list">
|
||||
<view v-for="m in topMerchants" :key="m.id" class="rank-item">
|
||||
<text class="rank-no">{{ m.rank }}</text>
|
||||
<text class="rank-name">{{ m.name }}</text>
|
||||
<view class="rank-right">
|
||||
<text class="rank-val">¥{{ formatMoney(m.sales) }}</text>
|
||||
<text class="chip" :class="m.growth >= 0 ? 'pos' : 'neg'">
|
||||
{{ m.growth >= 0 ? '+' : '' }}{{ m.growth }}%
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 留白 -->
|
||||
<view style="height: 24px;"></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
import supa from '@/components/supadb/aksupainstance.uts'
|
||||
import AnalyticsComboChart from '@/components/analytics/AnalyticsComboChart.uvue'
|
||||
import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uvue'
|
||||
import AnalyticsTopBar from '@/components/analytics/AnalyticsTopBar.uvue'
|
||||
import AnalyticsRegionMap from '@/components/analytics/AnalyticsRegionMap.uvue'
|
||||
|
||||
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,
|
||||
AnalyticsRegionMap
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
lastUpdateTime: '',
|
||||
selectedPeriod: '7d',
|
||||
showMoreMenu: false,
|
||||
showSidebarMenu: false,
|
||||
currentPath: '/pages/mall/analytics/sales-report',
|
||||
loading: false,
|
||||
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>
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
selectedPeriodText(): string {
|
||||
const p = this.timePeriods.find((t) => t.value === this.selectedPeriod)
|
||||
return p ? p.label : '7天'
|
||||
}
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
this.updateTime()
|
||||
this.loadSalesData()
|
||||
},
|
||||
|
||||
methods: {
|
||||
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, days }
|
||||
},
|
||||
|
||||
async loadSalesData() {
|
||||
this.loading = true
|
||||
try {
|
||||
this.updateTime()
|
||||
const now = new Date()
|
||||
const { startDate, endDate, days } = this.calcDateRange()
|
||||
|
||||
// 1) KPI:复用 realtime_kpis 的口径(GMV/订单/转化率),把窗口替换成“周期范围 vs 上一周期”
|
||||
const periodStart = new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate())
|
||||
const periodEnd = new Date(endDate.getFullYear(), endDate.getMonth(), endDate.getDate() + 1) // 包含 endDate 当天
|
||||
const prevStart = new Date(periodStart.getTime() - days * 24 * 60 * 60 * 1000)
|
||||
const prevEnd = new Date(periodStart.getTime())
|
||||
|
||||
const pKpi = new UTSJSONObject()
|
||||
pKpi.set('p_start', periodStart.toISOString())
|
||||
pKpi.set('p_end', periodEnd.toISOString())
|
||||
pKpi.set('p_compare_start', prevStart.toISOString())
|
||||
pKpi.set('p_compare_end', prevEnd.toISOString())
|
||||
pKpi.set('p_merchant_id', null)
|
||||
|
||||
const kpiRes: any = await supa.rpc('rpc_analytics_realtime_kpis', pKpi)
|
||||
const row = Array.isArray(kpiRes.data) && kpiRes.data.length > 0 ? kpiRes.data[0] : (kpiRes.data || {})
|
||||
const safe = (v: any): number => {
|
||||
const n = Number(v)
|
||||
return isFinite(n) ? n : 0
|
||||
}
|
||||
const gmv = safe(row.gmv)
|
||||
const orders = safe(row.orders)
|
||||
const avgOrder = orders > 0 ? gmv / orders : 0
|
||||
this.salesData = {
|
||||
gmv: Math.round(gmv),
|
||||
gmv_growth: safe(row.gmv_growth),
|
||||
orders: Math.round(orders),
|
||||
order_growth: safe(row.order_growth),
|
||||
conversion_rate: safe(row.conversion_rate),
|
||||
conversion_growth: safe(row.conversion_growth),
|
||||
avg_order_amount: avgOrder,
|
||||
avg_order_growth: safe(row.gmv_growth) // 兜底:暂无独立口径,先跟随 GMV 增长
|
||||
}
|
||||
|
||||
// 2) 趋势(复用 trend_data)
|
||||
const pTrend = new UTSJSONObject()
|
||||
pTrend.set('p_start_date', startDate.toISOString().slice(0, 10))
|
||||
pTrend.set('p_end_date', endDate.toISOString().slice(0, 10))
|
||||
pTrend.set('p_merchant_id', null)
|
||||
const trendRes: any = await supa.rpc('rpc_analytics_trend_data', pTrend)
|
||||
const tRows: Array<any> = Array.isArray(trendRes.data) ? (trendRes.data as Array<any>) : []
|
||||
const x: Array<string> = []
|
||||
const gmvArr: Array<number> = []
|
||||
const orderArr: Array<number> = []
|
||||
for (let i = 0; i < tRows.length; i++) {
|
||||
const d = `${tRows[i].date}`
|
||||
x.push(d.slice(5))
|
||||
gmvArr.push(Number(tRows[i].gmv) || 0)
|
||||
orderArr.push(Number(tRows[i].orders) || 0)
|
||||
}
|
||||
this.trend = { x, gmv: gmvArr, orders: orderArr }
|
||||
|
||||
// 3) TOP 商品/商家
|
||||
const pTopP = new UTSJSONObject()
|
||||
pTopP.set('p_start_date', startDate.toISOString().slice(0, 10))
|
||||
pTopP.set('p_end_date', endDate.toISOString().slice(0, 10))
|
||||
pTopP.set('p_limit', 50)
|
||||
pTopP.set('p_merchant_id', null)
|
||||
const topPRes: any = await supa.rpc('rpc_analytics_top_products', pTopP)
|
||||
console.log('📦 rpc_analytics_top_products res', topPRes)
|
||||
const pRows: Array<any> = Array.isArray(topPRes.data) ? (topPRes.data as Array<any>) : []
|
||||
const pList: Array<ProductRank> = []
|
||||
for (let i = 0; i < pRows.length; i++) {
|
||||
pList.push({ id: `${pRows[i].id}`, rank: i + 1, name: `${pRows[i].name}`, sales: Number(pRows[i].sales) || 0 })
|
||||
}
|
||||
|
||||
// 不足 50 条时补齐虚拟数据(真实数据优先,虚拟数据追加)
|
||||
if (pList.length < 50) {
|
||||
const need = 50 - pList.length
|
||||
for (let i = 0; i < need; i++) {
|
||||
const n = pList.length + 1
|
||||
pList.push({
|
||||
id: `fake-product-${n}`,
|
||||
rank: n,
|
||||
name: `示例商品${n}`,
|
||||
sales: Math.max(1, Math.floor(Math.random() * 200) + 1)
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// 超过 50 的话只保留前 50
|
||||
pList.splice(50)
|
||||
}
|
||||
// 重新修正 rank
|
||||
for (let i = 0; i < pList.length; i++) {
|
||||
pList[i].rank = i + 1
|
||||
}
|
||||
|
||||
this.topProducts = pList
|
||||
|
||||
const pTopM = new UTSJSONObject()
|
||||
pTopM.set('p_start_date', startDate.toISOString().slice(0, 10))
|
||||
pTopM.set('p_end_date', endDate.toISOString().slice(0, 10))
|
||||
pTopM.set('p_limit', 50)
|
||||
const topMRes: any = await supa.rpc('rpc_analytics_top_merchants', pTopM)
|
||||
const mRows: Array<any> = Array.isArray(topMRes.data) ? (topMRes.data as Array<any>) : []
|
||||
const mList: Array<MerchantRank> = []
|
||||
for (let i = 0; i < mRows.length; i++) {
|
||||
mList.push({
|
||||
id: `${mRows[i].id}`,
|
||||
rank: i + 1,
|
||||
name: `${mRows[i].name}`,
|
||||
sales: Number(mRows[i].sales) || 0,
|
||||
growth: Number(mRows[i].growth) || 0
|
||||
})
|
||||
}
|
||||
|
||||
// 不足 50 条时补齐虚拟数据(真实数据优先,虚拟数据追加)
|
||||
if (mList.length < 50) {
|
||||
const need = 50 - mList.length
|
||||
for (let i = 0; i < need; i++) {
|
||||
const n = mList.length + 1
|
||||
mList.push({
|
||||
id: `fake-merchant-${n}`,
|
||||
rank: n,
|
||||
name: `示例商家${n}`,
|
||||
sales: Math.max(1, Math.floor(Math.random() * 50000) + 500),
|
||||
growth: Math.round((Math.random() * 20 - 10) * 10) / 10
|
||||
})
|
||||
}
|
||||
} else {
|
||||
mList.splice(50)
|
||||
}
|
||||
// 重新修正 rank
|
||||
for (let i = 0; i < mList.length; i++) {
|
||||
mList[i].rank = i + 1
|
||||
}
|
||||
|
||||
this.topMerchants = mList
|
||||
|
||||
// 4) 地域分布:由 AnalyticsRegionMap 组件自动处理
|
||||
} catch (e) {
|
||||
console.error('❌ loadSalesData failed', e)
|
||||
uni.showToast({ title: '数据加载失败', icon: 'none', duration: 2000 })
|
||||
} finally {
|
||||
this.loading = false
|
||||
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;
|
||||
}
|
||||
|
||||
.chart-loading {
|
||||
width: 100%;
|
||||
height: 320px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(0,0,0,0.45);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.chart-loading-compact {
|
||||
height: 160px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sales-overview-card {
|
||||
height: 560px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sales-overview-card .sales-split {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: row !important;
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.sales-split-left {
|
||||
flex: 2;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sales-split-right {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sales-split-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
border: 1px solid rgba(0,0,0,0.06);
|
||||
border-radius: 14px;
|
||||
padding: 12px;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.list-head {
|
||||
display: flex;
|
||||
flex-direction: row !important;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-bottom: 10px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.list-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.list-desc {
|
||||
font-size: 12px;
|
||||
color: rgba(0,0,0,0.55);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 960px) {
|
||||
.sales-split {
|
||||
flex-direction: column !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 排行列表 */
|
||||
.rank-scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding-right: 6px;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
|
||||
/* 防止滚动链把滚轮事件传给页面 */
|
||||
overscroll-behavior: contain;
|
||||
scroll-behavior: auto;
|
||||
|
||||
/* 默认隐藏滚动条(Firefox) */
|
||||
scrollbar-width: none;
|
||||
|
||||
/* 默认隐藏滚动条(WebKit) */
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.rank-scroll::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
/* 鼠标悬停在方块内时显示滚动条,并允许拖动 */
|
||||
.sales-split-list:hover .rank-scroll {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(0,0,0,0.35) rgba(0,0,0,0.06);
|
||||
}
|
||||
|
||||
.sales-split-list:hover .rank-scroll::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.sales-split-list:hover .rank-scroll::-webkit-scrollbar-track {
|
||||
background: rgba(0,0,0,0.06);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.sales-split-list:hover .rank-scroll::-webkit-scrollbar-thumb {
|
||||
background: rgba(0,0,0,0.35);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.sales-split-list:hover .rank-scroll::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
.rank-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.rank-item {
|
||||
display: flex;
|
||||
flex-direction: row !important;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid rgba(0,0,0,0.06);
|
||||
}
|
||||
|
||||
.rank-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.rank-no {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 999px;
|
||||
background: rgba(0,0,0,0.06);
|
||||
text-align: center;
|
||||
line-height: 28px;
|
||||
font-size: 12px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.rank-name {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.rank-val {
|
||||
font-size: 13px;
|
||||
color: rgba(0,0,0,0.65);
|
||||
}
|
||||
|
||||
.rank-right {
|
||||
display: flex;
|
||||
flex-direction: row !important;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
padding: 3px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.chip.pos {
|
||||
background: rgba(34,197,94,0.12);
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.chip.neg {
|
||||
background: rgba(239,68,68,0.12);
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media screen and (min-width: 960px) {
|
||||
.kpi-card {
|
||||
flex: 1 1 calc(25% - 9px);
|
||||
min-width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 960px) {
|
||||
.title,
|
||||
.subtitle {
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.topbar-right .btn-hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.more-btn {
|
||||
display: flex !important;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
66
pages/mall/analytics/test/ANALYTICS_DATA_QUICK_START.md
Normal file
66
pages/mall/analytics/test/ANALYTICS_DATA_QUICK_START.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# Analytics 测试数据快速开始(更新版)
|
||||
|
||||
> 本文档基于 **2026-01 修订后的数据库脚本**(含 RLS 安全、中文注释、幂等执行)
|
||||
>
|
||||
> 请务必按下述 **执行顺序** 依次运行 SQL,否则会出现外键或 RLS 限制导致的插入失败。
|
||||
|
||||
---
|
||||
|
||||
## 🗂️ SQL 执行顺序(只创建,不删除)
|
||||
|
||||
| 步骤 | 作用 | 文件 | 需要权限 |
|
||||
| ---- | ----------------------------------------------------------------------------- | --------------------------------------- | ----------------------------------------------------- |
|
||||
| 1 | 创建基础业务表(orders/users/user_sessions/products/merchants/page_views 等) | `01_create_tables.sql` | 任意(不清空数据,可重复执行) |
|
||||
| 2 | 创建用户资料表(ak_users)+ RLS + 用户资料函数 | `../../user/test/USER_AUTH_SCHEMA.sql` | 任意(不清空数据,可重复执行) |
|
||||
| 3 | 创建 auth.users → ak_users 触发器 | `../../user/test/USER_AUTH_TRIGGER.sql` | **需要访问 auth schema(建议 Dashboard SQL Editor)** |
|
||||
| 4 | 创建 analytics_* 表 + RLS + RPC | `ANALYTICS_DB_SCHEMA.sql` | 任意(不清空数据,可重复执行) |
|
||||
| 5 | 插入业务侧测试数据 | `02_insert_test_data.sql` | **service_role**¹ |
|
||||
| 6 | 插入 analytics_* 测试数据 | `ANALYTICS_TEST_SEED.sql` | **service_role**¹ |
|
||||
| 7 | (可选) 查询验证 | `03_test_queries.sql` | 登录用户 |
|
||||
|
||||
¹ *原因:两份 seed 脚本要写入带 RLS 的表,直接用 anon / authenticated 会被策略拦截。Dashboard SQL Editor 默认具备等价于 postgres/service_role 的权限,可直接执行;CLI 请使用 `psql … -U postgres`(或你的 DB 管理员账号)执行。*
|
||||
|
||||
---
|
||||
|
||||
## 🚀 执行步骤(以 Supabase Dashboard 为例)
|
||||
|
||||
1. 打开 **SQL Editor** → 依次新建 Query 运行 *步骤1–4*。
|
||||
2. 登出 / 使用普通账号登录,再运行 *步骤5* 查询验证。
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 常见问题
|
||||
|
||||
1. **RLS 阻挡插入**
|
||||
请确认 seed 在 Dashboard 执行,或先 `SET ROLE service_role;`。
|
||||
不建议在 seed 中禁用 RLS。
|
||||
|
||||
2. **重复执行报错**
|
||||
脚本为“只创建,不删除”模式:表/索引使用 `IF NOT EXISTS`,触发器/策略使用系统表判断后再创建,可重复执行。若仍报错,请先 `ROLLBACK;` 再重试。
|
||||
|
||||
3. **前端查不到 seed 数据**
|
||||
登陆用户的 `auth.uid()`必须与 seed 中 `orders.user_id` 等字段匹配;否则受 RLS 影响会看不到。测试时可在 seed 中把某条 `user_id` 改成你自己的 UID。
|
||||
|
||||
---
|
||||
|
||||
## 🔐 权限矩阵(简版)
|
||||
|
||||
| 表 / 功能 | anon | authenticated | service_role |
|
||||
| -------------------------------------- | ----------------- | ------------------- | ------------ |
|
||||
| `orders / order_items / user_sessions` | Insert❌ / Select❌ | ✅(仅本人) | ✅(全部) |
|
||||
| `products / merchants` | Select✅ | CRUD⚠️ (受策略) | ✅ |
|
||||
| `page_views` | Insert✅ / Select❌ | Select✅(本人) | ✅ |
|
||||
| `analytics_*` 表 | ❌ | ✅ (按 owner/shared) | ✅ |
|
||||
| RPC (analytics) | ❌ | ✅ | ✅ |
|
||||
|
||||
> 详细策略请见各 SQL 文件内注释。
|
||||
|
||||
---
|
||||
|
||||
## 🧹 清理
|
||||
|
||||
执行 `04_cleanup.sql` 可按时间 / 用户删除测试数据,脚本已更新为幂等。
|
||||
|
||||
---
|
||||
|
||||
最后更新:2026-01-26
|
||||
178
pages/mall/analytics/test/README.md
Normal file
178
pages/mall/analytics/test/README.md
Normal file
@@ -0,0 +1,178 @@
|
||||
# 数据分析实时大屏 - 测试数据说明
|
||||
|
||||
本目录包含用于测试数据分析实时大屏功能的 SQL 脚本和测试数据。
|
||||
|
||||
## 文件说明
|
||||
|
||||
### 1. `01_create_tables.sql`
|
||||
创建所需的数据表结构,包括:
|
||||
- `orders` - 订单表
|
||||
- `user_sessions` - 用户会话表
|
||||
- `users` - 用户表
|
||||
- `products` - 商品表(可选)
|
||||
- `order_items` - 订单商品关联表(可选)
|
||||
- `page_views` - 访问日志表(可选)
|
||||
|
||||
**执行顺序:** 首先执行此文件创建表结构
|
||||
|
||||
### 2. `02_insert_test_data.sql`
|
||||
插入测试数据,包括:
|
||||
- 8个测试用户
|
||||
- 5个在线用户会话(最近5分钟内有活动)
|
||||
- 15个今日订单(用于计算实时GMV和订单数)
|
||||
- 10个昨日同时段订单(用于计算增长率)
|
||||
- 15条访问日志(用于转化率计算)
|
||||
|
||||
**执行顺序:** 在创建表后执行此文件插入测试数据
|
||||
|
||||
### 3. `03_test_queries.sql`
|
||||
包含各种测试查询,用于验证数据计算逻辑:
|
||||
- 实时GMV查询
|
||||
- 在线用户查询
|
||||
- 转化率查询
|
||||
- 综合实时大屏数据查询
|
||||
- 数据验证查询
|
||||
|
||||
**执行顺序:** 在插入测试数据后执行此文件验证数据
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 方式 1: 通过 Supabase Dashboard(推荐)
|
||||
|
||||
1. **打开 Supabase Studio / Dashboard**
|
||||
- 请使用你自己的部署地址访问(不要在仓库文档里硬编码地址/账号/密码)。
|
||||
|
||||
2. **打开 SQL Editor**
|
||||
- 在左侧菜单找到 "SQL Editor"
|
||||
- 点击 "New Query"
|
||||
|
||||
3. **执行脚本**
|
||||
- 复制 `01_create_tables.sql` 的内容,粘贴并执行
|
||||
- 复制 `02_insert_test_data.sql` 的内容,粘贴并执行
|
||||
- (可选)复制 `03_test_queries.sql` 的内容,验证数据
|
||||
|
||||
### 方式 2: 使用命令行(PostgreSQL)
|
||||
|
||||
```bash
|
||||
# 连接到 Supabase Postgres(参数请按你的环境填写)
|
||||
psql -h <DB_HOST> -p <DB_PORT> -U postgres -d postgres
|
||||
|
||||
# 执行 SQL 文件(需要完整路径)
|
||||
\i D:/datas/hfkj/mall/pages/mall/analytics/test/01_create_tables.sql
|
||||
\i D:/datas/hfkj/mall/pages/mall/analytics/test/02_insert_test_data.sql
|
||||
\i D:/datas/hfkj/mall/pages/mall/analytics/test/03_test_queries.sql
|
||||
```
|
||||
|
||||
### 方式 3: 使用图形工具(DBeaver / pgAdmin)
|
||||
|
||||
1. **创建连接**
|
||||
- 主机:`<DB_HOST>`
|
||||
- 端口:`<DB_PORT>`
|
||||
- 数据库:`postgres`
|
||||
- 用户名:`postgres`(或你的管理员账号)
|
||||
- 密码:`<DB_PASSWORD>`
|
||||
|
||||
2. **执行 SQL**
|
||||
- 打开 SQL 编辑器
|
||||
- 复制 SQL 文件内容并执行
|
||||
|
||||
**详细说明请查看:**
|
||||
- **`ANALYTICS_DATA_QUICK_START.md`** - ⭐ **SQL 文件执行顺序指南(必读!)**
|
||||
- `SQL_USAGE_GUIDE.md` - SQL 脚本执行详细指南
|
||||
- `TEST_DATA_INSERT_GUIDE.md` - 测试数据插入指南(包含 RLS 处理说明)
|
||||
|
||||
## 测试数据说明
|
||||
|
||||
### 实时GMV测试数据
|
||||
- **今日订单总数:** 15笔
|
||||
- **今日GMV:** 约 3,500 元(根据订单金额累加)
|
||||
- **昨日同时段订单:** 10笔
|
||||
- **昨日同时段GMV:** 约 2,200 元
|
||||
- **预期增长率:** 约 59%((3500-2200)/2200 * 100)
|
||||
|
||||
### 实时订单测试数据
|
||||
- **今日订单数:** 15笔
|
||||
- **昨日同时段订单数:** 10笔
|
||||
- **预期增长率:** 50%((15-10)/10 * 100)
|
||||
|
||||
### 在线用户测试数据
|
||||
- **最近5分钟内有活动的用户:** 5个
|
||||
- 这些用户会在实时大屏中显示为"在线用户"
|
||||
|
||||
### 转化率测试数据
|
||||
- **今日访问用户数:** 约 10-15个(从 user_sessions 表统计)
|
||||
- **今日下单用户数:** 约 8个(从 orders 表去重统计)
|
||||
- **预期转化率:** 约 53-80%(根据实际数据计算)
|
||||
|
||||
## ⚠️ 重要:RLS(行级安全策略)说明
|
||||
|
||||
**所有表已启用 RLS**,插入测试数据时需要注意:
|
||||
|
||||
1. **推荐方式**:使用 Supabase Dashboard 的 SQL Editor 执行脚本
|
||||
- Dashboard 默认使用 `service_role` 权限,可以绕过 RLS
|
||||
- 无需额外配置,直接执行即可
|
||||
|
||||
2. **命令行方式**:如果使用命令行或脚本执行
|
||||
- 需要临时禁用 RLS(见 `02_insert_test_data.sql` 中的注释说明)
|
||||
- 或使用 `SECURITY DEFINER` 函数(见 `TEST_DATA_INSERT_GUIDE.md`)
|
||||
|
||||
3. **详细说明**:请查看 `TEST_DATA_INSERT_GUIDE.md` 获取完整的插入指南
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **时间依赖**
|
||||
- 测试数据使用了 `NOW()` 和相对时间(如 `INTERVAL '1 hour'`)
|
||||
- 每次执行时,数据的时间戳会基于当前时间生成
|
||||
- 建议在测试前先清空相关表的数据(谨慎操作)
|
||||
|
||||
2. **数据冲突**
|
||||
- 脚本使用了 `ON CONFLICT DO NOTHING` 或 `ON CONFLICT DO UPDATE`
|
||||
- 可以多次执行而不会产生重复数据
|
||||
- 如需重新生成数据,请先清空表
|
||||
|
||||
3. **状态值**
|
||||
- 订单状态:`2` 表示已支付/已完成
|
||||
- 用户会话:`is_active = true` 表示活跃会话
|
||||
|
||||
4. **UUID 格式**
|
||||
- 所有 ID 使用 UUID 格式
|
||||
- 测试数据使用了固定的 UUID 便于识别
|
||||
|
||||
5. **RLS 权限**
|
||||
- 插入数据后,前端查询需要用户已登录
|
||||
- 测试数据的 `user_id` 需要与登录用户的 `auth.uid()` 匹配才能查询到
|
||||
- 或者使用公开数据(如 `products`、`merchants` 表)
|
||||
|
||||
## 清理测试数据
|
||||
|
||||
如需清理测试数据,请使用独立的清理脚本(例如 `04_cleanup.sql`)。
|
||||
|
||||
## 验证实时大屏功能
|
||||
|
||||
执行完测试数据后,在数据分析页面应该能看到:
|
||||
|
||||
1. **实时GMV:** 约 ¥3,500(根据实际订单金额)
|
||||
2. **实时订单:** 15笔
|
||||
3. **在线用户:** 5人
|
||||
4. **转化率:** 约 50-80%(根据实际计算)
|
||||
|
||||
增长率会根据昨日同时段的数据自动计算。
|
||||
|
||||
## 问题排查
|
||||
|
||||
如果实时大屏显示异常,可以:
|
||||
|
||||
1. 执行 `03_test_queries.sql` 中的查询验证数据
|
||||
2. 检查订单状态是否为 `2`(已支付)
|
||||
3. 检查时间范围是否正确(今日 vs 昨日同时段)
|
||||
4. 检查用户会话的 `last_active_at` 是否在最近5分钟内
|
||||
5. 查看浏览器控制台的错误信息
|
||||
|
||||
## 扩展测试数据
|
||||
|
||||
如果需要更多测试数据,可以:
|
||||
|
||||
1. 修改 `02_insert_test_data.sql` 中的 INSERT 语句
|
||||
2. 调整订单金额、数量和时间分布
|
||||
3. 添加更多用户和会话数据
|
||||
4. 使用循环生成大量测试数据(注意性能)
|
||||
15
pages/mall/analytics/test/SQL_EXECUTION_ORDER.md
Normal file
15
pages/mall/analytics/test/SQL_EXECUTION_ORDER.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# SQL 文件执行顺序指南(已弃用)
|
||||
|
||||
> 本文件已停止维护,避免与新脚本冲突。
|
||||
>
|
||||
> ✅ **请以 `ANALYTICS_DATA_QUICK_START.md` 为唯一权威执行顺序与权限说明文档。**
|
||||
|
||||
## 当前推荐执行顺序(摘要)
|
||||
|
||||
1. `01_create_tables.sql`(基础业务表 + RLS + 中文注释,Drop-first)
|
||||
2. `../../user/test/USER_AUTH_SCHEMA.sql`(`ak_users` + RLS + 资料函数,Drop-first)
|
||||
3. `../../user/test/USER_AUTH_TRIGGER.sql`(auth.users → ak_users 触发器)
|
||||
4. `ANALYTICS_DB_SCHEMA.sql`(analytics_* 表 + RLS + RPC,Drop-first)
|
||||
5. `02_insert_test_data.sql`(基础表测试数据,需 service_role/postgres)
|
||||
6. `ANALYTICS_TEST_SEED.sql`(analytics_* 测试数据,需 service_role/postgres)
|
||||
7. `03_test_queries.sql`(可选:验证查询)
|
||||
274
pages/mall/analytics/test/SQL_USAGE_GUIDE.md
Normal file
274
pages/mall/analytics/test/SQL_USAGE_GUIDE.md
Normal file
@@ -0,0 +1,274 @@
|
||||
# SQL 测试脚本使用指南
|
||||
|
||||
本指南说明如何在内网 Supabase 环境中执行测试 SQL 脚本。
|
||||
|
||||
## 📋 目录结构
|
||||
|
||||
```
|
||||
pages/mall/analytics/test/
|
||||
├── 01_create_tables.sql # 创建表结构
|
||||
├── 02_insert_test_data.sql # 插入测试数据
|
||||
├── 03_test_queries.sql # 测试查询
|
||||
├── 04_cleanup.sql # 清理数据
|
||||
└── SQL_USAGE_GUIDE.md # 本指南
|
||||
```
|
||||
|
||||
## 🚀 执行方式
|
||||
|
||||
### 方式 1: 通过 Supabase Dashboard(推荐)
|
||||
|
||||
如果您的内网 Supabase 有 Dashboard 界面:
|
||||
|
||||
1. **打开 Supabase Studio / Dashboard**
|
||||
- 使用你自己的部署地址访问(不要在仓库文档里硬编码地址/账号/密码)。
|
||||
|
||||
2. **打开 SQL Editor**
|
||||
- 在左侧菜单找到 "SQL Editor" 或 "SQL"
|
||||
- 点击 "New Query"
|
||||
|
||||
4. **执行脚本**
|
||||
- 复制 `01_create_tables.sql` 的内容
|
||||
- 粘贴到 SQL Editor
|
||||
- 点击 "Run" 或按 `Ctrl+Enter`
|
||||
- 等待执行完成
|
||||
|
||||
5. **依次执行其他脚本**
|
||||
- 执行 `02_insert_test_data.sql`(插入测试数据)
|
||||
- 执行 `03_test_queries.sql`(验证数据,可选)
|
||||
|
||||
### 方式 2: 通过 PostgreSQL 客户端(psql)
|
||||
|
||||
如果 Dashboard 不可用,可以直接连接 PostgreSQL:
|
||||
|
||||
1. **连接数据库**
|
||||
```bash
|
||||
# 使用 psql 连接
|
||||
psql -h <DB_HOST> -p <DB_PORT> -U postgres -d postgres
|
||||
|
||||
# 密码请按你的环境输入/从安全渠道获取(不要写进仓库)
|
||||
```
|
||||
|
||||
2. **执行 SQL 文件**
|
||||
```sql
|
||||
-- 在 psql 中执行
|
||||
\i /path/to/01_create_tables.sql
|
||||
\i /path/to/02_insert_test_data.sql
|
||||
\i /path/to/03_test_queries.sql
|
||||
```
|
||||
|
||||
或者直接复制粘贴 SQL 内容到 psql 中执行。
|
||||
|
||||
### 方式 3: 通过 DBeaver / pgAdmin 等图形工具
|
||||
|
||||
1. **创建新连接**
|
||||
- 主机:`<DB_HOST>`
|
||||
- 端口:`<DB_PORT>`
|
||||
- 数据库:`postgres`
|
||||
- 用户名:`postgres`
|
||||
- 密码:`<DB_PASSWORD>`
|
||||
|
||||
2. **执行 SQL**
|
||||
- 打开 SQL 编辑器
|
||||
- 复制 SQL 文件内容
|
||||
- 执行脚本
|
||||
|
||||
> 不建议通过 HTTP API “执行任意 SQL”(高风险)。
|
||||
> 如需服务端能力,请用 Supabase Edge Functions + 限定输入输出的 RPC。
|
||||
|
||||
## 📝 执行顺序
|
||||
|
||||
**重要:必须按顺序执行!**
|
||||
|
||||
> ✅ 以 `ANALYTICS_DATA_QUICK_START.md` 为权威执行顺序与权限说明(本文件只做执行方式补充)。
|
||||
|
||||
1. ✅ **第一步:创建表结构**
|
||||
```sql
|
||||
-- 执行 01_create_tables.sql
|
||||
-- 这会创建所有需要的表和索引
|
||||
```
|
||||
|
||||
2. ✅ **第二步:插入测试数据**
|
||||
```sql
|
||||
-- 执行 02_insert_test_data.sql
|
||||
-- 这会插入测试用户、订单、会话等数据
|
||||
```
|
||||
|
||||
3. ✅ **第三步:验证数据(可选)**
|
||||
```sql
|
||||
-- 执行 03_test_queries.sql
|
||||
-- 验证数据是否正确插入,查看统计信息
|
||||
```
|
||||
|
||||
4. ⚠️ **清理数据(需要时)**
|
||||
```sql
|
||||
-- 执行 04_cleanup.sql
|
||||
-- 谨慎使用:会删除测试数据
|
||||
```
|
||||
|
||||
## 🔍 验证执行结果
|
||||
|
||||
### 检查表是否创建成功
|
||||
|
||||
```sql
|
||||
-- 查看所有表
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
ORDER BY table_name;
|
||||
|
||||
-- 应该看到:
|
||||
-- orders
|
||||
-- user_sessions
|
||||
-- users
|
||||
-- products (可选)
|
||||
-- order_items (可选)
|
||||
-- page_views (可选)
|
||||
```
|
||||
|
||||
### 检查数据是否插入成功
|
||||
|
||||
```sql
|
||||
-- 检查用户数量
|
||||
SELECT COUNT(*) FROM users;
|
||||
-- 应该返回 8
|
||||
|
||||
-- 检查订单数量
|
||||
SELECT COUNT(*) FROM orders WHERE created_at >= DATE_TRUNC('day', NOW());
|
||||
-- 应该返回 15(今日订单)
|
||||
|
||||
-- 检查在线用户
|
||||
SELECT COUNT(*) FROM user_sessions
|
||||
WHERE last_active_at >= NOW() - INTERVAL '5 minutes' AND is_active = true;
|
||||
-- 应该返回 5
|
||||
```
|
||||
|
||||
### 检查实时大屏数据
|
||||
|
||||
```sql
|
||||
-- 执行 03_test_queries.sql 中的综合查询
|
||||
-- 应该能看到:
|
||||
-- - 实时GMV: 约 3,500 元
|
||||
-- - 实时订单: 15 笔
|
||||
-- - 在线用户: 5 人
|
||||
-- - 转化率: 约 50-80%
|
||||
```
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
### 1. 权限问题
|
||||
|
||||
如果遇到权限错误:
|
||||
```sql
|
||||
-- 确保 postgres 用户有足够权限
|
||||
GRANT ALL PRIVILEGES ON DATABASE postgres TO postgres;
|
||||
GRANT ALL PRIVILEGES ON SCHEMA public TO postgres;
|
||||
```
|
||||
|
||||
### 2. 表已存在
|
||||
|
||||
如果表已存在:
|
||||
- `01_create_tables.sql` / `ANALYTICS_DB_SCHEMA.sql` 现为 **只创建(Create-only)** 脚本,不包含 `DROP/DELETE/TRUNCATE`,可重复执行且不会清空数据。
|
||||
- 如需结构变更,请用迁移脚本(ALTER TABLE)。
|
||||
|
||||
> 如确实要“清理后重建”,请另外单独维护清理脚本(避免把破坏性操作放进默认文档/默认流程)。
|
||||
|
||||
### 3. 时间依赖
|
||||
|
||||
测试数据使用 `NOW()` 函数,每次执行都会基于当前时间生成。
|
||||
- 今日订单:基于当前日期
|
||||
- 昨日订单:当前时间往前推 24 小时
|
||||
- 在线用户:最近 5 分钟内有活动
|
||||
|
||||
### 4. UUID 冲突
|
||||
|
||||
如果重复执行插入脚本,由于使用了 `ON CONFLICT DO NOTHING`,不会产生重复数据。
|
||||
但如果需要重新插入,先执行清理脚本。
|
||||
|
||||
## 🐛 常见问题
|
||||
|
||||
### Q1: 连接被拒绝
|
||||
```
|
||||
Error: connection refused
|
||||
```
|
||||
**解决:**
|
||||
- 检查 Supabase 服务是否运行
|
||||
- 检查防火墙设置
|
||||
- 确认端口 5432 是否开放
|
||||
|
||||
### Q2: 认证失败
|
||||
```
|
||||
Error: password authentication failed
|
||||
```
|
||||
**解决:**
|
||||
- 确认密码是否正确:`yxyHINygZMLSq9jLddrZQBB-CoyGHSF5DwlwWmbrYXc`
|
||||
- 检查用户名是否为 `postgres`
|
||||
|
||||
### Q3: 表已存在错误
|
||||
```
|
||||
Error: relation "orders" already exists
|
||||
```
|
||||
**解决:**
|
||||
- 说明你执行的脚本版本与当前仓库不一致,或只拷贝了部分 SQL
|
||||
- 请按 `ANALYTICS_DATA_QUICK_START.md` 的顺序完整执行最新脚本(Drop-first,不应出现该错误)
|
||||
|
||||
### Q4: 权限不足
|
||||
```
|
||||
Error: permission denied
|
||||
```
|
||||
**解决:**
|
||||
- 使用 postgres 超级用户执行
|
||||
- 或授予相应权限
|
||||
|
||||
## 📊 执行后的预期结果
|
||||
|
||||
执行完所有脚本后,您应该能看到:
|
||||
|
||||
1. **数据库表**
|
||||
- 6 个表已创建(orders, user_sessions, users, products, order_items, page_views)
|
||||
- 所有索引已创建
|
||||
|
||||
2. **测试数据**
|
||||
- 8 个测试用户
|
||||
- 15 个今日订单
|
||||
- 10 个昨日订单
|
||||
- 5 个在线用户会话
|
||||
- 15 条访问日志
|
||||
|
||||
3. **实时大屏显示**
|
||||
- 在数据分析页面应该能看到实时数据
|
||||
- GMV、订单数、在线用户、转化率都有值
|
||||
|
||||
## 🔄 重新执行
|
||||
|
||||
如果需要重新生成测试数据:
|
||||
|
||||
1. **清理数据**
|
||||
```sql
|
||||
-- 执行 04_cleanup.sql
|
||||
```
|
||||
|
||||
2. **重新插入**
|
||||
```sql
|
||||
-- 执行 02_insert_test_data.sql
|
||||
```
|
||||
|
||||
## 📞 获取帮助
|
||||
|
||||
如果遇到问题:
|
||||
|
||||
1. 检查 Supabase 日志
|
||||
2. 查看数据库连接状态
|
||||
3. 验证配置文件 `ak/config.uts` 是否正确
|
||||
4. 使用测试页面验证连接:`/pages/mall/analytics/test/test-connection`
|
||||
|
||||
## 🎯 快速开始
|
||||
|
||||
**最简单的执行方式:**
|
||||
|
||||
1. 打开 Supabase Dashboard(如果有)
|
||||
2. 进入 SQL Editor
|
||||
3. 复制 `01_create_tables.sql` 内容,执行
|
||||
4. 复制 `02_insert_test_data.sql` 内容,执行
|
||||
5. 完成!
|
||||
|
||||
现在可以开始测试实时大屏功能了!🎉
|
||||
209
pages/mall/analytics/test/TEST_DATA_INSERT_GUIDE.md
Normal file
209
pages/mall/analytics/test/TEST_DATA_INSERT_GUIDE.md
Normal file
@@ -0,0 +1,209 @@
|
||||
# 测试数据插入指南
|
||||
|
||||
> 本文档说明如何在启用 RLS(行级安全策略)的情况下插入测试数据。
|
||||
|
||||
## 📋 前置条件
|
||||
|
||||
1. **已执行表结构创建脚本**
|
||||
- `01_create_tables.sql` - 创建表结构和 RLS 策略
|
||||
- `ANALYTICS_DB_SCHEMA.sql` - 创建 analytics_* 表(可选)
|
||||
|
||||
2. **确认 Supabase 连接**
|
||||
- 已配置 Supabase 项目
|
||||
- 可以访问 Supabase Dashboard 的 SQL Editor
|
||||
|
||||
## 🚀 插入测试数据的三种方式
|
||||
|
||||
### 方式一:使用 Supabase Dashboard(推荐)
|
||||
|
||||
**优点**:最简单,无需处理 RLS 权限问题
|
||||
**适用场景**:开发测试、快速验证
|
||||
|
||||
**步骤**:
|
||||
|
||||
1. 打开 Supabase Dashboard
|
||||
2. 进入 **SQL Editor**
|
||||
3. 复制 `02_insert_test_data.sql` 的全部内容
|
||||
4. 粘贴到 SQL Editor 中
|
||||
5. 点击 **Run** 执行
|
||||
|
||||
**说明**:Supabase Dashboard 的 SQL Editor 默认使用 `service_role` 权限,可以绕过 RLS 策略,直接插入数据。
|
||||
|
||||
---
|
||||
|
||||
### 方式二:临时禁用 RLS(适用于命令行)
|
||||
|
||||
**优点**:可以在命令行或脚本中执行
|
||||
**适用场景**:自动化脚本、CI/CD
|
||||
|
||||
**步骤**(不推荐,除非你明确理解风险):
|
||||
|
||||
1. 编辑 `02_insert_test_data.sql`
|
||||
2. 取消文件开头关于禁用 RLS 的注释(第 12-19 行)
|
||||
3. 取消文件末尾关于重新启用 RLS 的注释(第 137-144 行)
|
||||
4. 执行脚本
|
||||
|
||||
**示例**:
|
||||
|
||||
```sql
|
||||
-- 在脚本开头添加
|
||||
BEGIN;
|
||||
ALTER TABLE orders DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE user_sessions DISABLE ROW LEVEL SECURITY;
|
||||
-- ... 其他表
|
||||
|
||||
-- 插入数据...
|
||||
|
||||
-- 在脚本末尾添加
|
||||
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE user_sessions ENABLE ROW LEVEL SECURITY;
|
||||
-- ... 其他表
|
||||
COMMIT;
|
||||
```
|
||||
|
||||
**⚠️ 注意**:执行完成后务必重新启用 RLS,否则数据将不受保护!
|
||||
|
||||
---
|
||||
|
||||
### 方式三:使用 SECURITY DEFINER 函数(高级)
|
||||
|
||||
**优点**:更安全,不需要禁用 RLS
|
||||
**适用场景**:生产环境、需要定期插入测试数据
|
||||
|
||||
**步骤**:
|
||||
|
||||
1. 创建一个 SECURITY DEFINER 函数来插入测试数据
|
||||
2. 调用该函数执行插入
|
||||
|
||||
**示例函数**:
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE FUNCTION insert_test_data()
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
-- 插入测试用户
|
||||
INSERT INTO users (id, phone, email, nickname, last_login_at) VALUES
|
||||
('11111111-1111-1111-1111-111111111111', '13800000001', 'user1@test.com', '测试用户1', NOW() - INTERVAL '2 minutes')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- 插入其他测试数据...
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- 执行函数
|
||||
SELECT insert_test_data();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ 验证数据插入
|
||||
|
||||
执行以下查询验证数据是否插入成功:
|
||||
|
||||
```sql
|
||||
-- 检查用户数量
|
||||
SELECT COUNT(*) FROM users;
|
||||
-- 预期:8
|
||||
|
||||
-- 检查订单数量
|
||||
SELECT COUNT(*) FROM orders;
|
||||
-- 预期:25(15个今日订单 + 10个昨日订单)
|
||||
|
||||
-- 检查用户会话数量
|
||||
SELECT COUNT(*) FROM user_sessions;
|
||||
-- 预期:10
|
||||
|
||||
-- 检查访问日志数量
|
||||
SELECT COUNT(*) FROM page_views;
|
||||
-- 预期:15
|
||||
|
||||
-- 检查商家数量
|
||||
SELECT COUNT(*) FROM merchants;
|
||||
-- 预期:2
|
||||
|
||||
-- 检查商品数量
|
||||
SELECT COUNT(*) FROM products;
|
||||
-- 预期:3
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔍 常见问题
|
||||
|
||||
### Q1: 执行 INSERT 时提示 "new row violates row-level security policy"
|
||||
|
||||
**原因**:RLS 策略阻止了插入操作。
|
||||
|
||||
**解决方案**:
|
||||
- 使用方式一(Supabase Dashboard)
|
||||
- 或使用方式二(临时禁用 RLS)
|
||||
- 或使用方式三(SECURITY DEFINER 函数)
|
||||
|
||||
### Q2: 插入数据后,前端查询不到数据
|
||||
|
||||
**原因**:RLS 策略限制了查询权限。
|
||||
|
||||
**解决方案**:
|
||||
1. 确认前端已正确登录(`auth.uid()` 不为 NULL)
|
||||
2. 检查 RLS 策略是否正确配置
|
||||
3. 确认测试数据的 `user_id` 与登录用户的 `auth.uid()` 匹配
|
||||
|
||||
### Q3: 如何清空测试数据重新插入?
|
||||
|
||||
为避免在默认文档里包含破坏性 SQL,本项目将“清理/删除”动作放在独立清理脚本中(如 `04_cleanup.sql`)。
|
||||
|
||||
如你需要重新生成测试数据:
|
||||
- 先执行清理脚本
|
||||
- 再重新执行 seed 脚本
|
||||
|
||||
---
|
||||
|
||||
## 📝 测试数据说明
|
||||
|
||||
### 用户数据
|
||||
- **数量**:8 个测试用户
|
||||
- **UUID 范围**:`11111111-...` 到 `88888888-...`
|
||||
- **用途**:用于订单、会话、访问日志等关联数据
|
||||
|
||||
### 订单数据
|
||||
- **今日订单**:15 笔(status = 2,已支付)
|
||||
- **昨日订单**:10 笔(用于增长率对比)
|
||||
- **总 GMV**:约 3,500 元(今日)
|
||||
|
||||
### 在线用户
|
||||
- **最近 5 分钟活跃**:5 个用户
|
||||
- **用于**:实时大屏的"在线用户"统计
|
||||
|
||||
### 访问日志
|
||||
- **数量**:15 条
|
||||
- **来源分布**:direct/search/social/ad
|
||||
- **用于**:转化率计算、流量来源分析
|
||||
|
||||
---
|
||||
|
||||
## 🔗 相关文件
|
||||
|
||||
- `01_create_tables.sql` - 表结构创建脚本
|
||||
- `02_insert_test_data.sql` - 测试数据插入脚本
|
||||
- `03_test_queries.sql` - 数据验证查询脚本
|
||||
- `ANALYTICS_DB_SCHEMA.sql` - Analytics 表结构(可选)
|
||||
|
||||
---
|
||||
|
||||
## 📚 下一步
|
||||
|
||||
插入测试数据后,可以:
|
||||
|
||||
1. **验证前端页面**
|
||||
- 访问 `/pages/mall/analytics/index` 查看实时大屏
|
||||
- 检查 KPI 数据是否正确显示
|
||||
|
||||
2. **执行验证查询**
|
||||
- 运行 `03_test_queries.sql` 验证数据计算逻辑
|
||||
|
||||
3. **测试 RPC 函数**
|
||||
- 调用 `rpc_analytics_realtime_kpis` 验证实时 KPI 计算
|
||||
529
pages/mall/analytics/test/test-connection.uvue
Normal file
529
pages/mall/analytics/test/test-connection.uvue
Normal file
@@ -0,0 +1,529 @@
|
||||
<!-- Supabase 连接测试页面 -->
|
||||
<template>
|
||||
<view class="test-container">
|
||||
<view class="header">
|
||||
<text class="title">Supabase 连接测试</text>
|
||||
</view>
|
||||
|
||||
<view class="config-section">
|
||||
<text class="section-title">当前配置</text>
|
||||
<view class="config-item">
|
||||
<text class="config-label">Supabase URL:</text>
|
||||
<text class="config-value">{{ configUrl }}</text>
|
||||
</view>
|
||||
<view class="config-item">
|
||||
<text class="config-label">API Key:</text>
|
||||
<text class="config-value">{{ configKey.substring(0, 20) }}...</text>
|
||||
</view>
|
||||
<view class="config-item">
|
||||
<text class="config-label">WebSocket URL:</text>
|
||||
<text class="config-value">{{ configWs }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="test-section">
|
||||
<button class="test-btn" @click="testConnection" :disabled="isTesting">
|
||||
{{ isTesting ? '测试中...' : '测试连接' }}
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view class="result-section" v-if="testResult">
|
||||
<text class="section-title">测试结果</text>
|
||||
<view class="result-item" :class="{ success: testResult.success, error: !testResult.success }">
|
||||
<text class="result-icon">{{ testResult.success ? '✅' : '❌' }}</text>
|
||||
<text class="result-text">{{ testResult.message }}</text>
|
||||
</view>
|
||||
|
||||
<view v-if="testResult.details" class="result-details">
|
||||
<text class="details-title">详细信息:</text>
|
||||
<text class="details-text">{{ testResult.details }}</text>
|
||||
</view>
|
||||
|
||||
<view v-if="testResult.data" class="result-data">
|
||||
<text class="data-title">返回数据:</text>
|
||||
<text class="data-text">{{ JSON.stringify(testResult.data, null, 2) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="test-list">
|
||||
<text class="section-title">测试项目</text>
|
||||
|
||||
<view class="test-item" v-for="(test, index) in testList" :key="index">
|
||||
<view class="test-info">
|
||||
<text class="test-name">{{ test.name }}</text>
|
||||
<text class="test-status" :class="test.status">{{ getStatusText(test.status) }}</text>
|
||||
</view>
|
||||
<button class="test-item-btn" @click="runTest(test)" :disabled="isTesting">执行</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
import supa from '@/components/supadb/aksupainstance.uts'
|
||||
import { SUPA_URL, SUPA_KEY, WS_URL } from '@/ak/config.uts'
|
||||
|
||||
type TestResultType = {
|
||||
success: boolean
|
||||
message: string
|
||||
details?: string
|
||||
data?: any
|
||||
}
|
||||
|
||||
type TestItemType = {
|
||||
name: string
|
||||
status: string
|
||||
func: () => Promise<TestResultType>
|
||||
}
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
configUrl: SUPA_URL,
|
||||
configKey: SUPA_KEY,
|
||||
configWs: WS_URL,
|
||||
isTesting: false,
|
||||
testResult: null as TestResultType | null,
|
||||
testList: [
|
||||
{
|
||||
name: '1. 基础连接测试',
|
||||
status: 'pending',
|
||||
func: this.testBasicConnection
|
||||
} as TestItemType,
|
||||
{
|
||||
name: '2. 查询测试(查询用户表)',
|
||||
status: 'pending',
|
||||
func: this.testQuery
|
||||
} as TestItemType,
|
||||
{
|
||||
name: '3. 认证测试',
|
||||
status: 'pending',
|
||||
func: this.testAuth
|
||||
} as TestItemType,
|
||||
{
|
||||
name: '4. 实时连接测试',
|
||||
status: 'pending',
|
||||
func: this.testRealtime
|
||||
} as TestItemType
|
||||
] as Array<TestItemType>
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// 综合连接测试
|
||||
async testConnection() {
|
||||
this.isTesting = true
|
||||
this.testResult = null
|
||||
|
||||
try {
|
||||
// 测试1: 基础连接
|
||||
const basicResult = await this.testBasicConnection()
|
||||
this.updateTestStatus(0, basicResult.success ? 'success' : 'error')
|
||||
|
||||
if (!basicResult.success) {
|
||||
this.testResult = basicResult
|
||||
this.isTesting = false
|
||||
return
|
||||
}
|
||||
|
||||
// 测试2: 查询测试
|
||||
const queryResult = await this.testQuery()
|
||||
this.updateTestStatus(1, queryResult.success ? 'success' : 'error')
|
||||
|
||||
// 测试3: 认证测试
|
||||
const authResult = await this.testAuth()
|
||||
this.updateTestStatus(2, authResult.success ? 'success' : 'error')
|
||||
|
||||
// 汇总结果
|
||||
const allSuccess = basicResult.success && queryResult.success && authResult.success
|
||||
this.testResult = {
|
||||
success: allSuccess,
|
||||
message: allSuccess
|
||||
? '所有测试通过!Supabase 连接正常。'
|
||||
: '部分测试失败,请查看详细信息。',
|
||||
details: `基础连接: ${basicResult.success ? '✓' : '✗'}, 查询: ${queryResult.success ? '✓' : '✗'}, 认证: ${authResult.success ? '✓' : '✗'}`,
|
||||
data: {
|
||||
basic: basicResult,
|
||||
query: queryResult,
|
||||
auth: authResult
|
||||
}
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
this.testResult = {
|
||||
success: false,
|
||||
message: '测试过程中发生错误',
|
||||
details: err?.toString() || '未知错误'
|
||||
}
|
||||
} finally {
|
||||
this.isTesting = false
|
||||
}
|
||||
},
|
||||
|
||||
// 测试1: 基础连接
|
||||
async testBasicConnection(): Promise<TestResultType> {
|
||||
try {
|
||||
// 尝试访问 Supabase REST API
|
||||
const response = await uni.request({
|
||||
url: `${SUPA_URL}/rest/v1/`,
|
||||
method: 'GET',
|
||||
header: {
|
||||
'apikey': SUPA_KEY,
|
||||
'Authorization': `Bearer ${SUPA_KEY}`
|
||||
},
|
||||
timeout: 5000
|
||||
})
|
||||
|
||||
if (response.statusCode === 200 || response.statusCode === 404) {
|
||||
// 404 也是正常的,说明服务器响应了
|
||||
return {
|
||||
success: true,
|
||||
message: '基础连接成功',
|
||||
details: `HTTP 状态码: ${response.statusCode}`,
|
||||
data: response.data
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
message: '连接失败',
|
||||
details: `HTTP 状态码: ${response.statusCode}`
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
message: '无法连接到 Supabase',
|
||||
details: err?.toString() || '网络错误或服务器不可达'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 测试2: 查询测试
|
||||
async testQuery(): Promise<TestResultType> {
|
||||
try {
|
||||
// 尝试查询 users 表(如果存在)
|
||||
const { data, error } = await supa
|
||||
.from('users')
|
||||
.select('id, phone, nickname')
|
||||
.limit(5)
|
||||
|
||||
if (error !== null) {
|
||||
// 如果表不存在,尝试查询其他表
|
||||
if (error.message?.includes('relation') || error.message?.includes('does not exist')) {
|
||||
// 尝试查询 orders 表
|
||||
const { data: orderData, error: orderError } = await supa
|
||||
.from('orders')
|
||||
.select('id')
|
||||
.limit(1)
|
||||
|
||||
if (orderError !== null) {
|
||||
return {
|
||||
success: false,
|
||||
message: '查询失败',
|
||||
details: `错误: ${orderError.message || orderError.toString()}`
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '查询成功(使用 orders 表)',
|
||||
details: 'users 表不存在,但 orders 表可访问',
|
||||
data: orderData
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: '查询失败',
|
||||
details: `错误: ${error.message || error.toString()}`
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '查询成功',
|
||||
details: `返回 ${data?.length || 0} 条记录`,
|
||||
data: data
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
message: '查询测试失败',
|
||||
details: err?.toString() || '未知错误'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 测试3: 认证测试
|
||||
async testAuth(): Promise<TestResultType> {
|
||||
try {
|
||||
// 检查是否已登录
|
||||
const { data: sessionData, error: sessionError } = await supa.auth.getSession()
|
||||
|
||||
if (sessionError !== null) {
|
||||
return {
|
||||
success: false,
|
||||
message: '获取会话失败',
|
||||
details: sessionError.message || sessionError.toString()
|
||||
}
|
||||
}
|
||||
|
||||
if (sessionData?.session !== null) {
|
||||
return {
|
||||
success: true,
|
||||
message: '认证成功',
|
||||
details: `用户已登录: ${sessionData.session.user.email || sessionData.session.user.phone || '未知'}`,
|
||||
data: {
|
||||
user: sessionData.session.user,
|
||||
expires_at: sessionData.session.expires_at
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
message: '未登录',
|
||||
details: '需要先登录才能测试认证功能'
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
message: '认证测试失败',
|
||||
details: err?.toString() || '未知错误'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 测试4: 实时连接测试
|
||||
async testRealtime(): Promise<TestResultType> {
|
||||
try {
|
||||
// WebSocket 连接测试比较复杂,这里只做 URL 验证
|
||||
if (WS_URL.startsWith('ws://') || WS_URL.startsWith('wss://')) {
|
||||
return {
|
||||
success: true,
|
||||
message: 'WebSocket URL 格式正确',
|
||||
details: `URL: ${WS_URL}`
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
message: 'WebSocket URL 格式错误',
|
||||
details: `URL 应以 ws:// 或 wss:// 开头`
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
message: '实时连接测试失败',
|
||||
details: err?.toString() || '未知错误'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 运行单个测试
|
||||
async runTest(test: TestItemType) {
|
||||
this.isTesting = true
|
||||
test.status = 'testing'
|
||||
|
||||
try {
|
||||
const result = await test.func()
|
||||
test.status = result.success ? 'success' : 'error'
|
||||
this.testResult = result
|
||||
} catch (err) {
|
||||
test.status = 'error'
|
||||
this.testResult = {
|
||||
success: false,
|
||||
message: '测试执行失败',
|
||||
details: err?.toString() || '未知错误'
|
||||
}
|
||||
} finally {
|
||||
this.isTesting = false
|
||||
}
|
||||
},
|
||||
|
||||
// 更新测试状态
|
||||
updateTestStatus(index: number, status: string) {
|
||||
if (this.testList[index]) {
|
||||
this.testList[index].status = status
|
||||
}
|
||||
},
|
||||
|
||||
// 获取状态文本
|
||||
getStatusText(status: string): string {
|
||||
const statusMap: Record<string, string> = {
|
||||
'pending': '待测试',
|
||||
'testing': '测试中...',
|
||||
'success': '✓ 通过',
|
||||
'error': '✗ 失败'
|
||||
}
|
||||
return statusMap[status] || '未知'
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.test-container {
|
||||
padding: 40rpx;
|
||||
background-color: #f5f5f5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 40rpx;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 36rpx;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.config-section, .test-section, .result-section, .test-list {
|
||||
background-color: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 30rpx;
|
||||
margin-bottom: 30rpx;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.config-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.config-label {
|
||||
font-size: 24rpx;
|
||||
color: #666;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.config-value {
|
||||
font-size: 22rpx;
|
||||
color: #333;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.test-btn {
|
||||
width: 100%;
|
||||
height: 80rpx;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #fff;
|
||||
border-radius: 12rpx;
|
||||
font-size: 28rpx;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.test-btn:disabled {
|
||||
background: #ccc;
|
||||
}
|
||||
|
||||
.result-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 20rpx;
|
||||
border-radius: 12rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.result-item.success {
|
||||
background-color: #e8f5e8;
|
||||
}
|
||||
|
||||
.result-item.error {
|
||||
background-color: #ffebee;
|
||||
}
|
||||
|
||||
.result-icon {
|
||||
font-size: 32rpx;
|
||||
margin-right: 15rpx;
|
||||
}
|
||||
|
||||
.result-text {
|
||||
font-size: 26rpx;
|
||||
color: #333;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.result-details, .result-data {
|
||||
margin-top: 20rpx;
|
||||
padding: 20rpx;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
|
||||
.details-title, .data-title {
|
||||
font-size: 24rpx;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
margin-bottom: 10rpx;
|
||||
}
|
||||
|
||||
.details-text, .data-text {
|
||||
font-size: 22rpx;
|
||||
color: #666;
|
||||
word-break: break-all;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.test-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 25rpx 0;
|
||||
border-bottom: 1rpx solid #f0f0f0;
|
||||
}
|
||||
|
||||
.test-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.test-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.test-name {
|
||||
font-size: 26rpx;
|
||||
color: #333;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.test-status {
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
.test-status.pending {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.test-status.testing {
|
||||
color: #2196f3;
|
||||
}
|
||||
|
||||
.test-status.success {
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
.test-status.error {
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
.test-item-btn {
|
||||
padding: 12rpx 24rpx;
|
||||
background-color: #667eea;
|
||||
color: #fff;
|
||||
border-radius: 8rpx;
|
||||
font-size: 24rpx;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.test-item-btn:disabled {
|
||||
background-color: #ccc;
|
||||
}
|
||||
</style>
|
||||
619
pages/mall/analytics/user-analysis.uvue
Normal file
619
pages/mall/analytics/user-analysis.uvue
Normal file
@@ -0,0 +1,619 @@
|
||||
<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>
|
||||
<view v-if="loading || !growthChartOption || !growthChartOption.series || growthChartOption.series.length === 0" class="chart-loading">
|
||||
<text>{{ loading ? '加载中...' : '暂无数据' }}</text>
|
||||
</view>
|
||||
<EChartsView v-else class="chart-box" :option="growthChartOption" />
|
||||
</view>
|
||||
|
||||
<!-- 留存 / 新老 / 活跃 / 画像(统一公共块) -->
|
||||
<view class="card card-full">
|
||||
<view class="card-head">
|
||||
<text class="card-title">用户洞察</text>
|
||||
<text class="card-desc">留存率 / 新老对比 / 活跃度 / 画像</text>
|
||||
</view>
|
||||
<view class="quad-grid">
|
||||
<view class="quad-item">
|
||||
<view class="sub-head">
|
||||
<text class="sub-title">用户留存率</text>
|
||||
<text class="sub-desc">按留存天数统计</text>
|
||||
</view>
|
||||
<EChartsView class="chart-box chart-box-sm" :option="retentionChartOption" />
|
||||
</view>
|
||||
<view class="quad-item">
|
||||
<view class="sub-head">
|
||||
<text class="sub-title">新老用户对比</text>
|
||||
<text class="sub-desc">GMV、订单数、客单价</text>
|
||||
</view>
|
||||
<EChartsView class="chart-box chart-box-sm" :option="comparisonChartOption" />
|
||||
</view>
|
||||
<view class="quad-item">
|
||||
<view class="sub-head">
|
||||
<text class="sub-title">用户活跃度</text>
|
||||
<text class="sub-desc">日活/周活/月活</text>
|
||||
</view>
|
||||
<EChartsView class="chart-box chart-box-sm" :option="activityChartOption" />
|
||||
</view>
|
||||
<view class="quad-item">
|
||||
<view class="sub-head">
|
||||
<text class="sub-title">用户画像</text>
|
||||
<text class="sub-desc">性别/年龄/地域</text>
|
||||
</view>
|
||||
<EChartsView class="chart-box chart-box-sm" :option="profileChartOption" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 用户分群 + 流量来源 -->
|
||||
<view class="card card-full">
|
||||
<view class="card-head">
|
||||
<text class="card-title">用户分群 & 流量来源</text>
|
||||
<text class="card-desc">{{ selectedPeriodText }} · 分群占比 & 来源分布</text>
|
||||
</view>
|
||||
<view class="two-col">
|
||||
<view class="two-col-item">
|
||||
<EChartsView class="chart-box" :option="segmentChartOption" />
|
||||
</view>
|
||||
<view class="two-col-item">
|
||||
<EChartsView class="chart-box" :option="trafficChartOption" />
|
||||
</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'
|
||||
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,
|
||||
segmentChartOption: {} as any,
|
||||
trafficChartOption: {} as any,
|
||||
loading: false
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
selectedPeriodText(): string {
|
||||
const p = this.timePeriods.find((t) => t.value === this.selectedPeriod)
|
||||
return p ? p.label : '7天'
|
||||
}
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
this.updateTime()
|
||||
this.loadUserData()
|
||||
},
|
||||
|
||||
methods: {
|
||||
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 loadUserData() {
|
||||
this.loading = true
|
||||
try {
|
||||
this.updateTime()
|
||||
const { startDate, endDate } = this.calcDateRange()
|
||||
const startStr = startDate.toISOString().slice(0, 10)
|
||||
const endStr = endDate.toISOString().slice(0, 10)
|
||||
|
||||
const p = new UTSJSONObject()
|
||||
p.set('p_start_date', startStr)
|
||||
p.set('p_end_date', endStr)
|
||||
|
||||
// KPI(RPC)
|
||||
const res: any = await supa.rpc('rpc_analytics_user_kpis', p)
|
||||
const row = Array.isArray(res.data) && res.data.length > 0 ? res.data[0] : (res.data || {})
|
||||
const safe = (v: any): number => {
|
||||
const n = Number(v)
|
||||
return isFinite(n) ? n : 0
|
||||
}
|
||||
this.userData = {
|
||||
total_users: Math.round(safe(row.total_users)),
|
||||
user_growth: safe(row.user_growth),
|
||||
new_users: Math.round(safe(row.new_users)),
|
||||
new_user_growth: safe(row.new_user_growth),
|
||||
active_rate: safe(row.active_rate),
|
||||
active_growth: safe(row.active_growth),
|
||||
repurchase_rate: safe(row.repurchase_rate),
|
||||
repurchase_growth: safe(row.repurchase_growth)
|
||||
}
|
||||
|
||||
// 增长趋势(RPC)
|
||||
const tRes: any = await supa.rpc('rpc_analytics_user_growth_trend', p)
|
||||
const rows: Array<any> = Array.isArray(tRes.data) ? (tRes.data as Array<any>) : []
|
||||
const x: Array<string> = []
|
||||
const newArr: Array<number> = []
|
||||
const totalArr: Array<number> = []
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const d = `${rows[i].date}`
|
||||
x.push(d.slice(5))
|
||||
newArr.push(Number(rows[i].new_users) || 0)
|
||||
totalArr.push(Number(rows[i].total_users) || 0)
|
||||
}
|
||||
|
||||
this.growthChartOption = {
|
||||
grid: { left: 40, right: 18, top: 20, bottom: 40 },
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: ['新用户', '总用户'], bottom: 0 },
|
||||
xAxis: { type: 'category', data: x, axisLabel: { color: 'rgba(0,0,0,0.55)' } },
|
||||
yAxis: { type: 'value', axisLabel: { color: 'rgba(0,0,0,0.55)' }, splitLine: { lineStyle: { color: 'rgba(0,0,0,0.06)' } } },
|
||||
series: [
|
||||
{ name: '新用户', type: 'bar', data: newArr, barWidth: 14, itemStyle: { borderRadius: 6 } },
|
||||
{ name: '总用户', type: 'line', data: totalArr, smooth: true, symbolSize: 6 }
|
||||
]
|
||||
}
|
||||
|
||||
// 用户分群(RPC)
|
||||
const sRes: any = await supa.rpc('rpc_analytics_user_segments', p)
|
||||
const sRows: Array<any> = Array.isArray(sRes.data) ? (sRes.data as Array<any>) : []
|
||||
const segData: Array<any> = []
|
||||
for (let i = 0; i < sRows.length; i++) {
|
||||
const name = `${sRows[i].name}`
|
||||
const value = Number(sRows[i].value) || 0
|
||||
segData.push({ name, value })
|
||||
}
|
||||
this.segmentChartOption = {
|
||||
tooltip: { trigger: 'item' },
|
||||
legend: { top: 10, left: 'center', padding: [12, 0, 24, 0] },
|
||||
series: [
|
||||
{
|
||||
name: '用户分群',
|
||||
type: 'pie',
|
||||
// 下移饼图,避免被上方 legend 遮挡标签
|
||||
center: ['48%', '60%'],
|
||||
radius: ['42%', '66%'],
|
||||
avoidLabelOverlap: true,
|
||||
label: { show: true, formatter: '{b}\n{d}%' },
|
||||
labelLine: { length: 14, length2: 10 },
|
||||
data: segData
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// 流量来源(RPC)
|
||||
const t2Res: any = await supa.rpc('rpc_analytics_traffic_sources', p)
|
||||
const tRows: Array<any> = Array.isArray(t2Res.data) ? (t2Res.data as Array<any>) : []
|
||||
const srcNames: Array<string> = []
|
||||
const srcVals: Array<number> = []
|
||||
for (let i = 0; i < tRows.length; i++) {
|
||||
const name = `${tRows[i].name}`
|
||||
const value = Number(tRows[i].value) || 0
|
||||
srcNames.push(name)
|
||||
srcVals.push(value)
|
||||
}
|
||||
this.trafficChartOption = {
|
||||
grid: { left: 60, right: 18, top: 20, bottom: 40 },
|
||||
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
|
||||
xAxis: { type: 'value', axisLabel: { color: 'rgba(0,0,0,0.55)' }, splitLine: { lineStyle: { color: 'rgba(0,0,0,0.06)' } } },
|
||||
yAxis: { type: 'category', data: srcNames, axisLabel: { color: 'rgba(0,0,0,0.55)' } },
|
||||
series: [{ type: 'bar', data: srcVals, barWidth: 14, itemStyle: { borderRadius: 6 } }]
|
||||
}
|
||||
|
||||
// 四宫格:先占位(后续补 RPC)
|
||||
this.retentionChartOption = { title: { text: '留存率(待接入)', left: 'center', top: 10, textStyle: { fontSize: 12, color: 'rgba(0,0,0,0.55)' } }, series: [] }
|
||||
this.activityChartOption = { title: { text: '活跃度(待接入)', left: 'center', top: 10, textStyle: { fontSize: 12, color: 'rgba(0,0,0,0.55)' } }, series: [] }
|
||||
this.comparisonChartOption = { title: { text: '新老用户对比(待接入)', left: 'center', top: 10, textStyle: { fontSize: 12, color: 'rgba(0,0,0,0.55)' } }, series: [] }
|
||||
this.profileChartOption = { title: { text: '用户画像(待接入:需要性别/年龄/地域字段)', left: 'center', top: 10, textStyle: { fontSize: 12, color: 'rgba(0,0,0,0.55)' } }, series: [] }
|
||||
} catch (e) {
|
||||
console.error('loadUserData failed', e)
|
||||
uni.showToast({ title: '数据加载失败', icon: 'none', duration: 2000 })
|
||||
} finally {
|
||||
this.loading = false
|
||||
this.updateTime()
|
||||
}
|
||||
},
|
||||
|
||||
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)}%`
|
||||
},
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/* 时间维度 tabs */
|
||||
.tabs {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
flex-direction: row !important;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(0,0,0,0.06);
|
||||
overflow-x: auto;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
background: #f3f4f6;
|
||||
color: #111;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
background: #111;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* KPI 网格 */
|
||||
.kpi-grid {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
flex-direction: row !important;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.kpi-card {
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(0,0,0,0.06);
|
||||
padding: 14px;
|
||||
box-sizing: border-box;
|
||||
flex: 1 1 calc(50% - 6px);
|
||||
min-width: 260px;
|
||||
}
|
||||
|
||||
.kpi-label {
|
||||
font-size: 12px;
|
||||
color: rgba(0,0,0,0.55);
|
||||
}
|
||||
|
||||
.kpi-value {
|
||||
margin-top: 8px;
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.kpi-meta {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: rgba(0,0,0,0.55);
|
||||
}
|
||||
|
||||
/* 卡片 */
|
||||
.card {
|
||||
margin-top: 12px;
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(0,0,0,0.06);
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.04);
|
||||
padding: 14px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.card-full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.card-head {
|
||||
display: flex;
|
||||
flex-direction: row !important;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.card-desc {
|
||||
font-size: 12px;
|
||||
color: rgba(0,0,0,0.55);
|
||||
}
|
||||
|
||||
.chart-box {
|
||||
width: 100%;
|
||||
height: 360px;
|
||||
}
|
||||
|
||||
.chart-box-sm {
|
||||
height: 240px;
|
||||
}
|
||||
|
||||
.chart-loading {
|
||||
width: 100%;
|
||||
height: 320px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(0,0,0,0.45);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.two-col {
|
||||
display: flex;
|
||||
flex-direction: row !important;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.two-col-item {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.quad-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.quad-item {
|
||||
border: 1px solid rgba(0,0,0,0.06);
|
||||
border-radius: 14px;
|
||||
padding: 12px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.sub-head {
|
||||
display: flex;
|
||||
flex-direction: row !important;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.sub-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.sub-desc {
|
||||
font-size: 12px;
|
||||
color: rgba(0,0,0,0.55);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 960px) {
|
||||
.two-col {
|
||||
flex-direction: column !important;
|
||||
}
|
||||
.quad-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -44,7 +44,6 @@
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import supaClient from '@/components/supadb/aksupainstance.uts'
|
||||
import { getCurrentUserId } from '@/utils/store.uts'
|
||||
|
||||
@@ -127,7 +126,8 @@ const goPlanList = () => {
|
||||
}
|
||||
|
||||
onMounted(loadSubs)
|
||||
onShow(loadSubs)
|
||||
// 注意:uni-app x 的 <script setup> 中不支持 onShow,使用 onMounted 代替
|
||||
// 如果需要页面显示时刷新,可以在页面选项中定义 onShow
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
191
pages/mall/delivery/delivery.md
Normal file
191
pages/mall/delivery/delivery.md
Normal file
@@ -0,0 +1,191 @@
|
||||
✅ 明白了!你不需要完整代码,而是希望我**用简洁的 Markdown 文档形式,解释这个 `delivery` 文件夹下的项目结构和每个页面的功能定位**。
|
||||
|
||||
---
|
||||
|
||||
# 🚚 配送端模块项目结构说明(`/pages/mall/delivery/`)
|
||||
|
||||
> 本模块为配送员使用的移动端应用核心功能区,涵盖个人中心、任务管理、收入统计、车辆管理等核心功能。
|
||||
|
||||
---
|
||||
|
||||
## 📁 目录结构概览
|
||||
|
||||
```
|
||||
delivery/
|
||||
├── index.uvue # 配送端首页(工作台)
|
||||
├── profile.uvue # 个人中心
|
||||
├── profile-edit.uvue # 编辑个人信息
|
||||
├── settings.uvue # 设置页面
|
||||
├── tasks.uvue # 配送任务列表(按状态筛选)
|
||||
├── task-detail.uvue # 单个任务详情页
|
||||
├── order-history.uvue # 历史订单列表
|
||||
├── order-detail.uvue # 订单详情页(从历史或当前跳转)
|
||||
├── earnings.uvue # 收入明细
|
||||
├── ratings.uvue # 评价记录
|
||||
├── vehicle.uvue # 车辆管理主页
|
||||
├── vehicle-add.uvue # 添加车辆
|
||||
├── vehicle-edit.uvue # 编辑车辆信息
|
||||
└── index.uvue # 首页(与上面重复?应统一命名)
|
||||
```
|
||||
|
||||
> 💡 注:`index.uvue` 出现两次,建议保留一个作为首页入口,另一个可重命名为 `dashboard.uvue` 或 `home.uvue`
|
||||
|
||||
---
|
||||
|
||||
## 🧭 各页面功能说明
|
||||
|
||||
### 1. `index.uvue` —— **配送端首页 / 工作台**
|
||||
- ✅ 核心入口页面
|
||||
- 📍 展示当前任务、今日数据、快捷操作入口
|
||||
- 🔄 可跳转到“个人中心”、“任务列表”、“收入明细”等
|
||||
|
||||
---
|
||||
|
||||
### 2. `profile.uvue` —— **个人中心**
|
||||
- 👤 显示配送员基本信息(头像、姓名、评分、总单数)
|
||||
- 📊 展示今日配送数据(完成单数、收入、里程、准时率)
|
||||
- 📈 收入统计图表(最近7天)
|
||||
- 🚗 功能菜单:收入明细、车辆管理、评价记录、帮助中心、意见反馈
|
||||
|
||||
---
|
||||
|
||||
### 3. `profile-edit.uvue` —— **编辑个人信息**
|
||||
- 🖋️ 修改头像、姓名、身份证号、驾驶证、车辆信息、服务区域等
|
||||
- 📱 界面包含表单输入 + 保存按钮
|
||||
- ⬅️ 左上角返回按钮(箭头+文字垂直排列)
|
||||
- 💾 数据本地模拟或调用API更新
|
||||
|
||||
---
|
||||
|
||||
### 4. `settings.uvue` —— **设置页面**
|
||||
- ⚙️ 通用设置项(如通知、隐私、退出登录等)
|
||||
- 🔐 安全相关设置(修改密码、绑定手机等)
|
||||
- 📲 通常由 `profile.uvue` 中的“⚙️”图标进入
|
||||
|
||||
---
|
||||
|
||||
### 5. `tasks.uvue` —— **配送任务列表**
|
||||
- 📋 按状态分类展示任务:
|
||||
- 全部任务
|
||||
- 待接单
|
||||
- 配送中
|
||||
- 已完成
|
||||
- 🚀 点击任一任务 → 跳转至 `task-detail.uvue`
|
||||
- 📈 页面顶部有“工作状态”切换开关(工作中/休息中)
|
||||
|
||||
---
|
||||
|
||||
### 6. `task-detail.uvue` —— **任务详情页**
|
||||
- 📍 显示取货地址、送达地址、距离、预计时间
|
||||
- 📞 “联系客户”按钮
|
||||
- 📝 查看任务详情(可选)
|
||||
- ✅ 适用于“当前任务”或“待接单”的操作场景
|
||||
|
||||
---
|
||||
|
||||
### 7. `order-history.uvue` —— **历史订单列表**
|
||||
- 📜 展示已完成、已接受、配送中的历史订单
|
||||
- 📌 包含订单号、状态、取送货地址、配送费、距离、时间
|
||||
- 🔍 点击“查看详情” → 跳转至 `order-detail.uvue`,并携带参数 `?from=history`
|
||||
- 📅 支持查看“已完成”的订单(仅显示“联系客服”按钮)
|
||||
|
||||
---
|
||||
|
||||
### 8. `order-detail.uvue` —— **订单详情页(多来源)**
|
||||
- 🔄 从 `tasks.uvue` 或 `order-history.uvue` 进入
|
||||
- 🎯 **关键逻辑**:
|
||||
- 若来自历史订单(`from=history`)且状态为“已完成” → 只显示“联系客服”
|
||||
- 若来自历史订单且状态为“进行中” → 显示“接受/拒绝/导航/完成”等操作按钮
|
||||
- 若非历史来源 → 显示完整操作按钮
|
||||
- 📞 包含联系顾客、联系商家、联系客服三个联系方式
|
||||
|
||||
---
|
||||
|
||||
### 9. `earnings.uvue` —— **收入明细**
|
||||
- 💰 展示总收入、用户打赏、商家打赏、总订单数
|
||||
- 📊 按订单聚合的收入数据列表
|
||||
- 📈 图表展示最近7天收入趋势
|
||||
- ➕ 加载更多按钮
|
||||
|
||||
---
|
||||
|
||||
### 10. `ratings.uvue` —— **评价记录**
|
||||
- ⭐ 展示用户对配送员的评价
|
||||
- 📝 包含评分、评价内容、订单号、时间
|
||||
- 📊 统计平均分、好评率等
|
||||
|
||||
---
|
||||
|
||||
### 11. `vehicle.uvue` —— **车辆管理主页**
|
||||
- 🚗 列出当前绑定的所有车辆
|
||||
- ➕ “添加车辆”按钮
|
||||
- 🖋️ 点击车辆 → 跳转至 `vehicle-edit.uvue`
|
||||
- 🗑️ 支持删除、设为主用车等操作
|
||||
|
||||
---
|
||||
|
||||
### 12. `vehicle-add.uvue` —— **添加车辆**
|
||||
- 📝 表单填写:车牌号、车型、行驶证照片、车辆类型等
|
||||
- ✅ 提交后绑定到当前账户
|
||||
- ⬅️ 返回车辆管理页
|
||||
|
||||
---
|
||||
|
||||
### 13. `vehicle-edit.uvue` —— **编辑车辆信息**
|
||||
- 🖋️ 修改已有车辆信息(车牌、车型、照片等)
|
||||
- 📸 支持重新上传行驶证照片
|
||||
- ✅ 保存后更新车辆信息
|
||||
|
||||
---
|
||||
|
||||
## 🔄 页面跳转关系图(简化版)
|
||||
|
||||
```
|
||||
index.uvue
|
||||
│
|
||||
├──→ profile.uvue → profile-edit.uvue
|
||||
│
|
||||
├──→ tasks.uvue → task-detail.uvue
|
||||
│
|
||||
├──→ order-history.uvue → order-detail.uvue (from=history)
|
||||
│
|
||||
├──→ earnings.uvue
|
||||
│
|
||||
├──→ ratings.uvue
|
||||
│
|
||||
└──→ vehicle.uvue → vehicle-add.uvue / vehicle-edit.uvue
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 设计原则
|
||||
|
||||
- ✅ **一致性**:所有页面使用相同导航栏样式(左上角返回按钮 + 居中标题)
|
||||
- ✅ **响应式**:适配 H5、APP、小程序等平台
|
||||
- ✅ **状态驱动**:根据订单状态动态显示不同操作按钮
|
||||
- ✅ **用户体验优先**:历史订单跳转后只显示必要操作,避免冗余按钮
|
||||
|
||||
---
|
||||
|
||||
## 📌 建议优化点
|
||||
|
||||
| 问题 | 建议 |
|
||||
|------|------|
|
||||
| `index.uvue` 重复 | 重命名为 `dashboard.uvue` 或 `home.uvue` |
|
||||
| 页面间参数传递 | 使用 `uni.navigateTo({ url: '?param=value' })` 并在 `onLoad` 中接收 |
|
||||
| UTS 语法兼容性 | 如遇问题,可临时改用标准 TS 语法调试 |
|
||||
|
||||
---
|
||||
|
||||
## ✅ 总结
|
||||
|
||||
这个 `delivery` 模块是一个完整的配送员工作系统,涵盖了:
|
||||
|
||||
- **个人管理**(资料、设置)
|
||||
- **任务处理**(接单、配送、完成)
|
||||
- **数据统计**(收入、评价、历史)
|
||||
- **车辆管理**
|
||||
|
||||
结构清晰、功能完整,适合用于实际配送平台开发。
|
||||
|
||||
---
|
||||
542
pages/mall/delivery/earnings.uvue
Normal file
542
pages/mall/delivery/earnings.uvue
Normal file
@@ -0,0 +1,542 @@
|
||||
<template>
|
||||
<view class="earnings-container">
|
||||
<!-- 顶部导航栏 -->
|
||||
<view class="page-header">
|
||||
<!-- 左上角:返回主页按钮(箭头+文字 垂直排列) -->
|
||||
<view class="nav-left" @click="goBackToHome">
|
||||
<text class="nav-icon">←</text>
|
||||
<text class="nav-title">返回上一页</text>
|
||||
</view>
|
||||
|
||||
<!-- 页面标题居中 -->
|
||||
<text class="page-title">收入明细</text>
|
||||
|
||||
<!-- 右上角留空 -->
|
||||
<view class="nav-right"></view>
|
||||
</view>
|
||||
|
||||
<!-- 顶部统计卡片 -->
|
||||
<view class="stats-section">
|
||||
<view class="stat-card">
|
||||
<text class="stat-value">¥{{ totalEarnings }}</text>
|
||||
<text class="stat-label">总收入</text>
|
||||
</view>
|
||||
<view class="stat-card">
|
||||
<text class="stat-value">¥{{ totalUserTips }}</text>
|
||||
<text class="stat-label">用户打赏</text>
|
||||
</view>
|
||||
<view class="stat-card">
|
||||
<text class="stat-value">¥{{ totalMerchantTips }}</text>
|
||||
<text class="stat-label">商家打赏</text>
|
||||
</view>
|
||||
<view class="stat-card">
|
||||
<text class="stat-value">{{ totalOrders }}</text>
|
||||
<text class="stat-label">总订单数</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 主要内容区域 -->
|
||||
<view class="content-wrapper">
|
||||
<!-- 订单收入列表 -->
|
||||
<view v-if="orderEarningsList.length > 0" class="order-earnings-list">
|
||||
<view v-for="order in orderEarningsList" :key="order.order_no" class="order-earnings-item">
|
||||
<!-- 订单总收入 -->
|
||||
<view class="order-total">
|
||||
<text class="order-total-label">订单收入:</text>
|
||||
<text class="order-total-amount">¥{{ order.totalAmount.toFixed(2) }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 订单基础信息 -->
|
||||
<view class="order-info">
|
||||
<text class="info-item">订单号: {{ order.order_no }}</text>
|
||||
<text class="info-item">时间: {{ formatTime(order.date) }}</text>
|
||||
<text class="info-item" v-if="order.distance !== undefined">距离: {{ order.distance }}km</text>
|
||||
</view>
|
||||
|
||||
<!-- 收入明细 -->
|
||||
<view class="earning-details">
|
||||
<view v-for="detail in order.details" :key="detail.id" class="detail-item">
|
||||
<text class="detail-type">{{ getSourceText(detail.source) }}</text>
|
||||
<text class="detail-amount">+¥{{ detail.amount.toFixed(2) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 无数据时显示 -->
|
||||
<view v-else class="no-data">
|
||||
<text class="no-data-text">暂无收入记录</text>
|
||||
</view>
|
||||
|
||||
<!-- 加载更多按钮 -->
|
||||
<view v-if="hasMore" class="load-more">
|
||||
<button @click="loadMoreEarnings" class="load-more-btn">加载更多</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
// 统计数据
|
||||
totalEarnings: '266.10',
|
||||
totalUserTips: '12.00',
|
||||
totalMerchantTips: '8.50',
|
||||
totalOrders: 8,
|
||||
|
||||
// 按订单聚合后的收入数据
|
||||
orderEarningsList: [] as Array<OrderEarningType>,
|
||||
allOrderEarnings: [] as Array<OrderEarningType>,
|
||||
pageSize: 10,
|
||||
currentPage: 1,
|
||||
}
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
this.loadAllEarnings()
|
||||
},
|
||||
|
||||
computed: {
|
||||
hasMore(): boolean {
|
||||
return this.allOrderEarnings.length > this.orderEarningsList.length
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
// 加载所有收入明细并按订单聚合
|
||||
loadAllEarnings() {
|
||||
const rawEarnings = [
|
||||
// 配送费
|
||||
{
|
||||
id: '1',
|
||||
date: '2025-01-08T14:30:00Z',
|
||||
amount: 8.5,
|
||||
source: 'delivery_fee',
|
||||
order_no: 'D202501081234',
|
||||
distance: 12.5
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
date: '2025-01-08T15:00:00Z',
|
||||
amount: 12.0,
|
||||
source: 'delivery_fee',
|
||||
order_no: 'D202501081235',
|
||||
distance: 8.2
|
||||
},
|
||||
// 用户打赏
|
||||
{
|
||||
id: '3',
|
||||
date: '2025-01-08T15:30:00Z',
|
||||
amount: 5.0,
|
||||
source: 'user_tip',
|
||||
order_no: 'D202501081236'
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
date: '2025-01-08T16:00:00Z',
|
||||
amount: 7.0,
|
||||
source: 'user_tip',
|
||||
order_no: 'D202501081237'
|
||||
},
|
||||
// 商家打赏
|
||||
{
|
||||
id: '5',
|
||||
date: '2025-01-08T16:30:00Z',
|
||||
amount: 3.5,
|
||||
source: 'merchant_tip',
|
||||
order_no: 'D202501081238'
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
date: '2025-01-08T17:00:00Z',
|
||||
amount: 5.0,
|
||||
source: 'merchant_tip',
|
||||
order_no: 'D202501081239'
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
date: '2025-01-08T17:30:00Z',
|
||||
amount: 6.0,
|
||||
source: 'delivery_fee',
|
||||
order_no: 'D202501081240',
|
||||
distance: 3.5
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
date: '2025-01-08T18:00:00Z',
|
||||
amount: 10.0,
|
||||
source: 'user_tip',
|
||||
order_no: 'D202501081241'
|
||||
},
|
||||
{
|
||||
id: '9',
|
||||
date: '2025-01-08T18:30:00Z',
|
||||
amount: 8.5,
|
||||
source: 'merchant_tip',
|
||||
order_no: 'D202501081242'
|
||||
},
|
||||
{
|
||||
id: '10',
|
||||
date: '2025-01-08T19:00:00Z',
|
||||
amount: 12.0,
|
||||
source: 'delivery_fee',
|
||||
order_no: 'D202501081243',
|
||||
distance: 5.0
|
||||
},
|
||||
{
|
||||
id: '11',
|
||||
date: '2025-01-08T19:30:00Z',
|
||||
amount: 5.0,
|
||||
source: 'user_tip',
|
||||
order_no: 'D202501081244'
|
||||
},
|
||||
{
|
||||
id: '12',
|
||||
date: '2025-01-08T20:00:00Z',
|
||||
amount: 3.5,
|
||||
source: 'merchant_tip',
|
||||
order_no: 'D202501081245'
|
||||
}
|
||||
]
|
||||
|
||||
// 按订单号聚合数据
|
||||
const orderMap = new Map<string, OrderEarningType>()
|
||||
rawEarnings.forEach(item => {
|
||||
if (!orderMap.has(item.order_no)) {
|
||||
orderMap.set(item.order_no, {
|
||||
order_no: item.order_no,
|
||||
date: item.date,
|
||||
distance: item.distance,
|
||||
totalAmount: 0,
|
||||
details: []
|
||||
})
|
||||
}
|
||||
const order = orderMap.get(item.order_no)!
|
||||
order.totalAmount += item.amount
|
||||
order.details.push(item)
|
||||
})
|
||||
|
||||
/* ---------- 新增:把「配送费」「商家打赏」「用户打赏」三种类型全部补齐 ---------- */
|
||||
const allTypes: Array<'delivery_fee' | 'merchant_tip' | 'user_tip'> = ['delivery_fee', 'merchant_tip', 'user_tip']
|
||||
orderMap.forEach(order => {
|
||||
// 看当前订单已存在的类型
|
||||
const existSet = new Set(order.details.map(d => d.source))
|
||||
// 缺哪种就补 0 的占位,保证顺序:配送费 / 商家打赏 / 用户打赏
|
||||
allTypes.forEach(type => {
|
||||
if (!existSet.has(type)) {
|
||||
order.details.push({
|
||||
id: `${order.order_no}_${type}`, // 唯一 key
|
||||
date: order.date,
|
||||
amount: 0,
|
||||
source: type,
|
||||
order_no: order.order_no,
|
||||
distance: order.distance
|
||||
})
|
||||
}
|
||||
})
|
||||
// 按固定顺序排个序(可选)
|
||||
order.details.sort((a, b) =>
|
||||
allTypes.indexOf(a.source as any) - allTypes.indexOf(b.source as any)
|
||||
)
|
||||
})
|
||||
|
||||
this.allOrderEarnings = Array.from(orderMap.values())
|
||||
this.calculateTotalStats()
|
||||
this.loadPage()
|
||||
},
|
||||
|
||||
// 计算总统计数据
|
||||
calculateTotalStats() {
|
||||
let totalEarnings = 0
|
||||
let totalUserTips = 0
|
||||
let totalMerchantTips = 0
|
||||
|
||||
this.allOrderEarnings.forEach(order => {
|
||||
order.details.forEach(detail => {
|
||||
totalEarnings += detail.amount || 0
|
||||
if (detail.source === 'user_tip') {
|
||||
totalUserTips += detail.amount || 0
|
||||
} else if (detail.source === 'merchant_tip') {
|
||||
totalMerchantTips += detail.amount || 0
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
this.totalEarnings = totalEarnings.toFixed(2)
|
||||
this.totalUserTips = totalUserTips.toFixed(2)
|
||||
this.totalMerchantTips = totalMerchantTips.toFixed(2)
|
||||
this.totalOrders = this.allOrderEarnings.length
|
||||
},
|
||||
|
||||
// 加载当前页数据
|
||||
loadPage() {
|
||||
const start = (this.currentPage - 1) * this.pageSize
|
||||
const end = start + this.pageSize
|
||||
const newOrders = this.allOrderEarnings.slice(start, end)
|
||||
this.orderEarningsList.push(...newOrders)
|
||||
},
|
||||
|
||||
// 加载更多
|
||||
loadMoreEarnings() {
|
||||
this.currentPage++
|
||||
this.loadPage()
|
||||
},
|
||||
|
||||
// 获取收入来源文本
|
||||
getSourceText(source: string): string {
|
||||
switch (source) {
|
||||
case 'delivery_fee': return '配送费'
|
||||
case 'user_tip': return '用户打赏'
|
||||
case 'merchant_tip': return '商家打赏'
|
||||
default: return '其他'
|
||||
}
|
||||
},
|
||||
|
||||
// 格式化时间
|
||||
formatTime(timeStr: string): string {
|
||||
const date = new Date(timeStr)
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - date.getTime()
|
||||
const minutes = Math.floor(diff / (1000 * 60))
|
||||
|
||||
if (minutes < 60) {
|
||||
return `${minutes}分钟前`
|
||||
} else {
|
||||
return `${Math.floor(minutes / 60)}小时前`
|
||||
}
|
||||
},
|
||||
|
||||
// 返回到来源页面
|
||||
goBackToHome() {
|
||||
uni.navigateBack()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 定义聚合后的订单收入类型
|
||||
type OrderEarningType = {
|
||||
order_no: string
|
||||
date: string
|
||||
distance?: number
|
||||
totalAmount: number
|
||||
details: Array<EarningType>
|
||||
}
|
||||
|
||||
type EarningType = {
|
||||
id: string
|
||||
date: string
|
||||
amount?: number
|
||||
source: 'delivery_fee' | 'user_tip' | 'merchant_tip' | string
|
||||
order_no?: string
|
||||
distance?: number
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 全局样式 */
|
||||
.earnings-container {
|
||||
background-color: #f5f5f5;
|
||||
min-height: 100vh;
|
||||
padding: 20rpx 30rpx;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
background-color: #fff;
|
||||
padding: 20rpx 30rpx;
|
||||
border-bottom: 1rpx solid #e9ecef;
|
||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
|
||||
position: relative;
|
||||
min-height: 80rpx; /* 确保有足够空间放垂直排列的按钮和标题 */
|
||||
}
|
||||
|
||||
.nav-left {
|
||||
position: absolute;
|
||||
top: 20rpx;
|
||||
left: 30rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
font-size: 36rpx;
|
||||
margin-right: 10rpx;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.nav-title {
|
||||
font-size: 28rpx;
|
||||
color: #000000; /* 红色 */
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: bold;
|
||||
color: #333; /* 黑色 */
|
||||
text-align: center;
|
||||
margin-top: 20rpx; /* 与 nav-left 保持一定距离 */
|
||||
}
|
||||
|
||||
.nav-right {
|
||||
/* 为了保持左右对齐,右侧需要一个占位元素 */
|
||||
width: 1rpx;
|
||||
height: 1rpx;
|
||||
}
|
||||
|
||||
.stats-section {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 20rpx;
|
||||
margin: 20rpx;
|
||||
padding: 20rpx;
|
||||
background-color: #fff;
|
||||
border-radius: 16rpx;
|
||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20rpx;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 12rpx;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 36rpx;
|
||||
font-weight: bold;
|
||||
color: #4CAF50;
|
||||
margin-bottom: 10rpx;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 24rpx;
|
||||
color: #666;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
|
||||
.order-earnings-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.order-earnings-item {
|
||||
background-color: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 20rpx;
|
||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
|
||||
border-left: 6rpx solid #4CAF50;
|
||||
}
|
||||
|
||||
.order-total {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 15rpx;
|
||||
}
|
||||
|
||||
.order-total-label {
|
||||
font-size: 28rpx;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.order-total-amount {
|
||||
font-size: 32rpx;
|
||||
font-weight: bold;
|
||||
color: #4CAF50;
|
||||
}
|
||||
|
||||
.order-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 10rpx;
|
||||
font-size: 24rpx;
|
||||
color: #666;
|
||||
margin-bottom: 15rpx;
|
||||
padding-bottom: 10rpx;
|
||||
border-bottom: 1rpx solid #f0f0f0;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
flex: 1 1 45%;
|
||||
min-width: 120rpx;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.earning-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10rpx;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8rpx 12rpx;
|
||||
border-radius: 8rpx;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.detail-type {
|
||||
font-size: 24rpx;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.detail-amount {
|
||||
font-size: 24rpx;
|
||||
color: #4CAF50;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.load-more {
|
||||
text-align: center;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
|
||||
.load-more-btn {
|
||||
background-color: #4CAF50;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8rpx;
|
||||
padding: 20rpx 40rpx;
|
||||
font-size: 28rpx;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.load-more-btn:hover {
|
||||
background-color: #45a049; /* 按钮悬停效果 */
|
||||
}
|
||||
|
||||
.load-more-btn:active {
|
||||
background-color: #3d8b40; /* 按钮点击效果 */
|
||||
}
|
||||
|
||||
.no-data {
|
||||
text-align: center;
|
||||
padding: 80rpx 30rpx;
|
||||
border-radius: 16rpx;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.no-data-text {
|
||||
font-size: 32rpx;
|
||||
color: #999;
|
||||
margin-bottom: 15rpx;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,17 +1,35 @@
|
||||
<!-- 配送端 - 订单详情页 -->
|
||||
<template>
|
||||
<view class="delivery-order-detail">
|
||||
<!-- 返回按钮 -->
|
||||
<view class="back-header">
|
||||
<view class="back-box" @click="goBack">
|
||||
<text class="back-icon">‹</text>
|
||||
<text class="back-text">返回</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 订单状态 -->
|
||||
<view class="order-status">
|
||||
<view class="status-progress">
|
||||
<view class="progress-item" :class="{ active: order.status >= 3 }">
|
||||
<view class="progress-item" :class="{ active: order.status >= 1 }">
|
||||
<view class="progress-dot"></view>
|
||||
<text class="progress-text">待接单</text>
|
||||
</view>
|
||||
<view class="progress-line" :class="{ active: order.status >= 2 }"></view>
|
||||
<view class="progress-item" :class="{ active: order.status >= 2 }">
|
||||
<view class="progress-dot"></view>
|
||||
<text class="progress-text">已接单</text>
|
||||
</view>
|
||||
<view class="progress-line" :class="{ active: order.status >= 3 }"></view>
|
||||
<view class="progress-item" :class="{ active: order.status >= 3 }">
|
||||
<view class="progress-dot"></view>
|
||||
<text class="progress-text">取货中</text>
|
||||
</view>
|
||||
<view class="progress-line" :class="{ active: order.status >= 4 }"></view>
|
||||
<view class="progress-item" :class="{ active: order.status >= 4 }">
|
||||
<view class="progress-dot"></view>
|
||||
<text class="progress-text">配送中</text>
|
||||
<text class="progress-text">已取货</text>
|
||||
</view>
|
||||
<view class="progress-line" :class="{ active: order.status >= 5 }"></view>
|
||||
<view class="progress-item" :class="{ active: order.status >= 5 }">
|
||||
@@ -33,7 +51,8 @@
|
||||
<text class="route-address">{{ merchant.contact_name }} · {{ merchant.contact_phone }}</text>
|
||||
<text class="route-detail">{{ pickupAddress }}</text>
|
||||
</view>
|
||||
<button v-if="order.status === 3" class="route-action" @click="confirmPickup">确认取货</button>
|
||||
<!-- 只在取货中状态且订单未完成时显示按钮 -->
|
||||
<button v-if="order.status === 3 && order.status < 5" class="route-action" @click="confirmPickup">确认取货</button>
|
||||
</view>
|
||||
|
||||
<view class="route-line"></view>
|
||||
@@ -45,7 +64,8 @@
|
||||
<text class="route-address">{{ getDeliveryAddress().name }} · {{ getDeliveryAddress().phone }}</text>
|
||||
<text class="route-detail">{{ getDeliveryAddress().detail }}</text>
|
||||
</view>
|
||||
<button v-if="order.status === 4" class="route-action" @click="confirmDelivery">确认送达</button>
|
||||
<!-- 只在已取货状态且订单未完成时显示按钮 -->
|
||||
<button v-if="order.status === 4 && order.status < 5" class="route-action" @click="confirmDelivery">确认送达</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -104,10 +124,16 @@
|
||||
<text class="note-label">商家备注:</text>
|
||||
<text class="note-content">{{ merchantNote || '无备注' }}</text>
|
||||
</view>
|
||||
<view class="note-item">
|
||||
<!-- 只在订单未完成时显示输入框 -->
|
||||
<view v-if="order.status < 5" class="note-item">
|
||||
<text class="note-label">配送备注:</text>
|
||||
<input v-model="deliveryNote" class="note-input" placeholder="请输入配送备注" />
|
||||
</view>
|
||||
<!-- 如果订单已完成,显示存储的备注 -->
|
||||
<view v-else-if="deliveryNote" class="note-item">
|
||||
<text class="note-label">配送备注:</text>
|
||||
<text class="note-content">{{ deliveryNote }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 联系方式 -->
|
||||
@@ -133,17 +159,28 @@
|
||||
|
||||
<!-- 底部操作 -->
|
||||
<view class="bottom-actions">
|
||||
<button v-if="order.status === 3" class="action-btn accept" @click="acceptOrder">接受订单</button>
|
||||
<button v-if="order.status === 3" class="action-btn reject" @click="rejectOrder">拒绝订单</button>
|
||||
<button v-if="order.status === 4" class="action-btn navigate" @click="startNavigation">开始导航</button>
|
||||
<button v-if="order.status === 4" class="action-btn complete" @click="completeDelivery">完成配送</button>
|
||||
<!-- 只在待接单状态且订单未完成时显示接受/拒绝订单按钮 -->
|
||||
<button v-if="order.status === 1" class="action-btn accept" @click="acceptOrder">接受订单</button>
|
||||
<button v-if="order.status === 1" class="action-btn reject" @click="rejectOrder">拒绝订单</button>
|
||||
|
||||
<!-- 只在已接单状态且订单未完成时显示“前往取货”和“正在取货”按钮 -->
|
||||
<button v-if="order.status === 2" class="action-btn navigate" @click="startNavigation">前往取货</button>
|
||||
<button v-if="order.status === 2" class="action-btn complete" @click="confirmArrivedAtPickup">正在取货</button>
|
||||
|
||||
<!-- 只在取货中状态且订单未完成时显示“确认取货”按钮 -->
|
||||
<button v-if="order.status === 3" class="action-btn complete" @click="confirmPickup">确认取货</button>
|
||||
|
||||
<!-- 只在已取货状态且订单未完成时显示“确认送达”按钮 -->
|
||||
<button v-if="order.status === 4" class="action-btn complete" @click="confirmDelivery">确认送达</button>
|
||||
|
||||
<!-- 始终显示联系客服按钮 -->
|
||||
<button class="action-btn contact" @click="contactService">联系客服</button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { OrderType, OrderItemType, MerchantType } from '@/types/mall-types.uts'
|
||||
<script lang="uts">
|
||||
import type { OrderType, OrderItemType, MerchantType } from '@/types/mall-types.uts'
|
||||
|
||||
type DeliveryInfoType = {
|
||||
distance: number
|
||||
@@ -161,7 +198,7 @@ export default {
|
||||
order_no: '',
|
||||
user_id: '',
|
||||
merchant_id: '',
|
||||
status: 0,
|
||||
status: 0, // 👈 从 URL 参数获取
|
||||
total_amount: 0,
|
||||
discount_amount: 0,
|
||||
delivery_fee: 0,
|
||||
@@ -196,24 +233,38 @@ export default {
|
||||
pickupAddress: '',
|
||||
customerNote: '',
|
||||
merchantNote: '',
|
||||
deliveryNote: ''
|
||||
deliveryNote: '', // 配送备注
|
||||
}
|
||||
},
|
||||
|
||||
onLoad(options: any) {
|
||||
const orderId = options.orderId as string
|
||||
const orderId = options.id as string
|
||||
// 👇 从 URL 参数获取 status
|
||||
const status = parseInt(options.status as string) || 0
|
||||
|
||||
if (orderId) {
|
||||
// 将从 URL 获取的状态赋值给 order 对象
|
||||
this.order.id = orderId
|
||||
this.order.status = status // 👈 设置状态
|
||||
this.loadOrderDetail(orderId)
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
// 返回上一页
|
||||
goBack() {
|
||||
uni.navigateBack()
|
||||
},
|
||||
|
||||
loadOrderDetail(orderId: string) {
|
||||
// 模拟加载订单详情数据
|
||||
// 注意:现在 status 的值在 onLoad 时已经从 URL 获取并设置,这里可以依据它来调整数据加载逻辑(如果需要)
|
||||
// 为了演示,我们保持模拟数据不变,但实际应用中可以根据 status 加载不同的数据
|
||||
this.order = {
|
||||
id: orderId,
|
||||
...this.order, // 保留从 URL 获取的 id 和 status
|
||||
order_no: 'ORD202401150001',
|
||||
user_id: 'user_001',
|
||||
merchant_id: 'merchant_001',
|
||||
status: 3, // 3:待接单 4:配送中 5:已送达
|
||||
total_amount: 299.98,
|
||||
discount_amount: 30.00,
|
||||
delivery_fee: 8.00,
|
||||
@@ -261,26 +312,32 @@ export default {
|
||||
this.pickupAddress = '北京市朝阳区商家街道123号'
|
||||
this.customerNote = '请送到门口,谢谢'
|
||||
this.merchantNote = '商品易碎,小心搬运'
|
||||
this.deliveryNote = '已按时送达,顾客签收'
|
||||
|
||||
this.deliveryInfo = {
|
||||
distance: 3.2,
|
||||
estimated_time: 25,
|
||||
courier_id: 'courier_001',
|
||||
pickup_time: '',
|
||||
delivery_time: ''
|
||||
pickup_time: '2024-01-15 15:00:00',
|
||||
delivery_time: '2024-01-15 15:25:00'
|
||||
}
|
||||
},
|
||||
|
||||
getStatusDesc(): string {
|
||||
const statusDescs = [
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'等待配送员接单',
|
||||
'配送员正在前往取货',
|
||||
'订单已送达完成'
|
||||
]
|
||||
return statusDescs[this.order.status] || ''
|
||||
// 根据 order.status 动态返回描述
|
||||
if (this.order.status >= 5) {
|
||||
return '订单已送达完成'
|
||||
} else if (this.order.status === 4) {
|
||||
return '已取货,正在前往送达'
|
||||
} else if (this.order.status === 3) {
|
||||
return '配送员正在前往取货'
|
||||
} else if (this.order.status === 2) {
|
||||
return '订单已接取'
|
||||
} else if (this.order.status === 1) {
|
||||
return '等待配送员接单'
|
||||
} else {
|
||||
return '订单状态未知'
|
||||
}
|
||||
},
|
||||
|
||||
getDeliveryAddress(): any {
|
||||
@@ -292,97 +349,124 @@ export default {
|
||||
return Object.keys(specifications).map(key => `${key}: ${specifications[key]}`).join(', ')
|
||||
},
|
||||
|
||||
confirmPickup() {
|
||||
uni.showModal({
|
||||
title: '确认取货',
|
||||
content: '确认已从商家处取到商品?',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
this.deliveryInfo.pickup_time = new Date().toISOString()
|
||||
uni.showToast({
|
||||
title: '取货确认成功',
|
||||
icon: 'success'
|
||||
})
|
||||
// ✅ 新增:点击“正在取货”按钮
|
||||
confirmArrivedAtPickup() {
|
||||
// 仅在订单未完成时执行
|
||||
if (this.order.status < 5) {
|
||||
uni.showModal({
|
||||
title: '正在取货',
|
||||
content: '确认已到达商家,开始取货?',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
this.order.status = 3 // 更新状态为取货中
|
||||
uni.showToast({
|
||||
title: '已进入取货流程',
|
||||
icon: 'success'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
confirmDelivery() {
|
||||
uni.showModal({
|
||||
title: '确认送达',
|
||||
content: '确认商品已送达到顾客手中?',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
this.order.status = 5
|
||||
this.deliveryInfo.delivery_time = new Date().toISOString()
|
||||
uni.showToast({
|
||||
title: '送达确认成功',
|
||||
icon: 'success'
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
acceptOrder() {
|
||||
uni.showModal({
|
||||
title: '接受订单',
|
||||
content: '确定接受这个配送订单吗?',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
this.order.status = 4
|
||||
uni.showToast({
|
||||
title: '订单接受成功',
|
||||
icon: 'success'
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
rejectOrder() {
|
||||
uni.showModal({
|
||||
title: '拒绝订单',
|
||||
content: '确定拒绝这个配送订单吗?',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
uni.showToast({
|
||||
title: '订单已拒绝',
|
||||
icon: 'success'
|
||||
})
|
||||
uni.navigateBack()
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
startNavigation() {
|
||||
// 开启导航功能
|
||||
uni.showToast({
|
||||
title: '正在启动导航',
|
||||
icon: 'loading'
|
||||
})
|
||||
|
||||
// 模拟调用地图导航
|
||||
setTimeout(() => {
|
||||
uni.showToast({
|
||||
title: '导航已启动',
|
||||
icon: 'success'
|
||||
})
|
||||
}, 1000)
|
||||
},
|
||||
|
||||
completeDelivery() {
|
||||
if (!this.deliveryNote.trim()) {
|
||||
uni.showToast({
|
||||
title: '请填写配送备注',
|
||||
icon: 'none'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
this.confirmDelivery()
|
||||
},
|
||||
|
||||
// ✅ 保留:点击“确认取货”按钮
|
||||
confirmPickup() {
|
||||
// 仅在订单未完成时执行
|
||||
if (this.order.status < 5) {
|
||||
uni.showModal({
|
||||
title: '确认取货',
|
||||
content: '确认已从商家处取到商品?',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
this.order.status = 4 // 更新状态为已取货
|
||||
this.deliveryInfo.pickup_time = new Date().toISOString()
|
||||
uni.showToast({
|
||||
title: '取货确认成功',
|
||||
icon: 'success'
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
// ✅ 保留:点击“确认送达”按钮
|
||||
confirmDelivery() {
|
||||
// 仅在订单未完成时执行
|
||||
if (this.order.status < 5) {
|
||||
uni.showModal({
|
||||
title: '确认送达',
|
||||
content: '确认商品已送达到顾客手中?',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
this.order.status = 5 // 更新状态为已完成
|
||||
this.deliveryInfo.delivery_time = new Date().toISOString()
|
||||
uni.showToast({
|
||||
title: '送达确认成功',
|
||||
icon: 'success'
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
// ✅ 保留:接受订单
|
||||
acceptOrder() {
|
||||
// 仅在订单未完成时执行
|
||||
if (this.order.status < 5) {
|
||||
uni.showModal({
|
||||
title: '接受订单',
|
||||
content: '确定接受这个配送订单吗?',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
this.order.status = 2 // 更新状态为已接单
|
||||
uni.showToast({
|
||||
title: '订单接受成功',
|
||||
icon: 'success'
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
// ✅ 保留:拒绝订单
|
||||
rejectOrder() {
|
||||
// 仅在订单未完成时执行
|
||||
if (this.order.status < 5) {
|
||||
uni.showModal({
|
||||
title: '拒绝订单',
|
||||
content: '确定拒绝这个配送订单吗?',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
uni.showToast({
|
||||
title: '订单已拒绝',
|
||||
icon: 'success'
|
||||
})
|
||||
uni.navigateBack()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
// ✅ 保留:启动导航(用于“前往取货”按钮)
|
||||
startNavigation() {
|
||||
// 仅在订单未完成时执行
|
||||
if (this.order.status < 5) {
|
||||
uni.showToast({
|
||||
title: '正在启动导航',
|
||||
icon: 'loading'
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
uni.showToast({
|
||||
title: '导航已启动',
|
||||
icon: 'success'
|
||||
})
|
||||
}, 1000)
|
||||
}
|
||||
},
|
||||
|
||||
callCustomer() {
|
||||
@@ -408,12 +492,52 @@ export default {
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* ... 保持原有 style 部分不变 ... */
|
||||
.delivery-order-detail {
|
||||
background-color: #f5f5f5;
|
||||
min-height: 100vh;
|
||||
padding-bottom: 160rpx;
|
||||
}
|
||||
|
||||
/* 返回按钮头部 */
|
||||
.back-header {
|
||||
background-color: #fff;
|
||||
padding: 20rpx 30rpx;
|
||||
position: relative;
|
||||
height: 80rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.back-box {
|
||||
position: absolute;
|
||||
left: 30rpx;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
padding: 10rpx;
|
||||
border-radius: 8rpx;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.back-box:active {
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
.back-icon {
|
||||
font-size: 36rpx;
|
||||
color: #333;
|
||||
margin-right: 5rpx;
|
||||
}
|
||||
|
||||
.back-text {
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.order-status {
|
||||
background-color: #fff;
|
||||
padding: 40rpx 30rpx;
|
||||
@@ -742,14 +866,18 @@ export default {
|
||||
box-shadow: 0 -2rpx 10rpx rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
gap: 15rpx;
|
||||
flex-wrap: wrap; /* 允许按钮换行 */
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
min-width: 120rpx; /* 设置最小宽度 */
|
||||
height: 70rpx;
|
||||
border-radius: 35rpx;
|
||||
font-size: 26rpx;
|
||||
border: none;
|
||||
margin: 5rpx; /* 添加外边距 */
|
||||
box-sizing: border-box; /* 确保宽高包含边距 */
|
||||
}
|
||||
|
||||
.action-btn.accept, .action-btn.complete {
|
||||
@@ -771,4 +899,4 @@ export default {
|
||||
background-color: #ffa726;
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
472
pages/mall/delivery/order-history.uvue
Normal file
472
pages/mall/delivery/order-history.uvue
Normal file
@@ -0,0 +1,472 @@
|
||||
<template>
|
||||
<view class="order-history-container">
|
||||
<!-- 顶部导航栏 -->
|
||||
<view class="page-header">
|
||||
<!-- 左上角:返回主页按钮(箭头+文字 垂直排列) -->
|
||||
<view class="nav-left" @click="goBackToHome">
|
||||
<text class="nav-icon">←</text>
|
||||
</view>
|
||||
|
||||
<!-- 页面标题居中 -->
|
||||
<text class="page-title">历史订单</text>
|
||||
|
||||
<!-- 右上角留空 -->
|
||||
<view class="nav-right"></view>
|
||||
</view>
|
||||
|
||||
<!-- 主要内容区域 -->
|
||||
<view class="content-wrapper">
|
||||
<!-- 订单列表 -->
|
||||
<view v-if="orderList.length > 0" class="order-list">
|
||||
<view v-for="order in orderList" :key="order.id" class="order-item">
|
||||
<view class="order-header">
|
||||
<text class="order-id">订单号: {{ order.order_no }}</text>
|
||||
<text class="order-status" :class="getOrderStatusClass(order.status)">{{ getOrderStatusText(order.status) }}</text>
|
||||
</view>
|
||||
|
||||
<view class="order-addresses">
|
||||
<view class="address-item">
|
||||
<text class="address-icon">📍</text>
|
||||
<view class="address-info">
|
||||
<text class="address-label">取货地址</text>
|
||||
<text class="address-text">{{ order.pickup_address.detail }}</text>
|
||||
<text class="contact-info">联系人: {{ order.pickup_contact.name }} {{ order.pickup_contact.phone }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="address-line"></view>
|
||||
|
||||
<view class="address-item">
|
||||
<text class="address-icon">🏠</text>
|
||||
<view class="address-info">
|
||||
<text class="address-label">收货地址</text>
|
||||
<text class="address-text">{{ order.delivery_address.detail }}</text>
|
||||
<text class="contact-info">联系人: {{ order.delivery_contact.name }} {{ order.delivery_contact.phone }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="order-details">
|
||||
<text class="order-info">配送费: ¥{{ order.delivery_fee }}</text>
|
||||
<text class="order-info">预计距离: {{ order.distance }}km</text>
|
||||
<text class="order-info">预计时间: {{ order.estimated_time }}分钟</text>
|
||||
</view>
|
||||
|
||||
<view class="order-actions">
|
||||
<button class="action-btn primary" @click="viewOrderDetail(order.id, order.status)">查看详情</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 无数据时显示 -->
|
||||
<view v-else class="no-data">
|
||||
<text class="no-data-text">暂无历史订单</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
import type { DeliveryTaskType } from '@/types/mall-types.uts'
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
// 模拟历史订单数据
|
||||
orderList: [] as Array<DeliveryTaskType>
|
||||
}
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
this.loadOrderHistory()
|
||||
},
|
||||
|
||||
onShow() {
|
||||
// 页面每次显示时都检查是否有新的已完成订单
|
||||
this.checkForNewCompletedOrder()
|
||||
},
|
||||
|
||||
methods: {
|
||||
// 检查是否有新的已完成订单
|
||||
checkForNewCompletedOrder() {
|
||||
const completedOrderFromStorage = uni.getStorageSync('completed_order_for_history')
|
||||
if (completedOrderFromStorage) {
|
||||
// 如果有,将其添加到订单列表的开头
|
||||
// 检查是否已经存在于列表中,避免重复添加
|
||||
const exists = this.orderList.some(order => order.id === completedOrderFromStorage.id)
|
||||
if (!exists) {
|
||||
this.orderList.unshift(completedOrderFromStorage)
|
||||
}
|
||||
// 清除本地存储,防止下次进入页面时重复添加
|
||||
uni.removeStorageSync('completed_order_for_history')
|
||||
}
|
||||
},
|
||||
|
||||
// 加载历史订单
|
||||
loadOrderHistory() {
|
||||
// TODO: 调用API获取历史订单列表
|
||||
this.orderList = [
|
||||
{
|
||||
id: '1',
|
||||
order_no: 'D202501081234',
|
||||
status: 4, // 已接取
|
||||
pickup_address: {
|
||||
detail: '深圳公司',
|
||||
area: '购物公园'
|
||||
},
|
||||
delivery_address: {
|
||||
detail: '梅州',
|
||||
area: '海岸城'
|
||||
},
|
||||
pickup_contact: {
|
||||
name: '商家联系人',
|
||||
phone: '138****6567'
|
||||
},
|
||||
delivery_contact: {
|
||||
name: '张先生',
|
||||
phone: '139****9786'
|
||||
},
|
||||
delivery_fee: 12.0,
|
||||
distance: 8.2,
|
||||
estimated_time: 25,
|
||||
created_at: '2025-01-08T15:00:00Z'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
order_no: 'D202501081235',
|
||||
status: 5, // 已完成
|
||||
pickup_address: {
|
||||
detail: '福田区购物公园',
|
||||
area: '购物公园'
|
||||
},
|
||||
delivery_address: {
|
||||
detail: '南山区海岸城',
|
||||
area: '海岸城'
|
||||
},
|
||||
pickup_contact: {
|
||||
name: '商家联系人',
|
||||
phone: '138****5678'
|
||||
},
|
||||
delivery_contact: {
|
||||
name: '张先生',
|
||||
phone: '139****1234'
|
||||
},
|
||||
delivery_fee: 12.0,
|
||||
distance: 8.2,
|
||||
estimated_time: 25,
|
||||
created_at: '2025-01-08T15:00:00Z'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
order_no: 'D202501081236',
|
||||
status: 1, // 取货中
|
||||
pickup_address: {
|
||||
detail: '罗湖区东门步行街',
|
||||
area: '罗湖'
|
||||
},
|
||||
delivery_address: {
|
||||
detail: '福田区市民中心',
|
||||
area: '福田'
|
||||
},
|
||||
pickup_contact: {
|
||||
name: '商家联系人',
|
||||
phone: '138****5678'
|
||||
},
|
||||
delivery_contact: {
|
||||
name: '李先生',
|
||||
phone: '139****5678'
|
||||
},
|
||||
delivery_fee: 10.0,
|
||||
distance: 5.0,
|
||||
estimated_time: 15,
|
||||
created_at: '2025-01-08T16:00:00Z'
|
||||
}
|
||||
]
|
||||
|
||||
// 检查是否有新完成的订单(在加载初始数据后)
|
||||
this.checkForNewCompletedOrder()
|
||||
},
|
||||
|
||||
// 获取订单状态样式
|
||||
getOrderStatusClass(status: number): string {
|
||||
switch (status) {
|
||||
case 1: return 'status-pending'
|
||||
case 2: return 'status-accepted'
|
||||
case 3: return 'status-picking'
|
||||
case 4: return 'status-picked' // 已取货
|
||||
case 5: return 'status-delivered' // 已送达
|
||||
default: return 'status-default'
|
||||
}
|
||||
},
|
||||
|
||||
// 获取订单状态文本
|
||||
getOrderStatusText(status: number): string {
|
||||
switch (status) {
|
||||
case 1: return '待接取'
|
||||
case 2: return '已接取'
|
||||
case 3: return '取货中'
|
||||
case 4: return '已取货'
|
||||
case 5: return '已完成'
|
||||
default: return '未知状态'
|
||||
}
|
||||
},
|
||||
|
||||
// 查看订单详情
|
||||
viewOrderDetail(orderId: string, status: number) {
|
||||
uni.navigateTo({
|
||||
url: `/pages/mall/delivery/order-detail?id=${orderId}&status=${status}`
|
||||
})
|
||||
},
|
||||
|
||||
// 返回主页
|
||||
goBackToHome() {
|
||||
uni.reLaunch({
|
||||
url: '/pages/mall/delivery/index'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.order-history-container {
|
||||
background-color: #f5f5f5;
|
||||
min-height: 100vh;
|
||||
padding: 20rpx 30rpx;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
background-color: #fff;
|
||||
padding: 20rpx 30rpx;
|
||||
border-bottom: 1rpx solid #e9ecef;
|
||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
|
||||
position: relative;
|
||||
min-height: 80rpx; /* 确保有足够空间放垂直排列的按钮和标题 */
|
||||
}
|
||||
|
||||
.nav-left {
|
||||
position: absolute;
|
||||
top: 20rpx;
|
||||
left: 30rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
padding: 10rpx;
|
||||
border-radius: 8rpx;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.nav-left:hover {
|
||||
background-color: #f0f0f0; /* 悬停效果 */
|
||||
}
|
||||
|
||||
.nav-left:active {
|
||||
background-color: #e0e0e0; /* 点击效果 */
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
font-size: 36rpx;
|
||||
color: #333;
|
||||
margin-bottom: 5rpx;
|
||||
}
|
||||
|
||||
.nav-title {
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
text-align: center;
|
||||
margin-top: 20rpx; /* 与 nav-left 保持一定距离 */
|
||||
}
|
||||
|
||||
.nav-right {
|
||||
/* 为了保持左右对齐,右侧需要一个占位元素 */
|
||||
width: 1rpx;
|
||||
height: 1rpx;
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
|
||||
.order-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.order-item {
|
||||
background-color: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 20rpx;
|
||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
|
||||
border-left: 6rpx solid #74b9ff;
|
||||
}
|
||||
|
||||
.order-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20rpx;
|
||||
padding-bottom: 15rpx;
|
||||
border-bottom: 1rpx solid #f8f9fa;
|
||||
}
|
||||
|
||||
.order-id {
|
||||
font-size: 28rpx;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.order-status {
|
||||
font-size: 24rpx;
|
||||
padding: 6rpx 12rpx;
|
||||
border-radius: 20rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-pending {
|
||||
background-color: #E3F2FD;
|
||||
color: #1976D2;
|
||||
}
|
||||
|
||||
.status-accepted {
|
||||
background-color: #FFF3E0;
|
||||
color: #F57C00;
|
||||
}
|
||||
|
||||
.status-picking {
|
||||
background-color: #FFF3E0;
|
||||
color: #F57C00;
|
||||
}
|
||||
|
||||
.status-picked {
|
||||
background-color: #E8F5E8;
|
||||
color: #388E3C;
|
||||
}
|
||||
|
||||
.status-delivered {
|
||||
background-color: #E8F5E8;
|
||||
color: #388E3C;
|
||||
}
|
||||
|
||||
.status-default {
|
||||
background-color: #F8F8F8;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.order-addresses {
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.address-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 15rpx;
|
||||
}
|
||||
|
||||
.address-icon {
|
||||
font-size: 28rpx;
|
||||
margin-right: 15rpx;
|
||||
margin-top: 5rpx;
|
||||
}
|
||||
|
||||
.address-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.address-label {
|
||||
font-size: 24rpx;
|
||||
color: #666;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.address-text {
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.contact-info {
|
||||
font-size: 24rpx;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.address-line {
|
||||
width: 2rpx;
|
||||
height: 30rpx;
|
||||
background-color: #ddd;
|
||||
margin: 10rpx 0 10rpx 14rpx;
|
||||
}
|
||||
|
||||
.order-details {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20rpx;
|
||||
padding: 15rpx;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 8rpx;
|
||||
font-size: 24rpx;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.order-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
font-size: 24rpx;
|
||||
color: #666;
|
||||
margin: 0 5rpx;
|
||||
}
|
||||
|
||||
.order-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10rpx;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
height: 80rpx;
|
||||
border-radius: 8rpx;
|
||||
font-size: 28rpx;
|
||||
border: none;
|
||||
font-weight: 500;
|
||||
padding: 0 10rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.primary {
|
||||
background-color: #4CAF50;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.secondary {
|
||||
background-color: #f0f0f0;
|
||||
color: #333;
|
||||
border: 1rpx solid #ddd;
|
||||
}
|
||||
|
||||
.no-data {
|
||||
text-align: center;
|
||||
padding: 80rpx 30rpx;
|
||||
border-radius: 16rpx;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.no-data-text {
|
||||
font-size: 32rpx;
|
||||
color: #999;
|
||||
margin-bottom: 15rpx;
|
||||
}
|
||||
</style>
|
||||
439
pages/mall/delivery/profile-edit.uvue
Normal file
439
pages/mall/delivery/profile-edit.uvue
Normal file
@@ -0,0 +1,439 @@
|
||||
<template>
|
||||
<view class="profile-edit-container">
|
||||
<!-- 顶部导航栏 -->
|
||||
<view class="page-header">
|
||||
<!-- 左上角:返回上一页按钮(箭头+文字 垂直排列) -->
|
||||
<view class="nav-left" @click="goBack">
|
||||
<text class="nav-icon">←</text>
|
||||
<text class="nav-title">返回</text>
|
||||
</view>
|
||||
|
||||
<!-- 页面标题居中 -->
|
||||
<text class="page-title">编辑资料</text>
|
||||
|
||||
<!-- 右上角:保存按钮 -->
|
||||
<view class="save-btn" @click="saveProfile">保存</view>
|
||||
</view>
|
||||
|
||||
<!-- 编辑表单 -->
|
||||
<view class="edit-form">
|
||||
<!-- 头像上传 -->
|
||||
<view class="form-item">
|
||||
<text class="item-label">头像</text>
|
||||
<view class="avatar-upload" @click="chooseAvatar">
|
||||
<image :src="formData.avatar_url || '/static/default-avatar.png'" class="avatar-image" />
|
||||
<text class="upload-text">点击更换</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 姓名 -->
|
||||
<view class="form-item">
|
||||
<text class="item-label">姓名</text>
|
||||
<input class="item-input" v-model="formData.real_name" placeholder="请输入姓名" />
|
||||
</view>
|
||||
|
||||
<!-- 身份证号 -->
|
||||
<view class="form-item">
|
||||
<text class="item-label">身份证号</text>
|
||||
<input class="item-input" v-model="formData.id_card" placeholder="请输入身份证号" />
|
||||
</view>
|
||||
|
||||
<!-- 驾驶证号 -->
|
||||
<view class="form-item">
|
||||
<text class="item-label">驾驶证号</text>
|
||||
<input class="item-input" v-model="formData.driver_license" placeholder="请输入驾驶证号" />
|
||||
</view>
|
||||
|
||||
<!-- 车辆类型 -->
|
||||
<view class="form-item">
|
||||
<text class="item-label">车辆类型</text>
|
||||
<picker :value="vehicleTypeIndex" :range="vehicleTypes" @change="onVehicleTypeChange">
|
||||
<view class="picker-view">{{ formData.vehicle_type ? vehicleTypes[vehicleTypeIndex] : '请选择车辆类型' }}</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<!-- 车牌号 -->
|
||||
<view class="form-item">
|
||||
<text class="item-label">车牌号</text>
|
||||
<input class="item-input" v-model="formData.vehicle_number" placeholder="请输入车牌号" />
|
||||
</view>
|
||||
|
||||
<!-- 服务区域 -->
|
||||
<view class="form-item">
|
||||
<text class="item-label">服务区域</text>
|
||||
<view class="service-areas">
|
||||
<view class="area-tag" v-for="(area, index) in formData.service_areas" :key="index">
|
||||
<text class="area-text">{{ area }}</text>
|
||||
<text class="remove-area" @click="removeArea(index)">×</text>
|
||||
</view>
|
||||
<view class="add-area" @click="showAddAreaModal">
|
||||
<text class="add-icon">+</text>
|
||||
<text class="add-text">添加区域</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 联系电话(可选) -->
|
||||
<view class="form-item">
|
||||
<text class="item-label">联系电话</text>
|
||||
<input class="item-input" v-model="formData.phone" placeholder="请输入联系电话" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 添加服务区域弹窗 -->
|
||||
<view v-if="showAreaModal" class="modal-overlay" @click="hideAddAreaModal">
|
||||
<view class="modal-content" @click.stop="noop">
|
||||
<text class="modal-title">添加服务区域</text>
|
||||
<input class="modal-input" v-model="newAreaName" placeholder="输入区域名称" />
|
||||
<view class="modal-actions">
|
||||
<button class="modal-btn cancel" @click="hideAddAreaModal">取消</button>
|
||||
<button class="modal-btn confirm" @click="addNewArea">确定</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
|
||||
// 响应式数据
|
||||
const formData = reactive({
|
||||
id: '',
|
||||
user_id: '',
|
||||
real_name: '李师傅',
|
||||
id_card: '110101199001011234',
|
||||
driver_license: 'C1',
|
||||
vehicle_type: 1,
|
||||
vehicle_number: '京A12345',
|
||||
phone: '13888888888',
|
||||
service_areas: ['朝阳区', '东城区'],
|
||||
avatar_url: ''
|
||||
})
|
||||
|
||||
const vehicleTypeIndex = ref(0)
|
||||
const showAreaModal = ref(false)
|
||||
const newAreaName = ref('')
|
||||
const vehicleTypes = ref(['摩托车', '电动自行车', '面包车', '小型货车'])
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
loadProfileData()
|
||||
})
|
||||
|
||||
// 方法
|
||||
function loadProfileData() {
|
||||
// 模拟加载当前用户资料
|
||||
// 实际项目中应从 API 获取
|
||||
}
|
||||
|
||||
function chooseAvatar() {
|
||||
uni.chooseImage({
|
||||
count: 1,
|
||||
sizeType: ['compressed'],
|
||||
sourceType: ['album', 'camera'],
|
||||
success: (res) => {
|
||||
formData.avatar_url = res.tempFilePaths[0]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function onVehicleTypeChange(e: UniEvent<HTMLInputElement>) {
|
||||
const index = parseInt(e.detail.value)
|
||||
vehicleTypeIndex.value = index
|
||||
formData.vehicle_type = index + 1 // 假设后端从1开始
|
||||
}
|
||||
|
||||
function showAddAreaModal() {
|
||||
newAreaName.value = ''
|
||||
showAreaModal.value = true
|
||||
}
|
||||
|
||||
function hideAddAreaModal() {
|
||||
showAreaModal.value = false
|
||||
}
|
||||
|
||||
function addNewArea() {
|
||||
if (newAreaName.value.trim()) {
|
||||
if (!formData.service_areas.includes(newAreaName.value.trim())) {
|
||||
formData.service_areas.push(newAreaName.value.trim())
|
||||
}
|
||||
newAreaName.value = ''
|
||||
}
|
||||
hideAddAreaModal()
|
||||
}
|
||||
|
||||
function removeArea(index: number) {
|
||||
formData.service_areas.splice(index, 1)
|
||||
}
|
||||
|
||||
function saveProfile() {
|
||||
// 模拟保存
|
||||
uni.showLoading({
|
||||
title: '保存中...'
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
uni.hideLoading()
|
||||
uni.showToast({
|
||||
title: '保存成功',
|
||||
icon: 'success'
|
||||
})
|
||||
// 保存成功后返回上一页
|
||||
uni.navigateBack()
|
||||
}, 1000)
|
||||
|
||||
// 实际项目中应调用 API 保存数据
|
||||
console.log('保存的资料:', formData)
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
uni.navigateBack()
|
||||
}
|
||||
|
||||
function noop() {
|
||||
// 阻止事件冒泡的空函数
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.profile-edit-container {
|
||||
background-color: #f5f5f5;
|
||||
min-height: 100vh;
|
||||
padding: 20rpx 30rpx;
|
||||
}
|
||||
|
||||
/* 导航栏样式 */
|
||||
.page-header {
|
||||
background-color: #fff;
|
||||
padding: 20rpx 30rpx;
|
||||
border-bottom: 1rpx solid #e9ecef;
|
||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
|
||||
position: relative;
|
||||
min-height: 80rpx;
|
||||
}
|
||||
|
||||
.nav-left {
|
||||
position: absolute;
|
||||
top: 20rpx;
|
||||
left: 30rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
padding: 10rpx;
|
||||
border-radius: 8rpx;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.nav-left:hover {
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
.nav-left:active {
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
font-size: 36rpx;
|
||||
color: #333;
|
||||
margin-bottom: 5rpx;
|
||||
}
|
||||
|
||||
.nav-title {
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
text-align: center;
|
||||
margin-top: 20rpx; /* 与 nav-left 保持一定距离 */
|
||||
}
|
||||
|
||||
.save-btn {
|
||||
position: absolute;
|
||||
top: 20rpx;
|
||||
right: 30rpx;
|
||||
font-size: 28rpx;
|
||||
color: #4CAF50;
|
||||
font-weight: bold;
|
||||
padding: 10rpx 20rpx;
|
||||
background-color: #e8f5e8;
|
||||
border-radius: 12rpx;
|
||||
}
|
||||
|
||||
/* 编辑表单 */
|
||||
.edit-form {
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
|
||||
.form-item {
|
||||
background-color: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 20rpx 30rpx;
|
||||
margin-bottom: 20rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.item-label {
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
min-width: 120rpx;
|
||||
}
|
||||
|
||||
.item-input {
|
||||
flex: 1;
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
padding: 10rpx 0;
|
||||
border: none;
|
||||
outline: none;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.picker-view {
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
text-align: right;
|
||||
padding: 10rpx 0;
|
||||
}
|
||||
|
||||
/* 头像上传 */
|
||||
.avatar-upload {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.avatar-image {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
border-radius: 60rpx;
|
||||
border: 4rpx solid #ddd;
|
||||
}
|
||||
|
||||
.upload-text {
|
||||
font-size: 24rpx;
|
||||
color: #666;
|
||||
margin-top: 10rpx;
|
||||
}
|
||||
|
||||
/* 服务区域 */
|
||||
.service-areas {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10rpx;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.area-tag {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: #e8f4fd;
|
||||
padding: 8rpx 16rpx;
|
||||
border-radius: 20rpx;
|
||||
font-size: 24rpx;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.remove-area {
|
||||
margin-left: 8rpx;
|
||||
font-size: 20rpx;
|
||||
color: #ff4757;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.add-area {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: #f0f0f0;
|
||||
padding: 8rpx 16rpx;
|
||||
border-radius: 20rpx;
|
||||
font-size: 24rpx;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.add-icon {
|
||||
margin-right: 5rpx;
|
||||
font-size: 20rpx;
|
||||
}
|
||||
|
||||
/* 弹窗样式 */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background-color: #fff;
|
||||
width: 80%;
|
||||
max-width: 600rpx;
|
||||
border-radius: 16rpx;
|
||||
padding: 30rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.modal-input {
|
||||
width: 100%;
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
padding: 10rpx 20rpx;
|
||||
border: 1rpx solid #ddd;
|
||||
border-radius: 8rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.modal-btn {
|
||||
flex: 1;
|
||||
height: 80rpx;
|
||||
border-radius: 8rpx;
|
||||
font-size: 28rpx;
|
||||
font-weight: bold;
|
||||
margin: 0 10rpx;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cancel {
|
||||
background-color: #f0f0f0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.confirm {
|
||||
background-color: #4CAF50;
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
@@ -1,8 +1,13 @@
|
||||
<!-- 配送端 - 个人中心 -->
|
||||
<template>
|
||||
<view class="delivery-profile">
|
||||
<!-- 配送员信息头部 -->
|
||||
<!-- 1. 蓝色头像条(profile-header) -->
|
||||
<view class="profile-header">
|
||||
<!-- 返回按钮:最左边垂直居中 -->
|
||||
<view class="back-box" @click="backToIndex">
|
||||
<text class="back-icon">‹</text>
|
||||
</view>
|
||||
|
||||
<image :src="driverInfo.avatar_url || '/static/default-avatar.png'" class="driver-avatar" @click="editProfile" />
|
||||
<view class="driver-info">
|
||||
<text class="driver-name">{{ driverInfo.real_name }}</text>
|
||||
@@ -15,7 +20,7 @@
|
||||
<view class="settings-icon" @click="goToSettings">⚙️</view>
|
||||
</view>
|
||||
|
||||
<!-- 工作状态切换 -->
|
||||
<!-- 2. 工作状态切换 -->
|
||||
<view class="work-status">
|
||||
<view class="section-title">工作状态</view>
|
||||
<view class="status-controls">
|
||||
@@ -31,7 +36,7 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 配送任务快捷入口 -->
|
||||
<!-- 3. 配送任务快捷入口 -->
|
||||
<view class="task-shortcuts">
|
||||
<view class="section-title">配送任务</view>
|
||||
<view class="task-tabs">
|
||||
@@ -58,7 +63,7 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 今日配送数据 -->
|
||||
<!-- 4. 今日配送数据 -->
|
||||
<view class="today-stats">
|
||||
<view class="section-title">今日配送</view>
|
||||
<view class="stats-grid">
|
||||
@@ -81,7 +86,7 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 当前任务 -->
|
||||
<!-- 5. 当前任务 -->
|
||||
<view v-if="currentTask" class="current-task">
|
||||
<view class="section-title">当前任务</view>
|
||||
<view class="task-card">
|
||||
@@ -113,7 +118,7 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 最近任务 -->
|
||||
<!-- 6. 最近任务 -->
|
||||
<view class="recent-tasks">
|
||||
<view class="section-header">
|
||||
<text class="section-title">最近任务</text>
|
||||
@@ -136,7 +141,7 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 收入统计 -->
|
||||
<!-- 7. 收入统计 -->
|
||||
<view class="earnings-chart">
|
||||
<view class="section-header">
|
||||
<text class="section-title">收入统计</text>
|
||||
@@ -153,7 +158,7 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 功能菜单 -->
|
||||
<!-- 8. 功能菜单 -->
|
||||
<view class="function-menu">
|
||||
<view class="menu-group">
|
||||
<view class="menu-item" @click="goToEarnings">
|
||||
@@ -193,7 +198,12 @@
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import type { DeliveryDriverType, DeliveryTaskType, ApiResponseType } from '@/types/mall-types'
|
||||
|
||||
// 响应式数据
|
||||
/* ----------------- 返回按钮 ----------------- */
|
||||
function backToIndex() {
|
||||
uni.navigateBack({ url: '/pages/mall/delivery/index' })
|
||||
}
|
||||
|
||||
/* ----------------- 数据 ----------------- */
|
||||
const driverInfo = ref({
|
||||
id: '',
|
||||
real_name: '配送员',
|
||||
@@ -201,17 +211,12 @@ const driverInfo = ref({
|
||||
rating: 4.9,
|
||||
total_orders: 368,
|
||||
work_status: 1
|
||||
} as DeliveryDriverType)
|
||||
})
|
||||
|
||||
const workStatus = ref(1) // 1: 工作中, 0: 休息中
|
||||
const workStatus = ref(1) // 1 工作中 0 休息中
|
||||
const currentLocation = ref('朝阳区建国门附近')
|
||||
|
||||
const taskCounts = ref({
|
||||
total: 0,
|
||||
pending: 0,
|
||||
ongoing: 0,
|
||||
completed: 0
|
||||
})
|
||||
const taskCounts = ref({ total: 0, pending: 0, ongoing: 0, completed: 0 })
|
||||
|
||||
const todayStats = ref({
|
||||
deliveries: 12,
|
||||
@@ -220,8 +225,8 @@ const todayStats = ref({
|
||||
efficiency: 96.5
|
||||
})
|
||||
|
||||
const currentTask = ref(null as DeliveryTaskType | null)
|
||||
const recentTasks = ref([] as Array<DeliveryTaskType>)
|
||||
const currentTask = ref<DeliveryTaskType | null>(null)
|
||||
const recentTasks = ref<DeliveryTaskType[]>([])
|
||||
|
||||
const weeklyEarnings = ref([
|
||||
{ day: '周一', amount: 120 },
|
||||
@@ -233,12 +238,9 @@ const weeklyEarnings = ref([
|
||||
{ day: '周日', amount: 198 }
|
||||
])
|
||||
|
||||
// 计算属性
|
||||
const maxEarnings = computed(() => {
|
||||
return Math.max(...weeklyEarnings.value.map(item => item.amount))
|
||||
})
|
||||
const maxEarnings = computed(() => Math.max(...weeklyEarnings.value.map(i => i.amount)))
|
||||
|
||||
// 生命周期
|
||||
/* ----------------- 生命周期 ----------------- */
|
||||
onMounted(() => {
|
||||
loadDriverInfo()
|
||||
loadTaskCounts()
|
||||
@@ -246,9 +248,8 @@ onMounted(() => {
|
||||
loadRecentTasks()
|
||||
})
|
||||
|
||||
// 方法
|
||||
/* ----------------- 方法 ----------------- */
|
||||
function loadDriverInfo() {
|
||||
// 模拟加载配送员信息
|
||||
driverInfo.value = {
|
||||
id: 'driver001',
|
||||
user_id: 'user001',
|
||||
@@ -269,17 +270,10 @@ function loadDriverInfo() {
|
||||
}
|
||||
|
||||
function loadTaskCounts() {
|
||||
// 模拟加载任务统计
|
||||
taskCounts.value = {
|
||||
total: 25,
|
||||
pending: 3,
|
||||
ongoing: 1,
|
||||
completed: 21
|
||||
}
|
||||
taskCounts.value = { total: 25, pending: 3, ongoing: 1, completed: 21 }
|
||||
}
|
||||
|
||||
function loadCurrentTask() {
|
||||
// 模拟加载当前任务
|
||||
currentTask.value = {
|
||||
id: 'task001',
|
||||
order_id: 'order001',
|
||||
@@ -300,7 +294,6 @@ function loadCurrentTask() {
|
||||
}
|
||||
|
||||
function loadRecentTasks() {
|
||||
// 模拟加载最近任务
|
||||
recentTasks.value = [
|
||||
{
|
||||
id: 'task002',
|
||||
@@ -340,49 +333,31 @@ function loadRecentTasks() {
|
||||
}
|
||||
|
||||
function getWorkStatus(): string {
|
||||
const statusMap = {
|
||||
0: '休息中',
|
||||
1: '工作中',
|
||||
2: '忙碌中'
|
||||
}
|
||||
return statusMap[driverInfo.value.work_status] || '未知状态'
|
||||
const m: Record<number, string> = { 0: '休息中', 1: '工作中', 2: '忙碌中' }
|
||||
return m[driverInfo.value.work_status] || '未知状态'
|
||||
}
|
||||
|
||||
function getTaskStatusText(status: number): string {
|
||||
const statusMap = {
|
||||
1: '待接单',
|
||||
2: '已接单',
|
||||
3: '配送中',
|
||||
4: '已完成',
|
||||
5: '已取消'
|
||||
}
|
||||
return statusMap[status] || '未知'
|
||||
const m: Record<number, string> = { 1: '待接单', 2: '已接单', 3: '配送中', 4: '已完成', 5: '已取消' }
|
||||
return m[status] || '未知'
|
||||
}
|
||||
|
||||
function getAddressText(address: UTSJSONObject): string {
|
||||
return address['address'] as string || '地址信息'
|
||||
return (address['address'] as string) || '地址信息'
|
||||
}
|
||||
|
||||
function formatTime(dateStr: string): string {
|
||||
const date = new Date(dateStr)
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - date.getTime()
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60))
|
||||
|
||||
if (hours < 1) {
|
||||
return '刚刚'
|
||||
} else if (hours < 24) {
|
||||
return `${hours}小时前`
|
||||
} else {
|
||||
return `${Math.floor(hours / 24)}天前`
|
||||
}
|
||||
const diff = Date.now() - new Date(dateStr).getTime()
|
||||
const hours = Math.floor(diff / 36e5)
|
||||
if (hours < 1) return '刚刚'
|
||||
if (hours < 24) return `${hours}小时前`
|
||||
return `${Math.floor(hours / 24)}天前`
|
||||
}
|
||||
|
||||
// 交互方法
|
||||
/* ----------------- 交互 ----------------- */
|
||||
function toggleWorkStatus() {
|
||||
workStatus.value = workStatus.value === 1 ? 0 : 1
|
||||
driverInfo.value.work_status = workStatus.value
|
||||
|
||||
uni.showToast({
|
||||
title: workStatus.value === 1 ? '已开始工作' : '已停止工作',
|
||||
icon: 'success'
|
||||
@@ -392,123 +367,112 @@ function toggleWorkStatus() {
|
||||
function contactCustomer() {
|
||||
uni.showActionSheet({
|
||||
itemList: ['拨打电话', '发送短信'],
|
||||
success: (res) => {
|
||||
if (res.tapIndex === 0) {
|
||||
uni.makePhoneCall({
|
||||
phoneNumber: '13888888888'
|
||||
})
|
||||
}
|
||||
success: res => {
|
||||
if (res.tapIndex === 0) uni.makePhoneCall({ phoneNumber: '13888888888' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function viewTaskDetail(taskId: string = '') {
|
||||
function viewTaskDetail(taskId = '') {
|
||||
const id = taskId || currentTask.value?.id || ''
|
||||
uni.navigateTo({
|
||||
url: `/pages/mall/delivery/task-detail?id=${id}`
|
||||
})
|
||||
uni.navigateTo({ url: `/pages/mall/delivery/task-detail?id=${id}` })
|
||||
}
|
||||
|
||||
// 导航方法
|
||||
/* ----------------- 导航 ----------------- */
|
||||
function editProfile() {
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/delivery/profile-edit'
|
||||
})
|
||||
uni.navigateTo({ url: '/pages/mall/delivery/profile-edit' })
|
||||
}
|
||||
|
||||
function goToSettings() {
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/delivery/settings'
|
||||
})
|
||||
uni.navigateTo({ url: '/pages/mall/delivery/settings' })
|
||||
}
|
||||
|
||||
function goToTasks(type: string) {
|
||||
uni.navigateTo({
|
||||
url: `/pages/mall/delivery/tasks?type=${type}`
|
||||
})
|
||||
uni.navigateTo({ url: `/pages/mall/delivery/tasks?type=${type}` })
|
||||
}
|
||||
|
||||
function goToEarnings() {
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/delivery/earnings'
|
||||
})
|
||||
uni.navigateTo({ url: '/pages/mall/delivery/earnings' })
|
||||
}
|
||||
|
||||
function goToVehicle() {
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/delivery/vehicle'
|
||||
})
|
||||
uni.navigateTo({ url: '/pages/mall/delivery/vehicle' })
|
||||
}
|
||||
|
||||
function goToRatings() {
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/delivery/ratings'
|
||||
})
|
||||
uni.navigateTo({ url: '/pages/mall/delivery/ratings' })
|
||||
}
|
||||
|
||||
function goToHelp() {
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/common/help'
|
||||
})
|
||||
uni.navigateTo({ url: '/pages/mall/common/help' })
|
||||
}
|
||||
|
||||
function goToFeedback() {
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/common/feedback'
|
||||
})
|
||||
uni.navigateTo({ url: '/pages/mall/common/feedback' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ---------- 返回按钮:蓝色条最左边垂直居中 ---------- */
|
||||
.profile-header {
|
||||
position: relative;
|
||||
}
|
||||
.back-box {
|
||||
position: absolute;
|
||||
left: 30rpx;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 60rpx;
|
||||
height: 60rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, .15);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.back-box:active {
|
||||
background: rgba(0, 0, 0, .3);
|
||||
}
|
||||
.back-icon {
|
||||
font-size: 40rpx;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ---------- 以下与原样式一致 ---------- */
|
||||
.delivery-profile {
|
||||
padding: 0 0 120rpx 0;
|
||||
background-color: #f5f5f5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.profile-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 40rpx 30rpx;
|
||||
background: linear-gradient(135deg, #74b9ff 0%, #0984e3 100%);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.driver-avatar {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
border-radius: 60rpx;
|
||||
margin-left: 80rpx; /* 给返回按钮留位置 */
|
||||
margin-right: 30rpx;
|
||||
border: 4rpx solid rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.driver-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.driver-name {
|
||||
font-size: 36rpx;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
margin-bottom: 10rpx;
|
||||
}
|
||||
|
||||
.driver-status {
|
||||
font-size: 24rpx;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
margin-bottom: 15rpx;
|
||||
}
|
||||
|
||||
.driver-stats {
|
||||
display: flex;
|
||||
gap: 30rpx;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
font-size: 22rpx;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.settings-icon {
|
||||
font-size: 36rpx;
|
||||
color: white;
|
||||
@@ -521,21 +485,18 @@ function goToFeedback() {
|
||||
border-radius: 20rpx;
|
||||
padding: 30rpx;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
margin-bottom: 30rpx;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 30rpx;
|
||||
}
|
||||
|
||||
.view-all, .view-more {
|
||||
font-size: 24rpx;
|
||||
color: #74b9ff;
|
||||
@@ -546,19 +507,16 @@ function goToFeedback() {
|
||||
flex-direction: column;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.status-toggle {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.toggle-label {
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
width: 100rpx;
|
||||
@@ -567,11 +525,9 @@ function goToFeedback() {
|
||||
border-radius: 25rpx;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.toggle-switch.active {
|
||||
background: #74b9ff;
|
||||
}
|
||||
|
||||
.toggle-handle {
|
||||
position: absolute;
|
||||
top: 5rpx;
|
||||
@@ -582,17 +538,14 @@ function goToFeedback() {
|
||||
border-radius: 50%;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.toggle-switch.active .toggle-handle {
|
||||
left: 55rpx;
|
||||
}
|
||||
|
||||
.current-location {
|
||||
padding: 15rpx 20rpx;
|
||||
background: #e8f4fd;
|
||||
border-radius: 15rpx;
|
||||
}
|
||||
|
||||
.location-text {
|
||||
font-size: 24rpx;
|
||||
color: #74b9ff;
|
||||
@@ -602,7 +555,6 @@ function goToFeedback() {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.task-tab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -610,17 +562,14 @@ function goToFeedback() {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tab-icon {
|
||||
font-size: 48rpx;
|
||||
margin-bottom: 10rpx;
|
||||
}
|
||||
|
||||
.tab-text {
|
||||
font-size: 24rpx;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.tab-badge {
|
||||
position: absolute;
|
||||
top: -10rpx;
|
||||
@@ -633,12 +582,10 @@ function goToFeedback() {
|
||||
min-width: 30rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tab-badge.alert {
|
||||
background: #ff4757;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { transform: scale(1); }
|
||||
50% { transform: scale(1.1); }
|
||||
@@ -651,7 +598,6 @@ function goToFeedback() {
|
||||
flex-wrap: wrap;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -662,14 +608,12 @@ function goToFeedback() {
|
||||
background: #e8f4fd;
|
||||
border-radius: 15rpx;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 36rpx;
|
||||
font-weight: bold;
|
||||
color: #74b9ff;
|
||||
margin-bottom: 5rpx;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 24rpx;
|
||||
color: #666;
|
||||
@@ -681,20 +625,17 @@ function goToFeedback() {
|
||||
border-radius: 15rpx;
|
||||
border-left: 6rpx solid #74b9ff;
|
||||
}
|
||||
|
||||
.task-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.task-id {
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.task-status {
|
||||
font-size: 22rpx;
|
||||
padding: 6rpx 12rpx;
|
||||
@@ -702,39 +643,32 @@ function goToFeedback() {
|
||||
background: #74b9ff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.task-route {
|
||||
margin-bottom: 25rpx;
|
||||
}
|
||||
|
||||
.route-point {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 15rpx;
|
||||
}
|
||||
|
||||
.point-icon {
|
||||
font-size: 32rpx;
|
||||
margin-right: 15rpx;
|
||||
margin-top: 5rpx;
|
||||
}
|
||||
|
||||
.point-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.point-label {
|
||||
font-size: 22rpx;
|
||||
color: #666;
|
||||
margin-bottom: 5rpx;
|
||||
}
|
||||
|
||||
.point-address {
|
||||
font-size: 26rpx;
|
||||
color: #333;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.route-line {
|
||||
width: 2rpx;
|
||||
height: 30rpx;
|
||||
@@ -742,12 +676,10 @@ function goToFeedback() {
|
||||
margin-left: 16rpx;
|
||||
margin-bottom: 5rpx;
|
||||
}
|
||||
|
||||
.task-actions {
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
padding: 20rpx;
|
||||
@@ -757,7 +689,6 @@ function goToFeedback() {
|
||||
color: #333;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.action-btn.primary {
|
||||
background: #74b9ff;
|
||||
color: white;
|
||||
@@ -768,44 +699,37 @@ function goToFeedback() {
|
||||
flex-direction: column;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.task-item {
|
||||
padding: 25rpx;
|
||||
background: #f8f9ff;
|
||||
border-radius: 15rpx;
|
||||
border-left: 6rpx solid #74b9ff;
|
||||
}
|
||||
|
||||
.task-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 15rpx;
|
||||
}
|
||||
|
||||
.task-order {
|
||||
font-size: 26rpx;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.task-fee {
|
||||
font-size: 24rpx;
|
||||
color: #74b9ff;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.task-time {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.time-text {
|
||||
font-size: 22rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: 22rpx;
|
||||
padding: 4rpx 8rpx;
|
||||
@@ -813,7 +737,6 @@ function goToFeedback() {
|
||||
background: #e3f2fd;
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
.status-4 {
|
||||
background: #e8f5e8;
|
||||
color: #388e3c;
|
||||
@@ -822,15 +745,13 @@ function goToFeedback() {
|
||||
.chart-container {
|
||||
padding: 20rpx 0;
|
||||
}
|
||||
|
||||
.chart-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: end;
|
||||
align-items: flex-end;
|
||||
height: 200rpx;
|
||||
gap: 10rpx;
|
||||
}
|
||||
|
||||
.bar-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -838,7 +759,6 @@ function goToFeedback() {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.bar-item::before {
|
||||
content: '';
|
||||
width: 100%;
|
||||
@@ -846,7 +766,6 @@ function goToFeedback() {
|
||||
border-radius: 8rpx 8rpx 0 0;
|
||||
min-height: 20rpx;
|
||||
}
|
||||
|
||||
.bar-label {
|
||||
font-size: 20rpx;
|
||||
color: #666;
|
||||
@@ -856,34 +775,28 @@ function goToFeedback() {
|
||||
.menu-group {
|
||||
margin-bottom: 30rpx;
|
||||
}
|
||||
|
||||
.menu-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 25rpx 0;
|
||||
border-bottom: 1rpx solid #f0f0f0;
|
||||
}
|
||||
|
||||
.menu-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
font-size: 36rpx;
|
||||
width: 60rpx;
|
||||
margin-right: 25rpx;
|
||||
}
|
||||
|
||||
.menu-label {
|
||||
flex: 1;
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.menu-arrow {
|
||||
font-size: 24rpx;
|
||||
color: #ccc;
|
||||
@@ -893,9 +806,8 @@ function goToFeedback() {
|
||||
text-align: center;
|
||||
padding: 60rpx 0;
|
||||
}
|
||||
|
||||
.no-data-text {
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
202
pages/mall/delivery/ratings.uvue
Normal file
202
pages/mall/delivery/ratings.uvue
Normal file
@@ -0,0 +1,202 @@
|
||||
<template>
|
||||
<view class="ratings-page">
|
||||
<!-- 顶部总览卡片 -->
|
||||
<view class="summary-card">
|
||||
<!-- 返回按钮 -->
|
||||
<view class="back-box" @click="backToIndex">
|
||||
<text class="back-icon">‹</text>
|
||||
</view>
|
||||
|
||||
<view class="summary-left">
|
||||
<text class="score">4.9</text>
|
||||
<text class="stars">
|
||||
<text class="star-on">★</text>
|
||||
<text class="star-on">★</text>
|
||||
<text class="star-on">★</text>
|
||||
<text class="star-on">★</text>
|
||||
<text class="star-on">★</text>
|
||||
</text>
|
||||
<text class="count">共收到 128 条评价</text>
|
||||
</view>
|
||||
|
||||
<view class="summary-right">
|
||||
<view class="rate-item">
|
||||
<text class="rate-label">好评率</text>
|
||||
<text class="rate-value">96.1%</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 标签筛选 -->
|
||||
<view class="filter-bar">
|
||||
<view
|
||||
v-for="t in tabs"
|
||||
:key="t.key"
|
||||
:class="['filter-tab', currentTab===t.key?'active':'']"
|
||||
@click="switchTab(t.key)"
|
||||
>
|
||||
<text>{{ t.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 评价列表 -->
|
||||
<scroll-view
|
||||
scroll-y
|
||||
class="ratings-scroll"
|
||||
refresher-enabled
|
||||
:refresher-triggered="isRefreshing"
|
||||
@scrolltolower="loadMore"
|
||||
@refresherrefresh="onRefresh"
|
||||
>
|
||||
<view v-if="list.length" class="rating-list">
|
||||
<view v-for="item in list" :key="item.id" class="rating-card">
|
||||
<view class="rating-header">
|
||||
<image :src="item.avatar" class="user-avatar" mode="aspectFill" />
|
||||
<view class="user-info">
|
||||
<text class="user-name">{{ item.userName }}</text>
|
||||
<text class="rating-stars">
|
||||
<text v-for="s in 5" :key="s" :class="s<=item.score?'star-on':'star-off'">★</text>
|
||||
</text>
|
||||
</view>
|
||||
<text class="rating-time">{{ item.time }}</text>
|
||||
</view>
|
||||
|
||||
<text v-if="item.comment" class="rating-comment">{{ item.comment }}</text>
|
||||
<text v-else class="rating-comment empty">用户未写评价</text>
|
||||
|
||||
<view v-if="item.tags&&item.tags.length" class="rating-tags">
|
||||
<view v-for="tag in item.tags" :key="tag" class="tag">{{ tag }}</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="hasMore" class="load-tip">正在加载…</view>
|
||||
<view v-else class="load-tip">已加载全部</view>
|
||||
</view>
|
||||
|
||||
<view v-else class="no-data">
|
||||
<text class="no-data-icon">📝</text>
|
||||
<text class="no-data-text">暂无评价记录</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
/* 返回按钮 */
|
||||
function backToIndex() {
|
||||
uni.navigateBack({ url: '/pages/mall/delivery/index' })
|
||||
}
|
||||
|
||||
/* mock 数据 */
|
||||
const currentTab = ref('all')
|
||||
const isRefreshing = ref(false)
|
||||
const hasMore = ref(true)
|
||||
const page = ref(1)
|
||||
const list = ref<any[]>([])
|
||||
|
||||
const tabs = [
|
||||
{ key: 'all', label: '全部' },
|
||||
{ key: 'good', label: '好评' },
|
||||
{ key: 'bad', label: '差评' }
|
||||
]
|
||||
|
||||
function mockList() {
|
||||
const tagPool = ['准时送达', '着装整洁', '服务热情', '配送快', '餐品完好']
|
||||
return Array.from({ length: 10 }, (_, i) => ({
|
||||
id: `${currentTab.value}_${page.value}_${i}`,
|
||||
userName: '用户' + (Math.random() * 1000).toFixed(0),
|
||||
avatar: 'https://img.yzcdn.cn/vant/cat.jpeg',
|
||||
score: currentTab.value === 'bad' ? Math.floor(Math.random() * 2) + 1 : Math.floor(Math.random() * 2) + 4,
|
||||
comment: Math.random() > 0.3 ? '配送很及时,服务态度很好!' : '',
|
||||
tags: tagPool.slice(0, Math.floor(Math.random() * 3) + 1),
|
||||
time: `${Math.floor(Math.random() * 60)}分钟前`
|
||||
}))
|
||||
}
|
||||
|
||||
async function fetchList(reset = false) {
|
||||
if (reset) page.value = 1
|
||||
const newList = mockList()
|
||||
if (reset) list.value = newList
|
||||
else list.value.push(...newList)
|
||||
hasMore.value = page.value < 4
|
||||
page.value++
|
||||
}
|
||||
|
||||
function switchTab(key: string) {
|
||||
if (currentTab.value === key) return
|
||||
currentTab.value = key
|
||||
fetchList(true)
|
||||
}
|
||||
|
||||
function loadMore() {
|
||||
if (!hasMore.value) return
|
||||
fetchList(false)
|
||||
}
|
||||
|
||||
function onRefresh() {
|
||||
isRefreshing.value = true
|
||||
fetchList(true).finally(() => isRefreshing.value = false)
|
||||
}
|
||||
|
||||
onMounted(() => fetchList(true))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ratings-page{min-height:100vh;background:#f5f5f5;display:flex;flex-direction:column;}
|
||||
|
||||
.summary-card{
|
||||
margin:20rpx 30rpx;
|
||||
background:#fff;border-radius:20rpx;padding:40rpx;
|
||||
display:flex;align-items:center;position:relative;
|
||||
}
|
||||
.back-box{
|
||||
position:absolute;left:30rpx;top:50%;transform:translateY(-50%);
|
||||
width:60rpx;height:60rpx;border-radius:50%;
|
||||
background:rgba(0,0,0,.05);display:flex;align-items:center;justify-content:center;
|
||||
}
|
||||
.back-box:active{background:rgba(0,0,0,.15);}
|
||||
.back-icon{font-size:40rpx;color:#333;}
|
||||
.summary-left{flex:1;display:flex;flex-direction:center;align-items:center;}
|
||||
.score{font-size:64rpx;font-weight:bold;color:#ff9500;margin-right:20rpx;}
|
||||
.stars .star-on{color:#ff9500;}
|
||||
.count{font-size:24rpx;color:#666;margin-left:20rpx;}
|
||||
.summary-right .rate-item{text-align:center;}
|
||||
.rate-label{font-size:24rpx;color:#666;}
|
||||
.rate-value{font-size:40rpx;font-weight:bold;color:#4caf50;}
|
||||
|
||||
.filter-bar{
|
||||
margin:0 30rpx 20rpx;
|
||||
background:#fff;border-radius:20rpx;padding:20rpx;
|
||||
display:flex;justify-content:space-around;
|
||||
}
|
||||
.filter-tab{
|
||||
padding:10rpx 30rpx;border-radius:30rpx;font-size:26rpx;
|
||||
background:#f0f0f0;color:#666;
|
||||
}
|
||||
.filter-tab.active{background:#74b9ff;color:#fff;}
|
||||
|
||||
.ratings-scroll{flex:1;}
|
||||
.rating-list{padding:0 30rpx 30rpx;}
|
||||
.rating-card{
|
||||
background:#fff;border-radius:16rpx;padding:30rpx;margin-bottom:20rpx;
|
||||
}
|
||||
.rating-header{display:flex;align-items:center;}
|
||||
.user-avatar{width:80rpx;height:80rpx;border-radius:50%;margin-right:20rpx;}
|
||||
.user-info{flex:1;}
|
||||
.user-name{font-size:28rpx;color:#333;}
|
||||
.rating-stars{font-size:24rpx;}
|
||||
.rating-stars .star-on{color:#ff9500;}
|
||||
.rating-stars .star-off{color:#ddd;}
|
||||
.rating-time{font-size:22rpx;color:#999;}
|
||||
.rating-comment{margin:20rpx 0;font-size:28rpx;color:#333;line-height:1.5;}
|
||||
.rating-comment.empty{color:#bbb;}
|
||||
.rating-tags{display:flex;flex-wrap:wrap;gap:10rpx;margin-top:15rpx;}
|
||||
.tag{background:#e8f4fd;color:#1976d2;font-size:22rpx;padding:4rpx 12rpx;border-radius:8rpx;}
|
||||
|
||||
.load-tip{text-align:center;font-size:24rpx;color:#999;padding:20rpx 0;}
|
||||
.no-data{text-align:center;padding:120rpx 0;}
|
||||
.no-data-icon{font-size:80rpx;}
|
||||
.no-data-text{font-size:28rpx;color:#999;margin-top:20rpx;}
|
||||
</style>
|
||||
494
pages/mall/delivery/settings.uvue
Normal file
494
pages/mall/delivery/settings.uvue
Normal file
@@ -0,0 +1,494 @@
|
||||
<template>
|
||||
<view class="settings-container">
|
||||
<!-- 顶部导航栏 -->
|
||||
<view class="header-bar">
|
||||
<view class="nav-left" @click="goBack">
|
||||
<text class="nav-icon">←</text>
|
||||
<text class="nav-title">设置</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 设置列表 -->
|
||||
<scroll-view class="settings-list" scroll-y="true">
|
||||
<!-- 基础设置 -->
|
||||
<view class="setting-section">
|
||||
<text class="section-title">基础设置</text>
|
||||
|
||||
<view class="setting-item" @click="toggleDarkMode">
|
||||
<text class="item-label">深色模式</text>
|
||||
<switch :checked="darkModeEnabled" color="#4CAF50" />
|
||||
</view>
|
||||
|
||||
<view class="setting-item" @click="toggleAutoUpdate">
|
||||
<text class="item-label">自动更新</text>
|
||||
<switch :checked="autoUpdateEnabled" color="#4CAF50" />
|
||||
</view>
|
||||
|
||||
<view class="setting-item" @click="openLanguagePicker">
|
||||
<text class="item-label">语言</text>
|
||||
<text class="item-value">{{ selectedLanguage }}</text>
|
||||
<text class="item-arrow">›</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 通知设置 -->
|
||||
<view class="setting-section">
|
||||
<text class="section-title">通知设置</text>
|
||||
|
||||
<view class="setting-item" @click="toggleOrderNotifications">
|
||||
<text class="item-label">订单通知</text>
|
||||
<switch :checked="orderNotificationsEnabled" color="#4CAF50" />
|
||||
</view>
|
||||
|
||||
<view class="setting-item" @click="toggleSystemNotifications">
|
||||
<text class="item-label">系统通知</text>
|
||||
<switch :checked="systemNotificationsEnabled" color="#4CAF50" />
|
||||
</view>
|
||||
|
||||
<view class="setting-item" @click="openNotificationTimeRange">
|
||||
<text class="item-label">通知时段</text>
|
||||
<text class="item-value">{{ notificationTimeRange }}</text>
|
||||
<text class="item-arrow">›</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 隐私与安全 -->
|
||||
<view class="setting-section">
|
||||
<text class="section-title">隐私与安全</text>
|
||||
|
||||
<view class="setting-item" @click="openPasswordChange">
|
||||
<text class="item-label">修改密码</text>
|
||||
<text class="item-arrow">›</text>
|
||||
</view>
|
||||
|
||||
<view class="setting-item" @click="toggleLocationSharing">
|
||||
<text class="item-label">位置共享</text>
|
||||
<switch :checked="locationSharingEnabled" color="#4CAF50" />
|
||||
</view>
|
||||
|
||||
<view class="setting-item" @click="clearCache">
|
||||
<text class="item-label">清除缓存</text>
|
||||
<text class="item-value">{{ cacheSize }}</text>
|
||||
<text class="item-arrow">›</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 关于与帮助 -->
|
||||
<view class="setting-section">
|
||||
<text class="section-title">关于与帮助</text>
|
||||
|
||||
<view class="setting-item" @click="openAboutPage">
|
||||
<text class="item-label">关于我们</text>
|
||||
<text class="item-value">{{ appVersion }}</text>
|
||||
<text class="item-arrow">›</text>
|
||||
</view>
|
||||
|
||||
<view class="setting-item" @click="openHelpCenter">
|
||||
<text class="item-label">帮助中心</text>
|
||||
<text class="item-arrow">›</text>
|
||||
</view>
|
||||
|
||||
<view class="setting-item" @click="openFeedback">
|
||||
<text class="item-label">意见反馈</text>
|
||||
<text class="item-arrow">›</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 登出按钮 -->
|
||||
<view class="logout-section">
|
||||
<button class="logout-btn" @click="logout">退出登录</button>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 语言选择弹窗 -->
|
||||
<view v-if="showLanguagePicker" class="picker-overlay" @click="closeLanguagePicker">
|
||||
<view class="picker-panel" @click.stop="noop">
|
||||
<text class="picker-title">选择语言</text>
|
||||
<scroll-view scroll-y="true" class="picker-options">
|
||||
<view v-for="(lang, index) in languageOptions" :key="index"
|
||||
class="picker-option"
|
||||
:class="{ 'picker-option-selected': selectedLanguage === lang.label }"
|
||||
@click="selectLanguage(lang)">
|
||||
<text>{{ lang.label }}</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<view class="picker-actions">
|
||||
<button size="mini" @click="closeLanguagePicker">取消</button>
|
||||
<button size="mini" type="primary" @click="confirmLanguageSelection">确定</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 通知时段选择弹窗 -->
|
||||
<view v-if="showTimeRangePicker" class="picker-overlay" @click="closeTimeRangePicker">
|
||||
<view class="picker-panel" @click.stop="noop">
|
||||
<text class="picker-title">选择通知时段</text>
|
||||
<view class="time-range-picker">
|
||||
<text class="range-label">从</text>
|
||||
<picker mode="time" :value="startTime" @change="onStartTimeChange">
|
||||
<view class="time-input">{{ startTime }}</view>
|
||||
</picker>
|
||||
<text class="range-label">到</text>
|
||||
<picker mode="time" :value="endTime" @change="onEndTimeChange">
|
||||
<view class="time-input">{{ endTime }}</view>
|
||||
</picker>
|
||||
</view>
|
||||
<view class="picker-actions">
|
||||
<button size="mini" @click="closeTimeRangePicker">取消</button>
|
||||
<button size="mini" type="primary" @click="confirmTimeRange">确定</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
type LanguageOption = {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
darkModeEnabled: false,
|
||||
autoUpdateEnabled: true,
|
||||
orderNotificationsEnabled: true,
|
||||
systemNotificationsEnabled: true,
|
||||
locationSharingEnabled: true,
|
||||
|
||||
selectedLanguage: '简体中文',
|
||||
languageOptions: [
|
||||
{ value: 'zh-CN', label: '简体中文' },
|
||||
{ value: 'en-US', label: 'English' },
|
||||
{ value: 'ja-JP', label: '日本語' },
|
||||
{ value: 'ko-KR', label: '한국어' }
|
||||
] as Array<LanguageOption>,
|
||||
showLanguagePicker: false,
|
||||
|
||||
notificationTimeRange: '全天接收',
|
||||
startTime: '08:00',
|
||||
endTime: '22:00',
|
||||
showTimeRangePicker: false,
|
||||
|
||||
cacheSize: '15.2 MB',
|
||||
appVersion: 'v1.2.3'
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
goBack() {
|
||||
uni.navigateBack()
|
||||
},
|
||||
|
||||
toggleDarkMode() {
|
||||
this.darkModeEnabled = !this.darkModeEnabled
|
||||
// TODO: 保存设置到本地存储或同步到服务器
|
||||
console.log('Dark mode toggled:', this.darkModeEnabled)
|
||||
},
|
||||
|
||||
toggleAutoUpdate() {
|
||||
this.autoUpdateEnabled = !this.autoUpdateEnabled
|
||||
console.log('Auto update toggled:', this.autoUpdateEnabled)
|
||||
},
|
||||
|
||||
toggleOrderNotifications() {
|
||||
this.orderNotificationsEnabled = !this.orderNotificationsEnabled
|
||||
console.log('Order notifications toggled:', this.orderNotificationsEnabled)
|
||||
},
|
||||
|
||||
toggleSystemNotifications() {
|
||||
this.systemNotificationsEnabled = !this.systemNotificationsEnabled
|
||||
console.log('System notifications toggled:', this.systemNotificationsEnabled)
|
||||
},
|
||||
|
||||
toggleLocationSharing() {
|
||||
this.locationSharingEnabled = !this.locationSharingEnabled
|
||||
console.log('Location sharing toggled:', this.locationSharingEnabled)
|
||||
},
|
||||
|
||||
openLanguagePicker() {
|
||||
this.showLanguagePicker = true
|
||||
},
|
||||
|
||||
closeLanguagePicker() {
|
||||
this.showLanguagePicker = false
|
||||
},
|
||||
|
||||
selectLanguage(lang: LanguageOption) {
|
||||
// 可以在这里高亮选中项,但确认还需点击确定
|
||||
this.selectedLanguage = lang.label
|
||||
},
|
||||
|
||||
confirmLanguageSelection() {
|
||||
// 实际应用中,这里会保存选中的语言设置
|
||||
console.log('Language confirmed:', this.selectedLanguage)
|
||||
this.closeLanguagePicker()
|
||||
// TODO: 调用API或本地存储更新语言设置
|
||||
},
|
||||
|
||||
openNotificationTimeRange() {
|
||||
this.showTimeRangePicker = true
|
||||
},
|
||||
|
||||
closeTimeRangePicker() {
|
||||
this.showTimeRangePicker = false
|
||||
},
|
||||
|
||||
onStartTimeChange(e: UniEvent<HTMLInputElement>) {
|
||||
this.startTime = e.detail.value
|
||||
},
|
||||
|
||||
onEndTimeChange(e: UniEvent<HTMLInputElement>) {
|
||||
this.endTime = e.detail.value
|
||||
},
|
||||
|
||||
confirmTimeRange() {
|
||||
this.notificationTimeRange = `${this.startTime} - ${this.endTime}`
|
||||
console.log('Time range confirmed:', this.notificationTimeRange)
|
||||
this.closeTimeRangePicker()
|
||||
},
|
||||
|
||||
openPasswordChange() {
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/delivery/change-password'
|
||||
})
|
||||
},
|
||||
|
||||
clearCache() {
|
||||
// TODO: 调用API或本地方法清除缓存
|
||||
uni.showModal({
|
||||
title: '清除缓存',
|
||||
content: `确定要清除 ${this.cacheSize} 的缓存吗?`,
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
console.log('Cache cleared')
|
||||
this.cacheSize = '0.0 MB'
|
||||
uni.showToast({ title: '缓存已清除', icon: 'success' })
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
openAboutPage() {
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/delivery/about'
|
||||
})
|
||||
},
|
||||
|
||||
openHelpCenter() {
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/delivery/help-center'
|
||||
})
|
||||
},
|
||||
|
||||
openFeedback() {
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/delivery/feedback'
|
||||
})
|
||||
},
|
||||
|
||||
logout() {
|
||||
uni.showModal({
|
||||
title: '退出登录',
|
||||
content: '确定要退出当前账号吗?',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
// TODO: 调用登出API
|
||||
console.log('Logging out...')
|
||||
uni.reLaunch({
|
||||
url: '/pages/user/login'
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
noop() {
|
||||
// 阻止事件冒泡的空函数
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.settings-container {
|
||||
background-color: #f8f9fa;
|
||||
min-height: 100vh;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
}
|
||||
|
||||
.header-bar {
|
||||
background-color: #fff;
|
||||
padding: 20rpx 30rpx;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1rpx solid #e9ecef;
|
||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.nav-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
font-size: 36rpx;
|
||||
margin-right: 10rpx;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.nav-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.settings-list {
|
||||
flex: 1;
|
||||
padding: 20rpx 0;
|
||||
}
|
||||
|
||||
.setting-section {
|
||||
background-color: #fff;
|
||||
margin: 20rpx;
|
||||
padding: 0 30rpx;
|
||||
border-radius: 16rpx;
|
||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 28rpx;
|
||||
color: #999;
|
||||
padding: 20rpx 0 10rpx 0;
|
||||
border-bottom: 1rpx solid #f0f0f0;
|
||||
}
|
||||
|
||||
.setting-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20rpx 0;
|
||||
border-bottom: 1rpx solid #f8f9fa;
|
||||
}
|
||||
|
||||
.setting-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.item-label {
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.item-value {
|
||||
font-size: 26rpx;
|
||||
color: #999;
|
||||
margin-right: 10rpx;
|
||||
}
|
||||
|
||||
.item-arrow {
|
||||
font-size: 28rpx;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.logout-section {
|
||||
margin: 40rpx 20rpx 20rpx 20rpx;
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
width: 100%;
|
||||
height: 80rpx;
|
||||
background-color: #f44336;
|
||||
color: #fff;
|
||||
border-radius: 12rpx;
|
||||
font-size: 32rpx;
|
||||
font-weight: bold;
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* 弹窗遮罩 */
|
||||
.picker-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.picker-panel {
|
||||
background-color: #fff;
|
||||
width: 80%;
|
||||
max-width: 600rpx;
|
||||
border-radius: 16rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.picker-title {
|
||||
display: block;
|
||||
text-align: center;
|
||||
padding: 20rpx;
|
||||
font-size: 32rpx;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
border-bottom: 1rpx solid #eee;
|
||||
}
|
||||
|
||||
.picker-options {
|
||||
max-height: 400rpx;
|
||||
}
|
||||
|
||||
.picker-option {
|
||||
padding: 20rpx 30rpx;
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
border-bottom: 1rpx solid #f8f9fa;
|
||||
}
|
||||
|
||||
.picker-option:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.picker-option-selected {
|
||||
background-color: #E8F5E8;
|
||||
color: #4CAF50;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.time-range-picker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
padding: 30rpx;
|
||||
}
|
||||
|
||||
.range-label {
|
||||
font-size: 28rpx;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.time-input {
|
||||
font-size: 28rpx;
|
||||
padding: 10rpx 20rpx;
|
||||
border: 1rpx solid #ddd;
|
||||
border-radius: 8rpx;
|
||||
background-color: #f9f9f9;
|
||||
margin: 0 10rpx;
|
||||
}
|
||||
|
||||
.picker-actions {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
padding: 20rpx;
|
||||
border-top: 1rpx solid #eee;
|
||||
}
|
||||
|
||||
</style>
|
||||
428
pages/mall/delivery/task-detail.uvue
Normal file
428
pages/mall/delivery/task-detail.uvue
Normal file
@@ -0,0 +1,428 @@
|
||||
<template>
|
||||
<view class="task-detail-container">
|
||||
<!-- 顶部导航栏 -->
|
||||
<view class="page-header">
|
||||
<!-- 左上角:返回上一页按钮 -->
|
||||
<view class="nav-left" @click="goBack">
|
||||
<text class="nav-icon">←</text>
|
||||
<text class="nav-title">返回</text>
|
||||
</view>
|
||||
|
||||
<!-- 页面标题居中 -->
|
||||
<text class="page-title">任务详情</text>
|
||||
|
||||
<!-- 右上角留空 -->
|
||||
<view class="nav-right"></view>
|
||||
</view>
|
||||
|
||||
<!-- 任务详情卡片 -->
|
||||
<view class="task-card">
|
||||
<view class="task-header">
|
||||
<text class="task-id">任务 #{{ task?.id?.slice(-6) || '未知' }}</text>
|
||||
<view class="task-status" :class="'status-' + (task?.status || 0)">
|
||||
{{ getTaskStatusText(task?.status) }}
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="task-info">
|
||||
<view class="info-item">
|
||||
<text class="info-label">取货地址:</text>
|
||||
<text class="info-value">{{ getAddressText(task?.pickup_address) }}</text>
|
||||
</view>
|
||||
<view class="info-item">
|
||||
<text class="info-label">送达地址:</text>
|
||||
<text class="info-value">{{ getAddressText(task?.delivery_address) }}</text>
|
||||
</view>
|
||||
<view class="info-item" v-if="task?.distance">
|
||||
<text class="info-label">距离:</text>
|
||||
<text class="info-value">{{ task.distance }}km</text>
|
||||
</view>
|
||||
<view class="info-item" v-if="task?.estimated_time">
|
||||
<text class="info-label">预计时间:</text>
|
||||
<text class="info-value">{{ task.estimated_time }}分钟</text>
|
||||
</view>
|
||||
<view class="info-item" v-if="task?.delivery_fee">
|
||||
<text class="info-label">配送费:</text>
|
||||
<text class="info-value">¥{{ task.delivery_fee }}</text>
|
||||
</view>
|
||||
<view class="info-item" v-if="task?.created_at">
|
||||
<text class="info-label">创建时间:</text>
|
||||
<text class="info-value">{{ formatTime(task.created_at) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="task-actions">
|
||||
<button class="action-btn" @click="contactCustomer">联系客户</button>
|
||||
<button class="action-btn primary" @click="completeTask">完成配送</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 任务备注 -->
|
||||
<view v-if="task?.remark" class="task-remark">
|
||||
<text class="remark-title">备注:</text>
|
||||
<text class="remark-text">{{ task.remark }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 联系客户按钮(重复显示,符合截图) -->
|
||||
<view class="contact-client">
|
||||
<button class="contact-btn" @click="contactCustomer">联系客户</button>
|
||||
</view>
|
||||
|
||||
<!-- 查看详情按钮(实际就是返回上一页) -->
|
||||
<view class="view-detail">
|
||||
<button class="detail-btn" @click="goBack">返回</button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
// 响应式数据
|
||||
const task = ref(null)
|
||||
|
||||
// ✅ 关键:在 setup 中无法直接访问 onLoad,所以改用以下方式
|
||||
// 方案:通过 getCurrentPages() 获取当前页面参数
|
||||
function getQueryParams() {
|
||||
const pages = getCurrentPages()
|
||||
const currentPage = pages[pages.length - 1]
|
||||
return currentPage.options || {}
|
||||
}
|
||||
|
||||
// 在 onMounted 中获取参数
|
||||
onMounted(() => {
|
||||
const query = getQueryParams()
|
||||
const taskId = query.id
|
||||
|
||||
if (!taskId) {
|
||||
uni.showToast({
|
||||
title: '任务ID不能为空',
|
||||
icon: 'none'
|
||||
})
|
||||
console.error('❌ 未获取到 taskId,query:', query)
|
||||
return
|
||||
}
|
||||
|
||||
loadTaskDetail(taskId)
|
||||
})
|
||||
|
||||
// 其他方法保持不变...
|
||||
function loadTaskDetail(taskId: string) {
|
||||
const mockTasks = [
|
||||
{
|
||||
id: 'task001',
|
||||
order_id: 'ORD20250122001',
|
||||
pickup_address: { address: '深圳市南山区科技园南区深圳湾科技生态园' },
|
||||
delivery_address: { address: '深圳市南山区蛇口海上世界广场' },
|
||||
distance: 8.2,
|
||||
estimated_time: 25,
|
||||
delivery_fee: 12.0,
|
||||
status: 3,
|
||||
remark: '联系电话: 13800138000',
|
||||
created_at: '2025-01-22 14:00:00'
|
||||
},
|
||||
// 更多模拟数据...
|
||||
]
|
||||
|
||||
const foundTask = mockTasks.find(t => t.id === taskId)
|
||||
if (foundTask) {
|
||||
task.value = foundTask
|
||||
console.log('✅ 加载任务成功:', task.value)
|
||||
} else {
|
||||
uni.showToast({ title: '未找到该任务', icon: 'none' })
|
||||
console.warn('⚠️ 任务ID不存在:', taskId)
|
||||
}
|
||||
}
|
||||
|
||||
// 其他方法...
|
||||
function getTaskStatusText(status: number): string {
|
||||
const statusMap = {
|
||||
1: '待接单',
|
||||
2: '已接单',
|
||||
3: '配送中',
|
||||
4: '已完成',
|
||||
5: '已取消'
|
||||
}
|
||||
return statusMap[status] || '未知'
|
||||
}
|
||||
|
||||
function getAddressText(address: UTSJSONObject): string {
|
||||
return address?.['address'] as string || '地址信息'
|
||||
}
|
||||
|
||||
function formatTime(dateStr: string): string {
|
||||
if (!dateStr) return '未知时间'
|
||||
const date = new Date(dateStr)
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - date.getTime()
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60))
|
||||
|
||||
if (hours < 1) return '刚刚'
|
||||
if (hours < 24) return `${hours}小时前`
|
||||
return `${Math.floor(hours / 24)}天前`
|
||||
}
|
||||
|
||||
function contactCustomer() {
|
||||
uni.showActionSheet({
|
||||
itemList: ['拨打电话', '发送短信'],
|
||||
success: (res) => {
|
||||
if (res.tapIndex === 0) {
|
||||
uni.makePhoneCall({ phoneNumber: '13800138000' })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function completeTask() {
|
||||
if (task.value?.status !== 3) {
|
||||
uni.showToast({ title: '当前任务不是“配送中”状态', icon: 'none' })
|
||||
return
|
||||
}
|
||||
task.value.status = 4
|
||||
uni.showToast({ title: '任务已完成', icon: 'success' })
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
uni.navigateBack()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.task-detail-container {
|
||||
background-color: #f5f5f5;
|
||||
min-height: 100vh;
|
||||
padding: 20rpx 30rpx;
|
||||
}
|
||||
|
||||
/* 导航栏样式 */
|
||||
.page-header {
|
||||
background-color: #fff;
|
||||
padding: 20rpx 30rpx;
|
||||
border-bottom: 1rpx solid #e9ecef;
|
||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
|
||||
position: relative;
|
||||
min-height: 80rpx;
|
||||
}
|
||||
|
||||
.nav-left {
|
||||
position: absolute;
|
||||
top: 20rpx;
|
||||
left: 30rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
padding: 10rpx;
|
||||
border-radius: 8rpx;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.nav-left:hover {
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
.nav-left:active {
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
font-size: 36rpx;
|
||||
color: #333;
|
||||
margin-bottom: 5rpx;
|
||||
}
|
||||
|
||||
.nav-title {
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
text-align: center;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
|
||||
.nav-right {
|
||||
width: 1rpx;
|
||||
height: 1rpx;
|
||||
}
|
||||
|
||||
/* 任务详情卡片 */
|
||||
.task-card {
|
||||
background-color: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 20rpx;
|
||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
|
||||
border-left: 6rpx solid #74b9ff;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
|
||||
.task-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.task-id {
|
||||
font-size: 28rpx;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.task-status {
|
||||
font-size: 22rpx;
|
||||
padding: 6rpx 12rpx;
|
||||
border-radius: 20rpx;
|
||||
font-weight: 500;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-1 {
|
||||
background: #ffeb3b;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.status-3 {
|
||||
background: #2196f3;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-4 {
|
||||
background: #4caf50;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.task-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10rpx;
|
||||
padding: 15rpx 0;
|
||||
border-bottom: 1rpx solid #f0f0f0;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 24rpx;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-weight: 500;
|
||||
min-width: 100rpx;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
flex: 1;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.task-actions {
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
height: 80rpx;
|
||||
border-radius: 8rpx;
|
||||
font-size: 28rpx;
|
||||
border: none;
|
||||
font-weight: bold;
|
||||
padding: 0 10rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background-color: #45a049; /* 按钮悬停效果 */
|
||||
}
|
||||
|
||||
.action-btn:active {
|
||||
background-color: #3d8b40; /* 按钮点击效果 */
|
||||
}
|
||||
|
||||
.action-btn.primary {
|
||||
background-color: #4CAF50;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* 任务备注 */
|
||||
.task-remark {
|
||||
background-color: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 20rpx;
|
||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
|
||||
.remark-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
margin-bottom: 10rpx;
|
||||
}
|
||||
|
||||
.remark-text {
|
||||
font-size: 24rpx;
|
||||
color: #666;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* 联系客户按钮 */
|
||||
.contact-client {
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
|
||||
.contact-btn {
|
||||
width: 100%;
|
||||
height: 80rpx;
|
||||
background-color: #f0f0f0;
|
||||
color: #333;
|
||||
border: none;
|
||||
border-radius: 8rpx;
|
||||
font-size: 28rpx;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.contact-btn:hover {
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
|
||||
.contact-btn:active {
|
||||
background-color: #d0d0d0;
|
||||
}
|
||||
|
||||
/* 查看详情按钮 */
|
||||
.view-detail {
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
|
||||
.detail-btn {
|
||||
width: 100%;
|
||||
height: 80rpx;
|
||||
background-color: #4CAF50;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8rpx;
|
||||
font-size: 28rpx;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.detail-btn:hover {
|
||||
background-color: #45a049;
|
||||
}
|
||||
|
||||
.detail-btn:active {
|
||||
background-color: #3d8b40;
|
||||
}
|
||||
</style>
|
||||
433
pages/mall/delivery/tasks.uvue
Normal file
433
pages/mall/delivery/tasks.uvue
Normal file
@@ -0,0 +1,433 @@
|
||||
<template>
|
||||
<view class="tasks-container">
|
||||
<!-- 顶部导航栏 -->
|
||||
<view class="page-header">
|
||||
<!-- 左上角:返回主页按钮 -->
|
||||
<view class="nav-left" @click="goBackToHome">
|
||||
<text class="nav-icon">←</text>
|
||||
<text class="nav-title">返回</text>
|
||||
</view>
|
||||
|
||||
<!-- 页面标题居中 -->
|
||||
<text class="page-title">{{ getTitle() }}</text>
|
||||
|
||||
<!-- 右上角留空 -->
|
||||
<view class="nav-right"></view>
|
||||
</view>
|
||||
|
||||
<!-- 任务列表 -->
|
||||
<view class="tasks-list">
|
||||
<view v-if="taskList.length > 0" class="task-item" v-for="task in taskList" :key="task.id" @click="viewTaskDetail(task.id)">
|
||||
<view class="task-header">
|
||||
<text class="task-id">订单: {{ task.order_id.slice(-6) }}</text>
|
||||
<text class="task-status" :class="'status-' + task.status">{{ getTaskStatusText(task.status) }}</text>
|
||||
</view>
|
||||
<view class="task-info">
|
||||
<text class="info-item">取货: {{ getAddressText(task.pickup_address) }}</text>
|
||||
<text class="info-item">距离: {{ task.distance }}km</text>
|
||||
</view>
|
||||
<view class="task-actions">
|
||||
<button class="action-btn" @click.stop="contactCustomer(task)">联系客户</button>
|
||||
<button class="action-btn primary" @click.stop="viewTaskDetail(task.id)">查看详情</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 无数据时显示 -->
|
||||
<view v-else class="no-data">
|
||||
<text class="no-data-text">暂无{{ getTitle() }}任务</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 加载更多按钮 -->
|
||||
<view v-if="hasMore" class="load-more">
|
||||
<button @click="loadMoreTasks" class="load-more-btn">加载更多</button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import type { DeliveryTaskType } from '@/types/mall-types'
|
||||
|
||||
// 响应式数据
|
||||
const taskList = ref([] as Array<DeliveryTaskType>)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const hasMore = ref(true)
|
||||
const currentType = ref('all') // 默认全部任务
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
// 获取 URL 参数
|
||||
const query = uni.getLaunchOptionsSync().query
|
||||
currentType.value = query.type || 'all'
|
||||
|
||||
loadTasks()
|
||||
})
|
||||
|
||||
// 方法
|
||||
function loadTasks() {
|
||||
// 模拟加载任务数据
|
||||
const mockTasks = [
|
||||
{
|
||||
id: 'task001',
|
||||
order_id: 'ORD20250122001',
|
||||
driver_id: 'driver001',
|
||||
pickup_address: { address: '深圳市南山区科技园南区深圳湾科技生态园' },
|
||||
delivery_address: { address: '深圳市南山区蛇口海上世界广场' },
|
||||
distance: 8.2,
|
||||
estimated_time: 25,
|
||||
delivery_fee: 12.0,
|
||||
status: 3, // 配送中
|
||||
pickup_time: '2025-01-22 14:30:00',
|
||||
delivered_time: null,
|
||||
delivery_code: 'DEL001',
|
||||
remark: '联系电话: 13800138000',
|
||||
created_at: '2025-01-22 14:00:00',
|
||||
updated_at: '2025-01-22 14:35:00'
|
||||
},
|
||||
{
|
||||
id: 'task002',
|
||||
order_id: 'ORD20250122002',
|
||||
driver_id: 'driver001',
|
||||
pickup_address: { address: '深圳市南山区海岸城' },
|
||||
delivery_address: { address: '深圳市南山区欢乐颂广场' },
|
||||
distance: 3.5,
|
||||
estimated_time: 12,
|
||||
delivery_fee: 8.0,
|
||||
status: 4, // 已完成
|
||||
pickup_time: '2025-01-22 13:00:00',
|
||||
delivered_time: '2025-01-22 13:15:00',
|
||||
delivery_code: 'DEL002',
|
||||
remark: '',
|
||||
created_at: '2025-01-22 12:45:00',
|
||||
updated_at: '2025-01-22 13:15:00'
|
||||
},
|
||||
{
|
||||
id: 'task003',
|
||||
order_id: 'ORD20250122003',
|
||||
driver_id: 'driver001',
|
||||
pickup_address: { address: '深圳市南山区世界之窗' },
|
||||
delivery_address: { address: '深圳市南山区欢乐谷' },
|
||||
distance: 2.1,
|
||||
estimated_time: 8,
|
||||
delivery_fee: 6.5,
|
||||
status: 4, // 已完成
|
||||
pickup_time: '2025-01-22 11:30:00',
|
||||
delivered_time: '2025-01-22 11:40:00',
|
||||
delivery_code: 'DEL003',
|
||||
remark: '',
|
||||
created_at: '2025-01-22 11:15:00',
|
||||
updated_at: '2025-01-22 11:40:00'
|
||||
},
|
||||
{
|
||||
id: 'task004',
|
||||
order_id: 'ORD20250122004',
|
||||
driver_id: 'driver001',
|
||||
pickup_address: { address: '深圳市南山区万象天地' },
|
||||
delivery_address: { address: '深圳市南山区深圳湾体育中心' },
|
||||
distance: 5.8,
|
||||
estimated_time: 18,
|
||||
delivery_fee: 9.5,
|
||||
status: 4, // 已完成
|
||||
pickup_time: '2025-01-22 10:00:00',
|
||||
delivered_time: '2025-01-22 10:20:00',
|
||||
delivery_code: 'DEL004',
|
||||
remark: '',
|
||||
created_at: '2025-01-22 09:45:00',
|
||||
updated_at: '2025-01-22 10:20:00'
|
||||
},
|
||||
{
|
||||
id: 'task005',
|
||||
order_id: 'ORD20250122005',
|
||||
driver_id: 'driver001',
|
||||
pickup_address: { address: '深圳市南山区科技园' },
|
||||
delivery_address: { address: '深圳市南山区腾讯大厦' },
|
||||
distance: 1.5,
|
||||
estimated_time: 5,
|
||||
delivery_fee: 5.0,
|
||||
status: 1, // 待接单
|
||||
pickup_time: null,
|
||||
delivered_time: null,
|
||||
delivery_code: 'DEL005',
|
||||
remark: '',
|
||||
created_at: '2025-01-22 09:00:00',
|
||||
updated_at: '2025-01-22 09:00:00'
|
||||
}
|
||||
]
|
||||
|
||||
// 根据 type 筛选数据
|
||||
let filteredTasks = mockTasks
|
||||
|
||||
if (currentType.value === 'pending') {
|
||||
filteredTasks = mockTasks.filter(task => task.status === 1)
|
||||
} else if (currentType.value === 'ongoing') {
|
||||
filteredTasks = mockTasks.filter(task => task.status === 3)
|
||||
} else if (currentType.value === 'completed') {
|
||||
filteredTasks = mockTasks.filter(task => task.status === 4)
|
||||
}
|
||||
|
||||
// 分页
|
||||
const start = (currentPage.value - 1) * pageSize.value
|
||||
const end = start + pageSize.value
|
||||
const newTasks = filteredTasks.slice(start, end)
|
||||
|
||||
taskList.value.push(...newTasks)
|
||||
hasMore.value = newTasks.length === pageSize.value
|
||||
}
|
||||
|
||||
function loadMoreTasks() {
|
||||
currentPage.value++
|
||||
loadTasks()
|
||||
}
|
||||
|
||||
function getTitle(): string {
|
||||
switch (currentType.value) {
|
||||
case 'all':
|
||||
return '全部任务'
|
||||
case 'pending':
|
||||
return '待接单'
|
||||
case 'ongoing':
|
||||
return '配送中'
|
||||
case 'completed':
|
||||
return '已完成'
|
||||
default:
|
||||
return '任务列表'
|
||||
}
|
||||
}
|
||||
|
||||
function getTaskStatusText(status: number): string {
|
||||
const statusMap = {
|
||||
1: '待接单',
|
||||
2: '已接单',
|
||||
3: '配送中',
|
||||
4: '已完成',
|
||||
5: '已取消'
|
||||
}
|
||||
return statusMap[status] || '未知'
|
||||
}
|
||||
|
||||
function getAddressText(address: UTSJSONObject): string {
|
||||
return address['address'] as string || '地址信息'
|
||||
}
|
||||
|
||||
function contactCustomer(task: DeliveryTaskType) {
|
||||
uni.showActionSheet({
|
||||
itemList: ['拨打电话', '发送短信'],
|
||||
success: (res) => {
|
||||
if (res.tapIndex === 0) {
|
||||
uni.makePhoneCall({
|
||||
phoneNumber: '13800138000'
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function viewTaskDetail(taskId: string) {
|
||||
uni.navigateTo({
|
||||
url: `/pages/mall/delivery/task-detail?id=${taskId}`
|
||||
})
|
||||
}
|
||||
|
||||
// 返回上一页
|
||||
function goBackToHome() {
|
||||
uni.reLaunch({
|
||||
url: '/pages/mall/delivery/profile'
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tasks-container {
|
||||
background-color: #f5f5f5;
|
||||
min-height: 100vh;
|
||||
padding: 20rpx 30rpx;
|
||||
}
|
||||
|
||||
/* 导航栏样式 */
|
||||
.page-header {
|
||||
background-color: #fff;
|
||||
padding: 20rpx 30rpx;
|
||||
border-bottom: 1rpx solid #e9ecef;
|
||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
|
||||
position: relative;
|
||||
min-height: 80rpx;
|
||||
}
|
||||
|
||||
.nav-left {
|
||||
position: absolute;
|
||||
top: 20rpx;
|
||||
left: 30rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
padding: 10rpx;
|
||||
border-radius: 8rpx;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.nav-left:hover {
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
.nav-left:active {
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
font-size: 36rpx;
|
||||
color: #333;
|
||||
margin-bottom: 5rpx;
|
||||
}
|
||||
|
||||
.nav-title {
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
text-align: center;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
|
||||
.nav-right {
|
||||
width: 1rpx;
|
||||
height: 1rpx;
|
||||
}
|
||||
|
||||
/* 任务列表 */
|
||||
.tasks-list {
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
|
||||
.task-item {
|
||||
background-color: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 20rpx;
|
||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
|
||||
border-left: 6rpx solid #74b9ff;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.task-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 15rpx;
|
||||
}
|
||||
|
||||
.task-id {
|
||||
font-size: 28rpx;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.task-status {
|
||||
font-size: 22rpx;
|
||||
padding: 6rpx 12rpx;
|
||||
border-radius: 20rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-1 {
|
||||
background: #ffeb3b;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.status-3 {
|
||||
background: #2196f3;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-4 {
|
||||
background: #4caf50;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.task-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 10rpx;
|
||||
font-size: 24rpx;
|
||||
color: #666;
|
||||
margin-bottom: 15rpx;
|
||||
padding-bottom: 10rpx;
|
||||
border-bottom: 1rpx solid #f0f0f0;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
flex: 1 1 45%;
|
||||
min-width: 120rpx;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.task-actions {
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
height: 80rpx;
|
||||
border-radius: 8rpx;
|
||||
font-size: 28rpx;
|
||||
border: none;
|
||||
font-weight: bold;
|
||||
padding: 0 10rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background-color: #45a049; /* 按钮悬停效果 */
|
||||
}
|
||||
|
||||
.action-btn:active {
|
||||
background-color: #3d8b40; /* 按钮点击效果 */
|
||||
}
|
||||
|
||||
.no-data {
|
||||
text-align: center;
|
||||
padding: 80rpx 30rpx;
|
||||
border-radius: 16rpx;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.no-data-text {
|
||||
font-size: 32rpx;
|
||||
color: #999;
|
||||
margin-bottom: 15rpx;
|
||||
}
|
||||
|
||||
.load-more {
|
||||
text-align: center;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
|
||||
.load-more-btn {
|
||||
background-color: #4CAF50;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8rpx;
|
||||
padding: 20rpx 40rpx;
|
||||
font-size: 28rpx;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.load-more-btn:hover {
|
||||
background-color: #45a049;
|
||||
}
|
||||
|
||||
.load-more-btn:active {
|
||||
background-color: #3d8b40;
|
||||
}
|
||||
</style>
|
||||
320
pages/mall/delivery/vehicle-add.uvue
Normal file
320
pages/mall/delivery/vehicle-add.uvue
Normal file
@@ -0,0 +1,320 @@
|
||||
<template>
|
||||
<view class="add-vehicle-container">
|
||||
<!-- 顶部导航栏 -->
|
||||
<view class="header-bar">
|
||||
<!-- 左侧:返回按钮 -->
|
||||
<view class="nav-left" @click="goBack">
|
||||
<text class="nav-icon">←</text>
|
||||
</view>
|
||||
|
||||
<!-- 中部:页面标题 -->
|
||||
<text class="page-title">添加新车辆</text>
|
||||
|
||||
<!-- 右侧:留空 -->
|
||||
<view class="nav-right"></view>
|
||||
</view>
|
||||
|
||||
<!-- 表单区域 -->
|
||||
<scroll-view class="form-wrapper" scroll-y="true">
|
||||
<!-- 车牌号 -->
|
||||
<view class="form-item">
|
||||
<text class="item-label">车牌号</text>
|
||||
<input class="item-input" v-model="formData.plate_number" placeholder="请输入车牌号" />
|
||||
</view>
|
||||
|
||||
<!-- 车辆类型 -->
|
||||
<view class="form-item">
|
||||
<text class="item-label">车辆类型</text>
|
||||
<picker :value="vehicleTypeIndex" :range="vehicleTypes" @change="onVehicleTypeChange">
|
||||
<view class="picker-view">{{ formData.vehicle_type ? vehicleTypes[vehicleTypeIndex] : '请选择车辆类型' }}</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<!-- 车辆品牌 -->
|
||||
<view class="form-item">
|
||||
<text class="item-label">车辆品牌</text>
|
||||
<input class="item-input" v-model="formData.brand" placeholder="请输入车辆品牌" />
|
||||
</view>
|
||||
|
||||
<!-- 车辆颜色 -->
|
||||
<view class="form-item">
|
||||
<text class="item-label">车辆颜色</text>
|
||||
<input class="item-input" v-model="formData.color" placeholder="请输入车辆颜色" />
|
||||
</view>
|
||||
|
||||
<!-- 车辆照片 -->
|
||||
<view class="form-item">
|
||||
<text class="item-label">车辆照片</text>
|
||||
<view class="avatar-upload" @click="chooseVehicleImage">
|
||||
<image :src="formData.vehicle_image || '/static/default-vehicle.png'" class="avatar-image" />
|
||||
<text class="upload-text">点击上传车辆照片</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 备注 -->
|
||||
<view class="form-item">
|
||||
<text class="item-label">备注</text>
|
||||
<textarea class="item-textarea" v-model="formData.remark" placeholder="请输入备注信息" />
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 底部操作按钮 -->
|
||||
<view class="bottom-actions">
|
||||
<button class="action-btn cancel" @click="cancel">取消</button>
|
||||
<button class="action-btn save" @click="saveVehicle">保存</button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
// 表单数据
|
||||
formData: {
|
||||
plate_number: '',
|
||||
vehicle_type: 0,
|
||||
brand: '',
|
||||
color: '',
|
||||
vehicle_image: '',
|
||||
remark: ''
|
||||
},
|
||||
// 车辆类型选项
|
||||
vehicleTypes: ['电动车', '摩托车', '面包车', '小型货车'],
|
||||
vehicleTypeIndex: 0
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
// 返回上一页
|
||||
goBack() {
|
||||
uni.navigateBack()
|
||||
},
|
||||
|
||||
// 选择车辆类型
|
||||
onVehicleTypeChange(e: UniEvent<HTMLInputElement>) {
|
||||
const index = parseInt(e.detail.value)
|
||||
this.vehicleTypeIndex = index
|
||||
this.formData.vehicle_type = index + 1 // 假设后端从1开始
|
||||
},
|
||||
|
||||
// 选择车辆照片
|
||||
chooseVehicleImage() {
|
||||
uni.chooseImage({
|
||||
count: 1,
|
||||
sizeType: ['compressed'],
|
||||
sourceType: ['album', 'camera'],
|
||||
success: (res) => {
|
||||
this.formData.vehicle_image = res.tempFilePaths[0]
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
// 取消
|
||||
cancel() {
|
||||
uni.navigateBack()
|
||||
},
|
||||
|
||||
// 保存
|
||||
saveVehicle() {
|
||||
// 验证输入
|
||||
if (!this.formData.plate_number.trim()) {
|
||||
uni.showToast({
|
||||
title: '请输入车牌号',
|
||||
icon: 'none'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 模拟生成新车辆数据
|
||||
const newVehicle = {
|
||||
id: 'vehicle_' + Date.now(), // 使用时间戳生成唯一ID
|
||||
plate_number: this.formData.plate_number,
|
||||
vehicle_type: this.formData.vehicle_type,
|
||||
vehicle_type_name: this.vehicleTypes[this.vehicleTypeIndex],
|
||||
status: 1, // 默认状态为正常
|
||||
driver_id: 'driver001', // 假设固定司机ID
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
brand: this.formData.brand,
|
||||
color: this.formData.color,
|
||||
image: this.formData.vehicle_image,
|
||||
remark: this.formData.remark
|
||||
}
|
||||
|
||||
// 将新车辆数据保存到本地存储,以便上一页可以获取
|
||||
uni.setStorageSync('new_vehicle_for_list', newVehicle)
|
||||
|
||||
// 模拟保存过程
|
||||
uni.showLoading({
|
||||
title: '保存中...'
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
uni.hideLoading()
|
||||
uni.showToast({
|
||||
title: '保存成功',
|
||||
icon: 'success'
|
||||
})
|
||||
// 保存成功后返回上一页
|
||||
uni.navigateBack()
|
||||
}, 1000)
|
||||
|
||||
// 实际项目中应调用 API 保存数据
|
||||
console.log('保存的车辆数据:', newVehicle)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.add-vehicle-container {
|
||||
background-color: #f8f9fa;
|
||||
min-height: 100vh;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
}
|
||||
|
||||
.header-bar {
|
||||
background-color: #fff;
|
||||
padding: 20rpx 30rpx;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1rpx solid #e9ecef;
|
||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.nav-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
padding: 10rpx;
|
||||
border-radius: 8rpx;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.nav-left:hover {
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
.nav-left:active {
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
font-size: 36rpx;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
text-align: center;
|
||||
flex: 1; /* 让标题占据中间剩余空间 */
|
||||
}
|
||||
|
||||
.nav-right {
|
||||
width: 60rpx; /* 与左侧箭头区域宽度一致 */
|
||||
height: 1rpx;
|
||||
}
|
||||
|
||||
.form-wrapper {
|
||||
flex: 1;
|
||||
padding: 20rpx 0;
|
||||
}
|
||||
|
||||
.form-item {
|
||||
background-color: #fff;
|
||||
margin: 20rpx;
|
||||
padding: 20rpx 30rpx;
|
||||
border-radius: 16rpx;
|
||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.item-label {
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
margin-bottom: 10rpx;
|
||||
}
|
||||
|
||||
.item-input {
|
||||
width: 100%;
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
padding: 10rpx 0;
|
||||
border: none;
|
||||
outline: none;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.picker-view {
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
text-align: right;
|
||||
padding: 10rpx 0;
|
||||
}
|
||||
|
||||
.avatar-upload {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.avatar-image {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
border-radius: 60rpx;
|
||||
border: 4rpx solid #ddd;
|
||||
margin-bottom: 10rpx;
|
||||
}
|
||||
|
||||
.upload-text {
|
||||
font-size: 24rpx;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.item-textarea {
|
||||
width: 100%;
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
padding: 10rpx 0;
|
||||
border: none;
|
||||
outline: none;
|
||||
text-align: right;
|
||||
height: 100rpx;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.bottom-actions {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background-color: #fff;
|
||||
padding: 25rpx 30rpx;
|
||||
box-shadow: 0 -2rpx 10rpx rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
gap: 15rpx;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
height: 80rpx;
|
||||
border-radius: 35rpx;
|
||||
font-size: 26rpx;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.cancel {
|
||||
background-color: #f0f0f0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.save {
|
||||
background-color: #4CAF50;
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
379
pages/mall/delivery/vehicle-edit.uvue
Normal file
379
pages/mall/delivery/vehicle-edit.uvue
Normal file
@@ -0,0 +1,379 @@
|
||||
<template>
|
||||
<view class="edit-vehicle-container">
|
||||
<!-- 顶部导航栏 -->
|
||||
<view class="header-bar">
|
||||
<!-- 左侧:返回按钮 -->
|
||||
<view class="nav-left" @click="goBack">
|
||||
<text class="nav-icon">←</text>
|
||||
</view>
|
||||
|
||||
<!-- 中部:页面标题 -->
|
||||
<text class="page-title">编辑车辆</text>
|
||||
|
||||
<!-- 右侧:留空 -->
|
||||
<view class="nav-right"></view>
|
||||
</view>
|
||||
|
||||
<!-- 编辑表单区 -->
|
||||
<scroll-view class="form-wrapper" scroll-y="true">
|
||||
<!-- 车牌号 -->
|
||||
<view class="form-item">
|
||||
<text class="item-label">车牌号</text>
|
||||
<input class="item-input" v-model="formData.plate_number" placeholder="请输入车牌号" />
|
||||
</view>
|
||||
|
||||
<!-- 车辆类型 -->
|
||||
<view class="form-item">
|
||||
<text class="item-label">车辆类型</text>
|
||||
<picker :value="vehicleTypeIndex" :range="vehicleTypes" @change="onVehicleTypeChange">
|
||||
<view class="picker-view">{{ formData.vehicle_type ? vehicleTypes[vehicleTypeIndex] : '请选择车辆类型' }}</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<!-- 车辆品牌 -->
|
||||
<view class="form-item">
|
||||
<text class="item-label">车辆品牌</text>
|
||||
<input class="item-input" v-model="formData.brand" placeholder="请输入车辆品牌" />
|
||||
</view>
|
||||
|
||||
<!-- 车辆颜色 -->
|
||||
<view class="form-item">
|
||||
<text class="item-label">车辆颜色</text>
|
||||
<input class="item-input" v-model="formData.color" placeholder="请输入车辆颜色" />
|
||||
</view>
|
||||
|
||||
<!-- 车辆照片 -->
|
||||
<view class="form-item">
|
||||
<text class="item-label">车辆照片</text>
|
||||
<view class="avatar-upload" @click="chooseVehicleImage">
|
||||
<image :src="formData.vehicle_image || '/static/default-vehicle.png'" class="avatar-image" />
|
||||
<text class="upload-text">点击上传车辆照片</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 备注 -->
|
||||
<view class="form-item">
|
||||
<text class="item-label">备注</text>
|
||||
<textarea class="item-textarea" v-model="formData.remark" placeholder="请输入备注信息" />
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 底部操作按钮 -->
|
||||
<view class="bottom-actions">
|
||||
<button class="action-btn cancel" @click="cancel">取消</button>
|
||||
<button class="action-btn save" @click="saveVehicle">保存</button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
// 当前车辆ID
|
||||
vehicleId: '',
|
||||
// 表单数据
|
||||
formData: {
|
||||
plate_number: '',
|
||||
vehicle_type: 0,
|
||||
brand: '',
|
||||
color: '',
|
||||
vehicle_image: '',
|
||||
remark: ''
|
||||
},
|
||||
// 车辆类型选项
|
||||
vehicleTypes: ['电动车', '摩托车', '面包车', '小型货车'],
|
||||
vehicleTypeIndex: 0
|
||||
}
|
||||
},
|
||||
|
||||
onLoad(options: any) {
|
||||
this.vehicleId = options.id as string
|
||||
if (this.vehicleId) {
|
||||
this.loadVehicleData()
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
// 返回上一页
|
||||
goBack() {
|
||||
uni.navigateBack()
|
||||
},
|
||||
|
||||
// 加载车辆数据
|
||||
loadVehicleData() {
|
||||
// 模拟从API获取数据
|
||||
// 实际项目中应调用API
|
||||
const mockVehicles = [
|
||||
{
|
||||
id: '1',
|
||||
plate_number: '京A12345',
|
||||
vehicle_type: 1,
|
||||
vehicle_type_name: '电动车',
|
||||
status: 1, // 1: 正常, 2: 维修中, 3: 停用
|
||||
driver_id: 'driver001',
|
||||
created_at: '2024-01-01',
|
||||
updated_at: '2024-12-01',
|
||||
brand: '雅迪',
|
||||
color: '蓝色',
|
||||
image: '/static/vehicle1.jpg',
|
||||
remark: '日常使用'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
plate_number: '沪B67890',
|
||||
vehicle_type: 2,
|
||||
vehicle_type_name: '摩托车',
|
||||
status: 2, // 维修中
|
||||
driver_id: 'driver001',
|
||||
created_at: '2024-01-02',
|
||||
updated_at: '2024-12-02',
|
||||
brand: '本田',
|
||||
color: '黑色',
|
||||
image: '/static/vehicle2.jpg',
|
||||
remark: '维修中'
|
||||
}
|
||||
]
|
||||
|
||||
const found = mockVehicles.find(v => v.id === this.vehicleId)
|
||||
if (found) {
|
||||
this.formData = {
|
||||
plate_number: found.plate_number,
|
||||
vehicle_type: found.vehicle_type,
|
||||
brand: found.brand,
|
||||
color: found.color,
|
||||
vehicle_image: found.image,
|
||||
remark: found.remark
|
||||
}
|
||||
// 设置车辆类型索引
|
||||
this.vehicleTypeIndex = this.vehicleTypes.indexOf(found.vehicle_type_name)
|
||||
}
|
||||
},
|
||||
|
||||
// 选择车辆类型
|
||||
onVehicleTypeChange(e: UniEvent<HTMLInputElement>) {
|
||||
const index = parseInt(e.detail.value)
|
||||
this.vehicleTypeIndex = index
|
||||
this.formData.vehicle_type = index + 1 // 假设后端从1开始
|
||||
},
|
||||
|
||||
// 选择车辆照片
|
||||
chooseVehicleImage() {
|
||||
uni.chooseImage({
|
||||
count: 1,
|
||||
sizeType: ['compressed'],
|
||||
sourceType: ['album', 'camera'],
|
||||
success: (res) => {
|
||||
this.formData.vehicle_image = res.tempFilePaths[0]
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
// 取消
|
||||
cancel() {
|
||||
uni.navigateBack()
|
||||
},
|
||||
|
||||
// 保存
|
||||
saveVehicle() {
|
||||
// 验证输入
|
||||
if (!this.formData.plate_number.trim()) {
|
||||
uni.showToast({
|
||||
title: '请输入车牌号',
|
||||
icon: 'none'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 模拟更新车辆数据
|
||||
const updatedVehicle = {
|
||||
id: this.vehicleId,
|
||||
plate_number: this.formData.plate_number,
|
||||
vehicle_type: this.formData.vehicle_type,
|
||||
vehicle_type_name: this.vehicleTypes[this.vehicleTypeIndex],
|
||||
brand: this.formData.brand,
|
||||
color: this.formData.color,
|
||||
image: this.formData.vehicle_image,
|
||||
remark: this.formData.remark,
|
||||
updated_at: new Date().toISOString()
|
||||
}
|
||||
|
||||
// 将更新后的车辆数据保存到本地存储,以便上一页可以获取
|
||||
uni.setStorageSync('updated_vehicle_for_list', updatedVehicle)
|
||||
|
||||
// 模拟保存过程
|
||||
uni.showLoading({
|
||||
title: '保存中...'
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
uni.hideLoading()
|
||||
uni.showToast({
|
||||
title: '保存成功',
|
||||
icon: 'success'
|
||||
})
|
||||
// 保存成功后返回上一页
|
||||
uni.navigateBack()
|
||||
}, 1000)
|
||||
|
||||
// 实际项目中应调用 API 保存数据
|
||||
console.log('更新的车辆数据:', updatedVehicle)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.edit-vehicle-container {
|
||||
background-color: #f8f9fa;
|
||||
min-height: 100vh;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
}
|
||||
|
||||
.header-bar {
|
||||
background-color: #fff;
|
||||
padding: 20rpx 30rpx;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1rpx solid #e9ecef;
|
||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.nav-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
padding: 10rpx;
|
||||
border-radius: 8rpx;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.nav-left:hover {
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
.nav-left:active {
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
font-size: 36rpx;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
text-align: center;
|
||||
flex: 1; /* 让标题占据中间剩余空间 */
|
||||
}
|
||||
|
||||
.nav-right {
|
||||
width: 60rpx; /* 与左侧箭头区域宽度一致 */
|
||||
height: 1rpx;
|
||||
}
|
||||
|
||||
.form-wrapper {
|
||||
flex: 1;
|
||||
padding: 20rpx 0;
|
||||
}
|
||||
|
||||
.form-item {
|
||||
background-color: #fff;
|
||||
margin: 20rpx;
|
||||
padding: 20rpx 30rpx;
|
||||
border-radius: 16rpx;
|
||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.item-label {
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
margin-bottom: 10rpx;
|
||||
}
|
||||
|
||||
.item-input {
|
||||
width: 100%;
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
padding: 10rpx 0;
|
||||
border: none;
|
||||
outline: none;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.picker-view {
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
text-align: right;
|
||||
padding: 10rpx 0;
|
||||
}
|
||||
|
||||
.avatar-upload {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.avatar-image {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
border-radius: 60rpx;
|
||||
border: 4rpx solid #ddd;
|
||||
margin-bottom: 10rpx;
|
||||
}
|
||||
|
||||
.upload-text {
|
||||
font-size: 24rpx;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.item-textarea {
|
||||
width: 100%;
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
padding: 10rpx 0;
|
||||
border: none;
|
||||
outline: none;
|
||||
text-align: right;
|
||||
height: 100rpx;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.bottom-actions {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background-color: #fff;
|
||||
padding: 25rpx 30rpx;
|
||||
box-shadow: 0 -2rpx 10rpx rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
gap: 15rpx;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
height: 80rpx;
|
||||
border-radius: 35rpx;
|
||||
font-size: 26rpx;
|
||||
border: none;
|
||||
font-weight: 500;
|
||||
padding: 0 10rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.cancel {
|
||||
background-color: #f0f0f0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.save {
|
||||
background-color: #4CAF50;
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
338
pages/mall/delivery/vehicle.uvue
Normal file
338
pages/mall/delivery/vehicle.uvue
Normal file
@@ -0,0 +1,338 @@
|
||||
<!-- 配送端 - 车辆管理 -->
|
||||
<template>
|
||||
<view class="vehicle-container">
|
||||
<!-- 头部标题 -->
|
||||
<view class="page-header">
|
||||
<!-- 返回按钮:左上角垂直居中 -->
|
||||
<view class="back-box" @click="goBack">
|
||||
<text class="back-icon">‹</text>
|
||||
<text class="back-text">返回</text>
|
||||
</view>
|
||||
<text class="page-title">车辆管理</text>
|
||||
</view>
|
||||
|
||||
<!-- 主要内容区域 -->
|
||||
<view class="content-wrapper">
|
||||
<!-- 车辆列表 -->
|
||||
<view v-if="vehicleList.length > 0" class="vehicle-list">
|
||||
<view v-for="vehicle in vehicleList" :key="vehicle.id" class="vehicle-item">
|
||||
<view class="vehicle-info">
|
||||
<text class="vehicle-icon">🚗</text>
|
||||
<view class="info-details">
|
||||
<text class="info-label">车牌号: {{ vehicle.plate_number }}</text>
|
||||
<text class="info-label">车型: {{ vehicle.vehicle_type_name }}</text>
|
||||
<text class="info-label">状态: {{ getVehicleStatusText(vehicle.status) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="vehicle-actions">
|
||||
<button class="action-btn primary" @click="editVehicle(vehicle.id)">编辑</button>
|
||||
<button class="action-btn secondary" @click="deleteVehicle(vehicle.id)">删除</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 无数据时显示 -->
|
||||
<view v-else class="no-data">
|
||||
<text class="no-data-text">暂无车辆信息</text>
|
||||
<button class="add-btn" @click="addVehicle">添加车辆</button>
|
||||
</view>
|
||||
|
||||
<!-- 添加车辆按钮(如果列表为空) -->
|
||||
<view v-if="vehicleList.length > 0" class="add-button-section">
|
||||
<button class="add-btn" @click="addVehicle">添加新车辆</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
import type { DeliveryDriverType } from '@/types/mall-types.uts'
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
// 模拟车辆数据
|
||||
vehicleList: [] as Array<VehicleType>
|
||||
}
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
this.loadVehicles()
|
||||
},
|
||||
|
||||
onShow() {
|
||||
// 页面每次显示时都检查是否有新添加的车辆
|
||||
this.loadVehicles()
|
||||
},
|
||||
|
||||
methods: {
|
||||
// 返回上一页
|
||||
goBack() {
|
||||
uni.navigateBack()
|
||||
},
|
||||
|
||||
// 加载车辆信息
|
||||
loadVehicles() {
|
||||
// 1. 从本地存储获取新添加的车辆(如果存在)
|
||||
const newVehicleFromStorage = uni.getStorageSync('new_vehicle_for_list')
|
||||
if (newVehicleFromStorage) {
|
||||
// 2. 清除本地存储,防止重复添加
|
||||
uni.removeStorageSync('new_vehicle_for_list')
|
||||
// 3. 将新车辆添加到列表开头
|
||||
this.vehicleList.unshift(newVehicleFromStorage)
|
||||
return // 如果有新数据,直接使用,不加载旧的模拟数据
|
||||
}
|
||||
|
||||
// 4. 如果没有新数据,才加载旧的模拟数据(实际项目中应从API获取)
|
||||
if (this.vehicleList.length === 0) { // 避免重复加载模拟数据
|
||||
this.vehicleList = [
|
||||
{
|
||||
id: '1',
|
||||
plate_number: '京A12345',
|
||||
vehicle_type: 1,
|
||||
vehicle_type_name: '电动车',
|
||||
status: 1, // 1: 正常, 2: 维修中, 3: 停用
|
||||
driver_id: 'driver001',
|
||||
created_at: '2024-01-01',
|
||||
updated_at: '2024-12-01'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
plate_number: '沪B67890',
|
||||
vehicle_type: 2,
|
||||
vehicle_type_name: '摩托车',
|
||||
status: 2, // 维修中
|
||||
driver_id: 'driver001',
|
||||
created_at: '2024-01-02',
|
||||
updated_at: '2024-12-02'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
plate_number: '粤C11223',
|
||||
vehicle_type: 3,
|
||||
vehicle_type_name: '汽车',
|
||||
status: 3, // 停用
|
||||
driver_id: 'driver001',
|
||||
created_at: '2024-01-03',
|
||||
updated_at: '2024-12-03'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
// 获取车辆状态文本
|
||||
getVehicleStatusText(status: number): string {
|
||||
const statusMap = {
|
||||
1: '正常',
|
||||
2: '维修中',
|
||||
3: '停用'
|
||||
}
|
||||
return statusMap[status] || '未知状态'
|
||||
},
|
||||
|
||||
// 编辑车辆
|
||||
editVehicle(vehicleId: string) {
|
||||
uni.navigateTo({
|
||||
url: `/pages/mall/delivery/vehicle-edit?id=${vehicleId}`
|
||||
})
|
||||
},
|
||||
|
||||
// 删除车辆
|
||||
deleteVehicle(vehicleId: string) {
|
||||
uni.showModal({
|
||||
title: '确认删除',
|
||||
content: '确定要删除该车辆吗?',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
// TODO: 调用API删除车辆
|
||||
this.vehicleList = this.vehicleList.filter(v => v.id !== vehicleId)
|
||||
uni.showToast({
|
||||
title: '删除成功',
|
||||
icon: 'success'
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
// 添加车辆
|
||||
addVehicle() {
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/delivery/vehicle-add'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 定义 VehicleType 类型
|
||||
type VehicleType = {
|
||||
id: string
|
||||
plate_number: string
|
||||
vehicle_type: number
|
||||
vehicle_type_name: string
|
||||
status: number
|
||||
driver_id: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
brand?: string
|
||||
color?: string
|
||||
image?: string
|
||||
remark?: string
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.vehicle-container {
|
||||
background-color: #f5f5f5;
|
||||
min-height: 100vh;
|
||||
padding: 20rpx 30rpx;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
background-color: #fff;
|
||||
padding: 20rpx 30rpx;
|
||||
border-bottom: 1rpx solid #e9ecef;
|
||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
|
||||
position: relative;
|
||||
height: 80rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.back-box {
|
||||
position: absolute;
|
||||
left: 30rpx;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
padding: 10rpx;
|
||||
border-radius: 8rpx;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.back-box:active {
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
.back-icon {
|
||||
font-size: 36rpx;
|
||||
color: #333;
|
||||
margin-right: 5rpx;
|
||||
}
|
||||
|
||||
.back-text {
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
|
||||
.vehicle-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.vehicle-item {
|
||||
background-color: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 20rpx;
|
||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
|
||||
border-left: 6rpx solid #4CAF50;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.vehicle-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.vehicle-icon {
|
||||
font-size: 48rpx;
|
||||
margin-right: 15rpx;
|
||||
color: #4CAF50;
|
||||
}
|
||||
|
||||
.info-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: 24rpx;
|
||||
color: #333;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.vehicle-actions {
|
||||
display: flex;
|
||||
gap: 10rpx;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 20rpx;
|
||||
border-radius: 15rpx;
|
||||
font-size: 26rpx;
|
||||
border: none;
|
||||
font-weight: 500;
|
||||
padding: 0 10rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.primary {
|
||||
background: #4CAF50;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.secondary {
|
||||
background: #f0f0f0;
|
||||
color: #333;
|
||||
border: 1rpx solid #ddd;
|
||||
}
|
||||
|
||||
.no-data {
|
||||
text-align: center;
|
||||
padding: 80rpx 30rpx;
|
||||
border-radius: 16rpx;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.no-data-text {
|
||||
font-size: 32rpx;
|
||||
color: #999;
|
||||
margin-bottom: 15rpx;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
background-color: #4CAF50;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8rpx;
|
||||
padding: 20rpx 40rpx;
|
||||
font-size: 28rpx;
|
||||
font-weight: bold;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
|
||||
.add-button-section {
|
||||
text-align: center;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -1,569 +0,0 @@
|
||||
{
|
||||
"easycom": {
|
||||
"autoscan": true,
|
||||
"custom": {
|
||||
"^mall-(.*)": "@/components/mall/$1.uvue"
|
||||
}
|
||||
},
|
||||
"pages": [
|
||||
{
|
||||
"path": "pages/mall/consumer/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "商城首页",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/user/boot",
|
||||
"style": {
|
||||
"navigationBarTitleText": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/user/login",
|
||||
"style": {
|
||||
"navigationBarTitleText": "登录"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/user/register",
|
||||
"style": {
|
||||
"navigationBarTitleText": "注册"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/user/forgot-password",
|
||||
"style": {
|
||||
"navigationBarTitleText": "忘记密码"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/user/terms",
|
||||
"style": {
|
||||
"navigationBarTitleText": "用户协议与隐私政策"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/user/center",
|
||||
"style": {
|
||||
"navigationBarTitleText": "用户中心"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/user/profile",
|
||||
"style": {
|
||||
"navigationBarTitleText": "个人资料"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/mall/merchant/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "商家中心",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/mall/delivery/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "配送中心",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/mall/admin/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "管理后台",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/mall/service/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "客服工作台",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/mall/analytics/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "数据分析",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/mall/consumer/product-detail",
|
||||
"style": {
|
||||
"navigationBarTitleText": "商品详情",
|
||||
"enablePullDownRefresh": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/mall/consumer/order-detail",
|
||||
"style": {
|
||||
"navigationBarTitleText": "订单详情",
|
||||
"enablePullDownRefresh": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/mall/consumer/shop-detail",
|
||||
"style": {
|
||||
"navigationBarTitleText": "店铺详情",
|
||||
"enablePullDownRefresh": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/mall/merchant/product-detail",
|
||||
"style": {
|
||||
"navigationBarTitleText": "商品管理详情",
|
||||
"enablePullDownRefresh": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/mall/merchant/order-detail",
|
||||
"style": {
|
||||
"navigationBarTitleText": "订单详情",
|
||||
"enablePullDownRefresh": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/mall/merchant/shop-setting",
|
||||
"style": {
|
||||
"navigationBarTitleText": "店铺设置",
|
||||
"enablePullDownRefresh": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/mall/delivery/order-detail",
|
||||
"style": {
|
||||
"navigationBarTitleText": "配送订单详情",
|
||||
"enablePullDownRefresh": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/mall/delivery/route-detail",
|
||||
"style": {
|
||||
"navigationBarTitleText": "配送路线详情",
|
||||
"enablePullDownRefresh": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/mall/admin/user-detail",
|
||||
"style": {
|
||||
"navigationBarTitleText": "用户详情",
|
||||
"enablePullDownRefresh": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/mall/admin/merchant-detail",
|
||||
"style": {
|
||||
"navigationBarTitleText": "商家详情",
|
||||
"enablePullDownRefresh": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/mall/admin/system-monitor",
|
||||
"style": {
|
||||
"navigationBarTitleText": "系统监控详情",
|
||||
"enablePullDownRefresh": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/mall/service/ticket-detail",
|
||||
"style": {
|
||||
"navigationBarTitleText": "工单详情",
|
||||
"enablePullDownRefresh": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/mall/service/user-detail",
|
||||
"style": {
|
||||
"navigationBarTitleText": "用户详情",
|
||||
"enablePullDownRefresh": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/mall/service/chat",
|
||||
"style": {
|
||||
"navigationBarTitleText": "在线客服",
|
||||
"enablePullDownRefresh": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/mall/analytics/report-detail",
|
||||
"style": {
|
||||
"navigationBarTitleText": "报表详情",
|
||||
"enablePullDownRefresh": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/mall/analytics/data-detail",
|
||||
"style": {
|
||||
"navigationBarTitleText": "数据分析详情",
|
||||
"enablePullDownRefresh": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/mall/analytics/insight-detail",
|
||||
"style": {
|
||||
"navigationBarTitleText": "数据洞察详情",
|
||||
"enablePullDownRefresh": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"subPackages": [
|
||||
{
|
||||
"root": "pages/mall/consumer",
|
||||
"pages": [
|
||||
{
|
||||
"path": "product-detail",
|
||||
"style": {
|
||||
"navigationBarTitleText": "商品详情"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "category",
|
||||
"style": {
|
||||
"navigationBarTitleText": "商品分类"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "cart",
|
||||
"style": {
|
||||
"navigationBarTitleText": "购物车"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "checkout",
|
||||
"style": {
|
||||
"navigationBarTitleText": "确认订单"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "orders",
|
||||
"style": {
|
||||
"navigationBarTitleText": "我的订单"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "profile",
|
||||
"style": {
|
||||
"navigationBarTitleText": "个人中心"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "coupons",
|
||||
"style": {
|
||||
"navigationBarTitleText": "我的优惠券"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "address",
|
||||
"style": {
|
||||
"navigationBarTitleText": "收货地址"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "subscription/plan-list",
|
||||
"style": {
|
||||
"navigationBarTitleText": "软件订阅"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "subscription/plan-detail",
|
||||
"style": {
|
||||
"navigationBarTitleText": "订阅详情"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "subscription/subscribe-checkout",
|
||||
"style": {
|
||||
"navigationBarTitleText": "确认订阅"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "subscription/my-subscriptions",
|
||||
"style": {
|
||||
"navigationBarTitleText": "我的订阅"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "pages/mall/merchant",
|
||||
"pages": [
|
||||
{
|
||||
"path": "products",
|
||||
"style": {
|
||||
"navigationBarTitleText": "商品管理"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "orders",
|
||||
"style": {
|
||||
"navigationBarTitleText": "订单管理"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "statistics",
|
||||
"style": {
|
||||
"navigationBarTitleText": "数据统计"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "promotions",
|
||||
"style": {
|
||||
"navigationBarTitleText": "营销活动"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "finance",
|
||||
"style": {
|
||||
"navigationBarTitleText": "财务结算"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "settings",
|
||||
"style": {
|
||||
"navigationBarTitleText": "店铺设置"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "pages/mall/delivery",
|
||||
"pages": [
|
||||
{
|
||||
"path": "order-history",
|
||||
"style": {
|
||||
"navigationBarTitleText": "配送记录"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "earnings",
|
||||
"style": {
|
||||
"navigationBarTitleText": "收入明细"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "profile",
|
||||
"style": {
|
||||
"navigationBarTitleText": "个人资料"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "settings",
|
||||
"style": {
|
||||
"navigationBarTitleText": "设置"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "pages/mall/admin",
|
||||
"pages": [
|
||||
{
|
||||
"path": "user-management",
|
||||
"style": {
|
||||
"navigationBarTitleText": "用户管理"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "subscription/plan-management",
|
||||
"style": {
|
||||
"navigationBarTitleText": "订阅方案管理"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "subscription/user-subscriptions",
|
||||
"style": {
|
||||
"navigationBarTitleText": "用户订阅管理"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "merchant-management",
|
||||
"style": {
|
||||
"navigationBarTitleText": "商家管理"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "product-management",
|
||||
"style": {
|
||||
"navigationBarTitleText": "商品管理"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "order-management",
|
||||
"style": {
|
||||
"navigationBarTitleText": "订单管理"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "coupon-management",
|
||||
"style": {
|
||||
"navigationBarTitleText": "优惠券管理"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "delivery-management",
|
||||
"style": {
|
||||
"navigationBarTitleText": "配送管理"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "finance-management",
|
||||
"style": {
|
||||
"navigationBarTitleText": "财务管理"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "system-settings",
|
||||
"style": {
|
||||
"navigationBarTitleText": "系统设置"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "pages/mall/service",
|
||||
"pages": [
|
||||
{
|
||||
"path": "conversation",
|
||||
"style": {
|
||||
"navigationBarTitleText": "客服会话"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "order-inquiry",
|
||||
"style": {
|
||||
"navigationBarTitleText": "订单查询"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "refund-process",
|
||||
"style": {
|
||||
"navigationBarTitleText": "退款处理"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "knowledge-base",
|
||||
"style": {
|
||||
"navigationBarTitleText": "知识库"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "performance-report",
|
||||
"style": {
|
||||
"navigationBarTitleText": "绩效报表"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "pages/mall/analytics",
|
||||
"pages": [
|
||||
{
|
||||
"path": "sales-report",
|
||||
"style": {
|
||||
"navigationBarTitleText": "销售报表"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "user-analysis",
|
||||
"style": {
|
||||
"navigationBarTitleText": "用户分析"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "product-insights",
|
||||
"style": {
|
||||
"navigationBarTitleText": "商品洞察"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "market-trends",
|
||||
"style": {
|
||||
"navigationBarTitleText": "市场趋势"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "custom-report",
|
||||
"style": {
|
||||
"navigationBarTitleText": "自定义报表"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"tabBar": {
|
||||
"custom": true,
|
||||
"color": "#7A7E83",
|
||||
"selectedColor": "#3cc51f",
|
||||
"borderStyle": "black",
|
||||
"backgroundColor": "#ffffff",
|
||||
"list": [
|
||||
{
|
||||
"pagePath": "pages/mall/consumer/index",
|
||||
"iconPath": "static/tab-home.png",
|
||||
"selectedIconPath": "static/tab-home-current.png",
|
||||
"text": "首页"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/mall/consumer/category",
|
||||
"iconPath": "static/tab-category.png",
|
||||
"selectedIconPath": "static/tab-category-current.png",
|
||||
"text": "分类"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/mall/consumer/cart",
|
||||
"iconPath": "static/tab-cart.png",
|
||||
"selectedIconPath": "static/tab-cart-current.png",
|
||||
"text": "购物车"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/mall/consumer/profile",
|
||||
"iconPath": "static/tab-profile.png",
|
||||
"selectedIconPath": "static/tab-profile-current.png",
|
||||
"text": "我的"
|
||||
}
|
||||
]
|
||||
},
|
||||
"globalStyle": {
|
||||
"navigationBarTextStyle": "black",
|
||||
"navigationBarTitleText": "商城系统",
|
||||
"navigationBarBackgroundColor": "#F8F8F8",
|
||||
"backgroundColor": "#F8F8F8"
|
||||
},
|
||||
"condition": {
|
||||
"current": 0,
|
||||
"list": [
|
||||
{
|
||||
"name": "消费者端首页",
|
||||
"path": "pages/mall/consumer/index"
|
||||
},
|
||||
{
|
||||
"name": "启动页(登录态判断)",
|
||||
"path": "pages/user/boot"
|
||||
},
|
||||
{
|
||||
"name": "商家端首页",
|
||||
"path": "pages/mall/merchant/index"
|
||||
},
|
||||
{
|
||||
"name": "配送端首页",
|
||||
"path": "pages/mall/delivery/index"
|
||||
},
|
||||
{
|
||||
"name": "管理端首页",
|
||||
"path": "pages/mall/admin/index"
|
||||
},
|
||||
{
|
||||
"name": "客服端首页",
|
||||
"path": "pages/mall/service/index"
|
||||
},
|
||||
{
|
||||
"name": "数据分析端首页",
|
||||
"path": "pages/mall/analytics/index"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
646
pages/mall/pages.json
Normal file
646
pages/mall/pages.json
Normal file
@@ -0,0 +1,646 @@
|
||||
{
|
||||
"easycom": {
|
||||
"autoscan": true,
|
||||
"custom": {
|
||||
"^mall-(.*)": "@/components/mall/$1.uvue"
|
||||
}
|
||||
},
|
||||
"pages": [
|
||||
{
|
||||
"path": "pages/mall/consumer/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "商城首页",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/user/boot",
|
||||
"style": {
|
||||
"navigationBarTitleText": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/user/login",
|
||||
"style": {
|
||||
"navigationBarTitleText": "登录"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/user/register",
|
||||
"style": {
|
||||
"navigationBarTitleText": "注册"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/user/forgot-password",
|
||||
"style": {
|
||||
"navigationBarTitleText": "忘记密码"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/user/terms",
|
||||
"style": {
|
||||
"navigationBarTitleText": "用户协议与隐私政策"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/user/center",
|
||||
"style": {
|
||||
"navigationBarTitleText": "用户中心"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/user/profile",
|
||||
"style": {
|
||||
"navigationBarTitleText": "个人资料"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/mall/merchant/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "商家中心",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/mall/delivery/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "配送中心",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/mall/admin/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "管理后台",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/mall/service/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "客服工作台",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/mall/analytics/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "数据分析",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/mall/consumer/product-detail",
|
||||
"style": {
|
||||
"navigationBarTitleText": "商品详情",
|
||||
"enablePullDownRefresh": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/mall/consumer/order-detail",
|
||||
"style": {
|
||||
"navigationBarTitleText": "订单详情",
|
||||
"enablePullDownRefresh": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/mall/consumer/shop-detail",
|
||||
"style": {
|
||||
"navigationBarTitleText": "店铺详情",
|
||||
"enablePullDownRefresh": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/mall/merchant/product-detail",
|
||||
"style": {
|
||||
"navigationBarTitleText": "商品管理详情",
|
||||
"enablePullDownRefresh": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/mall/merchant/order-detail",
|
||||
"style": {
|
||||
"navigationBarTitleText": "订单详情",
|
||||
"enablePullDownRefresh": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/mall/merchant/shop-setting",
|
||||
"style": {
|
||||
"navigationBarTitleText": "店铺设置",
|
||||
"enablePullDownRefresh": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/mall/delivery/order-detail",
|
||||
"style": {
|
||||
"navigationBarTitleText": "配送订单详情",
|
||||
"enablePullDownRefresh": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/mall/delivery/route-detail",
|
||||
"style": {
|
||||
"navigationBarTitleText": "配送路线详情",
|
||||
"enablePullDownRefresh": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/mall/admin/user-detail",
|
||||
"style": {
|
||||
"navigationBarTitleText": "用户详情",
|
||||
"enablePullDownRefresh": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/mall/admin/merchant-detail",
|
||||
"style": {
|
||||
"navigationBarTitleText": "商家详情",
|
||||
"enablePullDownRefresh": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/mall/admin/system-monitor",
|
||||
"style": {
|
||||
"navigationBarTitleText": "系统监控详情",
|
||||
"enablePullDownRefresh": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/mall/service/ticket-detail",
|
||||
"style": {
|
||||
"navigationBarTitleText": "工单详情",
|
||||
"enablePullDownRefresh": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/mall/service/user-detail",
|
||||
"style": {
|
||||
"navigationBarTitleText": "用户详情",
|
||||
"enablePullDownRefresh": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/mall/service/chat",
|
||||
"style": {
|
||||
"navigationBarTitleText": "在线客服",
|
||||
"enablePullDownRefresh": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/mall/analytics/report-detail",
|
||||
"style": {
|
||||
"navigationBarTitleText": "报表详情",
|
||||
"enablePullDownRefresh": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/mall/analytics/data-detail",
|
||||
"style": {
|
||||
"navigationBarTitleText": "数据分析详情",
|
||||
"enablePullDownRefresh": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/mall/analytics/insight-detail",
|
||||
"style": {
|
||||
"navigationBarTitleText": "数据洞察详情",
|
||||
"enablePullDownRefresh": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"subPackages": [
|
||||
{
|
||||
"root": "pages/mall/consumer",
|
||||
"pages": [
|
||||
{
|
||||
"path": "product-detail",
|
||||
"style": {
|
||||
"navigationBarTitleText": "商品详情"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "category",
|
||||
"style": {
|
||||
"navigationBarTitleText": "商品分类"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "cart",
|
||||
"style": {
|
||||
"navigationBarTitleText": "购物车"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "checkout",
|
||||
"style": {
|
||||
"navigationBarTitleText": "确认订单"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "orders",
|
||||
"style": {
|
||||
"navigationBarTitleText": "我的订单"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "profile",
|
||||
"style": {
|
||||
"navigationBarTitleText": "个人中心"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "coupons",
|
||||
"style": {
|
||||
"navigationBarTitleText": "我的优惠券"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "address",
|
||||
"style": {
|
||||
"navigationBarTitleText": "收货地址"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "subscription/plan-list",
|
||||
"style": {
|
||||
"navigationBarTitleText": "软件订阅"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "subscription/plan-detail",
|
||||
"style": {
|
||||
"navigationBarTitleText": "订阅详情"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "subscription/subscribe-checkout",
|
||||
"style": {
|
||||
"navigationBarTitleText": "确认订阅"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "subscription/my-subscriptions",
|
||||
"style": {
|
||||
"navigationBarTitleText": "我的订阅"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "pages/mall/merchant",
|
||||
"pages": [
|
||||
{
|
||||
"path": "products",
|
||||
"style": {
|
||||
"navigationBarTitleText": "商品管理"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "orders",
|
||||
"style": {
|
||||
"navigationBarTitleText": "订单管理"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "statistics",
|
||||
"style": {
|
||||
"navigationBarTitleText": "数据统计"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "promotions",
|
||||
"style": {
|
||||
"navigationBarTitleText": "营销活动"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "finance",
|
||||
"style": {
|
||||
"navigationBarTitleText": "财务结算"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "settings",
|
||||
"style": {
|
||||
"navigationBarTitleText": "店铺设置"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "pages/mall/delivery",
|
||||
"pages": [
|
||||
{
|
||||
"path": "order-history",
|
||||
"style": {
|
||||
"navigationBarTitleText": "配送记录"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "earnings",
|
||||
"style": {
|
||||
"navigationBarTitleText": "收入明细"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "profile",
|
||||
"style": {
|
||||
"navigationBarTitleText": "个人资料"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "settings",
|
||||
"style": {
|
||||
"navigationBarTitleText": "设置"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "pages/mall/admin",
|
||||
"pages": [
|
||||
{
|
||||
"path": "user-management",
|
||||
"style": {
|
||||
"navigationBarTitleText": "用户管理"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "subscription/plan-management",
|
||||
"style": {
|
||||
"navigationBarTitleText": "订阅方案管理"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "subscription/user-subscriptions",
|
||||
"style": {
|
||||
"navigationBarTitleText": "用户订阅管理"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "merchant-management",
|
||||
"style": {
|
||||
"navigationBarTitleText": "商家管理"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "product-management",
|
||||
"style": {
|
||||
"navigationBarTitleText": "商品管理"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "order-management",
|
||||
"style": {
|
||||
"navigationBarTitleText": "订单管理"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "coupon-management",
|
||||
"style": {
|
||||
"navigationBarTitleText": "优惠券管理"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "marketing/coupon/list",
|
||||
"style": {
|
||||
"navigationBarTitleText": "优惠券列表",
|
||||
"enablePullDownRefresh": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "marketing/coupon/receive",
|
||||
"style": {
|
||||
"navigationBarTitleText": "用户领取记录",
|
||||
"enablePullDownRefresh": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "marketing/points/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "积分管理",
|
||||
"enablePullDownRefresh": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "marketing/signin/rule",
|
||||
"style": {
|
||||
"navigationBarTitleText": "签到规则",
|
||||
"enablePullDownRefresh": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "marketing/signin/record",
|
||||
"style": {
|
||||
"navigationBarTitleText": "签到记录",
|
||||
"enablePullDownRefresh": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "delivery-management",
|
||||
"style": {
|
||||
"navigationBarTitleText": "配送管理"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "finance-management",
|
||||
"style": {
|
||||
"navigationBarTitleText": "财务管理"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "system-settings",
|
||||
"style": {
|
||||
"navigationBarTitleText": "系统设置"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "marketing-management",
|
||||
"style": {
|
||||
"navigationBarTitleText": "营销管理"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "activity-log",
|
||||
"style": {
|
||||
"navigationBarTitleText": "活动日志"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "merchant-review",
|
||||
"style": {
|
||||
"navigationBarTitleText": "商家审核"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "product-review",
|
||||
"style": {
|
||||
"navigationBarTitleText": "商品审核"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "refund-review",
|
||||
"style": {
|
||||
"navigationBarTitleText": "退款审核"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "complaints",
|
||||
"style": {
|
||||
"navigationBarTitleText": "投诉处理"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "homePage/components/KpiMiniCard",
|
||||
"style": {
|
||||
"navigationBarTitleText": "卡片模板"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "pages/mall/service",
|
||||
"pages": [
|
||||
{
|
||||
"path": "conversation",
|
||||
"style": {
|
||||
"navigationBarTitleText": "客服会话"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "order-inquiry",
|
||||
"style": {
|
||||
"navigationBarTitleText": "订单查询"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "refund-process",
|
||||
"style": {
|
||||
"navigationBarTitleText": "退款处理"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "knowledge-base",
|
||||
"style": {
|
||||
"navigationBarTitleText": "知识库"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "performance-report",
|
||||
"style": {
|
||||
"navigationBarTitleText": "绩效报表"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "pages/mall/analytics",
|
||||
"pages": [
|
||||
{
|
||||
"path": "sales-report",
|
||||
"style": {
|
||||
"navigationBarTitleText": "销售报表"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "user-analysis",
|
||||
"style": {
|
||||
"navigationBarTitleText": "用户分析"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "product-insights",
|
||||
"style": {
|
||||
"navigationBarTitleText": "商品洞察"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "market-trends",
|
||||
"style": {
|
||||
"navigationBarTitleText": "市场趋势"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "custom-report",
|
||||
"style": {
|
||||
"navigationBarTitleText": "自定义报表"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"tabBar": {
|
||||
"custom": true,
|
||||
"color": "#7A7E83",
|
||||
"selectedColor": "#3cc51f",
|
||||
"borderStyle": "black",
|
||||
"backgroundColor": "#ffffff",
|
||||
"list": [
|
||||
{
|
||||
"pagePath": "pages/mall/consumer/index",
|
||||
"iconPath": "static/tab-home.png",
|
||||
"selectedIconPath": "static/tab-home-current.png",
|
||||
"text": "首页"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/mall/consumer/category",
|
||||
"iconPath": "static/tab-category.png",
|
||||
"selectedIconPath": "static/tab-category-current.png",
|
||||
"text": "分类"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/mall/consumer/cart",
|
||||
"iconPath": "static/tab-cart.png",
|
||||
"selectedIconPath": "static/tab-cart-current.png",
|
||||
"text": "购物车"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/mall/consumer/profile",
|
||||
"iconPath": "static/tab-profile.png",
|
||||
"selectedIconPath": "static/tab-profile-current.png",
|
||||
"text": "我的"
|
||||
}
|
||||
]
|
||||
},
|
||||
"globalStyle": {
|
||||
"navigationBarTextStyle": "black",
|
||||
"navigationBarTitleText": "商城系统",
|
||||
"navigationBarBackgroundColor": "#F8F8F8",
|
||||
"backgroundColor": "#F8F8F8"
|
||||
},
|
||||
"condition": {
|
||||
"current": 0,
|
||||
"list": [
|
||||
{
|
||||
"name": "消费者端首页",
|
||||
"path": "pages/mall/consumer/index"
|
||||
},
|
||||
{
|
||||
"name": "启动页(登录态判断)",
|
||||
"path": "pages/user/boot"
|
||||
},
|
||||
{
|
||||
"name": "商家端首页",
|
||||
"path": "pages/mall/merchant/index"
|
||||
},
|
||||
{
|
||||
"name": "配送端首页",
|
||||
"path": "pages/mall/delivery/index"
|
||||
},
|
||||
{
|
||||
"name": "管理端首页",
|
||||
"path": "pages/mall/admin/index"
|
||||
},
|
||||
{
|
||||
"name": "客服端首页",
|
||||
"path": "pages/mall/service/index"
|
||||
},
|
||||
{
|
||||
"name": "数据分析端首页",
|
||||
"path": "pages/mall/analytics/index"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user