moki 2 недель назад
Родитель
Сommit
86172beaab

+ 8 - 0
src/App.vue

@@ -16,6 +16,14 @@ const antTheme = computed(() => {
         colorTextSecondary: '#8c8c8c',
         colorBorder: '#303030',
         borderRadius: 6,
+        boxShadow: '0 2px 0 rgba(0, 0, 0, 0.5)',
+        boxShadowSecondary: '0 6px 16px rgba(0, 0, 0, 0.3)',
+        boxShadowTertiary: '0 2px 8px rgba(0, 0, 0, 0.25)',
+        controlOutline: 'rgba(24, 144, 255, 0.1)',
+        controlOutlineWidth: 1,
+        tableHeaderBg: '#1f1f1f',
+        tableRowHoverBg: '#262626',
+        tableBorderColor: '#303030',
       },
     }
   }

+ 22 - 0
src/api/ble.ts

@@ -0,0 +1,22 @@
+import http from './index'
+import type { BleConfig, BleConfigForm, ApiResponse } from '@/types'
+
+export function getBleList() {
+  return http.get<ApiResponse<BleConfig[]>>('/ble')
+}
+
+export function getBleById(id: number) {
+  return http.get<ApiResponse<BleConfig>>(`/ble/${id}`)
+}
+
+export function createBle(data: BleConfigForm) {
+  return http.post<ApiResponse<BleConfig>>('/ble', data)
+}
+
+export function updateBle(id: number, data: BleConfigForm) {
+  return http.put<ApiResponse<BleConfig>>(`/ble/${id}`, data)
+}
+
+export function deleteBle(id: number) {
+  return http.delete<ApiResponse>(`/ble/${id}`)
+}

+ 215 - 0
src/composables/useBleDevice.ts

