init
This commit is contained in:
+485
@@ -0,0 +1,485 @@
|
||||
# 技术实现方案
|
||||
|
||||
## 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 缓存策略
|
||||
|
||||
```javascript
|
||||
// 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 计时器优化
|
||||
|
||||
```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
|
||||
<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 认证模块
|
||||
|
||||
```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 { 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. 页面路由配置
|
||||
|
||||
```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]
|
||||
```
|
||||
Reference in New Issue
Block a user