617 lines
17 KiB
Markdown
617 lines
17 KiB
Markdown
# 现场回收 uni-app 程序设计文档
|
||
|
||
## 一、项目概述
|
||
|
||
### 1.1 项目背景
|
||
开发一套基于 uni-app 的跨平台现场回收程序,用于回收人员现场回收作业,支持实时称重、价格计算、蓝牙电子秤连接等功能。
|
||
|
||
### 1.2 技术栈
|
||
- **框架**: uni-app (Vue 3 + Vite)
|
||
- **UI组件**: uni-ui / uView UI
|
||
- **状态管理**: Pinia
|
||
- **蓝牙通信**: uni-app 蓝牙 API
|
||
- **数据存储**: 本地存储 (uni.getStorageSync) + 云端API
|
||
- **跨平台**: 支持 iOS、Android、H5(调试用)
|
||
|
||
---
|
||
|
||
## 二、功能需求
|
||
|
||
### 2.1 核心功能模块
|
||
|
||
#### 模块1:回收种类选择
|
||
**回收种类(暂定)**:
|
||
1. **纸类** - 按重量(kg)计价
|
||
2. **铁** - 按重量(kg)计价
|
||
3. **易拉罐** - 按个数计价
|
||
4. **塑料** - 按重量(kg)计价
|
||
5. **织物** - 按重量(kg)计价
|
||
|
||
**功能点**:
|
||
- 展示回收种类列表(图标+名称)
|
||
- 显示当日回收价格
|
||
- 支持搜索/筛选(未来扩展)
|
||
- 选择后进入称重/计数界面
|
||
|
||
#### 模块2:当日回收价格管理
|
||
**数据来源**:
|
||
- 云端API获取(管理员后台配置)
|
||
- 本地缓存(离线可用)
|
||
|
||
**价格展示**:
|
||
- 列表页:每种回收物的当日价格
|
||
- 详情页:价格趋势(可选)
|
||
|
||
**数据格式示例**:
|
||
```json
|
||
{
|
||
"date": "2026-05-14",
|
||
"prices": [
|
||
{"category": "paper", "name": "纸类", "unit": "kg", "price": 1.2},
|
||
{"category": "iron", "name": "铁", "unit": "kg", "price": 2.5},
|
||
{"category": "can", "name": "易拉罐", "unit": "piece", "price": 0.1},
|
||
{"category": "plastic", "name": "塑料", "unit": "kg", "price": 3.0},
|
||
{"category": "fabric", "name": "织物", "unit": "kg", "price": 0.8}
|
||
]
|
||
}
|
||
```
|
||
|
||
#### 模块3:蓝牙电子秤连接
|
||
**功能流程**:
|
||
1. 搜索蓝牙设备
|
||
2. 连接指定电子秤(需知道设备名称/MAC地址)
|
||
3. 实时接收重量数据
|
||
4. 断线重连机制
|
||
5. 手动/自动归零
|
||
|
||
**蓝牙通信协议**(需根据实际电子秤调整):
|
||
- 服务UUID:`0xFFE0`(示例)
|
||
- 特征UUID:`0xFFE1`(示例)
|
||
- 数据格式:通常返回 ASCII 字符串,如 `"+00123kg\n"`
|
||
|
||
**关键代码逻辑**:
|
||
```javascript
|
||
// 监听蓝牙数据
|
||
uni.onBLECharacteristicValueChange((res) => {
|
||
const raw = this.ab2str(res.value)
|
||
const weight = this.parseWeight(raw) // 解析重量
|
||
this.currentWeight = weight
|
||
this.calculateAmount() // 触发金额计算
|
||
})
|
||
```
|
||
|
||
#### 模块4:重量/数量获取与金额计算
|
||
**业务逻辑**:
|
||
|
||
| 回收种类 | 计价单位 | 计算方式 |
|
||
|---------|---------|---------|
|
||
| 纸类 | kg | 重量(kg) × 单价 |
|
||
| 铁 | kg | 重量(kg) × 单价 |
|
||
| 易拉罐 | 个 | 数量(个) × 单价 |
|
||
| 塑料 | kg | 重量(kg) × 单价 |
|
||
| 织物 | kg | 重量(kg) × 单价 |
|
||
|
||
**计算逻辑**:
|
||
```javascript
|
||
function calculateAmount(category, weightOrCount, unitPrice) {
|
||
if (category === 'can') {
|
||
return weightOrCount * unitPrice // 按个数
|
||
} else {
|
||
return weightOrCount * unitPrice // 按重量
|
||
}
|
||
}
|
||
```
|
||
|
||
**实时计算**:
|
||
- 重量变化时自动重新计算
|
||
- 显示预估金额(大字醒目)
|
||
- 支持手动修改重量/数量(特殊情况)
|
||
|
||
#### 模块5:订单管理
|
||
**订单数据模型**:
|
||
```json
|
||
{
|
||
"orderId": "ORD202605141430001",
|
||
"timestamp": 1715668200000,
|
||
"items": [
|
||
{
|
||
"category": "paper",
|
||
"name": "纸类",
|
||
"weight": 12.5,
|
||
"unit": "kg",
|
||
"unitPrice": 1.2,
|
||
"amount": 15.0
|
||
}
|
||
],
|
||
"totalAmount": 15.0,
|
||
"status": "completed",
|
||
"location": {
|
||
"latitude": 31.2304,
|
||
"longitude": 121.4737
|
||
}
|
||
}
|
||
```
|
||
|
||
**功能点**:
|
||
- 创建订单(一键生成)
|
||
- 订单列表(历史记录)
|
||
- 订单详情
|
||
- 导出/打印(可选)
|
||
|
||
---
|
||
|
||
## 三、页面设计
|
||
|
||
### 3.1 页面结构
|
||
|
||
```
|
||
pages/
|
||
├── index/
|
||
│ └── index.vue # 首页(回收种类选择)
|
||
├── price/
|
||
│ └── price.vue # 当日价格表
|
||
├── weigh/
|
||
│ └── weigh.vue # 称重/计数页面
|
||
├── bluetooth/
|
||
│ └── bluetooth.vue # 蓝牙连接管理
|
||
├── order/
|
||
│ ├── list.vue # 订单列表
|
||
│ └── detail.vue # 订单详情
|
||
└── user/
|
||
└── profile.vue # 个人中心
|
||
```
|
||
|
||
### 3.2 关键页面原型
|
||
|
||
#### 首页(index.vue)
|
||
```
|
||
┌─────────────────────────┐
|
||
│ 🏠 现场回收系统 │
|
||
│ 📅 2026-05-14 周四 │
|
||
├─────────────────────────┤
|
||
│ 📊 今日价格 > │
|
||
├─────────────────────────┤
|
||
│ 🗂️ 选择回收种类 │
|
||
│ │
|
||
│ [📄 纸类] [🔩 铁] │
|
||
│ 1.2元/kg 2.5元/kg │
|
||
│ │
|
||
│ [🥫 易拉罐] [♻️ 塑料] │
|
||
│ 0.1元/个 3.0元/kg │
|
||
│ │
|
||
│ [👕 织物] │
|
||
│ 0.8元/kg │
|
||
└─────────────────────────┘
|
||
```
|
||
|
||
#### 称重页面(weigh.vue)
|
||
```
|
||
┌─────────────────────────┐
|
||
│ ← 返回 🔵已连接 │
|
||
├─────────────────────────┤
|
||
│ 回收种类:纸类 │
|
||
│ 单价:1.2元/kg │
|
||
├─────────────────────────┤
|
||
│ │
|
||
│ 📏 实时重量 │
|
||
│ │
|
||
│ [ 12.5 ] kg │
|
||
│ │
|
||
│ 💰 预估金额 │
|
||
│ [ 15.00 ] 元 │
|
||
│ │
|
||
│ [📱 蓝牙连接] [⚖️ 归零]│
|
||
│ │
|
||
│ [ ✅ 确认回收 ] │
|
||
└─────────────────────────┘
|
||
```
|
||
|
||
#### 易拉罐特殊页面(weigh.vue 变体)
|
||
```
|
||
┌─────────────────────────┐
|
||
│ ← 返回 │
|
||
├─────────────────────────┤
|
||
│ 回收种类:易拉罐 │
|
||
│ 单价:0.1元/个 │
|
||
├─────────────────────────┤
|
||
│ │
|
||
│ 🥫 个数统计 │
|
||
│ │
|
||
│ [ 25 ] 个 │
|
||
│ [ + ] [ - ] │
|
||
│ │
|
||
│ 💰 预估金额 │
|
||
│ [ 2.50 ] 元 │
|
||
│ │
|
||
│ [ ✅ 确认回收 ] │
|
||
└─────────────────────────┘
|
||
```
|
||
|
||
---
|
||
|
||
## 四、技术实现细节
|
||
|
||
### 4.1 蓝牙连接实现
|
||
|
||
**步骤1:初始化蓝牙**
|
||
```javascript
|
||
async initBluetooth() {
|
||
try {
|
||
await uni.openBluetoothAdapter()
|
||
console.log('蓝牙适配器初始化成功')
|
||
} catch (err) {
|
||
console.error('请打开蓝牙', err)
|
||
}
|
||
}
|
||
```
|
||
|
||
**步骤2:搜索设备**
|
||
```javascript
|
||
startDiscovery() {
|
||
uni.startBluetoothDevicesDiscovery({
|
||
services: [], // 不指定服务,搜索所有设备
|
||
success: (res) => {
|
||
console.log('开始搜索蓝牙设备')
|
||
this.listenDeviceFound()
|
||
}
|
||
})
|
||
}
|
||
|
||
listenDeviceFound() {
|
||
uni.onBluetoothDeviceFound((res) => {
|
||
const devices = res.devices.filter(d =>
|
||
d.name && d.name.includes('Scale') // 过滤电子秤设备
|
||
)
|
||
this.deviceList = [...this.deviceList, ...devices]
|
||
})
|
||
}
|
||
```
|
||
|
||
**步骤3:连接设备**
|
||
```javascript
|
||
async connectDevice(deviceId) {
|
||
try {
|
||
await uni.createBLEConnection({ deviceId })
|
||
this.deviceId = deviceId
|
||
this.getBLEDeviceServices(deviceId)
|
||
} catch (err) {
|
||
console.error('连接失败', err)
|
||
}
|
||
}
|
||
```
|
||
|
||
**步骤4:监听数据**
|
||
```javascript
|
||
async getBLEDeviceServices(deviceId) {
|
||
const res = await uni.getBLEDeviceServices({ deviceId })
|
||
const service = res.services.find(s => s.uuid === '0000FFE0-0000-1000-8000-00805F9B34FB')
|
||
|
||
if (service) {
|
||
this.getBLEDeviceCharacteristics(deviceId, service.uuid)
|
||
}
|
||
}
|
||
|
||
async getBLEDeviceCharacteristics(deviceId, serviceId) {
|
||
const res = await uni.getBLEDeviceCharacteristics({ deviceId, serviceId })
|
||
const characteristic = res.characteristics.find(c =>
|
||
c.uuid === '0000FFE1-0000-1000-8000-00805F9B34FB' && c.properties.notify
|
||
)
|
||
|
||
if (characteristic) {
|
||
this.notifyBLECharacteristicValueChange(deviceId, serviceId, characteristic.uuid)
|
||
}
|
||
}
|
||
|
||
notifyBLECharacteristicValueChange(deviceId, serviceId, characteristicId) {
|
||
uni.notifyBLECharacteristicValueChange({
|
||
deviceId,
|
||
serviceId,
|
||
characteristicId,
|
||
state: true,
|
||
success: () => {
|
||
console.log('启用notify成功')
|
||
this.listenBLECharacteristicValueChange()
|
||
}
|
||
})
|
||
}
|
||
|
||
listenBLECharacteristicValueChange() {
|
||
uni.onBLECharacteristicValueChange((res) => {
|
||
const rawData = this.arrayBufferToString(res.value)
|
||
const weight = this.parseWeightData(rawData)
|
||
this.currentWeight = weight
|
||
this.calculateTotal()
|
||
})
|
||
}
|
||
```
|
||
|
||
**步骤5:解析重量数据**
|
||
```javascript
|
||
parseWeightData(rawData) {
|
||
// 示例:电子秤返回 "+00123kg\r\n"
|
||
// 需根据实际设备协议调整
|
||
const match = rawData.match(/[+-]?\d+\.\d+/)
|
||
if (match) {
|
||
return parseFloat(match[0])
|
||
}
|
||
return 0
|
||
}
|
||
|
||
arrayBufferToString(buffer) {
|
||
const decoder = new TextDecoder('ascii')
|
||
return decoder.decode(buffer)
|
||
}
|
||
```
|
||
|
||
### 4.2 价格管理实现
|
||
|
||
**API接口设计**:
|
||
```javascript
|
||
// 获取今日价格
|
||
GET /api/prices/today
|
||
|
||
// 响应示例
|
||
{
|
||
"code": 0,
|
||
"data": {
|
||
"date": "2026-05-14",
|
||
"prices": [
|
||
{ "category": "paper", "name": "纸类", "unit": "kg", "price": 1.2 },
|
||
{ "category": "iron", "name": "铁", "unit": "kg", "price": 2.5 },
|
||
{ "category": "can", "name": "易拉罐", "unit": "piece", "price": 0.1 },
|
||
{ "category": "plastic", "name": "塑料", "unit": "kg", "price": 3.0 },
|
||
{ "category": "fabric", "name": "织物", "unit": "kg", "price": 0.8 }
|
||
]
|
||
}
|
||
}
|
||
```
|
||
|
||
**本地缓存策略**:
|
||
```javascript
|
||
async getTodayPrices() {
|
||
// 1. 先读缓存
|
||
const cached = uni.getStorageSync('todayPrices')
|
||
const cacheTime = uni.getStorageSync('pricesCacheTime')
|
||
const now = Date.now()
|
||
|
||
// 缓存有效时间:1小时
|
||
if (cached && (now - cacheTime < 3600000)) {
|
||
return cached
|
||
}
|
||
|
||
// 2. 请求API
|
||
try {
|
||
const res = await uni.request({
|
||
url: 'https://api.example.com/prices/today',
|
||
method: 'GET'
|
||
})
|
||
|
||
if (res.statusCode === 200) {
|
||
uni.setStorageSync('todayPrices', res.data.data)
|
||
uni.setStorageSync('pricesCacheTime', now)
|
||
return res.data.data
|
||
}
|
||
} catch (err) {
|
||
console.error('API请求失败,使用缓存', err)
|
||
return cached || null
|
||
}
|
||
}
|
||
```
|
||
|
||
### 4.3 订单管理实现
|
||
|
||
**创建订单**:
|
||
```javascript
|
||
createOrder(items) {
|
||
const order = {
|
||
orderId: this.generateOrderId(),
|
||
timestamp: Date.now(),
|
||
items: items.map(item => ({
|
||
category: item.category,
|
||
name: item.name,
|
||
weight: item.weight || item.count,
|
||
unit: item.unit,
|
||
unitPrice: item.unitPrice,
|
||
amount: item.weight ? item.weight * item.unitPrice : item.count * item.unitPrice
|
||
})),
|
||
totalAmount: items.reduce((sum, item) => {
|
||
const amount = item.weight ? item.weight * item.unitPrice : item.count * item.unitPrice
|
||
return sum + amount
|
||
}, 0),
|
||
status: 'completed'
|
||
}
|
||
|
||
// 保存到本地
|
||
this.saveOrderToLocal(order)
|
||
|
||
// 同步到云端(可选)
|
||
this.syncOrderToCloud(order)
|
||
|
||
return order
|
||
}
|
||
|
||
generateOrderId() {
|
||
const now = new Date()
|
||
const dateStr = `${now.getFullYear()}${String(now.getMonth()+1).padStart(2,'0')}${String(now.getDate()).padStart(2,'0')}`
|
||
const timeStr = `${String(now.getHours()).padStart(2,'0')}${String(now.getMinutes()).padStart(2,'0')}`
|
||
const randomStr = String(Math.floor(Math.random() * 1000)).padStart(3,'0')
|
||
return `ORD${dateStr}${timeStr}${randomStr}`
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 五、数据库设计(后端)
|
||
|
||
### 5.1 价格表 (prices)
|
||
```sql
|
||
CREATE TABLE prices (
|
||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||
category VARCHAR(50) NOT NULL,
|
||
name VARCHAR(100) NOT NULL,
|
||
unit ENUM('kg', 'piece') NOT NULL,
|
||
price DECIMAL(10,2) NOT NULL,
|
||
effective_date DATE NOT NULL,
|
||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||
INDEX idx_date_category (effective_date, category)
|
||
);
|
||
```
|
||
|
||
### 5.2 订单表 (orders)
|
||
```sql
|
||
CREATE TABLE orders (
|
||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||
order_id VARCHAR(50) UNIQUE NOT NULL,
|
||
total_amount DECIMAL(10,2) NOT NULL,
|
||
status ENUM('pending', 'completed', 'cancelled') DEFAULT 'completed',
|
||
location_lat DECIMAL(10,8),
|
||
location_lng DECIMAL(11,8),
|
||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||
INDEX idx_order_id (order_id),
|
||
INDEX idx_created_at (created_at)
|
||
);
|
||
```
|
||
|
||
### 5.3 订单明细表 (order_items)
|
||
```sql
|
||
CREATE TABLE order_items (
|
||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||
order_id VARCHAR(50) NOT NULL,
|
||
category VARCHAR(50) NOT NULL,
|
||
name VARCHAR(100) NOT NULL,
|
||
weight DECIMAL(10,2),
|
||
unit ENUM('kg', 'piece') NOT NULL,
|
||
unit_price DECIMAL(10,2) NOT NULL,
|
||
amount DECIMAL(10,2) NOT NULL,
|
||
FOREIGN KEY (order_id) REFERENCES orders(order_id),
|
||
INDEX idx_order_id (order_id)
|
||
);
|
||
```
|
||
|
||
---
|
||
|
||
## 六、项目目录结构
|
||
|
||
```
|
||
recycle-app/
|
||
├── pages/ # 页面
|
||
│ ├── index/ # 首页
|
||
│ ├── price/ # 价格页
|
||
│ ├── weigh/ # 称重页
|
||
│ ├── bluetooth/ # 蓝牙连接页
|
||
│ ├── order/ # 订单相关页
|
||
│ └── user/ # 用户中心
|
||
├── components/ # 组件
|
||
│ ├── BluetoothStatus.vue # 蓝牙状态组件
|
||
│ ├── PriceCard.vue # 价格卡片
|
||
│ ├── WeightDisplay.vue # 重量显示
|
||
│ └── OrderItem.vue # 订单条目
|
||
├── stores/ # Pinia状态管理
|
||
│ ├── bluetooth.js # 蓝牙状态
|
||
│ ├── price.js # 价格状态
|
||
│ └── order.js # 订单状态
|
||
├── utils/ # 工具函数
|
||
│ ├── bluetooth.js # 蓝牙工具
|
||
│ ├── price.js # 价格工具
|
||
│ ├── order.js # 订单工具
|
||
│ └── calculate.js # 计算工具
|
||
├── api/ # API接口
|
||
│ ├── price.js # 价格API
|
||
│ └── order.js # 订单API
|
||
├── static/ # 静态资源
|
||
│ ├── icons/ # 图标
|
||
│ └── images/ # 图片
|
||
├── App.vue
|
||
├── main.js
|
||
├── manifest.json # uni-app配置
|
||
├── pages.json # 页面路由
|
||
└── uni.scss # 全局样式
|
||
```
|
||
|
||
---
|
||
|
||
## 七、开发优先级
|
||
|
||
### Phase 1(MVP)
|
||
1. ✅ 首页(回收种类选择)
|
||
2. ✅ 价格展示(写死本地数据)
|
||
3. ✅ 称重页面(手动输入重量)
|
||
4. ✅ 金额计算逻辑
|
||
5. ✅ 订单创建与本地存储
|
||
|
||
### Phase 2(核心功能)
|
||
1. ✅ 蓝牙连接电子秤
|
||
2. ✅ 实时重量获取
|
||
3. ✅ 价格API对接
|
||
4. ✅ 订单列表与详情
|
||
|
||
### Phase 3(优化体验)
|
||
1. 断线重连
|
||
2. 历史数据统计
|
||
3. 导出报表
|
||
4. 多语言支持
|
||
|
||
---
|
||
|
||
## 八、注意事项
|
||
|
||
### 8.1 蓝牙兼容性
|
||
- iOS和Android的蓝牙API有差异,需充分测试
|
||
- 部分Android设备需要位置权限才能使用蓝牙
|
||
- 电子秤的蓝牙协议需提前确认(建议让硬件供应商提供文档)
|
||
|
||
### 8.2 重量精度
|
||
- 建议使用 `Decimal` 类型处理金额计算,避免浮点数精度问题
|
||
- 重量显示保留2位小数
|
||
- 金额显示保留2位小数
|
||
|
||
### 8.3 离线支持
|
||
- 价格数据需缓存到本地
|
||
- 订单数据先存本地,网络恢复后同步
|
||
|
||
### 8.4 权限申请
|
||
- 蓝牙权限(Android)
|
||
- 位置权限(Android蓝牙扫描需要)
|
||
- 网络权限
|
||
|
||
---
|
||
|
||
## 九、测试用例
|
||
|
||
### 9.1 蓝牙连接测试
|
||
- [ ] 搜索设备
|
||
- [ ] 连接设备
|
||
- [ ] 接收数据
|
||
- [ ] 断线重连
|
||
- [ ] 断开连接
|
||
|
||
### 9.2 金额计算测试
|
||
- [ ] 纸类:12.5kg × 1.2元/kg = 15.0元
|
||
- [ ] 铁:5.0kg × 2.5元/kg = 12.5元
|
||
- [ ] 易拉罐:25个 × 0.1元/个 = 2.5元
|
||
- [ ] 塑料:3.2kg × 3.0元/kg = 9.6元
|
||
- [ ] 织物:8.0kg × 0.8元/kg = 6.4元
|
||
|
||
### 9.3 订单创建测试
|
||
- [ ] 单品类订单
|
||
- [ ] 多品类订单
|
||
- [ ] 订单保存到本地
|
||
- [ ] 订单同步到云端
|
||
|
||
---
|
||
|
||
## 十、后续扩展
|
||
|
||
1. **用户系统**:登录、权限管理
|
||
2. **支付集成**:微信支付、支付宝
|
||
3. **数据可视化**:每日/每月回收统计图表
|
||
4. **打印功能**:连接便携式打印机打印小票
|
||
5. **多秤支持**:同时连接多个电子秤
|
||
6. **AI识别**:拍照识别回收物种类(可选)
|
||
|
||
---
|
||
|
||
**文档版本**: v1.0
|
||
**创建时间**: 2026-05-14
|
||
**作者**: QClaw AI Assistant
|