# 技术实现方案 ## 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 (检查用户状态) │ │ │ └── /home (首页核心数据) │ │ │ │ │ 200ms ─ 核心数据返回,渲染计时器+统计卡片 │ │ │ │ │ 300ms ─ 首屏渲染完成 │ │ │ │ │ 500ms ─ 完整页面渲染 │ └─────────────────────────────────────────────────────────┘ ``` ### 3.2 缓存策略 ```javascript // 首页使用 /smoke/home 单接口返回当前屏所需字段。 // 页面级刷新由 onShow 触发,避免维护额外 dashboard store 和重复请求。 const homeData = ref(null) async function fetchRecordHomeData() { const res = await api.getHome() homeData.value = res.data || {} } ``` ### 3.3 计时器优化 ```javascript // 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 骨架屏 首页使用骨架屏避免白屏: ```vue ``` --- ## 4. 核心模块实现 ### 4.1 认证模块 ```javascript // 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 请求封装 ```javascript // 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 首页数据加载 ```javascript // pages/index/index.vue import { ref, onMounted } from 'vue' import * as api from '@/api' const loading = ref(true) const homeData = ref(null) async function initPage() { loading.value = true try { const profileRes = await api.getSmokeProfile() if (!profileRes.exists || !profileRes.is_completed) { uni.redirectTo({ url: '/pages/onboarding/index' }) return } const homeRes = await api.getHome() homeData.value = homeRes.data || {} } finally { loading.value = false } } onMounted(initPage) ``` --- ## 5. 页面路由配置 ```json // 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 周统计柱状图 ```javascript // 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 绘制进度环: ```javascript // 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 全局错误拦截 ```javascript // 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 页面级错误处理 ```javascript // pages/index/index.vue async function initPage() { try { await loadData() } catch (error) { showRetry.value = true } } ``` --- ## 9. 性能监控 ```javascript // 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. 环境配置 ```javascript // 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] ```