moki 4 giorni fa
parent
commit
1d66721d56

+ 213 - 0
src/composables/useDeviceConfig.ts

@@ -0,0 +1,213 @@
+import {readonly, ref} from 'vue'
+
+export type ConnectionState = 'disconnected' | 'connecting' | 'connected'
+
+export interface DeviceConfigData {
+    id: number
+    device_name: string
+    config_topic: string
+    wifi_ssid: string
+    wifi_pass: string
+    mqtt_broker: string
+    mqtt_port: number
+    mqtt_user: string
+    mqtt_pass: string
+    mqtt_client: string
+    mqtt_topic: string
+    mqtt_status: string
+    mqtt_topic_config: string
+    pin_red: number
+    pin_green: number
+    pin_yellow: number
+    enabled: boolean
+}
+
+export function useDeviceConfig() {
+    const connectionState = ref<ConnectionState>('disconnected')
+    const deviceName = ref('')
+    const currentMode = ref('')
+    const config = ref<DeviceConfigData | null>(null)
+    const logs = ref<string[]>([])
+
+    let currentId: number | 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 = []
+    }
+
+    async function connect() {
+        if (connectionState.value === 'connected') {
+            disconnect()
+            return
+        }
+
+        try {
+            connectionState.value = 'connecting'
+            log('正在加载设备配置...')
+
+            const resp = await fetch('/api/device/config')
+            if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
+
+            const data = await resp.json()
+            if (data.code !== 0) throw new Error(data.message)
+
+            const configs = data.data || []
+            if (configs.length === 0) {
+                log('未找到设备配置,请先创建')
+                connectionState.value = 'disconnected'
+                return
+            }
+
+            const cfg = configs[0]
+            currentId = cfg.id
+            config.value = cfg
+            deviceName.value = cfg.device_name || '未命名设备'
+            connectionState.value = 'connected'
+            log('配置已加载: ' + deviceName.value)
+        } catch (err: any) {
+            log('加载失败: ' + err.message)
+            connectionState.value = 'disconnected'
+        }
+    }
+
+    function disconnect() {
+        connectionState.value = 'disconnected'
+        deviceName.value = ''
+        config.value = null
+        currentId = null
+        log('已断开')
+    }
+
+    async function setMode(mode: string) {
+        try {
+            log('设置灯效: ' + mode)
+            const resp = await fetch('/api/event', {
+                method: 'POST',
+                headers: {'Content-Type': 'application/json'},
+                body: JSON.stringify({code: mode}),
+            })
+            if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
+            const data = await resp.json()
+            if (data.code !== 0) throw new Error(data.message)
+            currentMode.value = mode
+            log('灯效已设置: ' + mode)
+        } catch (err: any) {
+            log('设置灯效失败: ' + err.message)
+            throw err
+        }
+    }
+
+    async function saveConfig(cfg: Record<string, any>) {
+        try {
+            log('正在保存配置...')
+            const payload = {
+                device_name: deviceName.value || 'AI-Light',
+                config_topic: cfg.mqtt_topic_config || 'agent/status/config',
+                wifi_ssid: cfg.wifi_ssid || '',
+                wifi_pass: cfg.wifi_pass || '',
+                mqtt_broker: cfg.mqtt_broker || '',
+                mqtt_port: cfg.mqtt_port || 1883,
+                mqtt_user: cfg.mqtt_user || '',
+                mqtt_pass: cfg.mqtt_pass || '',
+                mqtt_client: cfg.mqtt_client || '',
+                mqtt_topic: cfg.mqtt_topic || '',
+                mqtt_status: cfg.mqtt_status || '',
+                pin_red: cfg.pin_red ?? 4,
+                pin_green: cfg.pin_green ?? 3,
+                pin_yellow: cfg.pin_yellow ?? 2,
+                enabled: true,
+            }
+
+            let resp: Response
+            if (currentId) {
+                resp = await fetch(`/api/device/config/${currentId}`, {
+                    method: 'PUT',
+                    headers: {'Content-Type': 'application/json'},
+                    body: JSON.stringify(payload),
+                })
+            } else {
+                resp = await fetch('/api/device/config', {
+                    method: 'POST',
+                    headers: {'Content-Type': 'application/json'},
+                    body: JSON.stringify(payload),
+                })
+            }
+
+            if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
+            const data = await resp.json()
+            if (data.code !== 0) throw new Error(data.message)
+
+            if (data.data?.id) {
+                currentId = data.data.id
+            }
+
+            log('配置已保存到数据库')
+
+            await pushConfig()
+        } catch (err: any) {
+            log('保存失败: ' + err.message)
+            throw err
+        }
+    }
+
+    async function pushConfig() {
+        if (!currentId) {
+            log('无配置ID,跳过推送')
+            return
+        }
+
+        try {
+            log('正在推送配置到设备...')
+            const resp = await fetch('/api/device/config/push', {
+                method: 'POST',
+                headers: {'Content-Type': 'application/json'},
+                body: JSON.stringify({id: currentId}),
+            })
+            if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
+            const data = await resp.json()
+            if (data.code !== 0) throw new Error(data.message)
+            log('配置已推送到设备,设备将重启')
+        } catch (err: any) {
+            log('推送失败: ' + err.message)
+            throw err
+        }
+    }
+
+    async function restartDevice() {
+        try {
+            log('正在重启设备...')
+            const resp = await fetch('/api/event', {
+                method: 'POST',
+                headers: {'Content-Type': 'application/json'},
+                body: JSON.stringify({code: 'restart'}),
+            })
+            if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
+            const data = await resp.json()
+            if (data.code !== 0) throw new Error(data.message)
+            log('重启指令已发送')
+        } catch (err: any) {
+            log('重启失败: ' + err.message)
+            throw err
+        }
+    }
+
+    return {
+        connectionState: readonly(connectionState),
+        deviceName: readonly(deviceName),
+        currentMode: readonly(currentMode),
+        config,
+        logs: readonly(logs),
+        connect,
+        disconnect,
+        setMode,
+        saveConfig,
+        restartDevice,
+        clearLogs,
+    }
+}

