Files
intc-mixer-app/src/pages/mixer/control.vue
2026-01-21 20:23:35 +08:00

872 lines
22 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="control-page">
<!-- 顶部导航栏 -->
<view class="custom-navbar">
<view class="navbar-content">
<view class="nav-left" @click="goBack">
<uni-icons type="back" color="#fff" size="20"></uni-icons>
</view>
<view class="nav-center">
<text class="nav-title">{{ deviceName || 'Six-Channel Mixer' }}</text>
</view>
<view class="nav-right">
<view class="bluetooth-status">
<uni-icons type="bluetooth-filled" color="#fff" size="20"></uni-icons>
<text class="status-text">{{ connected ? 'Connected' : 'Disconnected' }}</text>
</view>
</view>
</view>
</view>
<view class="control-container">
<!-- 无使能通道提示 -->
<view v-if="enabledChannels.length === 0" class="empty-tip">
<uni-icons type="info" color="#94a3b8" size="48"></uni-icons>
<text class="empty-text">No enabled channels</text>
<text class="empty-hint">Please enable channels in Setting page first</text>
</view>
<!-- 通道状态列表 -->
<scroll-view v-else class="channel-scroll" scroll-y>
<view class="channel-list">
<view
v-for="channel in enabledChannels"
:key="channel.id"
class="channel-card"
>
<view class="card-header">
<text class="channel-title">{{ channel.name }}</text>
<view :class="['status-badge', getStatusClass(channel.runStatus)]">
<text>{{ getStatusText(channel.runStatus) }}</text>
</view>
</view>
<view class="status-info">
<!-- 三个模式状态显示 -->
<view class="mode-status-row">
<view :class="['mode-item', { active: channel.currentMode === 1, done: channel.currentMode > 1 }]">
<text class="mode-icon"></text>
<text class="mode-name">Fast</text>
<text v-if="channel.currentMode > 1" class="done-mark"></text>
</view>
<view :class="['mode-item', { active: channel.currentMode === 2, done: channel.currentMode > 2 }]">
<text class="mode-icon">🔄</text>
<text class="mode-name">Slow</text>
<text v-if="channel.currentMode > 2" class="done-mark"></text>
</view>
<view :class="['mode-item', { active: channel.currentMode === 3, done: channel.runStatus === 3 && channel.currentMode === 3 }]">
<text class="mode-icon"></text>
<text class="mode-name">Still</text>
<text v-if="channel.runStatus === 3" class="done-mark"></text>
</view>
</view>
<view class="info-row">
<text class="info-label">Remaining</text>
<text class="info-value time">{{ formatTime(channel.remainingTime) }}</text>
</view>
</view>
<!-- 进度条 -->
<view class="progress-bar">
<view
class="progress-fill"
:style="{ width: getProgressPercent(channel) + '%' }"
></view>
</view>
</view>
</view>
</scroll-view>
<!-- 控制按钮区域 -->
<view v-if="enabledChannels.length > 0" class="control-buttons">
<view class="button-row">
<view
:class="['ctrl-btn', 'start', { disabled: !canStart }]"
@click="handleStart"
>
<uni-icons type="circle-filled" color="#fff" size="24"></uni-icons>
<text>Start</text>
</view>
<view
:class="['ctrl-btn', 'pause', { disabled: !canPause }]"
@click="handlePause"
>
<uni-icons type="minus-filled" color="#fff" size="24"></uni-icons>
<text>Pause</text>
</view>
</view>
<view class="button-row">
<view
:class="['ctrl-btn', 'resume', { disabled: !canResume }]"
@click="handleResume"
>
<uni-icons type="forward" color="#fff" size="24"></uni-icons>
<text>Resume</text>
</view>
<view
:class="['ctrl-btn', 'stop', { disabled: !canStop }]"
@click="handleStop"
>
<uni-icons type="close" color="#fff" size="24"></uni-icons>
<text>Exit</text>
</view>
</view>
</view>
</view>
<!-- 底部Tab导航 -->
<view class="bottom-tabs">
<view class="tab-item" @click="goToSetting">
<uni-icons type="gear-filled" color="#666" size="24"></uni-icons>
<text class="tab-text">Setting</text>
</view>
<view class="tab-item active">
<uni-icons type="right" color="#667eea" size="24"></uni-icons>
<text class="tab-text active">Start</text>
</view>
</view>
</view>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
// 本地存储 key
const STORAGE_KEY = 'mixer_settings'
// 蓝牙连接信息
const connected = ref(false)
const deviceId = ref('')
const deviceName = ref('')
const serviceId = ref('')
// 新协议1.2特征值UUID从设置页面传递
const settingCharId = ref('') // 0xFF01 设置参数
const statusCharId = ref('') // 0xFF02 运行状态
const controlCharId = ref('') // 0xFF03 控制指令
// 定时器
let statusTimer = null
// 通道数据(从设置页面加载)
const channels = ref([])
// 已使能的通道
const enabledChannels = computed(() => {
return channels.value.filter(c => c.enabled)
})
// 按钮状态计算
const canStart = computed(() => {
return enabledChannels.value.some(c => c.runStatus === 0 || c.runStatus === 3)
})
const canPause = computed(() => {
return enabledChannels.value.some(c => c.runStatus === 1)
})
const canResume = computed(() => {
return enabledChannels.value.some(c => c.runStatus === 2)
})
const canStop = computed(() => {
return enabledChannels.value.some(c => c.runStatus === 1 || c.runStatus === 2)
})
// 页面加载
onLoad((options) => {
if (options.deviceId) {
deviceId.value = options.deviceId
deviceName.value = decodeURIComponent(options.deviceName || '') || 'Six-Channel Mixer'
serviceId.value = options.serviceId || ''
// 接收新协议1.2特征值UUID
settingCharId.value = options.settingCharId || ''
statusCharId.value = options.statusCharId || ''
controlCharId.value = options.controlCharId || ''
connected.value = true
}
// 加载设置
loadSettings()
// 初始化蓝牙通信
if (connected.value) {
initBluetooth()
}
})
// 页面卸载
onUnmounted(() => {
if (statusTimer) {
clearInterval(statusTimer)
statusTimer = null
}
})
// 加载设置
const loadSettings = () => {
try {
const savedData = uni.getStorageSync(STORAGE_KEY)
if (savedData) {
const data = JSON.parse(savedData)
// 合并设置数据和运行状态
channels.value = (data.channels || []).map(c => ({
...c,
runStatus: 0, // 运行状态: 0=等待 1=运行 2=暂停 3=完成
currentMode: 0, // 当前模式: 1=快速 2=慢速 3=静置
remainingTime: 0 // 剩余时间(秒)
}))
// 如果没有从 URL 接收到特征值,从本地存储读取
if (!settingCharId.value && data.settingCharId) {
settingCharId.value = data.settingCharId
}
if (!statusCharId.value && data.statusCharId) {
statusCharId.value = data.statusCharId
}
if (!controlCharId.value && data.controlCharId) {
controlCharId.value = data.controlCharId
}
}
} catch (e) {
console.error('Failed to load settings:', e)
}
}
// 初始化蓝牙
const initBluetooth = () => {
// 注册蓝牙数据监听
uni.onBLECharacteristicValueChange((res) => {
handleBluetoothData(res)
})
// 发送设置参数
sendSettings()
// 启动状态轮询
startStatusPolling()
}
// 发送设置参数到设备 (0xFF01)
const sendSettings = async () => {
const enabledList = enabledChannels.value
if (enabledList.length === 0) return
if (!settingCharId.value) {
console.error('设置参数特征值UUID未配置')
return
}
// 顺序发送每个通道的配置,避免蓝牙指令冲突
for (const channel of enabledList) {
const index = parseInt(channel.id.replace('CH', ''))
const buffer = buildSettingBuffer(index, channel)
await writeBLEDataAsync(settingCharId.value, buffer)
// 每条指令之间延迟100ms确保蓝牙设备有时间处理
await new Promise(resolve => setTimeout(resolve, 100))
}
console.log(`已发送 ${enabledList.length} 条配置指令`)
}
// 构建设置参数buffer
// 格式: 电机序号(1) + 使能(1) + 快速模式(1) + 快速速度(2) + 快速时间(2) +
// 慢速模式(1) + 慢速速度(2) + 慢速时间(2) + 静置模式(1) + 静置速度(2) + 静置时间(2)
const buildSettingBuffer = (motorIndex, channel) => {
const buffer = new ArrayBuffer(17)
const view = new Uint8Array(buffer)
let offset = 0
view[offset++] = motorIndex // 电机序号 1-6
view[offset++] = channel.enabled ? 1 : 0 // 使能
// 快速模式
view[offset++] = 1 // 模式标识
view[offset++] = channel.fastSpeed & 0xFF // 速度低位
view[offset++] = (channel.fastSpeed >> 8) & 0xFF // 速度高位
view[offset++] = channel.fastTime & 0xFF // 时间低位(秒)
view[offset++] = (channel.fastTime >> 8) & 0xFF // 时间高位
// 慢速模式
view[offset++] = 2 // 模式标识
view[offset++] = channel.slowSpeed & 0xFF // 速度低位
view[offset++] = (channel.slowSpeed >> 8) & 0xFF // 速度高位
const slowTimeSeconds = channel.slowTime * 60 // 转换为秒
view[offset++] = slowTimeSeconds & 0xFF // 时间低位
view[offset++] = (slowTimeSeconds >> 8) & 0xFF // 时间高位
// 静置模式
view[offset++] = 3 // 模式标识
view[offset++] = 0 // 速度低位(固定0)
view[offset++] = 0 // 速度高位(固定0)
const stillTimeSeconds = channel.stillTime * 60 // 转换为秒
view[offset++] = stillTimeSeconds & 0xFF // 时间低位
view[offset++] = (stillTimeSeconds >> 8) & 0xFF // 时间高位
return buffer
}
// 启动状态轮询
const startStatusPolling = () => {
if (statusTimer) {
clearInterval(statusTimer)
}
// 每800ms查询一次状态
statusTimer = setInterval(() => {
readStatus()
}, 800)
// 立即查询一次
readStatus()
}
// 读取运行状态 (0xFF02)
const readStatus = () => {
if (!connected.value) return
if (!statusCharId.value) {
console.error('运行状态特征值UUID未配置')
return
}
uni.readBLECharacteristicValue({
deviceId: deviceId.value,
serviceId: serviceId.value,
characteristicId: statusCharId.value,
success: () => {
console.log('Read status success')
},
fail: (err) => {
console.error('Read status failed:', err)
}
})
}
// 处理蓝牙数据
const handleBluetoothData = (res) => {
const dataView = new Uint8Array(res.value)
const charId = res.characteristicId.toUpperCase()
// 判断是状态数据 (0xFF02)
if (charId.includes('FF02') || charId === statusCharId.value.toUpperCase()) {
parseStatusData(dataView)
}
}
// 解析状态数据
// 格式: [电机序号(1) + 使能(1) + 运行状态(1) + 工作模式(1) + 剩余时间(2)] × 6
const parseStatusData = (dataView) => {
const bytesPerMotor = 6
const motorCount = Math.floor(dataView.length / bytesPerMotor)
for (let i = 0; i < motorCount && i < 6; i++) {
const offset = i * bytesPerMotor
const motorIndex = dataView[offset]
const enabled = dataView[offset + 1]
const runStatus = dataView[offset + 2]
const workMode = dataView[offset + 3]
const remainingTime = dataView[offset + 4] | (dataView[offset + 5] << 8)
// 更新对应通道数据
const channel = channels.value.find(c => c.id === `CH${motorIndex}`)
if (channel) {
channel.runStatus = runStatus
channel.currentMode = workMode
channel.remainingTime = remainingTime
}
}
}
// 发送控制指令 (0xFF03)
// 指令码: 1=启动 2=暂停 3=继续 4=退出
const sendControl = (command) => {
const enabledList = enabledChannels.value
if (enabledList.length === 0) return
if (!controlCharId.value) {
console.error('控制指令特征值UUID未配置')
return
}
// 构建buffer: [电机序号, 指令码] × n
const buffer = new ArrayBuffer(enabledList.length * 2)
const view = new Uint8Array(buffer)
enabledList.forEach((channel, i) => {
const motorIndex = parseInt(channel.id.replace('CH', ''))
view[i * 2] = motorIndex
view[i * 2 + 1] = command
})
writeBLEData(controlCharId.value, buffer)
}
// 写入蓝牙数据(异步版本)
const writeBLEDataAsync = (characteristicId, buffer) => {
return new Promise((resolve, reject) => {
if (!connected.value) {
uni.showToast({ title: 'Bluetooth not connected', icon: 'none' })
reject(new Error('Bluetooth not connected'))
return
}
uni.writeBLECharacteristicValue({
deviceId: deviceId.value,
serviceId: serviceId.value,
characteristicId: characteristicId,
value: buffer,
success: () => {
console.log('Write success:', characteristicId)
resolve()
},
fail: (err) => {
console.error('Write failed:', characteristicId, err)
reject(err)
}
})
})
}
// 写入蓝牙数据
const writeBLEData = (characteristicId, buffer) => {
if (!connected.value) {
uni.showToast({ title: 'Bluetooth not connected', icon: 'none' })
return
}
uni.writeBLECharacteristicValue({
deviceId: deviceId.value,
serviceId: serviceId.value,
characteristicId: characteristicId,
value: buffer,
success: () => {
console.log('Write success:', characteristicId)
},
fail: (err) => {
console.error('Write failed:', characteristicId, err)
uni.showToast({ title: 'Send failed', icon: 'none' })
}
})
}
// 控制按钮事件
const handleStart = () => {
if (!canStart.value) return
sendControl(1)
uni.showToast({ title: 'Starting...', icon: 'none' })
}
const handlePause = () => {
if (!canPause.value) return
sendControl(2)
uni.showToast({ title: 'Paused', icon: 'none' })
}
const handleResume = () => {
if (!canResume.value) return
sendControl(3)
uni.showToast({ title: 'Resuming...', icon: 'none' })
}
const handleStop = () => {
if (!canStop.value) return
uni.showModal({
title: 'Confirm Exit',
content: 'Are you sure to exit the current workflow?',
success: (res) => {
if (res.confirm) {
sendControl(4)
uni.showToast({ title: 'Exited', icon: 'none' })
}
}
})
}
// 获取状态文本
const getStatusText = (status) => {
const map = { 0: 'Waiting', 1: 'Running', 2: 'Paused', 3: 'Completed' }
return map[status] || 'Unknown'
}
// 获取状态样式类
const getStatusClass = (status) => {
const map = { 0: 'waiting', 1: 'running', 2: 'paused', 3: 'completed' }
return map[status] || 'waiting'
}
// 获取模式文本
const getModeText = (mode) => {
const map = { 0: '-', 1: 'Fast Mode', 2: 'Slow Mode', 3: 'Still Mode' }
return map[mode] || '-'
}
// 格式化时间
const formatTime = (seconds) => {
if (!seconds || seconds <= 0) return '--:--'
const min = Math.floor(seconds / 60)
const sec = seconds % 60
return `${min.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`
}
// 计算进度百分比
const getProgressPercent = (channel) => {
if (channel.runStatus === 0 || channel.runStatus === 3) return 0
// 计算总时间
let totalTime = 0
if (channel.currentMode === 1) {
totalTime = channel.fastTime
} else if (channel.currentMode === 2) {
totalTime = channel.slowTime * 60
} else if (channel.currentMode === 3) {
totalTime = channel.stillTime * 60
}
if (totalTime <= 0) return 0
const elapsed = totalTime - channel.remainingTime
return Math.min(100, Math.max(0, (elapsed / totalTime) * 100))
}
// 返回/跳转到设置页面
const goBack = () => {
const pages = getCurrentPages()
if (pages.length > 1) {
uni.navigateBack()
} else {
// 页面栈为空(预览模式),直接跳转
uni.redirectTo({ url: '/pages/mixer/setting' })
}
}
// 跳转到设置页面
const goToSetting = () => {
goBack()
}
</script>
<style lang="scss" scoped>
.control-page {
width: 100%;
height: 100vh;
display: flex;
flex-direction: column;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
// 自定义导航栏
.custom-navbar {
width: 100%;
background: rgba(102, 126, 234, 0.9);
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 999;
padding-top: max(env(safe-area-inset-top), 20px);
.navbar-content {
height: 44px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16rpx;
}
.nav-center {
position: absolute;
left: 50%;
transform: translateX(-50%);
.nav-title {
font-size: 32rpx;
font-weight: 600;
color: #fff;
}
}
}
.nav-left, .nav-right {
display: flex;
align-items: center;
}
.bluetooth-status {
display: flex;
align-items: center;
gap: 8rpx;
.status-text {
font-size: 24rpx;
color: #fff;
}
}
.control-container {
flex: 1;
display: flex;
flex-direction: column;
padding-top: max(calc(44px + env(safe-area-inset-top)), 64px);
padding-bottom: 120rpx;
overflow: hidden;
}
// 空状态提示
.empty-tip {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16rpx;
.empty-text {
font-size: 32rpx;
color: #fff;
font-weight: 600;
}
.empty-hint {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.7);
}
}
.channel-scroll {
flex: 1;
padding: 16rpx 20rpx;
overflow-y: auto;
}
.channel-list {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.channel-card {
background: rgba(255, 255, 255, 0.95);
border-radius: 16rpx;
padding: 20rpx;
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.1);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16rpx;
.channel-title {
font-size: 32rpx;
font-weight: 700;
color: #1e293b;
}
}
.status-badge {
padding: 6rpx 16rpx;
border-radius: 20rpx;
font-size: 22rpx;
font-weight: 600;
&.waiting {
background: #f1f5f9;
color: #64748b;
}
&.running {
background: #dcfce7;
color: #16a34a;
}
&.paused {
background: #fef3c7;
color: #d97706;
}
&.completed {
background: #dbeafe;
color: #2563eb;
}
}
.status-info {
display: flex;
flex-direction: column;
gap: 12rpx;
margin-bottom: 16rpx;
}
.mode-status-row {
display: flex;
gap: 12rpx;
}
.mode-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
padding: 8rpx;
border-radius: 8rpx;
background: #f1f5f9;
position: relative;
.mode-icon {
font-size: 24rpx;
}
.mode-name {
font-size: 20rpx;
color: #94a3b8;
}
.done-mark {
position: absolute;
top: 2rpx;
right: 4rpx;
font-size: 18rpx;
color: #22c55e;
font-weight: bold;
}
&.active {
background: linear-gradient(135deg, #667eea, #764ba2);
.mode-name {
color: #fff;
}
}
&.done {
background: #dcfce7;
.mode-name {
color: #16a34a;
}
}
}
.info-row {
flex: 1;
.info-label {
font-size: 22rpx;
color: #94a3b8;
display: block;
margin-bottom: 4rpx;
}
.info-value {
font-size: 28rpx;
color: #334155;
font-weight: 600;
&.time {
font-family: monospace;
color: #667eea;
}
}
}
.progress-bar {
height: 8rpx;
background: #e2e8f0;
border-radius: 4rpx;
overflow: hidden;
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #667eea, #764ba2);
border-radius: 4rpx;
transition: width 0.3s ease;
}
}
// 控制按钮区域
.control-buttons {
padding: 20rpx;
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
}
.button-row {
display: flex;
gap: 16rpx;
margin-bottom: 16rpx;
&:last-child {
margin-bottom: 0;
}
}
.ctrl-btn {
flex: 1;
height: 88rpx;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
gap: 8rpx;
font-size: 28rpx;
font-weight: 600;
color: #fff;
&.start {
background: linear-gradient(135deg, #22c55e, #16a34a);
}
&.pause {
background: linear-gradient(135deg, #f59e0b, #d97706);
}
&.resume {
background: linear-gradient(135deg, #3b82f6, #2563eb);
}
&.stop {
background: linear-gradient(135deg, #ef4444, #dc2626);
}
&.disabled {
opacity: 0.5;
pointer-events: none;
}
&:active {
transform: scale(0.98);
}
}
// 底部Tab导航
.bottom-tabs {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 100rpx;
background: #fff;
display: flex;
box-shadow: 0 -4rpx 20rpx rgba(0, 0, 0, 0.1);
padding-bottom: env(safe-area-inset-bottom);
}
.tab-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4rpx;
.tab-text {
font-size: 22rpx;
color: #666;
&.active {
color: #667eea;
font-weight: 600;
}
}
}
</style>