@@ -0,0 +1,215 @@
+import { ref, readonly } from 'vue'
+
+const SERVICE_UUID = 'b8b7e001-7a6b-4f4f-9a8b-11c0ffee0001'
+const MODE_UUID = 'b8b7e002-7a6b-4f4f-9a8b-11c0ffee0001'
+const CONFIG_UUID = 'b8b7e003-7a6b-4f4f-9a8b-11c0ffee0001'
+
+export type BleConnectionState = 'disconnected' | 'scanning' | 'connecting' | 'connected'
+
+export interface BleDeviceConfig {
+  wifi_ssid: string
+  mqtt_broker: string
+  mqtt_port: number
+  mqtt_user: string
+  mqtt_client: string
+  mqtt_topic: string
+  mqtt_status: string
+  comm_mode: number
+}
+
+interface BluetoothChar {
+  writeValue(value: BufferSource): Promise<void>
+  readValue(): Promise<DataView>
+  startNotifications(): Promise<BluetoothChar>
+  addEventListener(type: string, listener: (event: Event) => void): void
+  removeEventListener(type: string, listener: (event: Event) => void): void
+}
+
+export function useBleDevice() {
+  const connectionState = ref<BleConnectionState>('disconnected')
+  const deviceName = ref('')
+  const currentMode = ref('')
+  const config = ref<BleDeviceConfig | null>(null)
+  const logs = ref<string[]>([])
+
+  let device: any = null
+  let modeChar: BluetoothChar | null = null
+  let configChar: BluetoothChar | null = null
+
+  function log(msg: string) {
+    const t = new Date().toLocaleTimeString()
+    logs.value.push(`[${t}] ${msg}`)
+    if (logs.value.length > 200) logs.value.shift()
+  }
+
+  function clearLogs() {
+    logs.value = []
+  }
+
+  function onModeChanged(event: Event) {
+    const target = event.target as any
+    const decoder = new TextDecoder()
+    const mode = decoder.decode(target.value).trim()
+    currentMode.value = mode
+    log('Mode: ' + mode)
+  }
+
+  function onDisconnected() {
+    connectionState.value = 'disconnected'
+    modeChar = null
+    configChar = null
+    deviceName.value = ''
+    log('Device disconnected')
+  }
+
+  async function connect() {
+    if (connectionState.value === 'connected' && device?.gatt?.connected) {
+      device.gatt.disconnect()
+      return
+    }
+
+    try {
+      connectionState.value = 'scanning'
+      log('Requesting BLE device...')
+
+      const bluetooth = (navigator as any).bluetooth
+      if (!bluetooth) {
+        throw new Error('Web Bluetooth 不可用,请使用 Chrome/Edge 并通过 HTTPS 或 localhost 访问')
+      }
+
+      if (!device) {
+        device = await bluetooth.requestDevice({
+          filters: [{ name: 'AI-Light' }],
+          optionalServices: [SERVICE_UUID],
+        })
+        device.addEventListener('gattserverdisconnected', onDisconnected)
+      }
+
+      log('Device found: ' + device.name)
+      connectionState.value = 'connecting'
+
+      let server: any = null
+      let service: any = null
+      for (let attempt = 1; attempt <= 3; attempt++) {
+        try {
+          server = await device.gatt.connect()
+          if (!server.connected) {
+            throw new Error('连接立即断开')
+          }
+          log('GATT connected')
+          service = await Promise.race([
+            server.getPrimaryService(SERVICE_UUID),
+            new Promise((_, reject) => setTimeout(() => reject(new Error('服务发现超时')), 5000))
+          ])
+          log('Service found')
+          break
+        } catch (e: any) {
+          log(`连接失败 (${attempt}/3): ${e.message}`)
+          if (attempt === 3) throw e
+          await new Promise(r => setTimeout(r, 500))
+        }
+      }
+
+      modeChar = await service.getCharacteristic(MODE_UUID)
+      configChar = await service.getCharacteristic(CONFIG_UUID)
+      log('Characteristics found')
+
+      modeChar!.addEventListener('characteristicvaluechanged', onModeChanged)
+      await modeChar!.startNotifications()
+      log('Mode notifications enabled')
+
+      connectionState.value = 'connected'
+      deviceName.value = device.name
+
+      await readConfig()
+      await readMode()
+    } catch (err: any) {
+      log('Error: ' + err.message)
+      connectionState.value = 'disconnected'
+      deviceName.value = ''
+    }
+  }
+
+  function disconnect() {
+    if (device?.gatt?.connected) {
+      device.gatt.disconnect()
+    }
+  }
+
+  async function readMode() {
+    if (!modeChar) return
+    try {
+      const val = await modeChar.readValue()
+      const decoder = new TextDecoder()
+      const mode = decoder.decode(val).trim()
+      currentMode.value = mode
+      log('Current mode: ' + mode)
+    } catch (err: any) {
+      log('Read mode error: ' + err.message)
+    }
+  }
+
+  async function readConfig() {
+    if (!configChar) return
+    try {
+      const val = await configChar.readValue()
+      const decoder = new TextDecoder()
+      const json = decoder.decode(val)
+      log('Config loaded')
+      config.value = JSON.parse(json)
+    } catch (err: any) {
+      log('Read config error: ' + err.message)
+    }
+  }
+
+  async function setMode(mode: string) {
+    if (!modeChar) return
+    try {
+      const encoder = new TextEncoder()
+      await modeChar.writeValue(encoder.encode(mode))
+      log('Set mode: ' + mode)
+    } catch (err: any) {
+      log('Set mode error: ' + err.message)
+    }
+  }
+
+  async function saveConfig(cfg: Record<string, any>) {
+    if (!configChar) return
+    try {
+      const encoder = new TextEncoder()
+      await configChar.writeValue(encoder.encode(JSON.stringify(cfg)))
+      log('Config saved! Device will restart...')
+    } catch (err: any) {
+      log('Save config error: ' + err.message)
+      throw err
+    }
+  }
+
+  async function restartDevice() {
+    if (!configChar) return
+    try {
+      const encoder = new TextEncoder()
+      await configChar.writeValue(encoder.encode('{"restart":true}'))
+      log('Restart command sent')
+    } catch (err: any) {
+      log('Restart error: ' + err.message)
+      throw err
+    }
+  }
+
+  return {
+    connectionState: readonly(connectionState),
+    deviceName: readonly(deviceName),
+    currentMode: readonly(currentMode),
+    config,
+    logs: readonly(logs),
+    connect,
+    disconnect,
+    setMode,
+    readMode,
+    readConfig,
+    saveConfig,
+    restartDevice,
+    clearLogs,
+  }
+}