+ 3 - 3
src/layouts/BasicLayout.vue

@@ -29,7 +29,7 @@ const pageTitle = computed(() => {
   const titles: Record<string, string> = {
     '/dashboard': '仪表盘',
     '/server': '服务端配置',
-    '/ble-device': '设备配置',
+    '/device-config': '设备配置',
     '/setup': '安装指南'
   }
   return titles[route.path] || 'AI状态监控'
@@ -82,7 +82,7 @@ onUnmounted(() => window.removeEventListener('resize', onResize))
           <CloudServerOutlined/>
           <span>服务端配置</span>
         </a-menu-item>
-        <a-menu-item key="/ble-device">
+        <a-menu-item key="/device-config">
           <MobileOutlined />
           <span>设备配置</span>
         </a-menu-item>
@@ -121,7 +121,7 @@ onUnmounted(() => window.removeEventListener('resize', onResize))
           <CloudServerOutlined/>
           <span>服务端配置</span>
         </a-menu-item>
-        <a-menu-item key="/ble-device">
+        <a-menu-item key="/device-config">
           <MobileOutlined />
           <span>设备配置</span>
         </a-menu-item>

+ 3 - 3
src/router/index.ts

@@ -15,9 +15,9 @@ const router = createRouter({
         component: () => import('@/views/ServerConfig.vue'),
     },
     {
-      path: '/ble-device',
-      name: 'BleDevice',
-      component: () => import('@/views/BleDevice.vue'),
+        path: '/device-config',
+        name: 'DeviceConfig',
+        component: () => import('@/views/DeviceConfig.vue'),
     },
     {
       path: '/setup',

+ 0 - 452
src/views/BleDevice.vue

@@ -1,452 +0,0 @@
-<script setup lang="ts">
-import {onMounted, reactive, ref, watch} from 'vue'
-import {message} from 'ant-design-vue'
-import {ClearOutlined, LoadingOutlined, ReloadOutlined, SaveOutlined,} 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 activeTab = ref('bluetooth')
-const tabList = [
-  {key: 'bluetooth', tab: '蓝牙配置'},
-  {key: 'mqtt', tab: '消息配置'},
-]
-
-function onTabChange(key: string) {
-  activeTab.value = key
-}
-
-const wifiForm = reactive({
-  ssid: '',
-  password: '',
-})
-
-const mqttForm = reactive({
-  broker: '',
-  port: 1883,
-  client: 'AI-Light',
-  username: '',
-  password: '',
-  topic: 'agent/status',
-  statusTopic: 'openCodeLight/status',
-  topicConfig: 'agent/status/config',
-})
-
-const pinForm = reactive({
-  red: 4,
-  green: 3,
-  yellow: 2,
-})
-
-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 || ''
-    mqttForm.topicConfig = cfg.mqtt_topic_config || 'agent/status/config'
-    pinForm.red = cfg.pin_red ?? 4
-    pinForm.green = cfg.pin_green ?? 3
-    pinForm.yellow = cfg.pin_yellow ?? 2
-  }
-})
-
-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,
-    mqtt_topic_config: mqttForm.topicConfig,
-    pin_red: pinForm.red,
-    pin_green: pinForm.green,
-    pin_yellow: pinForm.yellow,
-  }
-  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">
-    <a-card
-        :active-tab-key="activeTab"
-        :body-style="{ padding: '8px' }"
-        :tab-list="tabList"
-        class="config-card"
-        @tabChange="onTabChange"
-    >
-      <!-- 蓝牙配置 Tab -->
-      <template v-if="activeTab === 'bluetooth'">
-        <!-- Connection Alert -->
-        <a-alert
-            v-if="connectionState === 'connected'"
-            :message="'已连接: ' + deviceName"
-            class="conn-alert"
-            show-icon
-            type="success"
-        >
-          <template #action>
-            <a-button danger size="small" @click="disconnect">断开</a-button>
-          </template>
-        </a-alert>
-        <a-alert
-            v-else-if="connectionState === 'scanning' || connectionState === 'connecting'"
-            class="conn-alert"
-            message="正在连接设备..."
-            show-icon
-            type="info"
-        >
-          <template #icon>
-            <LoadingOutlined spin/>
-          </template>
-          <template #action>
-            <a-button disabled size="small">连接中</a-button>
-          </template>
-        </a-alert>
-        <a-alert
-            v-else
-            class="conn-alert"
-            description="请先连接蓝牙设备以进行配置"
-            message="未连接设备"
-            show-icon
-            type="warning"
-        >
-          <template #action>
-            <a-button size="small" type="primary" @click="connect">连接设备</a-button>
-          </template>
-        </a-alert>
-
-        <div :class="isMobile ? 'mobile-layout' : 'desktop-layout'">
-          <div class="config-col">
-            <!-- Device Status (mobile only) -->
-            <a-card v-if="isMobile" class="section-card" size="small" title="设备状态">
-              <a-descriptions :column="1" :label-style="{ width: '80px' }" size="small">
-                <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 class="section-card" size="small" title="灯效模式">
-              <div :class="isMobile ? 'mode-grid-mobile' : 'mode-grid-desktop'">
-                <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 class="section-card" size="small" title="WiFi 配置">
-              <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>
-
-            <!-- Pin Config -->
-            <a-card class="section-card" size="small" title="引脚配置(灯序)">
-              <a-form layout="vertical">
-                <a-row :gutter="12">
-                  <a-col :span="8">
-                    <a-form-item label="红灯引脚">
-                      <a-input-number v-model:value="pinForm.red" :max="21" :min="0" style="width: 100%"/>
-                    </a-form-item>
-                  </a-col>
-                  <a-col :span="8">
-                    <a-form-item label="绿灯引脚">
-                      <a-input-number v-model:value="pinForm.green" :max="21" :min="0" style="width: 100%"/>
-                    </a-form-item>
-                  </a-col>
-                  <a-col :span="8">
-                    <a-form-item label="黄灯引脚">
-                      <a-input-number v-model:value="pinForm.yellow" :max="21" :min="0" style="width: 100%"/>
-                    </a-form-item>
-                  </a-col>
-                </a-row>
-                <a-alert
-                    banner
-                    message="默认接线:红=IO4、绿=IO3、黄=IO2。修改引脚后需确认硬件接线对应。"
-                    show-icon
-                    type="info"
-                />
-              </a-form>
-            </a-card>
-
-            <!-- Actions -->
-            <a-space :size="12" direction="vertical" style="width: 100%">
-              <a-button block size="large" type="primary" @click="handleSaveConfig">
-                <template #icon>
-                  <SaveOutlined/>
-                </template>
-                保存配置并重启
-              </a-button>
-              <a-button block size="large" @click="handleRestart">
-                <template #icon>
-                  <ReloadOutlined/>
-                </template>
-                重启设备
-              </a-button>
-            </a-space>
-          </div>
-
-          <!-- Right: Status + Log (desktop only) -->
-          <div v-if="!isMobile" class="side-col">
-            <a-card class="section-card" size="small" title="设备状态">
-              <a-descriptions :column="1" :label-style="{ width: '80px' }" size="small">
-                <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>
-
-            <a-card v-if="logs.length > 0" class="section-card" size="small" title="日志">
-              <template #extra>
-                <a-button size="small" type="text" @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>
-        </div>
-
-        <!-- Log (mobile only) -->
-        <a-card v-if="isMobile && logs.length > 0" class="section-card" size="small" title="日志">
-          <template #extra>
-            <a-button size="small" type="text" @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>
-      </template>
-
-      <!-- 消息配置 Tab -->
-      <template v-if="activeTab === 'mqtt'">
-        <a-card class="section-card" size="small" title="MQTT 配置">
-          <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" :max="65535" :min="1" 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="agent/status"/>
-            </a-form-item>
-            <a-form-item label="状态发布主题">
-              <a-input v-model:value="mqttForm.statusTopic" placeholder="openCodeLight/status"/>
-            </a-form-item>
-            <a-form-item label="配置订阅主题">
-              <a-input v-model:value="mqttForm.topicConfig" placeholder="agent/status/config"/>
-            </a-form-item>
-          </a-form>
-          <a-alert
-              banner
-              message="配置订阅主题用于远程更新设备配置,向该主题发送完整 JSON 配置即可生效并自动重启。"
-              show-icon
-              type="info"
-          />
-        </a-card>
-
-        <a-button block size="large" type="primary" @click="handleSaveConfig">
-          <template #icon>
-            <SaveOutlined/>
-          </template>
-          保存配置并重启
-        </a-button>
-      </template>
-    </a-card>
-  </div>
-</template>
-
-<style scoped>
-.ble-device-page {
-  width: 100%;
-  height: 100%;
-  color: var(--text-color);
-}
-
-.conn-alert {
-  margin-bottom: 8px;
-}
-
-.config-card {
-  width: 100%;
-  height: 100%;
-}
-
-.section-card {
-  margin-bottom: 8px;
-}
-
-.mode-grid-mobile {
-  display: grid;
-  grid-template-columns: repeat(3, 1fr);
-  gap: 8px;
-}
-
-.mode-grid-desktop {
-  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;
-}
-
-.desktop-layout {
-  display: flex;
-  gap: 24px;
-  align-items: flex-start;
-}
-
-.config-col {
-  flex: 1;
-  min-width: 0;
-}
-
-.side-col {
-  width: 360px;
-  flex-shrink: 0;
-  position: sticky;
-  top: 24px;
-}
-
-@media (max-width: 767px) {
-  .mode-grid-mobile {
-    grid-template-columns: repeat(2, 1fr);
-  }
-}
-
-@media (min-width: 1200px) {
-  .mode-grid-desktop {
-    grid-template-columns: repeat(3, 1fr);
-  }
-}
-</style>

