Files
attractor/attractor-ui/pages/devices/attractor/attractor.vue
2026-04-07 11:18:02 +08:00

1252 lines
30 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<view class="page">
<scroll-view class="content" scroll-y>
<view class="section card">
<view class="section-title-row">
<text class="section-title">设备信息</text>
<text class="section-tip">连接后自动同步</text>
</view>
<view class="name-edit-row">
<input class="name-input" type="text" v-model="name" placeholder="设备名称" />
<button class="save-name-btn" @click="saveName">保存</button>
</view>
<view class="info-grid two-col">
<view class="info-item">
<text class="label">电量</text>
<text class="value">{{ battery.level }}%</text>
<text class="sub">{{ battery.mv }} mV</text>
</view>
<view class="info-item">
<text class="label">版本</text>
<text class="value">{{ ver || '--' }}</text>
</view>
</view>
</view>
<view class="section card">
<view class="section-title-row">
<text class="section-title">治疗参数</text>
</view>
<view class="mode-wrap">
<button class="mode-btn" :class="Number(mode) === 0 ? 'active' : ''" @click="setMode(0)">舒缓模式</button>
<button class="mode-btn" :class="Number(mode) === 1 ? 'active' : ''" @click="setMode(1)">强效模式</button>
</view>
<view class="field-row">
<view class="field-header">
<text class="field-label">电压</text>
<view class="quick-actions">
<button class="mini-btn" @click="decreaseVoltage">-</button>
<button class="mini-btn" @click="increaseVoltage">+</button>
</view>
</view>
<view class="voltage-pill">{{ voltageText }}</view>
</view>
<view class="field-row">
<view class="field-header">
<text class="field-label">档位</text>
<view class="quick-actions">
<button class="mini-btn" @click="sendCtrl(0x03)">-</button>
<button class="mini-btn" @click="sendCtrl(0x02)">+</button>
</view>
</view>
<slider v-model="gear" step="1" :min="1" :max="10" show-value @change="handleGearChange" activeColor="#1f9f9a" backgroundColor="#bfe9e7" />
</view>
<view class="field-row">
<text class="field-label">缩放</text>
<slider v-model="scale" :step="0.1" :min="0.1" :max="5.0" show-value @change="setScale" activeColor="#1f9f9a" backgroundColor="#bfe9e7" />
</view>
<view class="field-row">
<text class="field-label">频率</text>
<slider v-model="frequency" step="20" :min="20" :max="200" show-value @change="setFrequency" activeColor="#1f9f9a" backgroundColor="#bfe9e7" />
</view>
<view class="field-row">
<text class="field-label">脉宽</text>
<slider v-model="pluse" step="100" :min="100" :max="500" show-value @change="setPluse" activeColor="#1f9f9a" backgroundColor="#bfe9e7" />
</view>
</view>
<view class="section card">
<view class="section-title-row">
<text class="section-title">灯光个性化</text>
</view>
<view class="color-row">
<view class="color-item" @click="handleOpenStandBy">
<text class="field-label">待机颜色</text>
<view class="color-dot" :style="{ backgroundColor: standbyColor || '#1e3a8a' }"></view>
</view>
<view class="color-item" @click="handleOpenRunning">
<text class="field-label">运行颜色</text>
<view class="color-dot" :style="{ backgroundColor: runningColor || '#dc2626' }"></view>
</view>
</view>
<view class="field-row">
<text class="field-label">亮度</text>
<uni-data-select v-model="lighting" :localdata="[
{ value: 0, text: '100%' },
{ value: 1, text: '50%' },
{ value: 2, text: '25%' },
{ value: 3, text: '12.5%' },
{ value: 4, text: '6.25%' }
]" @change="setLighting" />
</view>
</view>
</scroll-view>
<view class="footer-actions">
<button class="action-btn ghost" hover-class="btn-hover" @click="sendCMD(0x01, [])">刷新状态</button>
<button
v-if="isRunning"
class="action-btn danger"
hover-class="btn-hover"
@click="stop"
>停止</button>
<button
v-else
class="action-btn primary"
hover-class="btn-hover"
@click="start"
>启动</button>
</view>
<uni-popup ref="standByPopup" type="center">
<x-color-picker v-model="standbyColor" :show-alpha="false" :show-presets="false" @confirm="setStandByColor" />
</uni-popup>
<uni-popup ref="runningPopup" type="center">
<x-color-picker v-model="runningColor" :show-alpha="false" :show-presets="false" @confirm="setRunningColor" />
</uni-popup>
</view>
</template>
<script>
function toHex(n) {
return n.toString(16).padStart(2, '0');
}
function utf8Encode(str) {
let codePoints = [];
let i = 0;
const len = str.length;
while (i < len) {
let c = str.charCodeAt(i);
// 单字节字符 (0x00-0x7F)
if (c < 0x80) {
codePoints.push(c);
i++;
}
// 双字节字符 (0x80-0x7FF)
else if (c < 0x800) {
codePoints.push(0xC0 | (c >> 6));
codePoints.push(0x80 | (c & 0x3F));
i++;
}
// 三字节字符 (0x800-0xFFFF)
else if (c < 0x10000) {
codePoints.push(0xE0 | (c >> 12));
codePoints.push(0x80 | ((c >> 6) & 0x3F));
codePoints.push(0x80 | (c & 0x3F));
i++;
}
// 四字节字符 (0x10000-0x10FFFF)
else {
c -= 0x10000;
codePoints.push(0xF0 | (c >> 18));
codePoints.push(0x80 | ((c >> 12) & 0x3F));
codePoints.push(0x80 | ((c >> 6) & 0x3F));
codePoints.push(0x80 | (c & 0x3F));
i++;
}
}
return codePoints; // 直接返回数组,无需再转 Array.from
}
function getErrStr(e) {
return {
0: "成功",
1: "运行中禁止",
2: "参数越界",
3: "档位极限",
0xEE: "校验错误",
0xFF: "未知命令"
} [e] || `Err(0x${e.toString(16)})`;
}
function safeDecode(bytes) {
try {
// 1. 统一格式转为Uint8Array兼容uniapp各端传入的不同字节格式
let uint8Bytes = bytes;
if (!(bytes instanceof Uint8Array)) {
uint8Bytes = new Uint8Array(bytes);
}
// 2. 找到第一个0终止符截断无效内容和原逻辑一致
let endIndex = -1;
for (let i = 0; i < uint8Bytes.length; i++) {
if (uint8Bytes[i] === 0) {
endIndex = i;
break;
}
}
if (endIndex !== -1) {
uint8Bytes = uint8Bytes.slice(0, endIndex);
}
// 3. 兼容处理部分小程序端TextDecoder支持性差增加兜底方案
let result = '';
if (typeof TextDecoder !== 'undefined') {
// 优先使用标准TextDecoder
result = new TextDecoder("utf-8", {
fatal: false
}).decode(uint8Bytes);
} else {
// 兜底手动转换UTF-8字节到字符串兼容低版本小程序
result = decodeURIComponent(escape(String.fromCharCode.apply(null, uint8Bytes)));
}
// 过滤解码后的空字符(避免不可见字符)
return result.replace(/\0/g, '');
} catch (e) {
console.error('safeDecode解码失败', e);
return "[Decode Err]";
}
}
export default {
data() {
return {
isRunning: false,
deviceId: '',
logs: [],
name: '',
sn: '',
ver: '',
mode: '0',
gear: 1,
timer: '15',
frequency: '100',
pluse: '200',
scale: '1',
voltage: '0',
standbyColor: '',
runningColor: '',
lighting: '0',
SERVICE_UUID: '',
CHAR_WRITE_UUID: '',
CHAR_NOTIFY_UUID: '',
battery: {
level: 0,
mv: 1600
},
inited: false,
rxBuffer: [],
timer1: '',
timer2: ''
}
},
onLoad(options) {
uni.hideLoading()
if (options && options.deviceId) {
this.deviceId = decodeURIComponent(options.deviceId)
}
if (!this.deviceId) {
uni.showToast({
title: '缺少设备ID',
icon: 'none'
})
return
}
const that = this
uni.openBluetoothAdapter({
success() {
console.log('蓝牙已就绪')
that.inited = true
that.relink()
},
fail() {
uni.showToast({
title: '蓝牙不可用,请开启蓝牙',
icon: 'none'
})
}
})
},
destroyed() {
// 关闭蓝牙适配器,释放资源
uni.closeBluetoothAdapter({
success() {
console.log('蓝牙适配器已关闭');
},
fail(err) {
// 捕获关闭失败的异常(比如蓝牙未打开时),避免控制台报错
console.warn('关闭蓝牙适配器失败', err);
}
});
clearTimeout(this.timer1);
clearInterval(this.timer2)
},
computed: {
stimPl() {
const pl = [
parseInt(this.mode),
parseInt(this.gear) - 1,
parseInt(this.timer),
parseInt(this.frequency) >> 8,
parseInt(this.frequency) & 0xFF,
parseInt(this.pluse) >> 8,
parseInt(this.pluse) & 0xFF,
Math.round(parseFloat(this.scale) * 1000) >> 8,
Math.round(parseFloat(this.scale) * 1000) & 0xFF,
parseInt(this.voltage)
]
return pl
},
voltageText() {
const map = ['11V', '15V', '26V', '30V', '45V', '48V', '52V', '56V']
return map[parseInt(this.voltage)] || '--'
}
},
methods: {
handleGearChange(e) {
this.gear = e.detail.value;
this.sendSingle('GEAR');
},
setFrequency(e) {
this.frequency = e.detail.value;
this.sendStimAll()
},
setPluse(e) {
this.pluse = e.detail.value;
this.sendStimAll()
},
sendStimAll() {
this.sendCMD(0x10, this.stimPl)
},
// 设置设备名称
saveName() {
const nextName = (this.name || '').trim()
if (!nextName) {
uni.showToast({ title: '请输入设备名称', icon: 'none' })
return
}
const pl = utf8Encode(nextName)
this.sendCMD(0xC1, Array.from(pl))
uni.showToast({ title: '已发送保存', icon: 'none' })
},
relink() {
const that = this;
if (!this.inited) {
uni.openBluetoothAdapter({
success() {
that.inited = true
that.relink()
}
})
return;
}
const deviceId = this.deviceId;
uni.showLoading({ title: '正在准备连接设备' })
const bindNotifyAndReady = () => {
uni.notifyBLECharacteristicValueChange({
deviceId,
serviceId: that.SERVICE_UUID,
characteristicId: that.CHAR_NOTIFY_UUID,
state: true,
success() {
uni.onBLECharacteristicValueChange((res) => {
const data = new Uint8Array(res.value)
that.rxBuffer = that.rxBuffer || []
that.rxBuffer.push(...data)
while (true) {
let headIdx = -1
for (let i = 0; i < that.rxBuffer.length - 1; i++) {
if (that.rxBuffer[i] === 0xAA && that.rxBuffer[i + 1] === 0x55) {
headIdx = i
break
}
}
if (headIdx === -1) {
if (that.rxBuffer.length > 200) that.rxBuffer = []
break
}
if (headIdx > 0) that.rxBuffer.splice(0, headIdx)
if (that.rxBuffer.length < 3) break
const len = that.rxBuffer[2]
const totalLen = len + 4
if (that.rxBuffer.length < totalLen) break
const packet = that.rxBuffer.slice(0, totalLen)
that.rxBuffer.splice(0, totalLen)
that.parsePkt(packet)
}
})
that.timer1 = setTimeout(() => that.sendCMD(0xC0, []), 500)
uni.hideLoading()
},
fail(err) {
uni.hideLoading()
console.log('订阅失败', err)
uni.showToast({ icon: 'none', title: '订阅通知失败' })
}
})
}
const tryBindService = (services, idx) => {
if (idx >= services.length) {
uni.hideLoading()
console.log('可用服务列表', services)
uni.showToast({ icon: 'none', title: '未找到可通信特征值' })
return
}
const currService = services[idx]
uni.getBLEDeviceCharacteristics({
deviceId,
serviceId: currService.uuid,
success(charRes) {
const characteristics = charRes.characteristics || []
let writeUUID = ''
let notifyUUID = ''
for (let i = 0; i < characteristics.length; i++) {
const item = characteristics[i]
const cUuid = (item.uuid || '').toLowerCase()
const p = item.properties || {}
if (!writeUUID && (cUuid.includes('ae01') || p.write || p.writeNoResponse)) {
writeUUID = item.uuid
}
if (!notifyUUID && (cUuid.includes('ae02') || p.notify || p.indicate)) {
notifyUUID = item.uuid
}
}
if (writeUUID && notifyUUID) {
that.SERVICE_UUID = currService.uuid
that.CHAR_WRITE_UUID = writeUUID
that.CHAR_NOTIFY_UUID = notifyUUID
bindNotifyAndReady()
return
}
tryBindService(services, idx + 1)
},
fail() {
tryBindService(services, idx + 1)
}
})
}
const fetchServicesWithRetry = (retry = 0) => {
uni.getBLEDeviceServices({
deviceId,
success(res) {
const services = (res.services || []).filter(item => item && item.uuid)
console.log('服务', services)
if (!services.length) {
if (retry < 4) {
setTimeout(() => fetchServicesWithRetry(retry + 1), 700)
return
}
uni.hideLoading()
uni.showToast({ icon: 'none', title: '未发现可用服务' })
return
}
tryBindService(services, 0)
},
fail(error) {
if (retry < 1) {
setTimeout(() => fetchServicesWithRetry(retry + 1), 500)
return
}
uni.hideLoading()
console.log('读取服务失败', error)
uni.showToast({ icon: 'none', title: '设备连接异常' })
}
})
}
const continueWithConnectedLink = () => {
that.CHAR_WRITE_UUID = ''
that.CHAR_NOTIFY_UUID = ''
setTimeout(() => fetchServicesWithRetry(0), 500)
}
uni.getConnectedBluetoothDevices({
services: [],
success(res) {
const connected = (res.devices || []).some(d => d.deviceId === deviceId)
if (connected) {
console.log('复用已存在连接', deviceId)
continueWithConnectedLink()
return
}
uni.createBLEConnection({
deviceId,
success() {
console.log('新建BLE连接', deviceId)
continueWithConnectedLink()
},
fail(error) {
uni.hideLoading()
console.log(error)
uni.showToast({ icon: 'none', title: '设备连接失败' })
}
})
},
fail() {
uni.createBLEConnection({
deviceId,
success() {
console.log('新建BLE连接(兜底)', deviceId)
continueWithConnectedLink()
},
fail(error) {
uni.hideLoading()
console.log(error)
uni.showToast({ icon: 'none', title: '设备连接失败' })
}
})
}
})
},
// 构建底层协议
buildPacket(cmd, payload) {
const len = payload.length + 1;
let sum = len + cmd;
payload.forEach(b => sum += b);
const pkt = [0xAA, 0x55, len, cmd, ...payload];
pkt.push(sum & 0xFF);
return new Uint8Array(pkt);
},
// 记录日志 + 发送消息
sendCMD(cmd, payload, desc) {
const pkt = this.buildPacket(cmd, payload);
// 发送消息
uni.writeBLECharacteristicValue({
deviceId: this.deviceId,
serviceId: this.SERVICE_UUID,
characteristicId: this.CHAR_WRITE_UUID,
writeType: 'writeNoResponse',
value: pkt.buffer,
success() {
console.log('写入成功', pkt, pkt.buffer)
},
fail(error) {
console.log(error)
}
})
// 记录日志
},
log() {
// 本地日志 + 云端日志记录
},
// 单设通用
sendSingle(t) {
if (t == 'GEAR') {
console.log(this.gear)
this.sendCMD(0x13, [parseInt(this.gear) - 1], '单设档位');
} else if (t === 'SCALE') {
const s = Math.round(parseFloat(this.scale) * 1000);
this.sendCMD(0x14, [s >> 8, s & 0xFF], '单设缩放');
} else if (t === 'VOLT') {
this.sendCMD(0x15, [parseInt(this.voltage)], '单设电压')
} else if (t === 'BRIGHT') {
this.sendCMD(0x22, [parseInt(this.lighting)], '单设亮度')
} else if (t === 'COLOR') {
const [s_r, s_g, s_b] = this.getColorRGB(this.standbyColor);
const [r_r, r_g, r_b] = this.getColorRGB(this.runningColor);
this.sendCMD(0x21, [s_r, s_g, s_b, r_r, r_g, r_b], '单设颜色')
}
},
getColorRGB(hex) {
const r = parseInt(hex.substr(1, 2), 16)
const g = parseInt(hex.substr(3, 2), 16)
const b = parseInt(hex.substr(5, 2), 16)
return [r, g, b]
},
// 控制
sendCtrl(a) {
this.sendCMD(0x30, [a], `控制: ${['Stop','Start','Gear+','Gear-','ModeSw'][a]}`)
},
// 获取设备电刺激信息
// 设定电刺激
// 单设挡位
// 单设缩放
setScale(e) {
const value = e.detail.value
this.sendSingle("SCALE")
},
// 单设电压
setVoltage1(e) {
console.log(e)
const value = e;
this.sendSingle('VOLT')
},
increaseVoltage() {
const curr = parseInt(this.voltage)
if (curr >= 7) return
this.voltage = curr + 1
this.sendSingle('VOLT')
},
decreaseVoltage() {
const curr = parseInt(this.voltage)
if (curr <= 0) return
this.voltage = curr - 1
this.sendSingle('VOLT')
},
// 获取设备信息
// 修改设备名称
// 获取个性化信息
// 设置个性化信息
// 单设亮度
// 单设颜色
// 启动
start() {
if (this.isRunning) return
this.sendCtrl(0x01)
this.isRunning = true
},
// 停止
stop() {
if (!this.isRunning) return
this.sendCtrl(0x00)
this.isRunning = false
},
// 加档
// 减档
// 换模
setMode(m) {
if (this.mode === m) return
this.mode = m
if (m == 0) {
this.sendCMD(0x11, [this.timer, this.frequency >> 8, this.frequency & 0xFF, this.pluse >> 8, this.pluse &
0xFF
])
} else {
this.sendCMD(0x12, [this.timer, this.frequency >> 8, this.frequency & 0xFF, this.pluse >> 8, this.pluse &
0xFF
])
}
this.sendCtrl(0x04)
},
handleModeChange(e) {
const m = Number(e && e.detail ? e.detail.value : 0)
this.setMode(m)
},
// 单设档位
handleOpenStandBy() {
this.$refs.standByPopup.open()
},
handleOpenRunning() {
this.$refs.runningPopup.open()
},
setStandByColor(c) {
this.standbyColor = c.hex
this.$refs.standByPopup.close()
this.sendSingle('COLOR')
},
setRunningColor(c) {
this.runningColor = c.hex
this.$refs.runningPopup.close()
this.sendSingle('COLOR')
},
setLighting(v) {
this.sendSingle('BRIGHT')
},
parsePkt(pkt) {
const cmd = pkt[3];
const pl = pkt.slice(4, pkt.length - 1);
let desc = '',
style = '';
console.log('解析pkt', toHex(cmd), pl)
try {
switch (cmd) {
case 0xFF: // ACK
if (pl[1] === 0) desc = `ACK成功: 0x${pl[0].toString(16).toUpperCase()}`;
else {
desc = `ACK失败: 0x${pl[0].toString(16).toUpperCase()} -> ${getErrStr(pl[1])}`;
style = "err";
}
break;
case 0x80: // REPORT ALL
case 0x81: { // REPORT STIM
console.log('获取所有参数')
const m = pl[0],
g = pl[1],
t = pl[2];
const f = (pl[3] << 8) | pl[4],
w = (pl[5] << 8) | pl[6],
s = (pl[7] << 8) | pl[8],
v = pl[9],
run = pl[10];
desc =
`状态: ${m===0?'舒缓':'强效'}, ${g+1}档, ${t}min, ${f}Hz, ${w}us, ${(s/1000).toFixed(1)}x}`;
this.mode = m;
this.gear = g + 1;
this.timer = t;
this.frequency = f;
this.pluse = w;
this.scale = (s / 1000).toFixed(1);
this.voltage = v;
this.isRunning = !!run;
style = "sync";
if (cmd === 0x80 && pl.length >= 18) {
const sr = pl[11],
sg = pl[12],
sb = pl[13],
rr = pl[14],
rg = pl[15],
rb = pl[16],
br = pl[17];
desc +=
`\n个性: Sys(#${toHex(sr)}${toHex(sg)}${toHex(sb)}) Run(#${toHex(rr)}${toHex(rg)}${toHex(rb)})}`;
this.standbyColor = '#' + toHex(sr) + toHex(sg) + toHex(sb)
this.runningColor = '#' + toHex(rr) + toHex(rg) + toHex(rb)
console.log(this.standbyColor, this.runningColor)
this.lighting = br;
}
break;
}
case 0x82: { // REPORT PERSONAL
sync = true;
const sr2 = pl[0],
sg2 = pl[1],
sb2 = pl[2],
rr2 = pl[3],
rg2 = pl[4],
rb2 = pl[5],
br2 = pl[6];
desc =
`个性化: Sys(#${toHex(sr2)}${toHex(sg2)}${toHex(sb2)}) Run(#${toHex(rr2)}${toHex(rg2)}${toHex(rb2)}) 亮度${getBrightStr(br2)}`;
this.standbyColor = '#' + toHex(sr2) + toHex(sg2) + toHex(sb2)
this.runningColor = '#' + toHex(rr2) + toHex(rg2) + toHex(sb2)
this.lighting = br2;
break;
}
case 0xC0: { // Get All Info
style = "sys";
let ptr = 0;
if (pl.length > 3) {
const snLen = pl[ptr++];
console.log(snLen, ptr, pl.length)
if (ptr + snLen < pl.length) {
console.log('开始解析SN')
const sn = safeDecode(pl.slice(ptr, ptr + snLen));
console.log('SN', sn)
ptr += snLen;
this.sn = sn
desc += `SN:${sn} `;
}
if (ptr < pl.length) {
const nameLen = pl[ptr++];
if (ptr + nameLen < pl.length) {
const name = safeDecode(pl.slice(ptr, ptr + nameLen));
ptr += nameLen;
this.name = name
desc += `Name:${name} `;
}
}
if (ptr + 2 < pl.length) {
const v1 = pl[ptr++],
v2 = pl[ptr++],
v3 = pl[ptr++];
// safeSetVal('sys_ver', `v${v1}.${v2}.${v3}`);
this.ver = `v${v1}.${v2}.${v3}`
desc += `Ver:v${v1}.${v2}.${v3}`;
}
}
break;
}
case 0xC1: // Set Name Echo
case 0xC4: { // Get Name
style = "sys";
const nameStr = safeDecode(pl);
desc = `蓝牙名: ${nameStr}`;
this.name = nameStr
break;
}
case 0xC3: { // Get SN
style = "sys";
const snStr = safeDecode(pl);
desc = `SN序列号: ${snStr}`;
this.sn = snStr
break;
}
case 0xC5: { // Get Version
style = "sys";
desc = `固件版本: v${pl[0]}.${pl[1]}.${pl[2]}`;
this.ver = `v${pl[0]}.${pl[1]}.${pl[2]}`
break;
}
case 0xC2:
desc = `设置SN结果: ${pl[0]===0?'成功':'失败'}`;
style = "sys";
break;
case 0xB0:
desc = "通知: 蓝牙已连接";
uni.showToast({
title: '蓝牙已连接',
icon: 'none'
});
// 连接成功后获取一次设备信息
this.sendCMD(0xC0, [])
uni.hideLoading()
break;
case 0xB2:
desc = "通知: [低电量关机请求]";
uni.showToast({
icon: 'exception',
title: '电量不足'
})
style = "err";
break;
case 0xA1:
this.battery = {
level: pl[1],
mv: (pl[2] << 8 | pl[3])
}
desc = `电池上报: ${pl[1]}%, ${(pl[2]<<8|pl[3])}mV`;
break;
default:
desc = `CMD: 0x${cmd.toString(16).toUpperCase()}`;
}
} catch (e) {
desc = "解析异常: " + e.message;
style = "err";
}
},
}
}
</script>
<style scoped>
:root {
--bg-page: #f8fafc;
--bg-card: #ffffff;
--bg-soft: #f8fafc;
--border-soft: #edf2f7;
--border-input: #e2e8f0;
--text-main: #0f172a;
--text-sub: #64748b;
--text-muted: #94a3b8;
--primary: #1f9f9a;
--primary-soft: #e6f5f4;
--danger-soft: #fef2f2;
--danger: #b91c1c;
}
@media (prefers-color-scheme: dark) {
:root {
--bg-page: #0f172a;
--bg-card: #111827;
--bg-soft: #1f2937;
--border-soft: #273244;
--border-input: #334155;
--text-main: #e5e7eb;
--text-sub: #cbd5e1;
--text-muted: #94a3b8;
--primary: #22b3ad;
--primary-soft: #113c39;
--danger-soft: #3b1e24;
--danger: #f87171;
}
}
.page {
background: var(--bg-page);
min-height: 100vh;
display: flex;
flex-direction: column;
}
.content {
flex: 1;
padding: 20rpx 20rpx 140rpx;
box-sizing: border-box;
}
.section {
margin-bottom: 16rpx;
}
.card {
background: #ffffff;
border: 1rpx solid #edf2f7;
border-radius: 16rpx;
padding: 20rpx;
}
.section-title-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 14rpx;
}
.section-title {
font-size: 28rpx;
font-weight: 600;
color: #0f172a;
}
.section-tip {
font-size: 22rpx;
color: #94a3b8;
}
.name-edit-row {
display: flex;
align-items: center;
gap: 12rpx;
margin-bottom: 16rpx;
}
.name-input {
flex: 1;
height: 74rpx;
line-height: 74rpx;
background: var(--bg-soft);
border-radius: 12rpx;
padding: 0 18rpx;
font-size: 28rpx;
color: var(--text-main);
}
.save-name-btn {
height: 74rpx;
line-height: 74rpx;
padding: 0 20rpx;
border-radius: 12rpx;
border: 1rpx solid var(--border-input);
background: var(--bg-soft);
color: var(--text-sub);
font-size: 24rpx;
}
.info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12rpx;
}
.info-item {
background: #f8fafc;
border-radius: 12rpx;
padding: 14rpx;
display: flex;
flex-direction: column;
gap: 4rpx;
}
.label {
font-size: 22rpx;
color: #64748b;
}
.value {
font-size: 30rpx;
font-weight: 600;
color: #0f172a;
}
.sub {
font-size: 22rpx;
color: #94a3b8;
}
.text-ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mode-wrap {
margin-bottom: 12rpx;
display: flex;
gap: 12rpx;
}
.mode-btn {
flex: 1;
height: 68rpx;
line-height: 68rpx;
border-radius: 10rpx;
border: 1rpx solid #bfe9e7;
background: #f2fbfa;
color: #0f7f7a;
font-size: 24rpx;
font-weight: 500;
}
.mode-btn.active {
background: #1f9f9a;
border-color: #1f9f9a;
color: #ffffff;
}
.field-row {
margin-top: 12rpx;
}
.field-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8rpx;
}
.field-label {
font-size: 24rpx;
color: #0f7f7a;
margin-bottom: 6rpx;
display: block;
}
.quick-actions {
display: flex;
gap: 10rpx;
}
.mini-btn {
min-width: 54rpx;
height: 44rpx;
line-height: 44rpx;
border-radius: 10rpx;
background: #f2fbfa;
color: #0f7f7a;
font-size: 24rpx;
padding: 0 12rpx;
border: 1rpx solid #bfe9e7;
}
.voltage-pill {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 120rpx;
height: 56rpx;
padding: 0 16rpx;
border-radius: 10rpx;
background: #f2fbfa;
border: 1rpx solid #bfe9e7;
font-size: 24rpx;
color: #0f7f7a;
font-weight: 600;
}
.color-row {
display: flex;
gap: 12rpx;
margin-bottom: 8rpx;
}
.color-item {
flex: 1;
background: #f8fafc;
border-radius: 12rpx;
padding: 14rpx;
display: flex;
justify-content: space-between;
align-items: center;
}
.color-dot {
width: 44rpx;
height: 44rpx;
border-radius: 10rpx;
border: 1rpx solid #e2e8f0;
}
.footer-actions {
position: fixed;
left: 0;
right: 0;
bottom: 0;
padding: 16rpx 20rpx 20rpx;
background: #f8fafc;
display: flex;
gap: 12rpx;
border-top: 1rpx solid #edf2f7;
}
.action-btn {
flex: 1;
height: 76rpx;
line-height: 76rpx;
border-radius: 12rpx;
font-size: 26rpx;
border: none;
}
.action-btn.ghost {
background: #e2e8f0;
color: #334155;
}
.action-btn.danger {
background: #fee2e2;
color: #b91c1c;
}
.action-btn.primary {
background: #1f9f9a;
color: #ffffff;
}
.action-btn.danger {
background: var(--danger-soft);
color: var(--danger);
}
.btn-hover {
transform: scale(0.98);
opacity: 0.9;
}
.action-btn,
.mini-btn,
.save-name-btn,
.color-item {
transition: all 0.15s ease;
}
.action-btn:active,
.mini-btn:active,
.save-name-btn:active,
.color-item:active {
transform: scale(0.98);
opacity: 0.9;
}
/* 统一 uni 组件视觉 */
:deep(.uni-data-checklist) {
width: 100%;
}
:deep(.uni-data-checklist .checklist-group) {
display: flex;
gap: 12rpx;
}
:deep(.uni-data-checklist .checklist-box) {
flex: 1;
padding: 14rpx 0;
border-radius: 10rpx;
border: 1rpx solid #bfe9e7;
background: #f2fbfa;
text-align: center;
}
:deep(.uni-data-checklist .checklist-box.is--default.is-checked),
:deep(.uni-data-checklist .checklist-box.is-checked),
:deep(.uni-data-checklist .checklist-box.is--tag.is-checked) {
background: #e6f5f4 !important;
border-color: #bfe9e7 !important;
}
:deep(.uni-data-checklist .checklist-content) {
justify-content: center;
}
:deep(.uni-data-checklist .checklist-text) {
font-size: 24rpx;
color: #0f7f7a;
}
:deep(.uni-data-checklist .checklist-box.is--default.is-checked .checklist-text),
:deep(.uni-data-checklist .checklist-box.is-checked .checklist-text),
:deep(.uni-data-checklist .checklist-box.is--tag.is-checked .checklist-text) {
color: #0f7f7a !important;
font-weight: 600;
}
/* 覆盖 uni-data-checkbox 在 tag 模式下内部 uni-tag 的默认蓝色 */
:deep(.uni-data-checklist .uni-tag),
:deep(.uni-data-checklist .uni-tag--default) {
background-color: #f2fbfa !important;
border-color: #bfe9e7 !important;
color: #0f7f7a !important;
}
:deep(.uni-data-checklist .uni-tag--primary),
:deep(.uni-data-checklist .uni-tag--primary.uni-tag--inverted),
:deep(.uni-data-checklist .is--checked .uni-tag) {
background-color: #e6f5f4 !important;
border-color: #7fd3cf !important;
color: #0f7f7a !important;
}
:deep(.uni-data-checklist .uni-tag-text) {
color: inherit !important;
}
:deep(.uni-select) {
border-radius: 10rpx !important;
border: 1rpx solid #e2e8f0 !important;
background: #f8fafc !important;
min-height: 72rpx;
}
:deep(.uni-select__selector) {
border-radius: 10rpx !important;
border-color: #e2e8f0 !important;
}
:deep(.uni-select__input-box) {
padding: 0 12rpx;
}
:deep(.uni-select__input-text) {
font-size: 24rpx !important;
color: #0f172a !important;
}
:deep(.uni-icons) {
color: #64748b !important;
}
:deep(.uni-slider-handle-wrapper) {
height: 6rpx !important;
}
:deep(.uni-slider-thumb) {
width: 28rpx !important;
height: 28rpx !important;
border: 2rpx solid #ffffff !important;
box-shadow: 0 0 0 1rpx #bfdbfe !important;
}
</style>