+ 17 - 0
src/layouts/BasicLayout.vue

@@ -5,6 +5,7 @@ import { useRouter, useRoute } from 'vue-router'
 import {
   DashboardOutlined,
   ApiOutlined,
+  MobileOutlined,
   BulbOutlined,
   BulbFilled,
   FullscreenOutlined,
@@ -70,6 +71,14 @@ onUnmounted(() => window.removeEventListener('resize', onResize))
           <ApiOutlined />
           <span>MQTT 配置</span>
         </a-menu-item>
+        <a-menu-item key="/ble">
+          <BulbOutlined />
+          <span>BLE 配置</span>
+        </a-menu-item>
+        <a-menu-item key="/ble-device">
+          <MobileOutlined />
+          <span>设备配置</span>
+        </a-menu-item>
       </a-menu>
     </a-layout-sider>
 
@@ -101,6 +110,14 @@ onUnmounted(() => window.removeEventListener('resize', onResize))
           <ApiOutlined />
           <span>MQTT 配置</span>
         </a-menu-item>
+        <a-menu-item key="/ble">
+          <BulbOutlined />
+          <span>BLE 配置</span>
+        </a-menu-item>
+        <a-menu-item key="/ble-device">
+          <MobileOutlined />
+          <span>设备配置</span>
+        </a-menu-item>
       </a-menu>
     </a-drawer>
 

+ 10 - 0
src/router/index.ts

@@ -14,6 +14,16 @@ const router = createRouter({
       name: 'MqttConfig',
       component: () => import('@/views/MqttConfig.vue'),
     },
+    {
+      path: '/ble',
+      name: 'BleConfig',
+      component: () => import('@/views/BleConfig.vue'),
+    },
+    {
+      path: '/ble-device',
+      name: 'BleDevice',
+      component: () => import('@/views/BleDevice.vue'),
+    },
   ],
 })
 

+ 8 - 0
src/style.css

@@ -57,6 +57,14 @@ body {
   border-color: var(--border-color);
 }
 
+.ant-modal-content {
+  background: var(--content-bg);
+}
+
+.ant-modal-header {
+  background: var(--content-bg);
+}
+
 .ant-drawer-content {
   background: var(--content-bg);
 }

+ 15 - 0
src/types/index.ts

@@ -23,6 +23,21 @@ export interface MqttConfigForm {
   enabled: boolean
 }
 
+export interface BleConfig {
+  id: number
+  device_name: string
+  service_uuid: string
+  char_uuid: string
+  enabled: boolean
+}
+
+export interface BleConfigForm {
+  device_name: string
+  service_uuid: string
+  char_uuid: string
+  enabled: boolean
+}
+
 export type StatusCode =
   | 'idle'
   | 'busy'

+ 191 - 0
src/views/BleConfig.vue

