Files
nepiedg c883ae7b17 init
2026-01-25 11:45:16 +08:00

12 KiB

技术实现方案

1. 技术栈

层级 技术选型 说明
框架 uni-app (Vue3 + JavaScript) 跨平台小程序开发
状态管理 Pinia 轻量级状态管理
请求 uni.request 封装 统一拦截、Token管理
图表 uCharts 轻量级数据可视化
UI组件 自定义组件 符合设计规范
存储 uni.storage 本地缓存

2. 项目结构

├── pages/                    # 页面
│   ├── index/               # 首页
│   ├── stats/               # 统计
│   ├── ai/                  # AI助手
│   ├── logs/                # 历史记录
│   ├── profile/             # 个人中心
│   └── onboarding/          # 新用户引导
├── components/              # 组件
│   ├── common/              # 通用组件
│   ├── charts/              # 图表组件
│   └── business/            # 业务组件
├── stores/                  # Pinia stores
│   ├── user.js              # 用户状态
│   ├── smoke.js             # 抽烟记录状态
│   └── dashboard.js         # 首页看板状态
├── api/                     # API封装
│   ├── request.js           # 请求基类
│   ├── auth.js              # 认证接口
│   ├── smoke.js             # 抽烟记录接口
│   └── profile.js           # 用户档案接口
├── utils/                   # 工具函数
│   ├── time.js              # 时间处理
│   ├── storage.js           # 存储封装
│   └── format.js            # 格式化
├── hooks/                   # 组合式函数
│   ├── useTimer.js          # 计时器逻辑
│   ├── useDashboard.js      # 看板数据
│   └── useAuth.js           # 认证逻辑
└── static/                  # 静态资源
    └── icons/               # TabBar图标

3. 首页性能优化方案

3.1 加载策略

┌─────────────────────────────────────────────────────────┐
│                    首页加载时序                          │
├─────────────────────────────────────────────────────────┤
│  0ms    ─ 页面骨架屏渲染                                 │
│  │                                                      │
│  ├──── 并行请求 ────────────────────────────────────     │
│  │     ├── /profile (检查用户状态)                       │
│  │     ├── /dashboard (核心数据)                         │
│  │     └── /next_smoke_time (建议时间)                   │
│  │                                                      │
│  200ms ─ 核心数据返回,渲染计时器+统计卡片               │
│  │                                                      │
│  300ms ─ 首屏渲染完成                                    │
│  │                                                      │
│  │     ┌── 延迟加载 ──────────────────────────────       │
│  │     └── /ai/advice (AI提示卡片)                       │
│  │                                                      │
│  500ms ─ 完整页面渲染                                    │
└─────────────────────────────────────────────────────────┘

3.2 缓存策略

// stores/dashboard.js
import { defineStore } from 'pinia'

export const useDashboardStore = defineStore('dashboard', {
  state: () => ({
    todayCount: 0,
    minutesSinceLast: 0,
    weekly: [],
    nextSmokeTime: null,
    lastFetchTime: 0,
    cacheExpiry: 30 * 1000
  }),
  
  actions: {
    async fetchDashboard(forceRefresh = false) {
      const now = Date.now()
      if (!forceRefresh && now - this.lastFetchTime < this.cacheExpiry) {
        return
      }
      // 发起请求...
    }
  }
})

3.3 计时器优化

// hooks/useTimer.js
import { ref, onMounted, onUnmounted } from 'vue'

export function useTimer(minutesSinceLast) {
  const displayTime = ref('00:00:00')
  let rafId = null
  let lastTimestamp = 0
  
  function tick(timestamp) {
    if (timestamp - lastTimestamp >= 1000) {
      lastTimestamp = timestamp
      updateDisplay()
    }
    rafId = requestAnimationFrame(tick)
  }
  
  onMounted(() => {
    rafId = requestAnimationFrame(tick)
  })
  
  onUnmounted(() => {
    cancelAnimationFrame(rafId)
  })
  
  return { displayTime }
}

3.4 骨架屏

