提交昨晚至今早的修改2

This commit is contained in:
2026-01-28 10:56:43 +08:00
154 changed files with 38279 additions and 5115 deletions

View 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>

View 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>

View File

@@ -0,0 +1,11 @@
<template>
<view class="page">
<text>配送管理 - 占位页</text>
</view>
</template>
<script lang="uts">
export default {}
</script>
<style>
.page { padding: 30rpx; }
</style>

File diff suppressed because it is too large Load Diff

View 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>

View 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>

View File

@@ -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>

View File

@@ -0,0 +1,11 @@
<template>
<view class="page">
<text>优惠券管理 - 占位页</text>
</view>
</template>
<script lang="uts">
export default {}
</script>
<style>
.page { padding: 30rpx; }
</style>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -0,0 +1,13 @@
<template>
<view class="page">
<text>商家管理 - 占位页</text>
</view>
</template>
<script lang="uts">
export default {}
</script>
<style>
.page { padding: 30rpx; }
</style>

View 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>

View File

@@ -0,0 +1,11 @@
<template>
<view class="page">
<text>通知中心 - 占位页</text>
</view>
</template>
<script lang="uts">
export default {}
</script>
<style>
.page { padding: 30rpx; }
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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>

View File

@@ -1,4 +1,3 @@
<!-- 管理端 - 个人中心 -->
<template>
<view class="admin-profile">
<!-- 管理员信息头部 -->

View 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>

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,3 @@
<!-- 管理端 - 用户详情页 -->
<template>
<view class="user-detail-page">
<!-- 用户基本信息 -->

File diff suppressed because it is too large Load Diff

View 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>

View 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>

View 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>

View 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>

View 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>

View 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` 的表/视图;复杂统计使用 **RPCPostgres 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-1000-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 未参考任何实现代码的声明
本文档的表结构与字段设计为**基于可观察页面字段与需求规格独立推导**的原创设计,未复制/改写任何第三方或原项目实现源码。

View 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`
计算实时 KPIGMV、订单数、在线用户、转化率及增长率。
**参数:**
- `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`

View 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) 子页面 URLanalytics 子包)
- **销售报表**`/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页面骨架创建完成

View 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
**状态**: ✅ 已完成

View 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 缓存,需重新检查)

View 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 专用表的详细字段定义
- 索引建议
- RLSRow 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** - 添加数据库设计文档和快速开始指南

View 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

View 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>

View 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>

View 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>

View File

@@ -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>

View File

@@ -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>

View 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>

View 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 运行 *步骤14*
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

View 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. 使用循环生成大量测试数据(注意性能)

View 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 + RPCDrop-first
5. `02_insert_test_data.sql`(基础表测试数据,需 service_role/postgres
6. `ANALYTICS_TEST_SEED.sql`analytics_* 测试数据,需 service_role/postgres
7. `03_test_queries.sql`(可选:验证查询)

View 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. 完成!
现在可以开始测试实时大屏功能了!🎉

View 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;
-- 预期2515个今日订单 + 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 计算

View 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>

View 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)
// KPIRPC
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>

View File

@@ -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>

View 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` 模块是一个完整的配送员工作系统,涵盖了:
- **个人管理**(资料、设置)
- **任务处理**(接单、配送、完成)
- **数据统计**(收入、评价、历史)
- **车辆管理**
结构清晰、功能完整,适合用于实际配送平台开发。
---

View 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

View File

@@ -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>

View 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>

View 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>

View File

@@ -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>

View 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>

View 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>

View 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('❌ 未获取到 taskIdquery:', 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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
View 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"
}
]
}
}