@@ -0,0 +1,191 @@
+<script setup lang="ts">
+import { ref, reactive, onMounted, onUnmounted } from 'vue'
+import { message, Modal } from 'ant-design-vue'
+import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons-vue'
+import { getBleList, createBle, updateBle, deleteBle } from '@/api/ble'
+import type { BleConfig, BleConfigForm } from '@/types'
+
+const loading = ref(false)
+const list = ref<BleConfig[]>([])
+const modalVisible = ref(false)
+const editingId = ref<number | null>(null)
+const isMobile = ref(window.innerWidth < 768)
+
+const form = reactive<BleConfigForm>({
+  device_name: 'AI-Light',
+  service_uuid: 'b8b7e001-7a6b-4f4f-9a8b-11c0ffee0001',
+  char_uuid: 'b8b7e002-7a6b-4f4f-9a8b-11c0ffee0001',
+  enabled: true,
+})
+
+const columns = [
+  { title: 'ID', dataIndex: 'id', width: 60 },
+  { title: '设备名称', dataIndex: 'device_name', width: 120 },
+  { title: 'Service UUID', dataIndex: 'service_uuid', ellipsis: true },
+  { title: 'Characteristic UUID', dataIndex: 'char_uuid', ellipsis: true },
+  { title: '启用', dataIndex: 'enabled', width: 80, key: 'enabled' },
+  { title: '操作', key: 'actions', width: 140 },
+]
+
+async function fetchList() {
+  loading.value = true
+  try {
+    const res = await getBleList()
+    list.value = res.data.data || []
+  } catch {
+  } finally {
+    loading.value = false
+  }
+}
+
+function openModal(record?: BleConfig) {
+  if (record) {
+    editingId.value = record.id
+    form.device_name = record.device_name
+    form.service_uuid = record.service_uuid
+    form.char_uuid = record.char_uuid
+    form.enabled = record.enabled
+  } else {
+    editingId.value = null
+    form.device_name = 'AI-Light'
+    form.service_uuid = 'b8b7e001-7a6b-4f4f-9a8b-11c0ffee0001'
+    form.char_uuid = 'b8b7e002-7a6b-4f4f-9a8b-11c0ffee0001'
+    form.enabled = true
+  }
+  modalVisible.value = true
+}
+
+async function onSubmit() {
+  if (!form.device_name) {
+    message.warning('请填写设备名称')
+    return
+  }
+  if (!form.service_uuid) {
+    message.warning('请填写 Service UUID')
+    return
+  }
+  if (!form.char_uuid) {
+    message.warning('请填写 Characteristic UUID')
+    return
+  }
+  try {
+    if (editingId.value !== null) {
+      await updateBle(editingId.value, { ...form })
+      message.success('更新成功')
+    } else {
+      await createBle({ ...form })
+      message.success('创建成功')
+    }
+    modalVisible.value = false
+    fetchList()
+  } catch {}
+}
+
+function onDelete(record: BleConfig) {
+  Modal.confirm({
+    title: '确认删除',
+    content: `确定删除 BLE 配置 #${record.id}(${record.device_name})?`,
+    okType: 'danger',
+    async onOk() {
+      try {
+        await deleteBle(record.id)
+        message.success('删除成功')
+        fetchList()
+      } catch {}
+    },
+  })
+}
+
+function onResize() {
+  isMobile.value = window.innerWidth < 768
+}
+
+onMounted(() => {
+  fetchList()
+  window.addEventListener('resize', onResize)
+})
+onUnmounted(() => window.removeEventListener('resize', onResize))
+</script>
+
+<template>
+  <div class="ble-page">
+    <div class="page-header">
+      <span>BLE 配置管理</span>
+      <a-button type="primary" @click="openModal()">
+        <PlusOutlined /> 新建配置
+      </a-button>
+    </div>
+
+    <a-table
+      :columns="columns"
+      :data-source="list"
+      :loading="loading"
+      row-key="id"
+      :pagination="false"
+      :scroll="isMobile ? { x: 600 } : undefined"
+    >
+      <template #bodyCell="{ column, record }">
+        <template v-if="column.key === 'enabled'">
+          <a-badge :status="record.enabled ? 'success' : 'default'" :text="record.enabled ? '启用' : '禁用'" />
+        </template>
+        <template v-if="column.key === 'actions'">
+          <a-space>
+            <a-button type="link" size="small" @click="openModal(record)">
+              <EditOutlined /> 编辑
+            </a-button>
+            <a-button type="link" size="small" danger @click="onDelete(record)">
+              <DeleteOutlined /> 删除
+            </a-button>
+          </a-space>
+        </template>
+      </template>
+    </a-table>
+
+    <a-modal
+      :title="editingId !== null ? '编辑 BLE 配置' : '新建 BLE 配置'"
+      :open="modalVisible"
+      @cancel="modalVisible = false"
+      :width="480"
+      @ok="onSubmit"
+      :ok-text="editingId !== null ? '更新' : '创建'"
+      cancel-text="取消"
+    >
+      <a-form layout="vertical">
+        <a-form-item label="设备名称" required>
+          <a-input v-model:value="form.device_name" placeholder="AI-Light" />
+        </a-form-item>
+        <a-form-item label="Service UUID" required>
+          <a-input v-model:value="form.service_uuid" placeholder="b8b7e001-7a6b-4f4f-9a8b-11c0ffee0001" />
+        </a-form-item>
+        <a-form-item label="Characteristic UUID" required>
+          <a-input v-model:value="form.char_uuid" placeholder="b8b7e002-7a6b-4f4f-9a8b-11c0ffee0001" />
+        </a-form-item>
+        <a-form-item label="启用">
+          <a-switch v-model:checked="form.enabled" />
+        </a-form-item>
+      </a-form>
+    </a-modal>
+  </div>
+</template>
+
+<style scoped>
+.ble-page {
+  color: var(--text-color);
+}
+
+.page-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 24px;
+  font-size: 16px;
+  font-weight: 500;
+}
+
+@media (max-width: 767px) {
+  .page-header {
+    margin-bottom: 16px;
+    font-size: 14px;
+  }
+}
+</style>

