1174 lines
32 KiB
Vue
1174 lines
32 KiB
Vue
<template>
|
||
<view class="container">
|
||
<!-- 顶部导航 -->
|
||
<view class="nav-bar">
|
||
<text class="nav-title">表盘管理器</text>
|
||
</view>
|
||
|
||
<!-- 主内容区 -->
|
||
<view class="content">
|
||
<!-- 图片预览区 (圆形表盘) -->
|
||
<view class="preview-container">
|
||
<view class="preview-frame">
|
||
<image
|
||
:src="currentImageUrl"
|
||
class="preview-image"
|
||
mode="aspectFill"
|
||
v-if="currentImageUrl"
|
||
></image>
|
||
<view class="empty-preview" v-else>
|
||
<uni-icons type="image" size="60" color="#ccc"></uni-icons>
|
||
<text>请选择表盘图片</text>
|
||
</view>
|
||
</view>
|
||
<button
|
||
class="set-btn"
|
||
@click="setAsCurrentFace"
|
||
v-if="currentImageUrl"
|
||
:disabled="isSending"
|
||
>
|
||
{{ isSending ? '传输中 ' + transferProgress + '%' : '设置为当前表盘' }}
|
||
</button>
|
||
<view class="btn-row">
|
||
<button class="upload-btn" @click="chooseImage">
|
||
<uni-icons type="camera" size="24" color="#fff"></uni-icons>
|
||
<text>上传图片</text>
|
||
</button>
|
||
<button class="upload-btn gif-btn" @click="chooseGif">
|
||
<uni-icons type="videocam" size="24" color="#fff"></uni-icons>
|
||
<text>上传GIF</text>
|
||
</button>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 卡片式轮播区 -->
|
||
<view class="carousel-section">
|
||
<text class="section-title">已上传表盘</text>
|
||
<view class="carousel-container">
|
||
<!-- 卡片式轮播核心修改 -->
|
||
<swiper
|
||
class="card-swiper"
|
||
circular
|
||
previous-margin="200rpx"
|
||
next-margin="200rpx"
|
||
@change="handleCarouselChange"
|
||
>
|
||
<swiper-item v-for="(img, index) in uploadedImages" :key="index">
|
||
<view class="card-item" @click="selectImage(index)">
|
||
<view class="card-frame">
|
||
<image
|
||
:src="img.url"
|
||
class="card-image"
|
||
mode="aspectFill"
|
||
></image>
|
||
<view class="card-overlay" v-if="currentImageIndex === index">
|
||
<uni-icons type="checkmark" size="30" color="#007aff"></uni-icons>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</swiper-item>
|
||
</swiper>
|
||
<view class="carousel-hint" v-if="uploadedImages.length === 0">
|
||
<text>暂无上传图片,请点击上方上传按钮添加</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- BLE数据展示区 -->
|
||
<view class="data-section">
|
||
<text class="section-title">设备状态</text>
|
||
<view class="data-grid">
|
||
<!-- 电量信息 -->
|
||
<view class="data-card">
|
||
<view class="data-icon battery-icon">
|
||
<uni-icons type="battery" size="36" :color="batteryColor"></uni-icons>
|
||
</view>
|
||
<view class="data-info">
|
||
<text class="data-value">{{ batteryLevel }}%</text>
|
||
<text class="data-label">电量</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 温度信息 -->
|
||
<view class="data-card">
|
||
<view class="data-icon temp-icon">
|
||
<uni-icons type="thermometer" size="36" color="#ff7a45"></uni-icons>
|
||
</view>
|
||
<view class="data-info">
|
||
<text class="data-value">{{ temperature }}°C</text>
|
||
<text class="data-label">温度</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 湿度信息 -->
|
||
<view class="data-card">
|
||
<view class="data-icon humidity-icon">
|
||
<uni-icons type="water" size="36" color="#40a9ff"></uni-icons>
|
||
</view>
|
||
<view class="data-info">
|
||
<text class="data-value">{{ humidity }}%</text>
|
||
<text class="data-label">湿度</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 表盘状态 -->
|
||
<view class="data-card">
|
||
<view class="data-icon status-icon">
|
||
<uni-icons type="watch" size="36" color="#52c41a"></uni-icons>
|
||
</view>
|
||
<view class="data-info">
|
||
<text class="data-value">{{ faceStatusText }}</text>
|
||
<text class="data-label">表盘状态</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 设备连接状态 -->
|
||
<view class="connection-status">
|
||
<uni-icons
|
||
type="bluetooth"
|
||
size="24"
|
||
:color="isConnected ? '#52c41a' : '#ff4d4f'"
|
||
:animation="isScanning ? 'spin' : ''"
|
||
></uni-icons>
|
||
<view class="status-pot"
|
||
:style="{
|
||
backgroundColor:isConnected?'Green':'red'
|
||
}"
|
||
></view>
|
||
<text class="status-text">
|
||
{{ isConnected ? '已连接设备' : isScanning ? '正在搜索设备...' : '未连接设备' }}
|
||
</text>
|
||
<button
|
||
class="connect-btn"
|
||
@click="toggleConnection"
|
||
:disabled="isScanning"
|
||
>
|
||
{{ isConnected ? '断开连接' : '连接设备' }}
|
||
</button>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script>
|
||
// 脚本部分与原代码一致,无需修改
|
||
export default {
|
||
data() {
|
||
return {
|
||
// 图片相关
|
||
uploadedImages: [], // 已上传的图片列表
|
||
currentImageUrl: '', // 当前选中的图片
|
||
currentImageIndex: -1, // 当前选中的图片索引
|
||
imageDir: '', // 图片保存目录
|
||
|
||
// BLE设备数据
|
||
isConnected: true, // 是否连接
|
||
isScanning: false, // 是否正在扫描
|
||
batteryLevel: 0, // 电量
|
||
temperature: 0, // 温度
|
||
humidity: 0, // 湿度
|
||
faceStatus: 0, // 表盘状态 0-正常 1-更新中 2-异常
|
||
|
||
// statusPotColor:'Green',
|
||
|
||
deviceId:'',
|
||
imageServiceuuid:'',
|
||
imageWriteuuid:'',
|
||
imageEdituuid:'',
|
||
isSending: false,
|
||
transferProgress: 0,
|
||
// 模拟数据定时器
|
||
dataTimer: null
|
||
};
|
||
},
|
||
|
||
computed: {
|
||
// 表盘状态文本
|
||
faceStatusText() {
|
||
switch(this.faceStatus) {
|
||
case 0: return '正常';
|
||
case 1: return '更新中';
|
||
case 2: return '异常';
|
||
default: return '未知';
|
||
}
|
||
},
|
||
|
||
// 电池颜色(根据电量变化)
|
||
batteryColor() {
|
||
if (this.batteryLevel > 70) return '#52c41a';
|
||
if (this.batteryLevel > 30) return '#faad14';
|
||
return '#ff4d4f';
|
||
}
|
||
},
|
||
|
||
onLoad(options) {
|
||
this.deviceId = options.deviceId
|
||
this.initImageDir();
|
||
this.loadSavedImages();
|
||
this.setBleMtu();
|
||
},
|
||
|
||
onUnload() {
|
||
// 页面卸载时清理
|
||
this.stopDataSimulation();
|
||
if (this.isConnected) {
|
||
this.disconnectDevice();
|
||
}
|
||
},
|
||
|
||
methods: {
|
||
// BLE 单次写入(内部方法)
|
||
_bleWriteOnce(characteristicId, buffer, writeType) {
|
||
return new Promise((resolve, reject) => {
|
||
uni.writeBLECharacteristicValue({
|
||
deviceId: this.deviceId,
|
||
serviceId: this.imageServiceuuid,
|
||
characteristicId: characteristicId,
|
||
value: buffer,
|
||
writeType: writeType || 'writeNoResponse',
|
||
success: resolve,
|
||
fail: reject
|
||
});
|
||
});
|
||
},
|
||
// BLE 写入封装(带重试,writeNoResponse 失败自动降级为 write)
|
||
async bleWrite(characteristicId, buffer) {
|
||
const MAX_RETRY = 3;
|
||
for (let i = 0; i < MAX_RETRY; i++) {
|
||
try {
|
||
await this._bleWriteOnce(characteristicId, buffer, 'writeNoResponse');
|
||
return;
|
||
} catch (err) {
|
||
if (i < MAX_RETRY - 1) {
|
||
// BLE 写队列拥塞,等待后重试(退避递增)
|
||
await this.delay(20 * (i + 1));
|
||
} else {
|
||
// 最后一次尝试用 write(带应答),更可靠
|
||
try {
|
||
await this._bleWriteOnce(characteristicId, buffer, 'write');
|
||
return;
|
||
} catch (fallbackErr) {
|
||
throw fallbackErr;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
},
|
||
// 延时
|
||
delay(ms) {
|
||
return new Promise(resolve => setTimeout(resolve, ms));
|
||
},
|
||
// 读取图片文件为 ArrayBuffer
|
||
readImageAsArrayBuffer(filePath) {
|
||
return new Promise((resolve, reject) => {
|
||
console.log('readImageAsArrayBuffer 开始, 路径:', filePath);
|
||
plus.io.resolveLocalFileSystemURL(filePath, (fileEntry) => {
|
||
console.log('resolveLocalFileSystemURL 成功');
|
||
fileEntry.file((file) => {
|
||
console.log('获取File对象成功, 大小:', file.size);
|
||
const reader = new plus.io.FileReader();
|
||
reader.onloadend = (e) => {
|
||
console.log('FileReader onloadend, 有数据:', !!e.target.result);
|
||
if (e.target.result) {
|
||
resolve(e.target.result);
|
||
} else {
|
||
reject(new Error('读取文件结果为空'));
|
||
}
|
||
};
|
||
reader.onerror = (e) => {
|
||
console.error('FileReader onerror:', e);
|
||
reject(new Error('FileReader错误'));
|
||
};
|
||
reader.readAsDataURL(file);
|
||
}, (err) => {
|
||
console.error('fileEntry.file 失败:', JSON.stringify(err));
|
||
reject(err);
|
||
});
|
||
}, (err) => {
|
||
console.error('resolveLocalFileSystemURL 失败:', JSON.stringify(err));
|
||
reject(err);
|
||
});
|
||
});
|
||
},
|
||
// DataURL 转 ArrayBuffer
|
||
dataURLtoArrayBuffer(dataURL) {
|
||
const base64 = dataURL.split(',')[1];
|
||
const binary = atob(base64);
|
||
const len = binary.length;
|
||
const buffer = new ArrayBuffer(len);
|
||
const view = new Uint8Array(buffer);
|
||
for (let i = 0; i < len; i++) {
|
||
view[i] = binary.charCodeAt(i);
|
||
}
|
||
return buffer;
|
||
},
|
||
// 图片分包传输
|
||
async writeBleImage(filename, imageArrayBuffer) {
|
||
const data = new Uint8Array(imageArrayBuffer);
|
||
const len = data.length;
|
||
console.log('开始传输图片:', filename, '大小:', len, '字节');
|
||
|
||
// 1. 发送前序帧(26字节)
|
||
const header = new Uint8Array(26);
|
||
header[0] = 0xFD;
|
||
for (let i = 0; i < Math.min(filename.length, 22); i++) {
|
||
header[i + 1] = filename.charCodeAt(i);
|
||
}
|
||
header[23] = (len >> 16) & 0xFF;
|
||
header[24] = (len >> 8) & 0xFF;
|
||
header[25] = len & 0xFF;
|
||
|
||
await this.bleWrite(this.imageWriteuuid, header.buffer);
|
||
await this.delay(50);
|
||
|
||
// 2. 分包发送图片数据(无应答写入,不等ACK)
|
||
const CHUNK_SIZE = 507; // (MTU-3) - 2字节帧头 = 509 - 2
|
||
let offset = 0;
|
||
let packetNo = 0;
|
||
|
||
while (offset < len) {
|
||
const remaining = len - offset;
|
||
const chunkLen = Math.min(CHUNK_SIZE, remaining);
|
||
const isEnd = (offset + chunkLen >= len) ? 0x01 : 0x00;
|
||
|
||
const packet = new Uint8Array(2 + chunkLen);
|
||
packet[0] = packetNo & 0xFF;
|
||
packet[1] = isEnd;
|
||
packet.set(data.slice(offset, offset + chunkLen), 2);
|
||
|
||
await this.bleWrite(this.imageWriteuuid, packet.buffer);
|
||
// 每包间隔5ms(平衡传输速度与BLE控制器队列容量)
|
||
await this.delay(5);
|
||
|
||
offset += chunkLen;
|
||
packetNo++;
|
||
|
||
// 每10包更新一次进度(减少UI刷新开销)
|
||
if (packetNo % 10 === 0 || isEnd) {
|
||
this.transferProgress = Math.floor(offset / len * 100);
|
||
uni.showLoading({ title: '传输中 ' + this.transferProgress + '%', mask: true });
|
||
}
|
||
}
|
||
|
||
console.log('图片传输完成,共', packetNo, '包');
|
||
},
|
||
// 通过 16-bit UUID 在列表中查找(兼容多种格式)
|
||
findByUuid16(list, uuid16) {
|
||
const hex4 = uuid16.toString(16).padStart(4, '0').toLowerCase();
|
||
const fullTarget = '0000' + hex4 + '-0000-1000-8000-00805f9b34fb';
|
||
return list.find(item => {
|
||
const uuid = item.uuid.toLowerCase();
|
||
// 完整 128-bit 格式匹配
|
||
if (uuid === fullTarget) return true;
|
||
// 短格式匹配(如 "0b00" 或 "00000b00")
|
||
if (uuid === hex4 || uuid === '0000' + hex4) return true;
|
||
// 包含 16-bit 值的 128-bit 格式(不同基础UUID)
|
||
if (uuid.startsWith('0000' + hex4 + '-')) return true;
|
||
return false;
|
||
});
|
||
},
|
||
getBleService(){
|
||
var that = this;
|
||
uni.getBLEDeviceServices({
|
||
deviceId: that.deviceId,
|
||
success(res) {
|
||
// 打印所有服务UUID,便于调试格式问题
|
||
console.log('发现服务数量:', res.services.length);
|
||
res.services.forEach((s, i) => {
|
||
console.log(' 服务[' + i + ']:', s.uuid);
|
||
});
|
||
const service = that.findByUuid16(res.services, 0x0B00);
|
||
if (service) {
|
||
that.imageServiceuuid = service.uuid;
|
||
console.log('图片服务已找到:', service.uuid);
|
||
that.getBleChar();
|
||
} else {
|
||
console.error('未找到图片传输服务 0x0B00,请检查上方打印的UUID格式');
|
||
uni.showToast({ title: '未找到图片服务', icon: 'none' });
|
||
}
|
||
},
|
||
fail() {
|
||
console.log('获取服务Id失败');
|
||
}
|
||
});
|
||
},
|
||
getBleChar(){
|
||
var that = this;
|
||
uni.getBLEDeviceCharacteristics({
|
||
deviceId: that.deviceId,
|
||
serviceId: that.imageServiceuuid,
|
||
success(res) {
|
||
console.log('发现特征数量:', res.characteristics.length);
|
||
res.characteristics.forEach((c, i) => {
|
||
console.log(' 特征[' + i + ']:', c.uuid, '属性:', JSON.stringify(c.properties));
|
||
});
|
||
const writeChar = that.findByUuid16(res.characteristics, 0x0B01);
|
||
const editChar = that.findByUuid16(res.characteristics, 0x0B02);
|
||
if (writeChar) {
|
||
that.imageWriteuuid = writeChar.uuid;
|
||
console.log('写入特征已找到:', writeChar.uuid);
|
||
}
|
||
if (editChar) {
|
||
that.imageEdituuid = editChar.uuid;
|
||
console.log('编辑特征已找到:', editChar.uuid);
|
||
}
|
||
if (!writeChar || !editChar) {
|
||
console.error('特征发现不完整, write:', !!writeChar, 'edit:', !!editChar);
|
||
}
|
||
},
|
||
fail() {
|
||
console.log('获取特征Id失败');
|
||
}
|
||
});
|
||
},
|
||
setBleMtu(){
|
||
var that = this
|
||
uni.setBLEMTU({
|
||
deviceId:that.deviceId,
|
||
mtu:512,
|
||
success() {
|
||
that.isConnected = true
|
||
// that.statusPotColor = 'Green'
|
||
console.log('MTU设置成功')
|
||
that.getBleService();
|
||
},
|
||
fail() {
|
||
console.log('MTU设置失败')
|
||
}
|
||
})
|
||
},
|
||
// 初始化图片保存目录
|
||
initImageDir() {
|
||
// #ifdef APP-PLUS
|
||
// 获取应用私有目录
|
||
const docPath = plus.io.convertLocalFileSystemURL('_doc/');
|
||
this.imageDir = docPath + 'watch_faces/';
|
||
plus.io.resolveLocalFileSystemURL(this.imageDir,
|
||
() => {},
|
||
() => {
|
||
plus.io.resolveLocalFileSystemURL('_doc/', (root) => {
|
||
root.getDirectory('watch_faces', { create: true }, () => {
|
||
console.log('目录创建成功');
|
||
});
|
||
});
|
||
}
|
||
);
|
||
// #endif
|
||
|
||
// H5和小程序使用本地存储模拟
|
||
// #ifndef APP-PLUS
|
||
this.imageDir = 'watch_faces/';
|
||
// #endif
|
||
},
|
||
|
||
loadSavedImages() {
|
||
// #ifdef APP-PLUS
|
||
plus.io.resolveLocalFileSystemURL(this.imageDir, (dir) => {
|
||
const reader = dir.createReader();
|
||
reader.readEntries((entries) => {
|
||
const images = [];
|
||
entries.forEach((entry) => {
|
||
if (entry.isFile && this.isImageFile(entry.name)) {
|
||
images.push({
|
||
name: entry.name,
|
||
url: entry.toLocalURL()
|
||
});
|
||
}
|
||
});
|
||
this.uploadedImages = images;
|
||
// 如果有图片,默认选中第一张
|
||
if (images.length > 0) {
|
||
this.currentImageUrl = images[0].url;
|
||
this.currentImageIndex = 0;
|
||
}
|
||
});
|
||
});
|
||
// #endif
|
||
|
||
// H5和小程序从本地存储加载
|
||
// #ifndef APP-PLUS
|
||
const savedImages = uni.getStorageSync('watch_face_images') || [];
|
||
this.uploadedImages = savedImages;
|
||
if (savedImages.length > 0) {
|
||
this.currentImageUrl = savedImages[0].url;
|
||
this.currentImageIndex = 0;
|
||
}
|
||
// #endif
|
||
},
|
||
|
||
// 判断是否为图片文件
|
||
isImageFile(filename) {
|
||
const ext = filename.toLowerCase().split('.').pop();
|
||
return ['jpg', 'jpeg', 'png', 'gif', 'bmp'].includes(ext);
|
||
},
|
||
|
||
// 选择图片(支持 GIF 和普通图片)
|
||
chooseImage() {
|
||
uni.chooseImage({
|
||
count: 1,
|
||
sizeType: ['compressed'],
|
||
sourceType: ['album', 'camera'],
|
||
crop: {
|
||
quality: 100,
|
||
width: 360,
|
||
height: 360,
|
||
resize: true
|
||
},
|
||
success: (res) => {
|
||
console.log(res);
|
||
const tempFilePath = res.tempFilePaths[0];
|
||
|
||
// #ifdef APP-PLUS
|
||
plus.io.resolveLocalFileSystemURL(tempFilePath, (fileEntry) => {
|
||
fileEntry.file((file) => {
|
||
const reader = new FileReader();
|
||
reader.onloadend = (e) => {
|
||
this.imageBuffer = e.target.result;
|
||
this.log = '图片读取成功,大小:' + this.imageBuffer.byteLength + '字节';
|
||
};
|
||
reader.readAsArrayBuffer(file);
|
||
});
|
||
});
|
||
// #endif
|
||
|
||
// #ifndef APP-PLUS
|
||
const fs = uni.getFileSystemManager();
|
||
fs.readFile({
|
||
filePath: tempFilePath,
|
||
encoding: 'binary',
|
||
success: res => {
|
||
this.imageBuffer = res.data;
|
||
this.log = '图片读取成功,大小:' + this.imageBuffer.byteLength + '字节';
|
||
}
|
||
});
|
||
// #endif
|
||
|
||
this.saveImageToLocal(tempFilePath);
|
||
},
|
||
fail: (err) => {
|
||
console.error('选择图片失败:', err);
|
||
uni.showToast({ title: '选择图片失败', icon: 'none' });
|
||
}
|
||
});
|
||
},
|
||
|
||
// 选择GIF动图(不裁剪,保留原始动画)
|
||
chooseGif() {
|
||
uni.chooseImage({
|
||
count: 1,
|
||
sizeType: ['original'],
|
||
sourceType: ['album'],
|
||
success: (res) => {
|
||
console.log(res);
|
||
const tempFilePath = res.tempFilePaths[0];
|
||
|
||
// 检查是否为GIF文件
|
||
if (!tempFilePath.toLowerCase().endsWith('.gif')) {
|
||
uni.showToast({ title: '请选择GIF动图文件', icon: 'none' });
|
||
return;
|
||
}
|
||
|
||
// 检测 GIF 尺寸,非 360×360 提示用户
|
||
uni.getImageInfo({
|
||
src: tempFilePath,
|
||
success: (imgInfo) => {
|
||
console.log('GIF尺寸:', imgInfo.width, '×', imgInfo.height);
|
||
const sizeOk = (imgInfo.width === 360 && imgInfo.height === 360);
|
||
|
||
const doSave = () => {
|
||
// #ifdef APP-PLUS
|
||
// 检查文件大小(SPIFFS 限制,GIF 不宜超过 500KB)
|
||
plus.io.resolveLocalFileSystemURL(tempFilePath, (fileEntry) => {
|
||
fileEntry.file((file) => {
|
||
if (file.size > 500 * 1024) {
|
||
uni.showModal({
|
||
title: '文件较大',
|
||
content: `GIF文件 ${(file.size / 1024).toFixed(0)}KB,设备存储有限,建议不超过500KB。是否继续?`,
|
||
success: (modalRes) => {
|
||
if (modalRes.confirm) {
|
||
this.saveImageToLocal(tempFilePath);
|
||
}
|
||
}
|
||
});
|
||
} else {
|
||
this.saveImageToLocal(tempFilePath);
|
||
}
|
||
// 读取文件到buffer
|
||
const reader = new FileReader();
|
||
reader.onloadend = (e) => {
|
||
this.imageBuffer = e.target.result;
|
||
this.log = 'GIF读取成功,大小:' + this.imageBuffer.byteLength + '字节';
|
||
};
|
||
reader.readAsArrayBuffer(file);
|
||
});
|
||
});
|
||
// #endif
|
||
|
||
// #ifndef APP-PLUS
|
||
this.saveImageToLocal(tempFilePath);
|
||
// #endif
|
||
};
|
||
|
||
if (!sizeOk) {
|
||
uni.showModal({
|
||
title: 'GIF尺寸不匹配',
|
||
content: `当前GIF尺寸为 ${imgInfo.width}×${imgInfo.height},设备屏幕为 360×360。非标准尺寸的GIF播放可能不流畅且无法铺满屏幕,建议使用360×360的GIF动图。是否继续上传?`,
|
||
confirmText: '继续上传',
|
||
cancelText: '取消',
|
||
success: (modalRes) => {
|
||
if (modalRes.confirm) {
|
||
doSave();
|
||
}
|
||
}
|
||
});
|
||
} else {
|
||
doSave();
|
||
}
|
||
},
|
||
fail: () => {
|
||
// 获取尺寸失败,仍然允许上传
|
||
console.warn('获取GIF尺寸失败,跳过检测');
|
||
// #ifdef APP-PLUS
|
||
plus.io.resolveLocalFileSystemURL(tempFilePath, (fileEntry) => {
|
||
fileEntry.file((file) => {
|
||
this.saveImageToLocal(tempFilePath);
|
||
const reader = new FileReader();
|
||
reader.onloadend = (e) => {
|
||
this.imageBuffer = e.target.result;
|
||
this.log = 'GIF读取成功,大小:' + this.imageBuffer.byteLength + '字节';
|
||
};
|
||
reader.readAsArrayBuffer(file);
|
||
});
|
||
});
|
||
// #endif
|
||
// #ifndef APP-PLUS
|
||
this.saveImageToLocal(tempFilePath);
|
||
// #endif
|
||
}
|
||
});
|
||
},
|
||
fail: (err) => {
|
||
console.error('选择GIF失败:', err);
|
||
uni.showToast({ title: '选择GIF失败', icon: 'none' });
|
||
}
|
||
});
|
||
},
|
||
|
||
// 保存图片到本地目录
|
||
saveImageToLocal(tempPath) {
|
||
// #ifdef APP-PLUS
|
||
// 根据文件类型保留扩展名(GIF 保持 .gif,其他统一 .jpg)
|
||
const isGif = tempPath.toLowerCase().endsWith('.gif');
|
||
const fileName = `face_${Date.now()}.${isGif ? 'gif' : 'jpg'}`;
|
||
const targetPath = this.imageDir + fileName;
|
||
|
||
plus.io.resolveLocalFileSystemURL(tempPath, (file) => {
|
||
plus.io.resolveLocalFileSystemURL(this.imageDir, (dir) => {
|
||
file.copyTo(dir, fileName, (newFile) => {
|
||
const imageUrl = newFile.toLocalURL();
|
||
this.uploadedImages.push({
|
||
name: fileName,
|
||
url: imageUrl
|
||
});
|
||
// 保存到列表后选中新图片
|
||
this.currentImageIndex = this.uploadedImages.length - 1;
|
||
this.currentImageUrl = imageUrl;
|
||
uni.showToast({ title: '图片保存成功', icon: 'success' });
|
||
}, (err) => {
|
||
console.error('保存图片失败:', err);
|
||
uni.showToast({ title: '保存图片失败', icon: 'none' });
|
||
});
|
||
});
|
||
});
|
||
// #endif
|
||
|
||
// H5和小程序保存到本地存储
|
||
// #ifndef APP-PLUS
|
||
const isGif2 = tempPath.toLowerCase().endsWith('.gif');
|
||
const fileName = `face_${Date.now()}.${isGif2 ? 'gif' : 'jpg'}`;
|
||
this.uploadedImages.push({
|
||
name: fileName,
|
||
url: tempPath
|
||
});
|
||
// 保存到本地存储
|
||
uni.setStorageSync('watch_face_images', this.uploadedImages);
|
||
// 选中新图片
|
||
this.currentImageIndex = this.uploadedImages.length - 1;
|
||
this.currentImageUrl = tempPath;
|
||
uni.showToast({ title: '图片保存成功', icon: 'success' });
|
||
// #endif
|
||
},
|
||
|
||
// 选择轮播中的图片
|
||
selectImage(index) {
|
||
this.currentImageIndex = index;
|
||
this.currentImageUrl = this.uploadedImages[index].url;
|
||
},
|
||
|
||
// 轮播图变化时触发
|
||
handleCarouselChange(e) {
|
||
const index = e.detail.current;
|
||
this.currentImageIndex = index;
|
||
this.currentImageUrl = this.uploadedImages[index].url;
|
||
},
|
||
|
||
// 压缩图片为JPEG格式(设备端只支持JPEG解码)
|
||
compressToJpeg(srcPath) {
|
||
return new Promise((resolve, reject) => {
|
||
uni.compressImage({
|
||
src: srcPath,
|
||
quality: 100,
|
||
success: (res) => {
|
||
console.log('JPEG压缩完成:', res.tempFilePath);
|
||
resolve(res.tempFilePath);
|
||
},
|
||
fail: (err) => {
|
||
console.error('JPEG压缩失败:', err);
|
||
reject(err);
|
||
}
|
||
});
|
||
});
|
||
},
|
||
|
||
// 设置为当前表盘(BLE 传输图片到设备,支持 JPEG 和 GIF)
|
||
async setAsCurrentFace() {
|
||
if (this.isSending) return;
|
||
if (this.currentImageIndex < 0 || !this.imageWriteuuid) {
|
||
uni.showToast({ title: '请先选择图片并等待BLE就绪', icon: 'none' });
|
||
return;
|
||
}
|
||
|
||
const currentImage = this.uploadedImages[this.currentImageIndex];
|
||
if (!currentImage) return;
|
||
|
||
this.isSending = true;
|
||
this.faceStatus = 1;
|
||
this.transferProgress = 0;
|
||
|
||
const isGif = currentImage.name.toLowerCase().endsWith('.gif');
|
||
|
||
try {
|
||
let buffer;
|
||
let bleName;
|
||
|
||
if (isGif) {
|
||
// GIF: 直接读取原始文件,不压缩(压缩会破坏动画帧)
|
||
uni.showLoading({ title: '读取GIF...', mask: true });
|
||
const dataURL = await this.readImageAsArrayBuffer(currentImage.url);
|
||
buffer = this.dataURLtoArrayBuffer(dataURL);
|
||
bleName = currentImage.name; // 保留 .gif 后缀
|
||
console.log('GIF读取完成,大小:', buffer.byteLength, '字节');
|
||
|
||
// GIF 文件较大时提示传输时间
|
||
if (buffer.byteLength > 200 * 1024) {
|
||
const sizeKB = (buffer.byteLength / 1024).toFixed(0);
|
||
uni.showLoading({ title: `传输GIF ${sizeKB}KB...`, mask: true });
|
||
}
|
||
} else {
|
||
// JPEG: 压缩后传输
|
||
uni.showLoading({ title: '压缩图片...', mask: true });
|
||
const jpegPath = await this.compressToJpeg(currentImage.url);
|
||
|
||
uni.showLoading({ title: '读取图片...', mask: true });
|
||
const dataURL = await this.readImageAsArrayBuffer(jpegPath);
|
||
buffer = this.dataURLtoArrayBuffer(dataURL);
|
||
bleName = currentImage.name.replace(/\.\w+$/, '.jpg');
|
||
console.log('JPEG读取完成,大小:', buffer.byteLength, '字节');
|
||
}
|
||
|
||
// BLE 分包传输
|
||
await this.writeBleImage(bleName, buffer);
|
||
|
||
uni.hideLoading();
|
||
this.faceStatus = 0;
|
||
uni.showToast({ title: '表盘已更新', icon: 'success' });
|
||
uni.setStorageSync('current_watch_face', this.currentImageUrl);
|
||
} catch (err) {
|
||
uni.hideLoading();
|
||
this.faceStatus = 2;
|
||
console.error('传输失败:', err);
|
||
uni.showToast({ title: '传输失败: ' + (err.errMsg || err.message || err), icon: 'none' });
|
||
setTimeout(() => { this.faceStatus = 0; }, 3000);
|
||
} finally {
|
||
this.isSending = false;
|
||
}
|
||
},
|
||
|
||
// 切换设备连接状态
|
||
toggleConnection() {
|
||
if (this.isConnected) {
|
||
this.disconnectDevice();
|
||
} else {
|
||
this.connectDevice();
|
||
}
|
||
},
|
||
|
||
// 连接设备
|
||
connectDevice() {
|
||
var that = this;
|
||
this.isScanning = true;
|
||
uni.showToast({ title: '正在连接设备...', icon: 'none' });
|
||
uni.createBLEConnection({
|
||
deviceId:that.deviceId,
|
||
success() {
|
||
that.isScanning = false;
|
||
that.isConnected = true;
|
||
// that.statusPotColor = 'Green';
|
||
uni.showToast({ title: '设备连接成功', icon: 'success' });
|
||
that.setBleMtu();
|
||
that.startDataSimulation();
|
||
}
|
||
})
|
||
},
|
||
|
||
// 断开连接
|
||
disconnectDevice() {
|
||
var that = this;
|
||
uni.closeBLEConnection({
|
||
deviceId:that.deviceId,
|
||
success() {
|
||
that.isConnected = false;
|
||
that.statusPotColor = 'red';
|
||
uni.showToast({ title: '已断开连接', icon: 'none' });
|
||
that.stopDataSimulation();
|
||
},
|
||
fail(res) {
|
||
if(res == 10000){
|
||
uni.openBluetoothAdapter({
|
||
success() {
|
||
that.isConnected = false;
|
||
},
|
||
fail() {
|
||
console.log('初始化失败')
|
||
}
|
||
})
|
||
}
|
||
}
|
||
})
|
||
|
||
},
|
||
|
||
// 开始模拟数据更新
|
||
startDataSimulation() {
|
||
// 清除之前的定时器
|
||
if (this.dataTimer) {
|
||
clearInterval(this.dataTimer);
|
||
}
|
||
|
||
// 初始化随机数据
|
||
this.batteryLevel = Math.floor(Math.random() * 30) + 70; // 70-100%
|
||
this.temperature = Math.floor(Math.random() * 10) + 20; // 20-30°C
|
||
this.humidity = Math.floor(Math.random() * 30) + 40; // 40-70%
|
||
|
||
// 每5秒更新一次数据
|
||
this.dataTimer = setInterval(() => {
|
||
// 电量缓慢减少
|
||
if (this.batteryLevel > 1) {
|
||
this.batteryLevel = Math.max(1, this.batteryLevel - Math.random() * 2);
|
||
}
|
||
|
||
// 温度小幅波动
|
||
this.temperature = Math.max(15, Math.min(35, this.temperature + (Math.random() * 2 - 1)));
|
||
|
||
// 湿度小幅波动
|
||
this.humidity = Math.max(30, Math.min(80, this.humidity + (Math.random() * 4 - 2)));
|
||
|
||
}, 5000);
|
||
},
|
||
|
||
// 停止数据模拟
|
||
stopDataSimulation() {
|
||
if (this.dataTimer) {
|
||
clearInterval(this.dataTimer);
|
||
this.dataTimer = null;
|
||
}
|
||
}
|
||
}
|
||
};
|
||
</script>
|
||
|
||
<style scoped>
|
||
.status-pot{
|
||
width: 16px;
|
||
height: 16px;
|
||
border-radius: 50%;
|
||
}
|
||
.container {
|
||
background-color: #f5f5f7;
|
||
min-height: 100vh;
|
||
}
|
||
|
||
/* 导航栏 */
|
||
.nav-bar {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 20rpx 30rpx;
|
||
background-color: #ffffff;
|
||
}
|
||
|
||
.nav-title {
|
||
color: white;
|
||
font-size: 36rpx;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.btn-row {
|
||
display: flex;
|
||
justify-content: center;
|
||
gap: 20rpx;
|
||
margin-top: 10px;
|
||
}
|
||
|
||
.upload-btn {
|
||
align-items: center;
|
||
gap: 8rpx;
|
||
background-color: #28d50e;
|
||
color: white;
|
||
width: 35%;
|
||
margin: 0;
|
||
padding: 14rpx 0rpx;
|
||
border-radius: 8rpx;
|
||
font-size: 26rpx;
|
||
}
|
||
|
||
.gif-btn {
|
||
background-color: #7c3aed;
|
||
}
|
||
|
||
/* 内容区 */
|
||
.content {
|
||
padding: 30rpx;
|
||
}
|
||
|
||
.section-title {
|
||
display: block;
|
||
font-size: 32rpx;
|
||
color: #333;
|
||
margin: 30rpx 0 20rpx;
|
||
font-weight: 500;
|
||
}
|
||
|
||
/* 图片预览区 */
|
||
.preview-container {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
margin-bottom: 20rpx;
|
||
}
|
||
|
||
.preview-frame {
|
||
width: 480rpx;
|
||
height: 480rpx;
|
||
border-radius: 50%;
|
||
background-color: #ffffff;
|
||
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1);
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
overflow: hidden;
|
||
position: relative;
|
||
}
|
||
|
||
.preview-image {
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
|
||
.empty-preview {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
color: #ccc;
|
||
}
|
||
|
||
.empty-preview text {
|
||
margin-top: 20rpx;
|
||
font-size: 28rpx;
|
||
}
|
||
|
||
.set-btn {
|
||
margin-top: 20rpx;
|
||
background-color: #3cbb19;
|
||
color: white;
|
||
width: 35%;
|
||
padding: 15rpx 0rpx;
|
||
border-radius: 8rpx;
|
||
font-size: 28rpx;
|
||
}
|
||
|
||
/* 卡片式轮播区 */
|
||
.carousel-section {
|
||
margin-bottom: 30rpx;
|
||
}
|
||
|
||
.carousel-container {
|
||
background-color: white;
|
||
border-radius: 16rpx;
|
||
padding: 20rpx 0;
|
||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* 卡片轮播核心样式 */
|
||
.card-swiper {
|
||
width: 100%;
|
||
height: 240rpx;
|
||
}
|
||
|
||
.card-swiper .swiper-item {
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
/* 卡片样式 */
|
||
.card-item {
|
||
width: 240rpx;
|
||
height: 240rpx;
|
||
}
|
||
|
||
.card-frame {
|
||
width: 240rpx;
|
||
height: 100%;
|
||
border-radius: 50%;
|
||
overflow: hidden;
|
||
box-shadow: 0 6rpx 16rpx rgba(0, 0, 0, 0.15);
|
||
position: relative;
|
||
}
|
||
|
||
.card-image {
|
||
width: 240rpx;
|
||
height: 100%;
|
||
}
|
||
|
||
/* 选中状态覆盖层 */
|
||
.card-overlay {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
width: 240rpx;
|
||
height: 100%;
|
||
background-color: rgba(0, 0, 0, 0.3);
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
}
|
||
|
||
/* 卡片轮播动画效果 */
|
||
.card-swiper .swiper-item:not(.swiper-item-active) {
|
||
transform: scale(0.8);
|
||
opacity: 0.6;
|
||
z-index: 1;
|
||
}
|
||
|
||
.card-swiper .swiper-item-active {
|
||
transform: scale(1);
|
||
z-index: 2;
|
||
}
|
||
|
||
.carousel-hint {
|
||
height: 200rpx;
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
color: #999;
|
||
font-size: 26rpx;
|
||
text-align: center;
|
||
}
|
||
|
||
/* 数据展示区 */
|
||
.data-section {
|
||
background-color: white;
|
||
border-radius: 16rpx;
|
||
padding: 20rpx 30rpx;
|
||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
|
||
}
|
||
|
||
.data-grid {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 20rpx;
|
||
margin-bottom: 30rpx;
|
||
}
|
||
|
||
.data-card {
|
||
background-color: #f9f9f9;
|
||
border-radius: 12rpx;
|
||
padding: 20rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 20rpx;
|
||
}
|
||
|
||
.data-icon {
|
||
width: 60rpx;
|
||
height: 60rpx;
|
||
border-radius: 12rpx;
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
}
|
||
|
||
.battery-icon {
|
||
background-color: #f6ffed;
|
||
}
|
||
|
||
.temp-icon {
|
||
background-color: #fff7e6;
|
||
}
|
||
|
||
.humidity-icon {
|
||
background-color: #e6f7ff;
|
||
}
|
||
|
||
.status-icon {
|
||
background-color: #f0f9ff;
|
||
}
|
||
|
||
.data-info {
|
||
flex: 1;
|
||
}
|
||
|
||
.data-value {
|
||
font-size: 32rpx;
|
||
font-weight: bold;
|
||
color: #333;
|
||
}
|
||
|
||
.data-label {
|
||
font-size: 24rpx;
|
||
color: #666;
|
||
}
|
||
|
||
/* 连接状态 */
|
||
.connection-status {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 15rpx 0;
|
||
border-top: 1rpx solid #f0f0f0;
|
||
}
|
||
|
||
.status-text {
|
||
flex: 1;
|
||
margin: 0 15rpx;
|
||
font-size: 28rpx;
|
||
color: #333;
|
||
}
|
||
|
||
.connect-btn {
|
||
padding: 12rpx 24rpx;
|
||
border-radius: 8rpx;
|
||
font-size: 26rpx;
|
||
background-color: #007aff;
|
||
color: white;
|
||
}
|
||
|
||
.connect-btn:disabled {
|
||
background-color: #ccc;
|
||
}
|
||
</style> |