+ 692 - 0
src/views/DeviceConfig.vue

@@ -0,0 +1,692 @@
+<script lang="ts" setup>
+import {onMounted, reactive, ref, watch} from 'vue'
+import {message} from 'ant-design-vue'
+import {ClearOutlined, LoadingOutlined, ReloadOutlined, SaveOutlined,} from '@ant-design/icons-vue'
+import {useBleDevice} from '@/composables/useBleDevice'
+import {useDeviceConfig} from '@/composables/useDeviceConfig'
+
+const ble = useBleDevice()
+const mqtt = useDeviceConfig()
+
+const isMobile = ref(window.innerWidth < 768)
+const activeTab = ref('bluetooth')
+const tabList = [
+  {key: 'bluetooth', tab: '蓝牙配置'},
+  {key: 'mqtt', tab: '消息配置'},
+]
+
+function onTabChange(key: string) {
+  activeTab.value = key
+}
+
+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'},
+]
+
+const bleWifiForm = reactive({ssid: '', password: ''})
+const bleMqttForm = reactive({
+  broker: '',
+  port: 1883,
+  client: 'AI-Light',
+  username: '',
+  password: '',
+  topic: 'agent/status',
+  statusTopic: 'openCodeLight/status',
+  topicConfig: 'agent/status/config',
+})
+const blePinForm = reactive({red: 4, green: 3, yellow: 2})
+
+const mqttWifiForm = reactive({ssid: '', password: ''})
+const mqttMqttForm = reactive({
+  broker: '',
+  port: 1883,
+  client: 'AI-Light',
+  username: '',
+  password: '',
+  topic: 'agent/status',
+  statusTopic: 'openCodeLight/status',
+  topicConfig: 'agent/status/config',
+})
+const mqttPinForm = reactive({red: 4, green: 3, yellow: 2})
+
+watch(ble.config, (cfg) => {
+  if (cfg) {
+    bleWifiForm.ssid = cfg.wifi_ssid || ''
+    bleMqttForm.broker = cfg.mqtt_broker || ''
+    bleMqttForm.port = cfg.mqtt_port || 1883
+    bleMqttForm.client = cfg.mqtt_client || 'AI-Light'
+    bleMqttForm.username = cfg.mqtt_user || ''
+    bleMqttForm.topic = cfg.mqtt_topic || ''
+    bleMqttForm.statusTopic = cfg.mqtt_status || ''
+    bleMqttForm.topicConfig = cfg.mqtt_topic_config || 'agent/status/config'
+    blePinForm.red = cfg.pin_red ?? 4
+    blePinForm.green = cfg.pin_green ?? 3
+    blePinForm.yellow = cfg.pin_yellow ?? 2
+  }
+})
+
+watch(mqtt.config, (cfg) => {
+  if (cfg) {
+    mqttWifiForm.ssid = cfg.wifi_ssid || ''
+    mqttMqttForm.broker = cfg.mqtt_broker || ''
+    mqttMqttForm.port = cfg.mqtt_port || 1883
+    mqttMqttForm.client = cfg.mqtt_client || 'AI-Light'
+    mqttMqttForm.username = cfg.mqtt_user || ''
+    mqttMqttForm.topic = cfg.mqtt_topic || ''
+    mqttMqttForm.statusTopic = cfg.mqtt_status || ''
+    mqttMqttForm.topicConfig = cfg.config_topic || 'agent/status/config'
+    mqttPinForm.red = cfg.pin_red ?? 4
+    mqttPinForm.green = cfg.pin_green ?? 3
+    mqttPinForm.yellow = cfg.pin_yellow ?? 2
+  }
+})
+
+async function handleBleSave() {
+  const cfg = {
+    wifi_ssid: bleWifiForm.ssid,
+    wifi_pass: bleWifiForm.password,
+    mqtt_broker: bleMqttForm.broker,
+    mqtt_port: bleMqttForm.port,
+    mqtt_user: bleMqttForm.username,
+    mqtt_pass: bleMqttForm.password,
+    mqtt_client: bleMqttForm.client,
+    mqtt_topic: bleMqttForm.topic,
+    mqtt_status: bleMqttForm.statusTopic,
+    mqtt_topic_config: bleMqttForm.topicConfig,
+    pin_red: blePinForm.red,
+    pin_green: blePinForm.green,
+    pin_yellow: blePinForm.yellow,
+  }
+  try {
+    await ble.saveConfig(cfg)
+    message.success('配置已保存,设备将重启')
+  } catch {
+    message.error('保存失败')
+  }
+}
+
+async function handleBleRestart() {
+  try {
+    await ble.restartDevice()
+    message.success('重启指令已发送')
+  } catch {
+    message.error('重启失败')
+  }
+}
+
+async function handleMqttSave() {
+  const cfg = {
+    wifi_ssid: mqttWifiForm.ssid,
+    wifi_pass: mqttWifiForm.password,
+    mqtt_broker: mqttMqttForm.broker,
+    mqtt_port: mqttMqttForm.port,
+    mqtt_user: mqttMqttForm.username,
+    mqtt_pass: mqttMqttForm.password,
+    mqtt_client: mqttMqttForm.client,
+    mqtt_topic: mqttMqttForm.topic,
+    mqtt_status: mqttMqttForm.statusTopic,
+    mqtt_topic_config: mqttMqttForm.topicConfig,
+    pin_red: mqttPinForm.red,
+    pin_green: mqttPinForm.green,
+    pin_yellow: mqttPinForm.yellow,
+  }
+  try {
+    await mqtt.saveConfig(cfg)
+    message.success('配置已保存并推送')
+  } catch {
+    message.error('保存失败')
+  }
+}
+
+async function handleMqttRestart() {
+  try {
+    await mqtt.restartDevice()
+    message.success('重启指令已发送')
+  } catch {
+    message.error('重启失败')
+  }
+}
+
+function onResize() {
+  isMobile.value = window.innerWidth < 768
+}
+
+onMounted(() => window.addEventListener('resize', onResize))
+</script>
+
+<template>
+  <div class="device-config-page">
+    <a-card
+        :active-tab-key="activeTab"
+        :body-style="{ padding: '8px' }"
+        :tab-list="tabList"
+        class="config-card"
+        @tabChange="onTabChange"
+    >
+      <!-- 蓝牙配置 Tab -->
+      <template v-if="activeTab === 'bluetooth'">
+        <a-alert
+            v-if="ble.connectionState.value === 'connected'"
+            :message="'已连接: ' + ble.deviceName.value"
+            class="conn-alert"
+            show-icon
+            type="success"
+        >
+          <template #action>
+            <a-button danger size="small" @click="ble.disconnect">断开</a-button>
+          </template>
+        </a-alert>
+        <a-alert
+            v-else-if="ble.connectionState.value === 'scanning' || ble.connectionState.value === 'connecting'"
+            class="conn-alert"
+            message="正在连接设备..."
+            show-icon
+            type="info"
+        >
+          <template #icon>
+            <LoadingOutlined spin/>
+          </template>
+          <template #action>
+            <a-button disabled size="small">连接中</a-button>
+          </template>
+        </a-alert>
+        <a-alert
+            v-else
+            class="conn-alert"
+            description="请先连接蓝牙设备以进行配置"
+            message="未连接设备"
+            show-icon
+            type="warning"
+        >
+          <template #action>
+            <a-button size="small" type="primary" @click="ble.connect">连接设备</a-button>
+          </template>
+        </a-alert>
+
+        <div :class="isMobile ? 'mobile-layout' : 'desktop-layout'">
+          <div class="config-col">
+            <a-card v-if="isMobile" class="section-card" size="small" title="设备状态">
+              <a-descriptions :column="1" :label-style="{ width: '80px' }" size="small">
+                <a-descriptions-item label="WiFi">
+                  <a-tag v-if="ble.config.value?.wifi_ssid" color="success">{{ ble.config.value.wifi_ssid }}</a-tag>
+                  <a-tag v-else color="error">未配置</a-tag>
+                </a-descriptions-item>
+                <a-descriptions-item label="MQTT">
+                  <a-tag v-if="ble.config.value?.mqtt_broker" color="success">{{ ble.config.value.mqtt_broker }}</a-tag>
+                  <a-tag v-else color="error">未配置</a-tag>
+                </a-descriptions-item>
+                <a-descriptions-item label="模式">
+                  <a-tag color="processing">{{ ble.currentMode.value || '-' }}</a-tag>
+                </a-descriptions-item>
+                <a-descriptions-item label="通信">
+                  {{ ble.config.value?.comm_mode === 1 ? 'MQTT' : 'BLE-only' }}
+                </a-descriptions-item>
+              </a-descriptions>
+            </a-card>
+
+            <a-card class="section-card" size="small" title="灯效模式">
+              <div :class="isMobile ? 'mode-grid-mobile' : 'mode-grid-desktop'">
+                <a-button
+                    v-for="m in LIGHT_MODES"
+                    :key="m.key"
+                    :type="ble.currentMode.value === m.key ? 'primary' : 'default'"
+                    class="mode-btn"
+                    @click="ble.setMode(m.key)"
+                >
+                  {{ m.label }}
+                </a-button>
+              </div>
+            </a-card>
+
+            <a-card class="section-card" size="small" title="WiFi 配置">
+              <a-form layout="vertical">
+                <a-form-item label="SSID">
+                  <a-input v-model:value="bleWifiForm.ssid" placeholder="WiFi 名称"/>
+                </a-form-item>
+                <a-form-item label="密码">
+                  <a-input-password v-model:value="bleWifiForm.password" placeholder="WiFi 密码"/>
+                </a-form-item>
+              </a-form>
+            </a-card>
+
+            <a-card class="section-card" size="small" title="MQTT 配置">
+              <a-form layout="vertical">
+                <a-form-item label="Broker">
+                  <a-input v-model:value="bleMqttForm.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="bleMqttForm.port" :max="65535" :min="1" style="width: 100%"/>
+                    </a-form-item>
+                  </a-col>
+                  <a-col :span="12">
+                    <a-form-item label="Client ID">
+                      <a-input v-model:value="bleMqttForm.client" placeholder="AI-Light"/>
+                    </a-form-item>
+                  </a-col>
+                </a-row>
+                <a-form-item label="用户名">
+                  <a-input v-model:value="bleMqttForm.username" placeholder="可选"/>
+                </a-form-item>
+                <a-form-item label="密码">
+                  <a-input-password v-model:value="bleMqttForm.password" placeholder="可选"/>
+                </a-form-item>
+                <a-form-item label="订阅主题">
+                  <a-input v-model:value="bleMqttForm.topic" placeholder="agent/status"/>
+                </a-form-item>
+                <a-form-item label="状态发布主题">
+                  <a-input v-model:value="bleMqttForm.statusTopic" placeholder="openCodeLight/status"/>
+                </a-form-item>
+                <a-form-item label="配置订阅主题">
+                  <a-input v-model:value="bleMqttForm.topicConfig" placeholder="agent/status/config"/>
+                </a-form-item>
+              </a-form>
+            </a-card>
+
+            <a-card class="section-card" size="small" title="引脚配置(灯序)">
+              <a-form layout="vertical">
+                <a-row :gutter="12">
+                  <a-col :span="8">
+                    <a-form-item label="红灯引脚">
+                      <a-input-number v-model:value="blePinForm.red" :max="21" :min="0" style="width: 100%"/>
+                    </a-form-item>
+                  </a-col>
+                  <a-col :span="8">
+                    <a-form-item label="绿灯引脚">
+                      <a-input-number v-model:value="blePinForm.green" :max="21" :min="0" style="width: 100%"/>
+                    </a-form-item>
+                  </a-col>
+                  <a-col :span="8">
+                    <a-form-item label="黄灯引脚">
+                      <a-input-number v-model:value="blePinForm.yellow" :max="21" :min="0" style="width: 100%"/>
+                    </a-form-item>
+                  </a-col>
+                </a-row>
+                <a-alert
+                    banner
+                    message="默认接线:红=IO4、绿=IO3、黄=IO2。修改引脚后需确认硬件接线对应。"
+                    show-icon
+                    type="info"
+                />
+              </a-form>
+            </a-card>
+
+            <a-row :gutter="8">
+              <a-col :span="12">
+                <a-button block size="large" type="primary" @click="handleBleSave">
+                  <template #icon>
+                    <SaveOutlined/>
+                  </template>
+                  保存配置
+                </a-button>
+              </a-col>
+              <a-col :span="12">
+                <a-button block danger size="large" @click="handleBleRestart">
+                  <template #icon>
+                    <ReloadOutlined/>
+                  </template>
+                  重启设备
+                </a-button>
+              </a-col>
+            </a-row>
+          </div>
+
+          <div v-if="!isMobile" class="side-col">
+            <a-card class="section-card" size="small" title="设备状态">
+              <a-descriptions :column="1" :label-style="{ width: '80px' }" size="small">
+                <a-descriptions-item label="WiFi">
+                  <a-tag v-if="ble.config.value?.wifi_ssid" color="success">{{ ble.config.value.wifi_ssid }}</a-tag>
+                  <a-tag v-else color="error">未配置</a-tag>
+                </a-descriptions-item>
+                <a-descriptions-item label="MQTT">
+                  <a-tag v-if="ble.config.value?.mqtt_broker" color="success">{{ ble.config.value.mqtt_broker }}</a-tag>
+                  <a-tag v-else color="error">未配置</a-tag>
+                </a-descriptions-item>
+                <a-descriptions-item label="模式">
+                  <a-tag color="processing">{{ ble.currentMode.value || '-' }}</a-tag>
+                </a-descriptions-item>
+                <a-descriptions-item label="通信">
+                  {{ ble.config.value?.comm_mode === 1 ? 'MQTT' : 'BLE-only' }}
+                </a-descriptions-item>
+              </a-descriptions>
+            </a-card>
+
+            <a-card class="section-card log-card" size="small" title="日志">
+              <template #extra>
+                <a-button size="small" type="text" @click="ble.clearLogs">
+                  <ClearOutlined/>
+                  清空
+                </a-button>
+              </template>
+              <div class="log-container">
+                <div v-for="(line, i) in ble.logs.value" :key="i" class="log-line">{{ line }}</div>
+                <div v-if="ble.logs.value.length === 0" class="log-empty">暂无日志</div>
+              </div>
+            </a-card>
+          </div>
+        </div>
+      </template>
+
+      <!-- 消息配置 Tab -->
+      <template v-if="activeTab === 'mqtt'">
+        <a-alert
+            v-if="mqtt.connectionState.value === 'connected'"
+            :message="'已连接: ' + mqtt.deviceName.value"
+            class="conn-alert"
+            show-icon
+            type="success"
+        >
+          <template #action>
+            <a-button danger size="small" @click="mqtt.disconnect">断开</a-button>
+          </template>
+        </a-alert>
+        <a-alert
+            v-else-if="mqtt.connectionState.value === 'connecting'"
+            class="conn-alert"
+            message="正在加载配置..."
+            show-icon
+            type="info"
+        >
+          <template #icon>
+            <LoadingOutlined spin/>
+          </template>
+          <template #action>
+            <a-button disabled size="small">加载中</a-button>
+          </template>
+        </a-alert>
+        <a-alert
+            v-else
+            class="conn-alert"
+            description="请连接后端服务以进行远程配置"
+            message="未连接"
+            show-icon
+            type="warning"
+        >
+          <template #action>
+            <a-button size="small" type="primary" @click="mqtt.connect">连接</a-button>
+          </template>
+        </a-alert>
+
+        <div :class="isMobile ? 'mobile-layout' : 'desktop-layout'">
+          <div class="config-col">
+            <a-card v-if="isMobile" class="section-card" size="small" title="设备状态">
+              <a-descriptions :column="1" :label-style="{ width: '80px' }" size="small">
+                <a-descriptions-item label="WiFi">
+                  <a-tag v-if="mqtt.config.value?.wifi_ssid" color="success">{{ mqtt.config.value.wifi_ssid }}</a-tag>
+                  <a-tag v-else color="error">未配置</a-tag>
+                </a-descriptions-item>
+                <a-descriptions-item label="MQTT">
+                  <a-tag v-if="mqtt.config.value?.mqtt_broker" color="success">{{
+                      mqtt.config.value.mqtt_broker
+                    }}
+                  </a-tag>
+                  <a-tag v-else color="error">未配置</a-tag>
+                </a-descriptions-item>
+                <a-descriptions-item label="模式">
+                  <a-tag color="processing">{{ mqtt.currentMode.value || '-' }}</a-tag>
+                </a-descriptions-item>
+                <a-descriptions-item label="通信">MQTT</a-descriptions-item>
+              </a-descriptions>
+            </a-card>
+
+            <a-card class="section-card" size="small" title="灯效模式">
+              <div :class="isMobile ? 'mode-grid-mobile' : 'mode-grid-desktop'">
+                <a-button
+                    v-for="m in LIGHT_MODES"
+                    :key="m.key"
+                    :type="mqtt.currentMode.value === m.key ? 'primary' : 'default'"
+                    class="mode-btn"
+                    @click="mqtt.setMode(m.key)"
+                >
+                  {{ m.label }}
+                </a-button>
+              </div>
+            </a-card>
+
+            <a-card class="section-card" size="small" title="WiFi 配置">
+              <a-form layout="vertical">
+                <a-form-item label="SSID">
+                  <a-input v-model:value="mqttWifiForm.ssid" placeholder="WiFi 名称"/>
+                </a-form-item>
+                <a-form-item label="密码">
+                  <a-input-password v-model:value="mqttWifiForm.password" placeholder="WiFi 密码"/>
+                </a-form-item>
+              </a-form>
+            </a-card>
+
+            <a-card class="section-card" size="small" title="MQTT 配置">
+              <a-form layout="vertical">
+                <a-form-item label="Broker">
+                  <a-input v-model:value="mqttMqttForm.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="mqttMqttForm.port" :max="65535" :min="1" style="width: 100%"/>
+                    </a-form-item>
+                  </a-col>
+                  <a-col :span="12">
+                    <a-form-item label="Client ID">
+                      <a-input v-model:value="mqttMqttForm.client" placeholder="AI-Light"/>
+                    </a-form-item>
+                  </a-col>
+                </a-row>
+                <a-form-item label="用户名">
+                  <a-input v-model:value="mqttMqttForm.username" placeholder="可选"/>
+                </a-form-item>
+                <a-form-item label="密码">
+                  <a-input-password v-model:value="mqttMqttForm.password" placeholder="可选"/>
+                </a-form-item>
+                <a-form-item label="订阅主题">
+                  <a-input v-model:value="mqttMqttForm.topic" placeholder="agent/status"/>
+                </a-form-item>
+                <a-form-item label="状态发布主题">
+                  <a-input v-model:value="mqttMqttForm.statusTopic" placeholder="openCodeLight/status"/>
+                </a-form-item>
+                <a-form-item label="配置订阅主题">
+                  <a-input v-model:value="mqttMqttForm.topicConfig" placeholder="agent/status/config"/>
+                </a-form-item>
+              </a-form>
+            </a-card>
+
+            <a-card class="section-card" size="small" title="引脚配置(灯序)">
+              <a-form layout="vertical">
+                <a-row :gutter="12">
+                  <a-col :span="8">
+                    <a-form-item label="红灯引脚">
+                      <a-input-number v-model:value="mqttPinForm.red" :max="21" :min="0" style="width: 100%"/>
+                    </a-form-item>
+                  </a-col>
+                  <a-col :span="8">
+                    <a-form-item label="绿灯引脚">
+                      <a-input-number v-model:value="mqttPinForm.green" :max="21" :min="0" style="width: 100%"/>
+                    </a-form-item>
+                  </a-col>
+                  <a-col :span="8">
+                    <a-form-item label="黄灯引脚">
+                      <a-input-number v-model:value="mqttPinForm.yellow" :max="21" :min="0" style="width: 100%"/>
+                    </a-form-item>
+                  </a-col>
+                </a-row>
+                <a-alert
+                    banner
+                    message="默认接线:红=IO4、绿=IO3、黄=IO2。修改引脚后需确认硬件接线对应。"
+                    show-icon
+                    type="info"
+                />
+              </a-form>
+            </a-card>
+
+            <a-row :gutter="8">
+              <a-col :span="12">
+                <a-button block size="large" type="primary" @click="handleMqttSave">
+                  <template #icon>
+                    <SaveOutlined/>
+                  </template>
+                  保存配置
+                </a-button>
+              </a-col>
+              <a-col :span="12">
+                <a-button block danger size="large" @click="handleMqttRestart">
+                  <template #icon>
+                    <ReloadOutlined/>
+                  </template>
+                  重启设备
+                </a-button>
+              </a-col>
+            </a-row>
+          </div>
+
+          <div v-if="!isMobile" class="side-col">
+            <a-card class="section-card" size="small" title="设备状态">
+              <a-descriptions :column="1" :label-style="{ width: '80px' }" size="small">
+                <a-descriptions-item label="WiFi">
+                  <a-tag v-if="mqtt.config.value?.wifi_ssid" color="success">{{ mqtt.config.value.wifi_ssid }}</a-tag>
+                  <a-tag v-else color="error">未配置</a-tag>
+                </a-descriptions-item>
+                <a-descriptions-item label="MQTT">
+                  <a-tag v-if="mqtt.config.value?.mqtt_broker" color="success">{{
+                      mqtt.config.value.mqtt_broker
+                    }}
+                  </a-tag>
+                  <a-tag v-else color="error">未配置</a-tag>
+                </a-descriptions-item>
+                <a-descriptions-item label="模式">
+                  <a-tag color="processing">{{ mqtt.currentMode.value || '-' }}</a-tag>
+                </a-descriptions-item>
+                <a-descriptions-item label="通信">MQTT</a-descriptions-item>
+              </a-descriptions>
+            </a-card>
+
+            <a-card class="section-card log-card" size="small" title="日志">
+              <template #extra>
+                <a-button size="small" type="text" @click="mqtt.clearLogs">
+                  <ClearOutlined/>
+                  清空
+                </a-button>
+              </template>
+              <div class="log-container">
+                <div v-for="(line, i) in mqtt.logs.value" :key="i" class="log-line">{{ line }}</div>
+                <div v-if="mqtt.logs.value.length === 0" class="log-empty">暂无日志</div>
+              </div>
+            </a-card>
+          </div>
+        </div>
+      </template>
+    </a-card>
+  </div>
+</template>
+
+<style scoped>
+.device-config-page {
+  width: 100%;
+  height: 100%;
+  color: var(--text-color);
+}
+
+.conn-alert {
+  margin-bottom: 8px;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
+}
+
+.config-card {
+  width: 100%;
+  height: 100%;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
+}
+
+.section-card {
+  margin-bottom: 8px;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
+}
+
+.mode-grid-mobile {
+  display: grid;
+  grid-template-columns: repeat(3, 1fr);
+  gap: 8px;
+}
+
+.mode-grid-desktop {
+  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;
+  flex: 1;
+  overflow-y: auto;
+  line-height: 1.8;
+}
+
+.log-line {
+  color: #a5d6a7;
+  word-break: break-all;
+}
+
+.log-empty {
+  color: #666;
+  text-align: center;
+  padding: 20px 0;
+}
+
+.desktop-layout {
+  display: flex;
+  gap: 8px;
+  align-items: stretch;
+}
+
+.config-col {
+  flex: 1;
+  min-width: 0;
+}
+
+.side-col {
+  width: 360px;
+  flex-shrink: 0;
+  display: flex;
+  flex-direction: column;
+}
+
+.log-card {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+}
+
+.log-card :deep(.ant-card-body) {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+}
+
+@media (max-width: 767px) {
+  .mode-grid-mobile {
+    grid-template-columns: repeat(2, 1fr);
+  }
+}
+
+@media (min-width: 1200px) {
+  .mode-grid-desktop {
+    grid-template-columns: repeat(3, 1fr);
+  }
+}
+</style>