+ 315 - 0
src/views/BleDevice.vue

@@ -0,0 +1,315 @@
+<script setup lang="ts">
+import { ref, reactive, watch, onMounted } from 'vue'
+import { message } from 'ant-design-vue'
+import {
+  MobileOutlined,
+  DisconnectOutlined,
+  SaveOutlined,
+  ReloadOutlined,
+  ClearOutlined,
+  CheckCircleFilled,
+  CloseCircleFilled,
+  LoadingOutlined,
+} from '@ant-design/icons-vue'
+import { useBleDevice } from '@/composables/useBleDevice'
+
+const {
+  connectionState,
+  deviceName,
+  currentMode,
+  config,
+  logs,
+  connect,
+  disconnect,
+  setMode,
+  saveConfig,
+  restartDevice,
+  clearLogs,
+} = useBleDevice()
+
+const isMobile = ref(window.innerWidth < 768)
+
+const wifiForm = reactive({
+  ssid: '',
+  password: '',
+})
+
+const mqttForm = reactive({
+  broker: '',
+  port: 1883,
+  client: 'AI-Light',
+  username: '',
+  password: '',
+  topic: 'opencode/status',
+  statusTopic: 'openCodeLight/status',
+})
+
+watch(config, (cfg) => {
+  if (cfg) {
+    wifiForm.ssid = cfg.wifi_ssid || ''
+    mqttForm.broker = cfg.mqtt_broker || ''
+    mqttForm.port = cfg.mqtt_port || 1883
+    mqttForm.client = cfg.mqtt_client || 'AI-Light'
+    mqttForm.username = cfg.mqtt_user || ''
+    mqttForm.topic = cfg.mqtt_topic || ''
+    mqttForm.statusTopic = cfg.mqtt_status || ''
+  }
+})
+
+const LIGHT_MODES = [
+  { key: 'traffic', label: 'Traffic', color: '#52c41a' },
+  { key: 'thinking', label: 'Thinking', color: '#722ed1' },
+  { key: 'ai', label: 'AI', color: '#1890ff' },
+  { key: 'busy', label: 'Busy', color: '#faad14' },
+  { key: 'success', label: 'Success', color: '#52c41a' },
+  { key: 'error', label: 'Error', color: '#f5222d' },
+  { key: 'alarm', label: 'Alarm', color: '#fa541c' },
+  { key: 'init', label: 'Init', color: '#13c2c2' },
+  { key: 'off', label: 'Off', color: '#8c8c8c' },
+]
+
+async function handleSaveConfig() {
+  const cfg = {
+    wifi_ssid: wifiForm.ssid,
+    wifi_pass: wifiForm.password,
+    mqtt_broker: mqttForm.broker,
+    mqtt_port: mqttForm.port,
+    mqtt_user: mqttForm.username,
+    mqtt_pass: mqttForm.password,
+    mqtt_client: mqttForm.client,
+    mqtt_topic: mqttForm.topic,
+    mqtt_status: mqttForm.statusTopic,
+  }
+  try {
+    await saveConfig(cfg)
+    message.success('配置已保存,设备将重启')
+  } catch {
+    message.error('保存失败')
+  }
+}
+
+async function handleRestart() {
+  try {
+    await restartDevice()
+    message.success('重启指令已发送')
+  } catch {
+    message.error('重启失败')
+  }
+}
+
+function onResize() {
+  isMobile.value = window.innerWidth < 768
+}
+
+onMounted(() => window.addEventListener('resize', onResize))
+</script>
+
+<template>
+  <div class="ble-device-page">
+    <!-- Connection Bar -->
+    <a-alert
+      v-if="connectionState === 'disconnected'"
+      message="未连接设备"
+      type="warning"
+      show-icon
+      :banner="isMobile"
+      class="conn-bar"
+    />
+    <a-alert
+      v-else-if="connectionState === 'scanning' || connectionState === 'connecting'"
+      :message="connectionState === 'scanning' ? '正在扫描...' : '正在连接...'"
+      type="info"
+      show-icon
+      :banner="isMobile"
+      class="conn-bar"
+    >
+      <template #icon><LoadingOutlined spin /></template>
+    </a-alert>
+    <a-alert
+      v-else
+      :message="'已连接: ' + deviceName"
+      type="success"
+      show-icon
+      :banner="isMobile"
+      class="conn-bar"
+    />
+
+    <a-button
+      :type="connectionState === 'connected' ? 'default' : 'primary'"
+      block
+      size="large"
+      class="conn-btn"
+      :danger="connectionState === 'connected'"
+      @click="connect"
+    >
+      <template #icon>
+        <DisconnectOutlined v-if="connectionState === 'connected'" />
+        <MobileOutlined v-else />
+      </template>
+      {{ connectionState === 'connected' ? '断开连接' : '连接设备' }}
+    </a-button>
+
+    <template v-if="connectionState === 'connected'">
+      <!-- Device Status -->
+      <a-card title="设备状态" size="small" class="section-card">
+        <a-descriptions :column="1" size="small" :label-style="{ width: '80px' }">
+          <a-descriptions-item label="WiFi">
+            <a-tag v-if="config?.wifi_ssid" color="success">{{ config.wifi_ssid }}</a-tag>
+            <a-tag v-else color="error">未配置</a-tag>
+          </a-descriptions-item>
+          <a-descriptions-item label="MQTT">
+            <a-tag v-if="config?.mqtt_broker" color="success">{{ config.mqtt_broker }}</a-tag>
+            <a-tag v-else color="error">未配置</a-tag>
+          </a-descriptions-item>
+          <a-descriptions-item label="模式">
+            <a-tag color="processing">{{ currentMode || '-' }}</a-tag>
+          </a-descriptions-item>
+          <a-descriptions-item label="通信">
+            {{ config?.comm_mode === 1 ? 'MQTT' : 'BLE-only' }}
+          </a-descriptions-item>
+        </a-descriptions>
+      </a-card>
+
+      <!-- Light Mode -->
+      <a-card title="灯效模式" size="small" class="section-card">
+        <div class="mode-grid">
+          <a-button
+            v-for="m in LIGHT_MODES"
+            :key="m.key"
+            :type="currentMode === m.key ? 'primary' : 'default'"
+            class="mode-btn"
+            @click="setMode(m.key)"
+          >
+            {{ m.label }}
+          </a-button>
+        </div>
+      </a-card>
+
+      <!-- WiFi Config -->
+      <a-card title="WiFi 配置" size="small" class="section-card">
+        <a-form layout="vertical">
+          <a-form-item label="SSID">
+            <a-input v-model:value="wifiForm.ssid" placeholder="WiFi 名称" />
+          </a-form-item>
+          <a-form-item label="密码">
+            <a-input-password v-model:value="wifiForm.password" placeholder="WiFi 密码" />
+          </a-form-item>
+        </a-form>
+      </a-card>
+
+      <!-- MQTT Config -->
+      <a-card title="MQTT 配置" size="small" class="section-card">
+        <a-form layout="vertical">
+          <a-form-item label="Broker">
+            <a-input v-model:value="mqttForm.broker" placeholder="192.168.1.100" />
+          </a-form-item>
+          <a-row :gutter="12">
+            <a-col :span="12">
+              <a-form-item label="端口">
+                <a-input-number v-model:value="mqttForm.port" :min="1" :max="65535" style="width: 100%" />
+              </a-form-item>
+            </a-col>
+            <a-col :span="12">
+              <a-form-item label="Client ID">
+                <a-input v-model:value="mqttForm.client" placeholder="AI-Light" />
+              </a-form-item>
+            </a-col>
+          </a-row>
+          <a-form-item label="用户名">
+            <a-input v-model:value="mqttForm.username" placeholder="可选" />
+          </a-form-item>
+          <a-form-item label="密码">
+            <a-input-password v-model:value="mqttForm.password" placeholder="可选" />
+          </a-form-item>
+          <a-form-item label="订阅主题">
+            <a-input v-model:value="mqttForm.topic" placeholder="opencode/status" />
+          </a-form-item>
+          <a-form-item label="状态发布主题">
+            <a-input v-model:value="mqttForm.statusTopic" placeholder="openCodeLight/status" />
+          </a-form-item>
+        </a-form>
+      </a-card>
+
+      <!-- Actions -->
+      <a-space direction="vertical" style="width: 100%" :size="12">
+        <a-button type="primary" block size="large" @click="handleSaveConfig">
+          <template #icon><SaveOutlined /></template>
+          保存配置并重启
+        </a-button>
+        <a-button block size="large" @click="handleRestart">
+          <template #icon><ReloadOutlined /></template>
+          重启设备
+        </a-button>
+      </a-space>
+    </template>
+
+    <!-- Log -->
+    <a-card title="日志" size="small" class="section-card" v-if="logs.length > 0">
+      <template #extra>
+        <a-button type="text" size="small" @click="clearLogs">
+          <ClearOutlined /> 清空
+        </a-button>
+      </template>
+      <div class="log-container">
+        <div v-for="(line, i) in logs" :key="i" class="log-line">{{ line }}</div>
+      </div>
+    </a-card>
+  </div>
+</template>
+
+<style scoped>
+.ble-device-page {
+  max-width: 520px;
+  margin: 0 auto;
+  color: var(--text-color);
+}
+
+.conn-bar {
+  margin-bottom: 12px;
+}
+
+.conn-btn {
+  margin-bottom: 16px;
+}
+
+.section-card {
+  margin-bottom: 16px;
+}
+
+.mode-grid {
+  display: grid;
+  grid-template-columns: repeat(3, 1fr);
+  gap: 8px;
+}
+
+.mode-btn {
+  height: 40px;
+  font-weight: 600;
+}
+
+.log-container {
+  background: #1a1a2e;
+  border-radius: 8px;
+  padding: 12px;
+  font-family: 'SF Mono', 'Fira Code', monospace;
+  font-size: 11px;
+  max-height: 200px;
+  overflow-y: auto;
+  line-height: 1.8;
+}
+
+.log-line {
+  color: #a5d6a7;
+  word-break: break-all;
+}
+
+@media (max-width: 767px) {
+  .ble-device-page {
+    max-width: 100%;
+  }
+
+  .mode-grid {
+    grid-template-columns: repeat(3, 1fr);
+  }
+}
+</style>