首页使用骨架屏避免白屏:

<template>
  <view v-if="loading" class="skeleton">
    <view class="skeleton-header" />
    <view class="skeleton-timer" />
    <view class="skeleton-cards" />
  </view>
  <view v-else class="dashboard">
    <!-- 真实内容 -->
  </view>
</template>

4. 核心模块实现

4.1 认证模块

// api/auth.js
import { request } from './request'
import { MINI_PROGRAM_ID } from '@/config'

export async function login() {
  const [err, loginRes] = await uni.login({ provider: 'weixin' })
  if (err) throw err
  
  const res = await request.post('/auth/login', {
    mini_program_id: MINI_PROGRAM_ID,
    code: loginRes.code
  })
  
  uni.setStorageSync('session_key', res.data.session_key)
  uni.setStorageSync('user', res.data.user)
  
  return res.data
}

4.2 请求封装

// api/request.js
const BASE_URL = 'https://api.example.com/api/v1'

export const request = {
  async request(options) {
    const sessionKey = uni.getStorageSync('session_key')
    
    const [err, res] = await uni.request({
      url: BASE_URL + options.url,
      method: options.method || 'GET',
      data: options.data,
      header: {
        'Content-Type': 'application/json',
        'Authorization': sessionKey ? `Bearer ${sessionKey}` : ''
      }
    })
    
    if (err) {
      uni.showToast({ title: '网络错误', icon: 'none' })
      throw err
    }
    
    if (res.statusCode === 401) {
      const { login } = await import('./auth')
      await login()
      return this.request(options)
    }
    
    if (res.statusCode !== 200) {
      throw new Error(res.data?.message || '请求失败')
    }
    
    return res.data
  },
  
  get(url, params) {
    return this.request({ url, method: 'GET', data: params })
  },
  
  post(url, data) {
    return this.request({ url, method: 'POST', data })
  },
  
  put(url, data) {
    return this.request({ url, method: 'PUT', data })
  },
  
  delete(url) {
    return this.request({ url, method: 'DELETE' })
  }
}

4.3 首页数据加载

// pages/index/index.vue
import { ref, onMounted } from 'vue'
import { useDashboardStore } from '@/stores/dashboard'
import * as api from '@/api/smoke'

const loading = ref(true)
const dashboardStore = useDashboardStore()

async function initPage() {
  loading.value = true
  
  try {
    const [profileRes, dashboardRes, nextTimeRes] = await Promise.all([
      api.getProfile(),
      api.getDashboard(),
      api.getNextSmokeTime()
    ])
    
    if (!profileRes.data.exists || !profileRes.data.is_completed) {
      uni.redirectTo({ url: '/pages/onboarding/index' })
      return
    }
    
    dashboardStore.setDashboard(dashboardRes.data)
    dashboardStore.setNextSmokeTime(nextTimeRes.data)
    
  } finally {
    loading.value = false
  }
  
  setTimeout(loadAiAdvice, 300)
}

onMounted(initPage)

5. 页面路由配置

// pages.json
{
  "pages": [
    { "path": "pages/index/index" },
    { "path": "pages/stats/index" },
    { "path": "pages/ai/index" },
    { "path": "pages/logs/index" },
    { "path": "pages/profile/index" },
    { "path": "pages/onboarding/index" }
  ],
  "tabBar": {
    "color": "#6B7280",
    "selectedColor": "#4ADE80",
    "backgroundColor": "#0D1F17",
    "borderStyle": "black",
    "list": [
      { "pagePath": "pages/index/index", "text": "首页", "iconPath": "static/icons/home.png", "selectedIconPath": "static/icons/home-active.png" },
      { "pagePath": "pages/stats/index", "text": "统计", "iconPath": "static/icons/stats.png", "selectedIconPath": "static/icons/stats-active.png" },
      { "pagePath": "pages/ai/index", "text": "AI助手", "iconPath": "static/icons/ai.png", "selectedIconPath": "static/icons/ai-active.png" },
      { "pagePath": "pages/logs/index", "text": "记录", "iconPath": "static/icons/logs.png", "selectedIconPath": "static/icons/logs-active.png" },
      { "pagePath": "pages/profile/index", "text": "我的", "iconPath": "static/icons/profile.png", "selectedIconPath": "static/icons/profile-active.png" }
    ]
  },
  "globalStyle": {
    "navigationBarBackgroundColor": "#0D1F17",
    "navigationBarTextStyle": "white",
    "backgroundColor": "#0D1F17",
    "navigationStyle": "custom"
  }
}