+ 14 - 19
src/views/MqttConfig.vue

@@ -7,7 +7,7 @@ import type { MqttConfig, MqttConfigForm } from '@/types'
 
 const loading = ref(false)
 const list = ref<MqttConfig[]>([])
-const drawerVisible = ref(false)
+const modalVisible = ref(false)
 const editingId = ref<number | null>(null)
 const isMobile = ref(window.innerWidth < 768)
 
@@ -40,7 +40,7 @@ async function fetchList() {
   }
 }
 
-function openDrawer(record?: MqttConfig) {
+function openModal(record?: MqttConfig) {
   if (record) {
     editingId.value = record.id
     form.broker = record.broker
@@ -58,7 +58,7 @@ function openDrawer(record?: MqttConfig) {
     form.topic = 'opencode/status'
     form.enabled = true
   }
-  drawerVisible.value = true
+  modalVisible.value = true
 }
 
 async function onSubmit() {
@@ -74,7 +74,7 @@ async function onSubmit() {
       await createMqtt({ ...form })
       message.success('创建成功')
     }
-    drawerVisible.value = false
+    modalVisible.value = false
     fetchList()
   } catch {}
 }
@@ -109,7 +109,7 @@ onUnmounted(() => window.removeEventListener('resize', onResize))
   <div class="mqtt-page">
     <div class="page-header">
       <span>MQTT 配置管理</span>
-      <a-button type="primary" @click="openDrawer()">
+      <a-button type="primary" @click="openModal()">
         <PlusOutlined /> 新建配置
       </a-button>
     </div>
@@ -128,7 +128,7 @@ onUnmounted(() => window.removeEventListener('resize', onResize))
         </template>
         <template v-if="column.key === 'actions'">
           <a-space>
-            <a-button type="link" size="small" @click="openDrawer(record)">
+            <a-button type="link" size="small" @click="openModal(record)">
               <EditOutlined /> 编辑
             </a-button>
             <a-button type="link" size="small" danger @click="onDelete(record)">
@@ -139,11 +139,14 @@ onUnmounted(() => window.removeEventListener('resize', onResize))
       </template>
     </a-table>
 
-    <a-drawer
+    <a-modal
       :title="editingId !== null ? '编辑 MQTT 配置' : '新建 MQTT 配置'"
-      :open="drawerVisible"
-      @close="drawerVisible = false"
-      :width="isMobile ? '100%' : 480"
+      :open="modalVisible"
+      @cancel="modalVisible = false"
+      :width="480"
+      @ok="onSubmit"
+      :ok-text="editingId !== null ? '更新' : '创建'"
+      cancel-text="取消"
     >
       <a-form layout="vertical">
         <a-form-item label="Broker 地址" required>
@@ -165,15 +168,7 @@ onUnmounted(() => window.removeEventListener('resize', onResize))
           <a-switch v-model:checked="form.enabled" />
         </a-form-item>
       </a-form>
-      <template #footer>
-        <a-space>
-          <a-button @click="drawerVisible = false">取消</a-button>
-          <a-button type="primary" @click="onSubmit">
-            {{ editingId !== null ? '更新' : '创建' }}
-          </a-button>
-        </a-space>
-      </template>
-    </a-drawer>
+    </a-modal>
   </div>
 </template>