6. 状态管理设计

6.1 Store 划分

Store 职责 持久化
userStore 用户信息、登录状态
profileStore 用户档案(基准数据)
dashboardStore 首页看板数据 否(实时)
logsStore 记录列表、分页状态

6.2 数据流

用户操作 → Action → API请求 → 更新State → 视图更新
                       ↓
                  更新本地缓存

7. 图表实现

7.1 周统计柱状图

// components/charts/WeeklyChart.vue
import { computed } from 'vue'

const chartData = computed(() => ({
  categories: props.weekly.map(d => formatWeekday(d.date)),
  series: [{
    name: '吸烟量',
    data: props.weekly.map(d => d.count)
  }]
}))

const opts = {
  type: 'column',
  color: ['#4ADE80'],
  padding: [15, 15, 0, 5],
  xAxis: {
    disableGrid: true,
    fontColor: '#9CA3AF'
  },
  yAxis: {
    gridColor: '#374151',
    fontColor: '#9CA3AF'
  },
  extra: {
    column: {
      width: 20,
      barBorderRadius: [4, 4, 0, 0]
    }
  }
}

7.2 进度环

首页计时器使用 Canvas 绘制进度环:

// components/TimerRing.vue
function drawRing(progress) {
  const ctx = uni.createCanvasContext('timerCanvas', this)
  const centerX = 150
  const centerY = 150
  const radius = 120
  
  // 背景环
  ctx.beginPath()
  ctx.arc(centerX, centerY, radius, 0, 2 * Math.PI)
  ctx.setStrokeStyle('#1F3D2B')
  ctx.setLineWidth(12)
  ctx.stroke()
  
  // 进度环
  ctx.beginPath()
  ctx.arc(centerX, centerY, radius, -Math.PI/2, -Math.PI/2 + progress * 2 * Math.PI)
  ctx.setStrokeStyle('#4ADE80')
  ctx.setLineWidth(12)
  ctx.setLineCap('round')
  ctx.stroke()
  
  ctx.draw()
}

8. 错误处理

8.1 全局错误拦截

// api/request.js
function handleError(error) {
  if (error.statusCode === 401) {
    return reLogin()
  }
  
  if (error.statusCode === 403) {
    return { needUnlock: true, error }
  }
  
  if (error.errMsg && error.errMsg.includes('network')) {
    uni.showToast({ title: '网络连接失败', icon: 'none' })
  }
  
  throw error
}

8.2 页面级错误处理

// pages/index/index.vue
async function initPage() {
  try {
    await loadData()
  } catch (error) {
    showRetry.value = true
  }
}

9. 性能监控

// utils/performance.js
export function trackPageLoad(pageName) {
  const startTime = Date.now()
  
  return {
    markFirstPaint() {
      const fp = Date.now() - startTime
      console.log(`[Perf] ${pageName} First Paint: ${fp}ms`)
    },
    
    markFullyLoaded() {
      const loaded = Date.now() - startTime
      console.log(`[Perf] ${pageName} Fully Loaded: ${loaded}ms`)
    }
  }
}

10. 环境配置

// config/index.js
const ENV = {
  development: {
    BASE_URL: 'http://127.0.0.1:8080/api/v1',
    MINI_PROGRAM_ID: 1
  },
  production: {
    BASE_URL: 'https://api.example.com/api/v1',
    MINI_PROGRAM_ID: 1
  }
}

const env = process.env.NODE_ENV || 'development'
export const { BASE_URL, MINI_PROGRAM_ID } = ENV[env]