Browse Source

init
Signed-off-by: yangfei <1031503016@qq.com>

yangfei 1 year ago
parent
commit
272070d19f

+ 3 - 0
.env

@@ -0,0 +1,3 @@
+PORT=3000
+API_BASE_URL=http://localhost:3000
+

+ 2 - 1
.gitignore

@@ -27,4 +27,5 @@ build/Release
 # Dependency directory
 # https://docs.npmjs.com/misc/faq#should-i-check-my-node-modules-folder-into-git
 node_modules
-
+packeg-lock.json
+ecosystem.config.js

+ 15 - 0
.vscode/launch.json

@@ -0,0 +1,15 @@
+{
+    // 使用 IntelliSense 了解相关属性。 
+    // 悬停以查看现有属性的描述。
+    // 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387
+    "version": "0.2.0",
+    "configurations": [
+        {
+            "name": "Launch Chrome",
+            "request": "launch",
+            "type": "chrome",
+            "url": "http://localhost:8080",
+            "webRoot": "${workspaceFolder}"
+        }
+    ]
+}

+ 23 - 2
README.md

@@ -1,3 +1,24 @@
-# vueiot2
+# relay-control
 
-vue
+## Project setup
+```
+npm install
+```
+
+### Compiles and hot-reloads for development
+```
+npm run serve
+```
+
+### Compiles and minifies for production
+```
+npm run build
+```
+
+### Lints and fixes files
+```
+npm run lint
+```
+
+### Customize configuration
+See [Configuration Reference](https://cli.vuejs.org/config/).

+ 5 - 0
babel.config.js

@@ -0,0 +1,5 @@
+module.exports = {
+  presets: [
+    '@vue/cli-plugin-babel/preset'
+  ]
+}

+ 1 - 0
index.html

@@ -0,0 +1 @@
+<meta name="viewport" content="width=device-width, initial-scale=1.0">

+ 19 - 0
jsconfig.json

@@ -0,0 +1,19 @@
+{
+  "compilerOptions": {
+    "target": "es5",
+    "module": "esnext",
+    "baseUrl": "./",
+    "moduleResolution": "node",
+    "paths": {
+      "@/*": [
+        "src/*"
+      ]
+    },
+    "lib": [
+      "esnext",
+      "dom",
+      "dom.iterable",
+      "scripthost"
+    ]
+  }
+}

+ 38 - 0
package.json

@@ -0,0 +1,38 @@
+{
+  "name": "relay-control",
+  "version": "0.1.0",
+  "description": "IoT Relay Control Application",
+  "main": "app.js",
+  "scripts": {
+    "start": "node server/app.js",
+    "dev": "concurrently \"npm run start\" \"npm run serve\"",
+    "serve": "vue-cli-service serve --port 8080",
+    "build": "vue-cli-service build",
+    "lint": "eslint --ext .js,.vue src",
+    "test": "jest"
+  },
+  "dependencies": {
+    "axios": "^1.6.2",
+    "bcrypt": "^5.1.1",
+    "body-parser": "^1.20.2",
+    "cors": "^2.8.5",
+    "express": "^4.18.2",
+    "jsonwebtoken": "^9.0.2",
+    "mqtt": "^5.0.3",
+    "mysql2": "^3.6.0",
+    "vue": "^3.3.0",
+    "vue-router": "^4.2.5",
+    "winston": "^3.17.0"
+  },
+  "devDependencies": {
+    "@vue/cli-plugin-babel": "^5.0.8",
+    "@vue/cli-plugin-eslint": "^5.0.8",
+    "@vue/cli-service": "^5.0.8",
+    "concurrently": "^9.1.2",
+    "eslint": "^8.56.0",
+    "eslint-plugin-vue": "^9.17.0",
+    "install": "^0.13.0",
+    "jest": "^29.7.0",
+    "nodemon": "^3.0.2"
+  }
+}

BIN
public/favicon.ico


+ 17 - 0
public/index.html

@@ -0,0 +1,17 @@
+<!DOCTYPE html>
+<html lang="">
+  <head>
+    <meta charset="utf-8">
+    <meta http-equiv="X-UA-Compatible" content="IE=edge">
+    <meta name="viewport" content="width=device-width,initial-scale=1.0">
+    <link rel="icon" href="<%= BASE_URL %>favicon.ico">
+    <title><%= htmlWebpackPlugin.options.title %></title>
+  </head>
+  <body>
+    <noscript>
+      <strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
+    </noscript>
+    <div id="app"></div>
+    <!-- built files will be auto injected -->
+  </body>
+</html>

+ 161 - 0
src/App.vue

@@ -0,0 +1,161 @@
+<template>
+  <div id="app">
+    <div class="drawer" :class="{ close: !isDrawerOpen }">
+      <ul>
+        <li  @click.stop="toggleDrawer" class="drawer-link">
+          <img src="./assets/1.png" />
+          <span>{{ isDrawerOpen ? '关闭抽屉' : '打开抽屉' }}</span>
+        </li>
+        <li>
+          <router-link to="/" @click.stop class="drawer-link" :class="{ active: $route.path === '/RoomManagement' }">
+            <span class="icon">🏠</span>
+            <span class="text">房态显示</span>
+          </router-link>
+        </li>
+        <li>
+          <router-link to="/DeviceManagement" @click.stop class="drawer-link"
+            :class="{ active: $route.path === '/DeviceManagement' }">
+            <span class="icon">⚙️</span>
+            <span class="text">设备管理</span>
+          </router-link>
+        </li>
+        <li>
+          <router-link to="/second" @click.stop class="drawer-link" :class="{ active: $route.path === '/second' }">
+            <span class="icon">🛠️</span>
+            <span class="text">房间管理</span>
+          </router-link>
+        </li>
+        <li>
+          <router-link to="/home" @click.stop class="drawer-link" :class="{ active: $route.path === '/HelloWorld' }">
+            <span class="icon">🕹️</span>
+            <span class="text">开关控制</span>
+          </router-link>
+        </li>
+      </ul>
+    </div>
+    <div class="content" :class="{ 'drawer-open': isDrawerOpen }">
+      <router-view></router-view>
+    </div>
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'App',
+  data() {
+    return {
+      isDrawerOpen: true,
+    };
+  },
+  methods: {
+    toggleDrawer() {
+      this.isDrawerOpen = !this.isDrawerOpen;
+    },
+  },
+};
+</script>
+
+
+
+<style scoped>
+#app {
+  display: flex;
+  align-items: center;
+  width: 100vw;
+  height: 100vh;
+  font-family: 'Arial', sans-serif;
+}
+
+.drawer {
+  /* width: 250px; */
+  background-color: #2c3e50;
+  height: 100%;
+  transition: left 0.3s ease;
+  padding: 0 10px;
+  box-shadow: 2px 0 10px rgba(0, 0, 0, 0.2);
+  cursor: pointer;
+}
+
+.content {
+  margin-left: 0;
+  padding: 20px;
+  flex-grow: 1;
+  transition: margin-left 0.3s ease;
+  width: 100%;
+}
+
+.drawer-button {
+  font-size: 16px;
+  padding: 10px 15px;
+  cursor: pointer;
+  background-color: #34495e;
+  color: white;
+  border: none;
+  border-radius: 5px;
+  width: 100%;
+  margin-bottom: 20px;
+  transition: background-color 0.3s ease;
+}
+
+.drawer-button:hover {
+  background-color: #1abc9c;
+}
+
+ul {
+  list-style-type: none;
+  padding: 0;
+  margin: 0;
+  transition: left 0.3s ease;
+}
+
+li {
+  margin: 10px 0;
+  display: flex;
+  align-items: center;
+  color: white;
+}
+
+.drawer-link {
+  display: flex;
+  align-items: center;
+  padding: 10px;
+  background-color: #34495e;
+  border-radius: 5px;
+  text-decoration: none;
+  color: white;
+  transition: background-color 0.3s ease;
+}
+
+.drawer-link.active {
+  background-color: #1abc9c;
+  color: white;
+}
+
+.drawer-link:hover {
+  background-color: #1abc9c;
+}
+
+.icon {
+  font-size: 20px;
+  margin-right: 10px;
+}
+
+.text {
+  font-size: 16px;
+  white-space: nowrap;
+}
+.drawer>ul>li>img{
+  width: 24px;
+  height: 24px;
+  margin-right: 10px;
+}
+.drawer.close>ul>li>span:last-child,
+.drawer.close>ul>li>a>span:last-child{
+  display: none;
+}
+
+.drawer.close>ul>li>img,
+.drawer.close>ul>li>a>span:first-child{
+  margin: 0 auto;
+}
+</style>

BIN
src/assets/1.png


BIN
src/assets/logo.png


+ 598 - 0
src/components/DeviceManagement.vue

@@ -0,0 +1,598 @@
+<template>
+  <div class="device-management">
+    <h1>设备管理</h1>
+    <!-- 提示信息 -->
+    <div v-if="message" :class="['message', messageType]" @click="closeMessage">
+      {{ message }}
+      <span class="close-icon">×</span>
+    </div>
+    <!-- 已绑定设备列表 -->
+    <h2>已绑定设备</h2>
+    <input v-model="boundFilter" placeholder="搜索设备名称或ID" class="filter-input" />
+    <table class="device-table">
+      <thead>
+        <tr>
+          <th>设备ID</th>
+          <th>设备名称</th>
+          <th>房间</th>
+          <th>IP地址</th> <!-- 新增IP地址列 -->
+          <th>在线状态</th>
+          <th>操作</th>
+        </tr>
+      </thead>
+      <tbody>
+        <tr v-for="device in paginatedBoundDevices" :key="device.device_id">
+          <td>{{ device.device_id }}</td>
+          <td>
+            <input v-model="device.tempName" class="device-input" />
+          </td>
+          <td>
+            <select v-model="device.tempRoom" class="device-select">
+  <option value="" disabled>请选择房间</option>
+  <option v-for="room in roomsWithoutRelay" :key="room.id" :value="room.id">
+    {{ room.room_name }}
+  </option>
+</select>
+          </td>
+          <td>{{ device.ip_address }}</td> <!-- 显示IP地址 -->
+          <td>
+            <span :style="{ color: device.online ? 'green' : 'red' }">
+              {{ device.online ? '√' : '×' }}
+            </span>
+          </td>
+          <td>
+            <button class="save-button" @click="saveChanges(device)">保存</button>
+            <button class="remove-button" @click="unbindDevice(device.device_id)">移除</button>
+          </td>
+        </tr>
+      </tbody>
+    </table>
+    <div class="pagination">
+      <button @click="prevBoundPage" :disabled="currentBoundPage === 1">上一页</button>
+      <span>第 {{ currentBoundPage }} 页</span>
+      <button @click="nextBoundPage" :disabled="currentBoundPage === totalBoundPages">下一页</button>
+    </div>
+
+    <!-- 未绑定设备列表 -->
+    <h2>未绑定设备</h2>
+    <input v-model="unboundFilter" placeholder="搜索设备名称或ID" class="filter-input" />
+    <table class="device-table">
+      <thead>
+        <tr>
+          <th>设备ID</th>
+          <th>设备名称</th>
+          <th>选择房间</th>
+          <th>IP地址</th> <!-- 新增IP地址列 -->
+          <th>在线状态</th>
+          <th>操作</th>
+        </tr>
+      </thead>
+      <tbody>
+        <tr v-for="device in paginatedUnboundDevices" :key="device.device_id">
+          <td>{{ device.device_id }}</td>
+          <td>
+            <input v-model="device.tempName" class="device-input" />
+          </td>
+          <td>
+            <select v-model="device.tempRoom" class="device-select">
+              <option value="" disabled>请选择房间</option>
+              <option v-for="room in rooms" :key="room.id" :value="room.id">{{ room.room_name }}</option>
+            </select>
+          </td>
+          <td>{{ device.ip_address }}</td> <!-- 显示IP地址 -->
+          <td>
+            <span :style="{ color: device.online ? 'green' : 'red' }">
+              {{ device.online ? '√' : '×' }}
+            </span>
+          </td>
+          <td>
+            <button class="save-button" @click="saveChanges(device)">保存</button>
+            <button v-if="!device.online" class="delete-button" @click="removeDevice(device.device_id)">删除</button>
+          </td>
+        </tr>
+      </tbody>
+    </table>
+    <div class="pagination">
+      <button @click="prevUnboundPage" :disabled="currentUnboundPage === 1">上一页</button>
+      <span>第 {{ currentUnboundPage }} 页</span>
+      <button @click="nextUnboundPage" :disabled="currentUnboundPage === totalUnboundPages">下一页</button>
+    </div>
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'DeviceManagement',
+  data() {
+    return {
+      message: '',
+      messageType: '',
+      devices: [],
+      rooms: [],
+      boundFilter: '',
+      unboundFilter: '',
+      currentBoundPage: 1,
+      currentUnboundPage: 1,
+      itemsPerPage: 10,
+    };
+  },
+  computed: {
+// 获取所有继电器设备
+relayDevices() {
+    return this.devices.filter(device => device.device_id.includes('ESP32'));
+  },
+  // 获取已绑定继电器的房间 ID
+  roomsWithRelay() {
+    return this.relayDevices.map(device => device.room_id).filter(Boolean);
+  },
+  // 获取未绑定继电器的房间列表
+  roomsWithoutRelay() {
+    return this.rooms.filter(room => !this.roomsWithRelay.includes(room.id));
+  },
+
+    boundDevices() {
+      return this.devices.filter(device => device.tempRoom !== '');
+    },
+    filteredBoundDevices() {
+      return this.boundDevices.filter(device =>
+        device.device_id.includes(this.boundFilter) ||
+        device.tempName.includes(this.boundFilter)
+      );
+    },
+    paginatedBoundDevices() {
+      const start = (this.currentBoundPage - 1) * this.itemsPerPage;
+      const end = start + this.itemsPerPage;
+      return this.filteredBoundDevices.slice(start, end);
+    },
+    totalBoundPages() {
+      return Math.ceil(this.filteredBoundDevices.length / this.itemsPerPage);
+    },
+    unboundDevices() {
+      return this.devices.filter(device => device.tempRoom === '');
+    },
+    filteredUnboundDevices() {
+      return this.unboundDevices.filter(device =>
+        device.device_id.includes(this.unboundFilter) ||
+        device.tempName.includes(this.unboundFilter)
+      );
+    },
+    paginatedUnboundDevices() {
+      const start = (this.currentUnboundPage - 1) * this.itemsPerPage;
+      const end = start + this.itemsPerPage;
+      return this.filteredUnboundDevices.slice(start, end);
+    },
+    totalUnboundPages() {
+      return Math.ceil(this.filteredUnboundDevices.length / this.itemsPerPage);
+    },
+  },
+  watch: {
+    // 监听设备名称的变化
+    devices: {
+      handler(newDevices) {
+        newDevices.forEach(device => {
+          if (device.tempName !== device.name || device.tempRoom !== device.previousRoom) {
+            this.saveChanges(device);
+          }
+        });
+      },
+      deep: true, // 深度监听
+    },
+  },
+  async created() {
+    await this.fetchRooms();
+    await this.fetchDevices();
+  },
+  methods: {
+    closeMessage() {
+      this.message = ''; // 点击提示信息后关闭
+    },
+    prevBoundPage() {
+      if (this.currentBoundPage > 1) this.currentBoundPage--;
+    },
+    nextBoundPage() {
+      if (this.currentBoundPage < this.totalBoundPages) this.currentBoundPage++;
+    },
+    prevUnboundPage() {
+      if (this.currentUnboundPage > 1) this.currentUnboundPage--;
+    },
+    nextUnboundPage() {
+      if (this.currentUnboundPage < this.totalUnboundPages) this.currentUnboundPage++;
+    },
+    async fetchRooms() {
+      try {
+        const response = await fetch('/api/rooms');
+        if (response.ok) {
+          this.rooms = await response.json();
+        } else {
+          console.error('Failed to fetch rooms:', response.statusText);
+        }
+      } catch (error) {
+        console.error('Error fetching rooms:', error);
+      }
+    },
+    async fetchDevices() {
+      try {
+        const response = await fetch('/api/devices');
+        if (response.ok) {
+          const data = await response.json();
+          this.devices = data.map(device => ({
+            ...device,
+            tempName: device.name || '',
+            tempRoom: device.room_id ? device.room_id.toString() : '',
+            previousRoom: device.room_id ? device.room_id.toString() : '',
+            online: device.status === 'online',
+            temperature: device.temperature,
+            switchStatus: device.switch_status,
+            levelStatus: device.level_status,
+            boundDeviceId: device.bound_device_id,
+            boundTime: device.bound_time,
+            firstOnlineTime: device.first_online_time,
+            lastOnlineTime: device.last_online_time,
+            lastOfflineTime: device.last_offline_time,
+            room_name: device.room_name || '',
+            ip_address: device.ip_address || '', // 新增IP地址字段
+          }));
+        } else {
+          console.error('Failed to fetch devices:', response.statusText);
+        }
+      } catch (error) {
+        console.error('Error fetching devices:', error);
+      }
+    },
+    async saveChanges(device) {
+    if (!device.device_id) {
+      this.message = '设备唯一标识符不能为空';
+      this.messageType = 'error';
+      return;
+    }
+
+    if (!device.tempName) {
+      device.tempName = device.device_id;
+    }
+
+    try {
+      const response = await fetch('/api/devices/update', {
+        method: 'POST',
+        headers: {
+          'Content-Type': 'application/json',
+        },
+        body: JSON.stringify({
+          deviceId: device.device_id,
+          name: device.tempName,
+          roomId: device.tempRoom || null,
+        }),
+      });
+
+      const result = await response.json(); // 解析后端返回的 JSON 数据
+
+      if (response.ok) {
+        if (result.success) {
+          this.message = result.message || '设备绑定成功';
+          this.messageType = 'success';
+        } else {
+          this.message = result.message || '绑定设备失败';
+          this.messageType = 'error';
+        }
+
+        // 更新设备列表
+        const updatedDevice = this.devices.find(d => d.device_id === device.device_id);
+        if (updatedDevice) {
+          updatedDevice.tempRoom = device.tempRoom || null;
+          updatedDevice.room = device.tempRoom || null;
+          updatedDevice.name = device.tempName;
+          // 更新房间名称
+          const selectedRoom = this.rooms.find(room => room.id === device.tempRoom);
+          if (selectedRoom) {
+            updatedDevice.room_name = selectedRoom.room_name;
+          }
+        }
+
+        await this.fetchDevices();
+      } else {
+        this.message = result.message || '绑定设备失败';
+        this.messageType = 'error';
+        console.error('绑定设备失败:', result.message);
+      }
+    } catch (error) {
+      this.message = '绑定设备时出错';
+      this.messageType = 'error';
+      console.error('绑定设备时出错:', error);
+    }
+  },
+    async unbindDevice(deviceId) {
+      try {
+        const response = await fetch(`/api/devices/unbind/${deviceId}`, {
+          method: 'POST',
+          headers: {
+            'Content-Type': 'application/json',
+          },
+          body: JSON.stringify({ roomId: null }),
+        });
+        if (response.ok) {
+          const device = this.devices.find(d => d.device_id === deviceId);
+          if (device) {
+            device.tempRoom = '';
+            device.room_id = null;
+            device.room_name = ''; // 清除房间名称
+          }
+          console.log('设备已从房间中移除');
+        } else {
+          console.error('解除绑定失败:', response.statusText);
+        }
+      } catch (error) {
+        console.error('解除绑定时出错:', error);
+      }
+    },
+    async removeDevice(deviceId) {
+      try {
+        const response = await fetch(`/api/devices/${deviceId}`, {
+          method: 'DELETE',
+        });
+        if (response.ok) {
+          this.devices = this.devices.filter(device => device.device_id !== deviceId);
+          console.log('设备已删除');
+        } else {
+          console.error('删除设备失败:', response.statusText);
+        }
+      } catch (error) {
+        console.error('删除设备时出错:', error);
+      }
+    },
+  },
+};
+</script>
+
+
+
+  <style >
+/* 设备管理界面的整体布局 */
+.device-management {
+  max-height: 90vh; /* 设置最大高度为视口的 90% */
+  overflow-y: auto; /* 启用垂直滚动条 */
+  padding: 20px; /* 内边距 */
+  background-color: #f5f7fa; /* 背景颜色 */
+}
+
+/* 主标题样式 */
+h1 {
+  font-size: 24px; /* 字体大小 */
+  margin-bottom: 20px; /* 底部外边距 */
+  color: #333; /* 字体颜色 */
+  text-align: center; /* 文字居中 */
+}
+
+/* 副标题样式 */
+h2 {
+  font-size: 20px; /* 字体大小 */
+  margin-top: 30px; /* 顶部外边距 */
+  margin-bottom: 15px; /* 底部外边距 */
+  color: #555; /* 字体颜色 */
+}
+
+/* 搜索输入框样式 */
+.filter-input {
+  width: 200px; /* 宽度 */
+  padding: 8px; /* 内边距 */
+  margin-bottom: 20px; /* 底部外边距 */
+  border: 1px solid #e0e0e0; /* 边框 */
+  border-radius: 6px; /* 圆角 */
+  font-size: 14px; /* 字体大小 */
+  transition: all 0.3s ease; /* 过渡效果 */
+}
+
+/* 搜索输入框聚焦时的样式 */
+.filter-input:focus {
+  border-color: #4caf50; /* 边框颜色 */
+  box-shadow: 0 0 8px rgba(76, 175, 80, 0.3); /* 阴影效果 */
+  outline: none; /* 去除默认的聚焦轮廓 */
+}
+
+/* 搜索输入框悬停时的样式 */
+.filter-input:hover {
+  border-color: #4caf50; /* 边框颜色 */
+}
+
+/* 表格样式 */
+.device-table {
+  width: 100%; /* 表格宽度 */
+  border-collapse: separate; /* 使用 separate 而不是 collapse */
+  border-spacing: 0; /* 单元格间距 */
+  margin-bottom: 20px; /* 底部外边距 */
+  background-color: #fff; /* 背景颜色 */
+  border-radius: 8px; /* 圆角 */
+  box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); /* 阴影效果 */
+}
+
+/* 表头单元格样式 */
+.device-table th,
+.device-table td {
+  padding: 12px; /* 内边距 */
+  text-align: center; /* 文字居中 */
+  border-bottom: 1px solid #e0e0e0; /* 底部边框 */
+}
+
+/* 表头样式 */
+.device-table th {
+  background-color: #f8f9fa; /* 背景颜色 */
+  font-weight: 600; /* 字体粗细 */
+  color: #333; /* 字体颜色 */
+}
+
+/* 表格行悬停时的样式 */
+.device-table tr:hover {
+  background-color: #f1f1f1; /* 背景颜色 */
+  transition: background-color 0.3s ease; /* 过渡效果 */
+}
+
+/* 表格最后一行去除底部边框 */
+.device-table tr:last-child td {
+  border-bottom: none; /* 去除底部边框 */
+}
+
+/* 设备ID和IP地址列的字体样式 */
+.device-table td:nth-child(1),
+.device-table td:nth-child(4) {
+  font-family: 'Courier New', Courier, monospace; /* 等宽字体 */
+  font-size: 14px; /* 字体大小 */
+  color: #555; /* 字体颜色 */
+}
+
+/* 保存按钮样式 */
+.save-button,
+.remove-button,
+.delete-button {
+  padding: 8px 16px; /* 内边距 */
+  border: none; /* 去除边框 */
+  border-radius: 6px; /* 圆角 */
+  font-size: 14px; /* 字体大小 */
+  font-weight: 500; /* 字体粗细 */
+  cursor: pointer; /* 鼠标指针 */
+  transition: all 0.3s ease; /* 过渡效果 */
+  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); /* 阴影效果 */
+}
+
+/* 保存按钮的背景颜色 */
+.save-button {
+  background: linear-gradient(135deg, #4caf50, #81c784); /* 渐变背景 */
+  color: white; /* 字体颜色 */
+}
+
+/* 保存按钮悬停时的样式 */
+.save-button:hover {
+  background: linear-gradient(135deg, #45a049, #6bbf70); /* 渐变背景 */
+  box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); /* 阴影效果 */
+}
+
+/* 移除按钮的背景颜色 */
+.remove-button {
+  background: linear-gradient(135deg, #f44336, #e57373); /* 渐变背景 */
+  color: white; /* 字体颜色 */
+}
+
+/* 移除按钮悬停时的样式 */
+.remove-button:hover {
+  background: linear-gradient(135deg, #e53935, #d32f2f); /* 渐变背景 */
+  box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); /* 阴影效果 */
+}
+
+/* 删除按钮的背景颜色 */
+.delete-button {
+  background: linear-gradient(135deg, #ff9800, #ffb74d); /* 渐变背景 */
+  color: white; /* 字体颜色 */
+}
+
+/* 删除按钮悬停时的样式 */
+.delete-button:hover {
+  background: linear-gradient(135deg, #fb8c00, #f57c00); /* 渐变背景 */
+  box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); /* 阴影效果 */
+}
+
+/* 输入框和选择框的样式 */
+.device-input,
+.device-select {
+  width: 100%; /* 宽度 */
+  padding: 8px; /* 内边距 */
+  border: 1px solid #e0e0e0; /* 边框 */
+  border-radius: 6px; /* 圆角 */
+  font-size: 14px; /* 字体大小 */
+  transition: all 0.3s ease; /* 过渡效果 */
+}
+
+/* 输入框和选择框聚焦时的样式 */
+.device-input:focus,
+.device-select:focus {
+  border-color: #4caf50; /* 边框颜色 */
+  box-shadow: 0 0 8px rgba(76, 175, 80, 0.3); /* 阴影效果 */
+  outline: none; /* 去除默认的聚焦轮廓 */
+}
+
+/* 输入框和选择框悬停时的样式 */
+.device-input:hover,
+.device-select:hover {
+  border-color: #4caf50; /* 边框颜色 */
+}
+
+/* 分页按钮的样式 */
+.pagination {
+  margin-top: 20px; /* 顶部外边距 */
+  text-align: center; /* 文字居中 */
+}
+
+/* 分页按钮的样式 */
+.pagination button {
+  padding: 8px 16px; /* 内边距 */
+  border: none; /* 去除边框 */
+  border-radius: 6px; /* 圆角 */
+  font-size: 14px; /* 字体大小 */
+  font-weight: 500; /* 字体粗细 */
+  cursor: pointer; /* 鼠标指针 */
+  transition: all 0.3s ease; /* 过渡效果 */
+  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); /* 阴影效果 */
+  background-color: #4caf50; /* 背景颜色 */
+  color: white; /* 字体颜色 */
+  margin: 0 5px; /* 左右外边距 */
+}
+
+/* 分页按钮悬停时的样式 */
+.pagination button:hover {
+  background-color: #45a049; /* 背景颜色 */
+  box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); /* 阴影效果 */
+}
+
+/* 分页按钮禁用时的样式 */
+.pagination button:disabled {
+  background-color: #e0e0e0; /* 背景颜色 */
+  color: #999; /* 字体颜色 */
+  cursor: not-allowed; /* 禁用鼠标指针 */
+  box-shadow: none; /* 去除阴影 */
+}
+
+/* 提示信息的样式 */
+.message {
+  position: fixed;
+  top: 50%;
+  left: 50%;
+  transform: translate(-50%, -50%);
+  padding: 20px 30px;
+  border-radius: 8px;
+  font-size: 16px;
+  font-weight: bold;
+  color: white;
+  background: linear-gradient(135deg, #6a11cb, #2575fc);
+  box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
+  z-index: 1000;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  cursor: pointer;
+  animation: fadeIn 0.3s ease-in-out;
+}
+/* 成功提示信息的样式 */
+.message.success {
+  background: linear-gradient(135deg, #4caf50, #81c784);
+}
+
+/* 错误提示信息的样式 */
+.message.error {
+  background: linear-gradient(135deg, #f44336, #e57373);
+}
+/* 关闭图标的样式 */
+.close-icon {
+  margin-left: 15px;
+  font-size: 20px;
+  font-weight: bold;
+  cursor: pointer;
+}
+
+/* 淡入动画 */
+@keyframes fadeIn {
+  from {
+    opacity: 0;
+    transform: translate(-50%, -60%);
+  }
+  to {
+    opacity: 1;
+    transform: translate(-50%, -50%);
+  }
+}
+</style>

+ 172 - 0
src/components/HelloWorld.vue

@@ -0,0 +1,172 @@
+<template>
+  <div id="admin">
+    <h1>后台服务员</h1>
+    <div class="button-container">
+      <select v-model="selectedRoom" class="room-select">
+        <option v-for="room in rooms" :key="room.id" :value="room.id">{{ room.room_name }}</option>
+      </select>
+      <button @click="toggleRelay('on')" class="btn btn-on">打开继电器</button>
+      <button @click="toggleRelay('off')" class="btn btn-off">关闭继电器</button>
+    </div>
+    <p class="status">{{ status }}</p>
+  </div>
+</template>
+
+<script>
+export default {
+  name: "HelloWorld",
+  data() {
+    return {
+      status: "", // 显示继电器状态
+      rooms: [], // 存储房间列表
+      selectedRoom: null, // 当前选中的房间
+    };
+  },
+  async created() {
+    // 获取房间列表
+    try {
+      const response = await fetch('/api/rooms');
+      if (response.ok) {
+        this.rooms = await response.json();
+      } else {
+        console.error('获取房间列表失败:', response.statusText);
+      }
+    } catch (error) {
+      console.error('请求错误:', error);
+    }
+  },
+  methods: {
+  async toggleRelay(state) {
+    if (!this.selectedRoom) {
+      this.status = "请先选择一个房间";
+      return;
+    }
+
+    try {
+      // 发送请求到后端
+      const response = await fetch(`/relay/${state}?roomId=${this.selectedRoom}`);
+
+      // 解析响应数据
+      const data = await response.text();
+
+      // 根据响应状态码处理结果
+      if (response.ok) {
+        // 请求成功,更新状态
+        this.status = data; // 使用后端返回的消息
+      } else {
+        // 请求失败,根据状态码显示错误信息
+        switch (response.status) {
+          case 400:
+            this.status = "请求参数错误:" + data;
+            break;
+          case 404:
+            this.status = "未找到设备:" + data;
+            break;
+          case 500:
+            this.status = "服务器错误:" + data;
+            break;
+          default:
+            this.status = "请求失败:" + response.statusText;
+        }
+      }
+    } catch (error) {
+      // 捕获网络错误或其他异常
+      console.error("请求错误:", error);
+      this.status = "无法控制继电器,请检查网络连接";
+    }
+  },
+},
+};
+</script>
+
+<style >
+@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@400;600&display=swap');
+
+/* 重置默认样式 */
+html, body {
+  margin: 0;
+  padding: 0;
+  width: 100%;
+  height: 100%;
+  overflow-y: auto; /* 防止滚动条 */
+  font-family: 'Poppins', sans-serif;
+  background: linear-gradient(135deg, #f5f7fa, #c3cfe2); /* 背景色 */
+}
+
+#admin {
+  width: 100%;
+  height: 100vh; /* 占满整个视口高度 */
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  text-align: center;
+}
+
+h1 {
+  font-size: 2.5em;
+  margin-bottom: 30px;
+  color: #333;
+  font-weight: 600;
+}
+
+.button-container {
+  display: flex;
+  justify-content: center;
+  gap: 20px;
+  margin-bottom: 30px;
+}
+
+.room-select {
+  font-size: 16px;
+  padding: 10px 15px;
+  border-radius: 8px;
+  border: 2px solid #4CAF50;
+  background-color: white;
+  color: #333;
+  outline: none;
+  transition: border-color 0.3s;
+}
+
+.room-select:focus {
+  border-color: #45a049;
+}
+
+.btn {
+  font-size: 18px;
+  padding: 12px 24px;
+  cursor: pointer;
+  border: none;
+  border-radius: 8px;
+  box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
+  transition: background-color 0.3s, transform 0.2s, box-shadow 0.3s;
+  font-weight: 600;
+}
+
+.btn-on {
+  background-color: #4CAF50;
+  color: white;
+}
+
+.btn-off {
+  background-color: #f44336;
+  color: white;
+}
+
+.btn:hover {
+  transform: translateY(-2px);
+  box-shadow: 0 6px 8px rgba(0, 0, 0, 0.15);
+}
+
+.btn:active {
+  transform: translateY(0);
+  box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
+}
+
+.status {
+  margin-top: 20px;
+  font-size: 1.2em;
+  color: #555;
+  font-weight: 500;
+}
+</style>

+ 375 - 0
src/components/Login.vue

@@ -0,0 +1,375 @@
+<template>
+    <div class="login-container">
+      <div class="login-box">
+        <h1 class="login-title">欢迎登录</h1>
+        <form @submit.prevent="login" class="login-form">
+          <div class="form-group">
+            <label for="username" class="form-label">
+              <i class="fas fa-user icon"></i>
+              用户名
+            </label>
+            <input
+              v-model="username"
+              type="text"
+              id="username"
+              class="form-input"
+              placeholder="请输入用户名"
+              required
+            />
+          </div>
+          <div class="form-group">
+            <label for="password" class="form-label">
+              <i class="fas fa-lock icon"></i>
+              密码
+            </label>
+            <input
+              v-model="password"
+              type="password"
+              id="password"
+              class="form-input"
+              placeholder="请输入密码"
+              required
+            />
+          </div>
+          <button type="submit" class="login-button">登录</button>
+          <p v-if="errorMessage" class="error-message">{{ errorMessage }}</p>
+        </form>
+        <div class="login-footer">
+          <p>没有账号?<a href="#" @click="showRegister = true" class="register-link">立即注册</a></p>
+        </div>
+      </div>
+  
+      <!-- 注册弹窗 -->
+      <div v-if="showRegister" class="register-overlay">
+        <div class="register-box">
+          <h2 class="register-title">注册新账号</h2>
+          <form @submit.prevent="register" class="register-form">
+            <div class="form-group">
+              <label for="register-username" class="form-label">
+                <i class="fas fa-user icon"></i>
+                用户名
+              </label>
+              <input
+                v-model="registerUsername"
+                type="text"
+                id="register-username"
+                class="form-input"
+                placeholder="请输入用户名"
+                required
+              />
+            </div>
+            <div class="form-group">
+              <label for="register-password" class="form-label">
+                <i class="fas fa-lock icon"></i>
+                密码
+              </label>
+              <input
+                v-model="registerPassword"
+                type="password"
+                id="register-password"
+                class="form-input"
+                placeholder="请输入密码"
+                required
+              />
+            </div>
+            <button type="submit" class="register-button">注册</button>
+            <p v-if="registerErrorMessage" class="error-message">{{ registerErrorMessage }}</p>
+          </form>
+          <button @click="showRegister = false" class="close-button">关闭</button>
+        </div>
+      </div>
+    </div>
+  </template>
+  
+  <script>
+  export default {
+    name: 'LoginPage',
+    data() {
+      return {
+        username: '',
+        password: '',
+        errorMessage: '',
+        showRegister: false,
+        registerUsername: '',
+        registerPassword: '',
+        registerErrorMessage: '',
+      };
+    },
+    methods: {
+      async login() {
+        try {
+          const response = await fetch('/api/login', {
+            method: 'POST',
+            headers: {
+              'Content-Type': 'application/json',
+            },
+            body: JSON.stringify({
+              username: this.username,
+              password: this.password,
+            }),
+          });
+  
+          if (response.ok) {
+            const data = await response.json();
+            localStorage.setItem('token', data.token); // 存储 token
+            this.$router.push('/'); // 登录成功后跳转到首页
+          } else {
+            this.errorMessage = '用户名或密码错误';
+          }
+        } catch (error) {
+          this.errorMessage = '登录失败,请稍后重试';
+        }
+      },
+      async register() {
+        try {
+          const response = await fetch('/api/register', {
+            method: 'POST',
+            headers: {
+              'Content-Type': 'application/json',
+            },
+            body: JSON.stringify({
+              username: this.registerUsername,
+              password: this.registerPassword,
+            }),
+          });
+  
+          if (response.ok) {
+            await response.json(); // 移除未使用的 data 变量
+            alert('注册成功!');
+            this.showRegister = false; // 关闭注册弹窗
+          } else {
+            const errorData = await response.json();
+            this.registerErrorMessage = errorData.message || '注册失败,请稍后重试';
+          }
+        } catch (error) {
+          this.registerErrorMessage = '注册失败,请稍后重试';
+        }
+      },
+    },
+  };
+  </script>
+  
+  <style>
+  /* 全局样式 */
+  body {
+    margin: 0;
+    font-family: 'Poppins', sans-serif;
+    background-color: #f5f5f5;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    height: 100vh;
+    color: #333;
+  }
+  
+  /* 登录容器 */
+  .login-container {
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    width: 100%;
+    height: 100%;
+  }
+  
+  /* 登录框 */
+  .login-box {
+    background: white;
+    padding: 40px;
+    border-radius: 12px;
+    box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
+    width: 100%;
+    max-width: 400px;
+    text-align: center;
+    animation: fadeIn 0.5s ease-in-out;
+  }
+  
+  /* 注册弹窗 */
+  .register-overlay {
+    position: fixed;
+    top: 0;
+    left: 0;
+    width: 100%;
+    height: 100%;
+    background: rgba(0, 0, 0, 0.5);
+    display: flex;
+    justify-content: center;
+    align-items: center;
+  }
+  
+  .register-box {
+    background: white;
+    padding: 40px;
+    border-radius: 12px;
+    box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
+    width: 100%;
+    max-width: 400px;
+    text-align: center;
+    position: relative;
+  }
+  
+  .close-button {
+    position: absolute;
+    top: 10px;
+    right: 10px;
+    background: none;
+    border: none;
+    font-size: 16px;
+    cursor: pointer;
+    color: #666;
+  }
+  
+  .close-button:hover {
+    color: #333;
+  }
+  
+  /* 其他样式保持不变 */
+  </style>
+  
+  <style>
+  /* 引入 Font Awesome 图标库 */
+  @import url('https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css');
+  
+  /* 全局样式 */
+  body {
+    margin: 0;
+    font-family: 'Poppins', sans-serif;
+    background-color: #f5f5f5; /* 灰白色背景 */
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    height: 100vh;
+    color: #333;
+  }
+  
+  /* 登录容器 */
+  .login-container {
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    width: 100%;
+    height: 100%;
+  }
+  
+  /* 登录框 */
+  .login-box {
+    background: white;
+    padding: 40px;
+    border-radius: 12px;
+    box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1); /* 更柔和的阴影 */
+    width: 100%;
+    max-width: 400px;
+    text-align: center;
+    animation: fadeIn 0.5s ease-in-out;
+  }
+  
+  /* 登录标题 */
+  .login-title {
+    font-size: 24px;
+    font-weight: 600;
+    margin-bottom: 20px;
+    color: #333;
+  }
+  
+  /* 登录表单 */
+  .login-form {
+    display: flex;
+    flex-direction: column;
+    gap: 20px;
+  }
+  
+  /* 表单组 */
+  .form-group {
+    text-align: left;
+  }
+  
+  /* 表单标签 */
+  .form-label {
+    display: flex;
+    align-items: center;
+    font-size: 14px;
+    font-weight: 500;
+    margin-bottom: 8px;
+    color: #555;
+  }
+  
+  /* 图标 */
+  .icon {
+    margin-right: 8px;
+    color: #6a11cb;
+  }
+  
+  /* 输入框 */
+  .form-input {
+    width: 100%;
+    padding: 12px;
+    border: 1px solid #e0e0e0;
+    border-radius: 8px;
+    font-size: 14px;
+    transition: border-color 0.3s ease, box-shadow 0.3s ease;
+  }
+  
+  .form-input:focus {
+    border-color: #6a11cb;
+    box-shadow: 0 0 8px rgba(106, 17, 203, 0.2);
+    outline: none;
+  }
+  
+  /* 登录按钮 */
+  .login-button {
+    width: 100%;
+    padding: 12px;
+    background: linear-gradient(135deg, #6a11cb, #2575fc);
+    color: white;
+    border: none;
+    border-radius: 8px;
+    font-size: 16px;
+    font-weight: 500;
+    cursor: pointer;
+    transition: background 0.3s ease, transform 0.2s ease;
+  }
+  
+  .login-button:hover {
+    background: linear-gradient(135deg, #2575fc, #6a11cb);
+    transform: translateY(-2px);
+  }
+  
+  .login-button:active {
+    transform: translateY(0);
+  }
+  
+  /* 错误信息 */
+  .error-message {
+    color: #ff4d4d;
+    font-size: 14px;
+    margin-top: 10px;
+  }
+  
+  /* 登录页脚 */
+  .login-footer {
+    margin-top: 20px;
+    font-size: 14px;
+    color: #666;
+  }
+  
+  .login-footer a {
+    color: #6a11cb;
+    text-decoration: none;
+    font-weight: 500;
+  }
+  
+  .login-footer a:hover {
+    text-decoration: underline;
+  }
+  
+  /* 动画 */
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+      transform: translateY(-20px);
+    }
+    to {
+      opacity: 1;
+      transform: translateY(0);
+    }
+  }
+  </style>

+ 350 - 0
src/components/RoomManagement.vue

@@ -0,0 +1,350 @@
+<template>
+  <div class="room-management-container">
+    <h1 class="page-title">房态展示</h1>
+
+    <!-- 按楼层显示房间 -->
+    <div v-for="floor in floors" :key="floor" class="floor-section">
+      <h2 class="floor-title">{{ floor }} 楼</h2>
+      <div class="floor-content">
+        <!-- 西边房间 -->
+        <div class="room-list">
+          <div v-for="room in getRoomsByFloorAndOrientation(floor, '西')" :key="room.id" class="room-card" :class="{
+            'offline': !room.hasBoundDevices,
+            'occupied': room.occupancy === '有人',
+            'unoccupied': room.occupancy === '无人',
+            'sensor-offline': room.occupancy === '人体存在掉线'
+          }">
+            <h4>{{ room.room_name }}</h4>
+            <p>绑定设备数量: {{ room.deviceCount }}</p>
+            <p>在线设备数量: {{ room.onlineDeviceCount }}</p>
+            <p>
+              温度:
+              <span v-if="room.hasTemperatureDevice">
+                <span v-if="room.temperatureDeviceStatus === 'online'">
+                  <span v-if="room.temperature !== null">
+                    <span v-if="room.temperature >= 18">☀️</span>
+                    <span v-else>❄️</span>
+                    {{ room.temperature }}°C
+                  </span>
+                  <span v-else>温度数据无效</span>
+                </span>
+                <span v-else-if="room.temperatureDeviceStatus === 'offline'">温度离线</span>
+              </span>
+              <span v-else>未绑定传感器</span>
+            </p>
+            <p>
+              继电器状态:
+              <span v-if="room.hasTemperatureDevice">
+                <span v-if="room.temperatureDeviceStatus === 'online'">
+                  <span v-if="room.switchStatus === 'on'">🔥</span>
+                  <span v-else style="filter: grayscale(100%);">🔥</span>
+                  {{ room.switchStatus === 'on' ? '开启' : '关闭' }}
+                </span>
+                <span v-else-if="room.temperatureDeviceStatus === 'offline'">继电器离线</span>
+              </span>
+              <span v-else>未绑定继电器</span>
+            </p>
+            <p>
+              房间状态:
+              <span v-if="room.occupancy === '有人'" style="color: yellow;">有人</span>
+              <span v-else-if="room.occupancy === '无人'" style="color: green;">无人</span>
+              <span v-else-if="room.occupancy === '人体存在掉线'" style="color: red;">人体存在掉线</span>
+              <span v-else style="color: gray;">未绑定人体传感器</span>
+            </p>
+          </div>
+        </div>
+
+        <!-- 东边房间 -->
+        <div class="room-list">
+          <div v-for="room in getRoomsByFloorAndOrientation(floor, '东')" :key="room.id" class="room-card" :class="{
+            'offline': !room.hasBoundDevices,
+            'occupied': room.occupancy === '有人',
+            'unoccupied': room.occupancy === '无人',
+            'sensor-offline': room.occupancy === '人体存在掉线'
+          }">
+            <h4>{{ room.room_name }}</h4>
+            <p>绑定设备数量: {{ room.deviceCount }}</p>
+            <p>在线设备数量: {{ room.onlineDeviceCount }}</p>
+            <p>
+              温度:
+              <span v-if="room.hasTemperatureDevice">
+                <span v-if="room.temperatureDeviceStatus === 'online'">
+                  <span v-if="room.temperature !== null">
+                    <span v-if="room.temperature >= 18">☀️</span>
+                    <span v-else>❄️</span>
+                    {{ room.temperature }}°C
+                  </span>
+                  <span v-else>温度数据无效</span>
+                </span>
+                <span v-else-if="room.temperatureDeviceStatus === 'offline'">温度离线</span>
+              </span>
+              <span v-else>未绑定传感器</span>
+            </p>
+            <p>
+              继电器状态:
+              <span v-if="room.hasTemperatureDevice">
+                <span v-if="room.temperatureDeviceStatus === 'online'">
+                  <span v-if="room.switchStatus === 'on'">🔥</span>
+                  <span v-else style="filter: grayscale(100%);">🔥</span>
+                  {{ room.switchStatus === 'on' ? '开启' : '关闭' }}
+                </span>
+                <span v-else-if="room.temperatureDeviceStatus === 'offline'">继电器离线</span>
+              </span>
+              <span v-else>未绑定继电器</span>
+            </p>
+            <p>
+              房间状态:
+              <span v-if="room.occupancy === '有人'" style="color: yellow;">有人</span>
+              <span v-else-if="room.occupancy === '无人'" style="color: green;">无人</span>
+              <span v-else-if="room.occupancy === '人体存在掉线'" style="color: red;">人体存在掉线</span>
+              <span v-else style="color: gray;">未绑定人体传感器</span>
+            </p>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import axios from 'axios';
+
+export default {
+  data() {
+    return {
+      rooms: [], // 存储房间数据
+    };
+  },
+  computed: {
+    // 所有楼层列表
+    floors() {
+      const floors = new Set();
+      this.rooms.forEach((room) => {
+        floors.add(this.getFloor(room.room_name));
+      });
+      return Array.from(floors).sort((a, b) => b - a); // 从高到低排序
+    },
+  },
+  async mounted() {
+    await this.fetchRooms();
+    window.scrollTo(0, 0); // 强制滚动到顶部
+  },
+  methods: {
+    async fetchRooms() {
+      try {
+        // 获取所有房间
+        const response = await axios.get('/api/rooms');
+        console.log('API 返回的房间数据:', response.data);
+
+        // 遍历每个房间,获取绑定的设备信息
+        for (const room of response.data) {
+          const deviceResponse = await axios.get(
+            `/api/devices-by-room?roomId=${room.id}`
+          );
+          console.log(`房间 ${room.id} 的设备数据:`, deviceResponse.data);
+
+          // 初始化设备状态
+          room.hasBoundDevices = deviceResponse.data.length > 0;
+          room.deviceCount = deviceResponse.data.length; // 绑定设备数量
+          room.onlineDeviceCount = deviceResponse.data.filter(
+            (device) => device.status === 'online'
+          ).length; // 在线设备数量
+
+          room.temperature = null;
+          room.switchStatus = null;
+          room.occupancy = '未绑定人体传感器';
+          room.temperatureDeviceStatus = 'offline'; // 温度传感器状态
+          room.humanSensorStatus = 'offline'; // 人体传感器状态
+          room.hasTemperatureDevice = false; // 是否绑定温度传感器
+          room.hasHumanSensor = false; // 是否绑定人体传感器
+
+          // 遍历设备,判断设备类型和状态
+          for (const device of deviceResponse.data) {
+            if (device.device_id.includes('ESP32-')) {
+              // 继电器温度传感器
+              room.hasTemperatureDevice = true;
+              room.temperature = device.temperature || null;
+              room.switchStatus = device.switch_status || null;
+              room.temperatureDeviceStatus = device.status; // 设备状态
+            } else if (device.device_id.includes('24G-')) {
+              // 人体传感器
+              room.hasHumanSensor = true;
+              room.humanSensorStatus = device.status; // 设备状态
+              if (device.status === 'online') {
+                room.occupancy = device.level_status === 'high' ? '有人' : '无人';
+              } else {
+                room.occupancy = '人体存在掉线';
+              }
+            }
+          }
+
+          // 如果没有绑定温度传感器,则显示"未绑定设备"
+          if (!room.hasTemperatureDevice) {
+            room.temperature = null;
+            room.switchStatus = null;
+            room.temperatureDeviceStatus = 'offline';
+          }
+
+          // 如果没有绑定人体传感器,则显示"未绑定人体传感器"
+          if (!room.hasHumanSensor) {
+            room.occupancy = '未绑定人体传感器';
+          }
+        }
+
+        this.rooms = response.data;
+      } catch (error) {
+        console.error('获取房间数据失败:', error);
+      }
+    },
+    // 根据房间号解析楼层
+    getFloor(roomName) {
+      if (roomName.length === 4) {
+        return parseInt(roomName.substring(0, 2), 10); // 4 位数,前两位是楼层
+      } else if (roomName.length === 3) {
+        return parseInt(roomName.substring(0, 1), 10); // 3 位数,第一位是楼层
+      }
+      return 0; // 默认值
+    },
+    // 获取指定楼层和朝向的房间
+    getRoomsByFloorAndOrientation(floor, orientation) {
+      return this.rooms.filter(
+        (room) =>
+          this.getFloor(room.room_name) === floor &&
+          room.orientation === orientation
+      );
+    },
+  },
+};
+</script>
+
+
+<style>
+@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@400;600&display=swap');
+
+/* 重置默认样式 */
+html,
+body {
+  margin: 0;
+  padding: 0;
+  height: 100%;
+  overflow: hidden;
+  /* 防止页面整体滚动 */
+  font-family: 'Poppins', sans-serif;
+  background-color: #f5f7fa;
+}
+.room-management-container {
+  padding: 10px;
+  max-width: 1200px;
+  margin: 0 auto;
+  overflow-y: auto; /* 确保容器可以滚动 */
+  height: 100vh; /* 确保容器占满整个视口高度 */
+}
+
+.page-title {
+  font-size: 20px;
+  /* 缩小标题字体 */
+  font-weight: 600;
+  text-align: center;
+  margin-bottom: 10px;
+  /* 缩小标题与内容的间距 */
+  color: #333;
+  padding-top: 10px;
+  /* 缩小顶部内边距 */
+}
+
+.floor-section {
+  margin-bottom: -20px;
+  /* 缩小楼层间距 */
+}
+
+.floor-title {
+  font-size: 20px;
+  /* 缩小楼层标题字体 */
+  font-weight: 500;
+  color: #444;
+  margin-bottom: 10px;
+  /* 缩小楼层标题与内容的间距 */
+}
+
+.floor-content {
+  display: flex;
+  gap: 10px;
+  /* 缩小东西边房间的间距 */
+}
+.room-list {
+  display: flex;
+  flex-wrap: wrap;
+  justify-content: space-between;
+}
+
+.room-card {
+  flex: 0 0 calc(50% - 10px); /* 每行显示两个房间卡片,减去间距 */
+  margin-bottom: 10px;
+}
+
+@media (max-width: 768px) {
+  .room-card {
+    flex: 0 0 100%; /* 在手机上每行显示一个房间卡片 */
+  }
+}
+@media (max-width: 768px) {
+  .room-management-popup {
+    width: 90%; /* 调整弹窗宽度 */
+    max-width: none; /* 移除最大宽度限制 */
+    font-size: 14px; /* 调整字体大小 */
+  }
+
+  .room-management-popup h2 {
+    font-size: 18px; /* 调整标题字体大小 */
+  }
+
+  .room-management-popup p {
+    font-size: 14px; /* 调整段落字体大小 */
+  }
+
+  /* 其他需要调整的样式 */
+}
+.room-management-popup-content {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+
+.room-management-popup-content button {
+  margin-top: 10px; /* 调整按钮间距 */
+}
+
+@media (max-width: 768px) {
+  .room-management-popup-content button {
+    width: 100%; /* 在手机上按钮占满一行 */
+  }
+}
+
+@media (max-width: 768px) {
+  .room-management-container {
+    font-size: 14px; /* 调整字体大小 */
+  }
+
+  .page-title {
+    font-size: 18px; /* 调整标题字体大小 */
+  }
+
+  .floor-title {
+    font-size: 16px; /* 调整楼层标题字体大小 */
+  }
+
+  .room-card {
+    padding: 5px; /* 调整房间卡片内边距 */
+  }
+
+  /* 其他需要调整的样式 */
+}.room-card {
+  flex: 1 1 calc(50% - 8px);
+}
+
+@media (max-width: 480px) {
+  .room-card {
+    flex: 1 1 100%;
+  }
+}
+</style>

+ 700 - 0
src/components/SecondPage.vue

@@ -0,0 +1,700 @@
+<template>
+  <div class="room-management-container"> <!-- 修改为 room-management-container -->
+    <h1>房间管理</h1>
+    <!-- 提示信息 -->
+    <div v-if="message" :class="['message', messageType]">
+      {{ message }}
+    </div>
+    <button class="add-room-button" @click="showModal = true">新增房间</button>
+
+    <!-- 新增房间的模态框 -->
+    <div v-if="showModal" class="modal">
+      <div class="modal-content">
+        <span class="close" @click="showModal = false">&times;</span>
+        <h2>新增房间</h2>
+        <input v-model="newRoomName" placeholder="请输入房间名称" />
+        <input v-model="newRoomDescription" placeholder="请输入房间描述" />
+        <input v-model="newRoomFloor" placeholder="请输入楼层" type="number" />
+        <select v-model="newRoomOrientation">
+          <option value="" disabled>请选择朝向</option>
+          <option value="东">东</option>
+          <option value="南">南</option>
+          <option value="西">西</option>
+          <option value="北">北</option>
+        </select>
+        <button class="modal-button" @click="addNewRoom">确定</button>
+      </div>
+    </div>
+
+    <!-- 显示所有房间的列表 -->
+    <h2>所有房间</h2>
+    <input v-model="roomFilter" placeholder="搜索房间名称" class="filter-input" />
+    <table class="room-table">
+      <thead>
+        <tr>
+          <th>房间名称</th>
+          <th>描述</th>
+          <th>楼层</th>
+          <th>朝向</th>
+          <th>操作</th>
+        </tr>
+      </thead>
+      <tbody>
+        <tr v-for="room in paginatedRooms" :key="room.id">
+          <td>{{ room.room_name }}</td>
+          <td>{{ room.description }}</td>
+          <td>{{ room.floor }}</td>
+          <td>{{ room.orientation }}</td>
+          <td>
+            <!-- 只有绑定设备的房间才显示"查看设备"按钮 -->
+            <button
+              v-if="room.device_count > 0"
+              class="view-devices-button"
+              @click="toggleDevices(room.id)"
+            >
+              查看设备
+            </button>
+            <button class="edit-button" @click="openEditModal(room)">编辑</button>
+          </td>
+        </tr>
+      </tbody>
+    </table>
+    <div class="pagination">
+      <button @click="prevRoomPage" :disabled="currentRoomPage === 1">上一页</button>
+      <span>第 {{ currentRoomPage }} 页</span>
+      <button @click="nextRoomPage" :disabled="currentRoomPage === totalRoomPages">下一页</button>
+    </div>
+
+    <!-- 编辑房间的模态框 -->
+<div v-if="showEditModal" class="modal">
+  <div class="modal-content">
+    <span class="close" @click="showEditModal = false">&times;</span>
+    <h2>编辑房间</h2>
+    <input v-model="editRoom.room_name" placeholder="请输入房间名称" />
+    <input v-model="editRoom.description" placeholder="请输入房间描述" />
+    <input v-model="editRoom.floor" placeholder="请输入楼层" type="number" />
+    <select v-model="editRoom.orientation">
+      <option value="" disabled>请选择朝向</option>
+      <option value="东">东</option>
+      <option value="南">南</option>
+      <option value="西">西</option>
+      <option value="北">北</option>
+    </select>
+    <button class="modal-button" @click="saveEditedRoom">保存</button>
+  </div>
+</div>
+
+<!-- 查看设备弹框 -->
+<div v-if="showDevicesModal" class="device-modal">
+  <div class="device-modal-content">
+    <span class="close" @click="showDevicesModal = false">&times;</span>
+    <h2>已绑定设备</h2>
+    <table class="device-table">
+      <thead>
+        <tr>
+          <th>设备ID</th>
+          <th>设备名称</th>
+          <th>在线状态</th>
+          <th>温度</th>
+          <th>继电器状态</th>
+          <th>人体传感器状态</th>
+          <th>操作</th>
+        </tr>
+      </thead>
+      <tbody>
+        <tr v-for="device in boundDevices" :key="device.device_id">
+          <td>{{ device.device_id }}</td>
+          <td>{{ device.name }}</td>
+          <td>
+            <span :style="{ color: device.status === 'online' ? 'green' : 'red' }">
+              {{ device.status === 'online' ? '在线' : '离线' }}
+            </span>
+          </td>
+          <td>{{ device.temperature }}°C</td>
+          <td>
+            <span :style="{ color: device.switch_status === 'on' ? 'green' : 'red' }">
+              {{ device.switch_status === 'on' ? '开启' : '关闭' }}
+            </span>
+          </td>
+          <td>
+            <span :style="{ color: device.level_status === 'high' ? 'green' : 'red' }">
+              {{ device.level_status === 'high' ? '有人' : '无人' }}
+            </span>
+          </td>
+          <td>
+            <!-- 绑定继电器按钮 -->
+            <button
+    v-if="device.device_id.includes('24G-') && hasRelayInRoom"
+    class="bind-button"
+    @click="bindRelayToSensor(device.device_id)"
+  >
+    绑定继电器
+  </button>
+          </td>
+        </tr>
+      </tbody>
+    </table>
+  </div>
+</div>
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'RoomManagement',
+  data() {
+    return {
+      message: '',
+      messageType: '',
+      rooms: [],
+      roomFilter: '',
+      showModal: false,
+      newRoomName: '',
+      newRoomDescription: '',
+      newRoomFloor: null,
+      newRoomOrientation: '',
+      currentRoomPage: 1,
+      itemsPerPage: 10,
+      showDevicesModal: false,
+      boundDevices: [],
+      showEditModal: false,
+      editRoom: {
+        id: null,
+        room_name: '',
+        description: '',
+        floor: null,
+        orientation: '',
+      },
+      hasHumanSensor: false, // 是否有人体传感器
+      hasRelayInRoom: false, // 是否有继电器
+      roomId: null, // 当前查看的房间ID
+    };
+  },
+  computed: {
+    filteredRooms() {
+      return this.rooms.filter(room =>
+        room.room_name.includes(this.roomFilter)
+      );
+    },
+    paginatedRooms() {
+      const start = (this.currentRoomPage - 1) * this.itemsPerPage;
+      const end = start + this.itemsPerPage;
+      return this.filteredRooms.slice(start, end);
+    },
+    totalRoomPages() {
+      return Math.ceil(this.filteredRooms.length / this.itemsPerPage);
+    },
+  },
+  async created() {
+    await this.fetchRooms();
+  },
+  methods: {
+      // 打开编辑房间的模态框
+  openEditModal(room) {
+    this.editRoom = { ...room }; // 将当前房间信息赋值给 editRoom
+    this.showEditModal = true; // 显示编辑模态框
+  },
+    prevRoomPage() {
+      if (this.currentRoomPage > 1) this.currentRoomPage--;
+    },
+    nextRoomPage() {
+      if (this.currentRoomPage < this.totalRoomPages) this.currentRoomPage++;
+    },
+    async fetchRooms() {
+      try {
+        // 获取房间列表
+        const response = await fetch('/api/rooms');
+        if (!response.ok) {
+          console.error('Failed to fetch rooms:', response.statusText);
+          return;
+        }
+        const rooms = await response.json();
+
+        // 为每个房间获取绑定的设备数量
+        const roomsWithDeviceCount = await Promise.all(
+          rooms.map(async (room) => {
+            const devicesResponse = await fetch(`/api/devices-by-room?roomId=${room.id}`);
+            if (!devicesResponse.ok) {
+              console.error('Failed to fetch devices for room:', room.id);
+              return { ...room, device_count: 0 };
+            }
+            const devices = await devicesResponse.json();
+            return { ...room, device_count: devices.length };
+          })
+        );
+
+        this.rooms = roomsWithDeviceCount;
+      } catch (error) {
+        console.error('Error fetching rooms:', error);
+      }
+    },
+
+    // 绑定继电器到人体传感器
+    async bindRelayToSensor(sensorId) {
+  try {
+    const response = await fetch('/api/devices/bind-relay-to-sensor', {
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/json',
+      },
+      body: JSON.stringify({
+        sensorId: sensorId,
+        roomId: this.roomId,
+      }),
+    });
+
+    if (response.ok) {
+      const result = await response.json();
+      this.message = result.message || '继电器绑定成功';
+      this.messageType = 'success';
+      console.log('bindRelayToSensor 返回结果:', result);
+      // 重新获取设备列表以更新界面
+      await this.toggleDevices(this.roomId);
+    } else {
+      this.message = '绑定继电器时出错';
+      this.messageType = 'error';
+      console.error('绑定继电器时出错');
+    }
+  } catch (error) {
+    this.message = '绑定设备时出错';
+    this.messageType = 'error';
+    console.error('绑定设备时出错:', error);
+  }
+},
+
+async toggleDevices(roomId) {
+    this.roomId = roomId; // 保存当前房间ID
+    try {
+      const response = await fetch('/api/devices-by-room?roomId=' + roomId);
+      if (response.ok) {
+        this.boundDevices = await response.json();
+
+        // 检查房间中是否同时存在人体传感器和继电器
+        this.hasHumanSensor = this.boundDevices.some(
+          (device) => device.device_id.includes('24G-')
+        );
+        this.hasRelayInRoom = this.boundDevices.some(
+          (device) => device.device_id.includes('ESP32-')
+        );
+
+        this.showDevicesModal = true;
+      } else {
+        console.error('Failed to fetch devices:', response.statusText);
+      }
+    } catch (error) {
+      console.error('Error fetching devices:', error);
+    }
+  },
+  },
+};
+</script>
+
+<style>/* 房间管理界面的整体布局 */
+.room-management-container {
+  max-height: 90vh; /* 设置最大高度为视口的 90% */
+  overflow-y: auto; /* 启用垂直滚动条 */
+  padding: 20px; /* 内边距 */
+  background-color: #f5f7fa; /* 背景颜色 */
+}
+
+/* 主标题样式 */
+.page-title {
+  font-size: 24px; /* 字体大小 */
+  margin-bottom: 20px; /* 底部外边距 */
+  color: #333; /* 字体颜色 */
+  text-align: center; /* 文字居中 */
+}
+
+/* 楼层标题样式 */
+.floor-title {
+  font-size: 20px; /* 字体大小 */
+  margin-bottom: 15px; /* 底部外边距 */
+  color: #555; /* 字体颜色 */
+}
+
+/* 楼层内容区域样式 */
+.floor-content {
+  display: flex; /* 弹性布局 */
+  gap: 20px; /* 子元素间距 */
+  margin-bottom: 30px; /* 底部外边距 */
+}
+
+/* 房间列表样式 */
+.room-list {
+  flex: 1; /* 占据剩余空间 */
+  display: flex; /* 弹性布局 */
+  flex-wrap: wrap; /* 允许换行 */
+  gap: 10px; /* 子元素间距 */
+}
+
+/* 房间卡片样式 */
+.room-card {
+  flex: 1 1 calc(25% - 10px); /* 每行显示 4 个房间卡片 */
+  border: 1px solid #e0e0e0; /* 边框 */
+  border-radius: 8px; /* 圆角 */
+  padding: 15px; /* 内边距 */
+  background-color: #fff; /* 背景颜色 */
+  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1); /* 阴影效果 */
+  transition: transform 0.2s, box-shadow 0.2s; /* 过渡效果 */
+}
+
+/* 房间卡片悬停时的样式 */
+.room-card:hover {
+  transform: translateY(-3px); /* 上移效果 */
+  box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15); /* 阴影效果 */
+}
+
+/* 房间卡片标题样式 */
+.room-card h4 {
+  font-size: 18px; /* 字体大小 */
+  margin: 0 0 10px; /* 底部外边距 */
+  color: #333; /* 字体颜色 */
+}
+
+/* 房间卡片内容样式 */
+.room-card p {
+  font-size: 14px; /* 字体大小 */
+  margin: 6px 0; /* 上下外边距 */
+  color: #666; /* 字体颜色 */
+}
+
+/* 房间状态样式 */
+.room-card p span {
+  font-weight: 500; /* 字体粗细 */
+}
+
+/* 房间状态为"有人"时的样式 */
+.room-card.occupied {
+  background-color: #fff3cd; /* 背景颜色 */
+}
+
+/* 房间状态为"无人"时的样式 */
+.room-card.unoccupied {
+  background-color: #d4edda; /* 背景颜色 */
+}
+
+/* 房间状态为"人体存在掉线"时的样式 */
+.room-card.sensor-offline {
+  background-color: #f8d7da; /* 背景颜色 */
+}
+
+/* 房间状态为"离线"时的样式 */
+.room-card.offline {
+  background-color: #f8f8f8; /* 背景颜色 */
+  color: #999; /* 字体颜色 */
+}
+
+/* 新增房间按钮样式 */
+.add-room-button {
+  background: linear-gradient(135deg, #4caf50, #81c784); /* 渐变背景 */
+  color: white; /* 字体颜色 */
+  border: none; /* 去除边框 */
+  padding: 10px 20px; /* 内边距 */
+  border-radius: 6px; /* 圆角 */
+  font-size: 16px; /* 字体大小 */
+  font-weight: 500; /* 字体粗细 */
+  cursor: pointer; /* 鼠标指针 */
+  transition: all 0.3s ease; /* 过渡效果 */
+  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); /* 阴影效果 */
+  margin-bottom: 20px; /* 底部外边距 */
+}
+
+/* 新增房间按钮悬停时的样式 */
+.add-room-button:hover {
+  background: linear-gradient(135deg, #45a049, #6bbf70); /* 渐变背景 */
+  box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); /* 阴影效果 */
+}
+
+/* 模态框按钮样式 */
+.modal-button {
+  background: linear-gradient(135deg, #4caf50, #81c784); /* 渐变背景 */
+  color: white; /* 字体颜色 */
+  border: none; /* 去除边框 */
+  padding: 10px 20px; /* 内边距 */
+  border-radius: 6px; /* 圆角 */
+  font-size: 16px; /* 字体大小 */
+  font-weight: 500; /* 字体粗细 */
+  cursor: pointer; /* 鼠标指针 */
+  transition: all 0.3s ease; /* 过渡效果 */
+  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); /* 阴影效果 */
+  margin-top: 10px; /* 顶部外边距 */
+}
+
+/* 模态框按钮悬停时的样式 */
+.modal-button:hover {
+  background: linear-gradient(135deg, #45a049, #6bbf70); /* 渐变背景 */
+  box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); /* 阴影效果 */
+}
+
+/* 输入框和选择框的样式 */
+.modal-content input,
+.modal-content select {
+  width: 100%; /* 宽度 */
+  padding: 8px; /* 内边距 */
+  border: 1px solid #e0e0e0; /* 边框 */
+  border-radius: 6px; /* 圆角 */
+  font-size: 14px; /* 字体大小 */
+  transition: all 0.3s ease; /* 过渡效果 */
+  margin-bottom: 15px; /* 底部外边距 */
+}
+
+/* 输入框和选择框聚焦时的样式 */
+.modal-content input:focus,
+.modal-content select:focus {
+  border-color: #4caf50; /* 边框颜色 */
+  box-shadow: 0 0 8px rgba(76, 175, 80, 0.3); /* 阴影效果 */
+  outline: none; /* 去除默认的聚焦轮廓 */
+}
+
+/* 输入框和选择框悬停时的样式 */
+.modal-content input:hover,
+.modal-content select:hover {
+  border-color: #4caf50; /* 边框颜色 */
+}
+
+/* 模态框样式 */
+.modal {
+  position: fixed; /* 固定定位 */
+  top: 0; /* 顶部距离 */
+  left: 0; /* 左侧距离 */
+  width: 100%; /* 宽度 */
+  height: 100%; /* 高度 */
+  background-color: rgba(0, 0, 0, 0.5); /* 背景颜色 */
+  display: flex; /* 弹性布局 */
+  justify-content: center; /* 水平居中 */
+  align-items: center; /* 垂直居中 */
+}
+
+/* 模态框内容样式 */
+.modal-content {
+  background-color: white; /* 背景颜色 */
+  padding: 20px; /* 内边距 */
+  border-radius: 8px; /* 圆角 */
+  width: 400px; /* 宽度 */
+  box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); /* 阴影效果 */
+}
+
+/* 模态框标题样式 */
+.modal-content h2 {
+  margin-top: 0; /* 顶部外边距 */
+  color: #333; /* 字体颜色 */
+}
+
+/* 关闭按钮样式 */
+.close {
+  float: right; /* 右浮动 */
+  font-size: 24px; /* 字体大小 */
+  cursor: pointer; /* 鼠标指针 */
+  color: #666; /* 字体颜色 */
+}
+
+/* 关闭按钮悬停时的样式 */
+.close:hover {
+  color: #333; /* 字体颜色 */
+}
+
+/* 分页按钮的样式 */
+.pagination {
+  margin-top: 20px; /* 顶部外边距 */
+  text-align: center; /* 文字居中 */
+}
+
+/* 分页按钮的样式 */
+.pagination button {
+  padding: 8px 16px; /* 内边距 */
+  border: none; /* 去除边框 */
+  border-radius: 6px; /* 圆角 */
+  font-size: 14px; /* 字体大小 */
+  font-weight: 500; /* 字体粗细 */
+  cursor: pointer; /* 鼠标指针 */
+  transition: all 0.3s ease; /* 过渡效果 */
+  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); /* 阴影效果 */
+  background-color: #4caf50; /* 背景颜色 */
+  color: white; /* 字体颜色 */
+  margin: 0 5px; /* 左右外边距 */
+}
+
+/* 分页按钮悬停时的样式 */
+.pagination button:hover {
+  background-color: #45a049; /* 背景颜色 */
+  box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); /* 阴影效果 */
+}
+
+/* 分页按钮禁用时的样式 */
+.pagination button:disabled {
+  background-color: #e0e0e0; /* 背景颜色 */
+  color: #999; /* 字体颜色 */
+  cursor: not-allowed; /* 禁用鼠标指针 */
+  box-shadow: none; /* 去除阴影 */
+}
+
+/* 表格样式 */
+.room-table {
+  width: 100%; /* 表格宽度 */
+  border-collapse: separate; /* 使用 separate 而不是 collapse */
+  border-spacing: 0; /* 单元格间距 */
+  margin-bottom: 20px; /* 底部外边距 */
+  background-color: #fff; /* 背景颜色 */
+  border-radius: 8px; /* 圆角 */
+  box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); /* 阴影效果 */
+}
+
+/* 表头样式 */
+.room-table th {
+  background-color: #f8f9fa; /* 表头背景颜色 */
+  font-weight: 600; /* 字体粗细 */
+  color: #333; /* 字体颜色 */
+  padding: 12px; /* 内边距 */
+  text-align: center; /* 文字居中 */
+  border-bottom: 2px solid #e0e0e0; /* 底部边框 */
+}
+
+/* 表格数据样式 */
+.room-table td {
+  padding: 12px; /* 内边距 */
+  text-align: center; /* 文字居中 */
+  border-bottom: 1px solid #e0e0e0; /* 底部边框 */
+}
+
+/* 表格行悬停效果 */
+.room-table tr:hover {
+  background-color: #f1f1f1; /* 悬停背景颜色 */
+  transition: background-color 0.3s ease; /* 过渡效果 */
+}
+
+/* 表格最后一行去除底部边框 */
+.room-table tr:last-child td {
+  border-bottom: none; /* 去除底部边框 */
+}
+
+/* 查看设备弹框样式 */
+.device-modal {
+  position: fixed;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  background-color: rgba(0, 0, 0, 0.5);
+  display: flex;
+  justify-content: center;
+  align-items: center;
+}
+
+.device-modal-content {
+  background-color: white;
+  padding: 20px;
+  border-radius: 8px;
+  width: 80%;
+  max-width: 800px;
+  box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
+}
+
+.device-modal-content h2 {
+  margin-top: 0;
+  color: #333;
+}
+
+.device-modal-content .close {
+  float: right;
+  font-size: 24px;
+  cursor: pointer;
+  color: #666;
+}
+
+.device-modal-content .close:hover {
+  color: #333;
+}
+
+/* 设备表格样式 */
+.device-table {
+  width: 100%;
+  border-collapse: separate;
+  border-spacing: 0;
+  margin-bottom: 20px;
+  background-color: #fff;
+  border-radius: 8px;
+  box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
+}
+
+.device-table th {
+  background-color: #f8f9fa;
+  font-weight: 600;
+  color: #333;
+  padding: 12px;
+  text-align: center;
+  border-bottom: 2px solid #e0e0e0;
+}
+
+.device-table td {
+  padding: 12px;
+  text-align: center;
+  border-bottom: 1px solid #e0e0e0;
+}
+
+.device-table tr:hover {
+  background-color: #f1f1f1;
+  transition: background-color 0.3s ease;
+}
+
+.device-table tr:last-child td {
+  border-bottom: none;
+}
+
+/* 绑定按钮样式 */
+.bind-button {
+  background: linear-gradient(135deg, #4caf50, #81c784);
+  color: white;
+  border: none;
+  padding: 8px 16px;
+  border-radius: 6px;
+  font-size: 14px;
+  font-weight: 500;
+  cursor: pointer;
+  transition: all 0.3s ease;
+  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+}
+
+.bind-button:hover {
+  background: linear-gradient(135deg, #45a049, #6bbf70);
+  box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
+}
+
+/* 查看设备按钮样式 */
+.view-devices-button {
+  background: linear-gradient(135deg, #4caf50, #81c784); /* 渐变背景 */
+  color: white; /* 字体颜色 */
+  border: none; /* 去除边框 */
+  padding: 8px 16px; /* 内边距 */
+  border-radius: 6px; /* 圆角 */
+  font-size: 14px; /* 字体大小 */
+  font-weight: 500; /* 字体粗细 */
+  cursor: pointer; /* 鼠标指针 */
+  transition: all 0.3s ease; /* 过渡效果 */
+  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); /* 阴影效果 */
+  margin-right: 8px; /* 右侧外边距 */
+}
+
+.view-devices-button:hover {
+  background: linear-gradient(135deg, #45a049, #6bbf70); /* 渐变背景 */
+  box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); /* 阴影效果 */
+}
+
+/* 编辑按钮样式 */
+.edit-button {
+  background: linear-gradient(135deg, #2196f3, #64b5f6); /* 渐变背景 */
+  color: white; /* 字体颜色 */
+  border: none; /* 去除边框 */
+  padding: 8px 16px; /* 内边距 */
+  border-radius: 6px; /* 圆角 */
+  font-size: 14px; /* 字体大小 */
+  font-weight: 500; /* 字体粗细 */
+  cursor: pointer; /* 鼠标指针 */
+  transition: all 0.3s ease; /* 过渡效果 */
+  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); /* 阴影效果 */
+}
+
+.edit-button:hover {
+  background: linear-gradient(135deg, #1e88e5, #42a5f5); /* 渐变背景 */
+  box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); /* 阴影效果 */
+}
+</style>

+ 7 - 0
src/main.js

@@ -0,0 +1,7 @@
+import { createApp } from 'vue';
+import App from './App.vue';
+import router from './router';
+
+createApp(App)
+  .use(router)
+  .mount('#app');

+ 30 - 0
src/router.js

@@ -0,0 +1,30 @@
+import { createRouter, createWebHistory } from 'vue-router';
+import HelloWorld from './components/HelloWorld.vue';
+import SecondPage from './components/SecondPage.vue';
+import RoomManagement from './components/RoomManagement.vue';
+import DeviceManagement from './components/DeviceManagement.vue';
+import Login from './components/Login.vue';
+
+const routes = [
+  { path: '/home', name: 'Home', component: HelloWorld, meta: { requiresAuth: true } },
+  { path: '/second', name: 'SecondPage', component: SecondPage, meta: { requiresAuth: true } },
+  { path: '/', name: 'RoomManagement', component: RoomManagement, meta: { requiresAuth: true } },
+  { path: '/DeviceManagement', name: 'DeviceManagement', component: DeviceManagement, meta: { requiresAuth: true } },
+  { path: '/login', name: 'Login', component: Login },
+];
+
+const router = createRouter({
+  history: createWebHistory(),
+  routes,
+});
+
+router.beforeEach((to, from, next) => {
+  const token = localStorage.getItem('token');
+  if (to.meta.requiresAuth && !token) {
+    next('/login');
+  } else {
+    next();
+  }
+});
+
+export default router;

+ 55 - 0
src/router/index.js

@@ -0,0 +1,55 @@
+import { createRouter, createWebHistory } from 'vue-router';
+import HelloWorld from '../components/HelloWorld.vue';
+import SecondPage from '../components/SecondPage.vue';
+import RoomManagement from '../components/RoomManagement.vue';
+import DeviceManagement from '../components/DeviceManagement.vue';
+import Login from '../components/Login.vue'; // 导入登录组件
+
+const routes = [
+  {
+    path: '/home',
+    name: 'Home',
+    component: HelloWorld,
+    meta: { requiresAuth: true }, // 需要登录
+  },
+  {
+    path: '/second',
+    name: 'SecondPage',
+    component: SecondPage,
+    meta: { requiresAuth: true }, // 需要登录
+  },
+  {
+    path: '/',
+    name: 'RoomManagement',
+    component: RoomManagement,
+    meta: { requiresAuth: true }, // 需要登录
+  },
+  {
+    path: '/DeviceManagement',
+    name: 'DeviceManagement',
+    component: DeviceManagement,
+    meta: { requiresAuth: true }, // 需要登录
+  },
+  {
+    path: '/login',
+    name: 'Login',
+    component: Login,
+  },
+];
+
+const router = createRouter({
+  history: createWebHistory(),
+  routes,
+});
+
+// 路由守卫
+router.beforeEach((to, from, next) => {
+  const token = localStorage.getItem('token');
+  if (to.meta.requiresAuth && !token) {
+    next('/login');
+  } else {
+    next();
+  }
+});
+
+export default router;

+ 30 - 0
vue.config.js

@@ -0,0 +1,30 @@
+const { defineConfig } = require('@vue/cli-service');
+const fs = require('fs');
+const path = require('path');
+
+module.exports = defineConfig({
+  transpileDependencies: true,
+  lintOnSave: false, // 禁用 ESLint
+  devServer: {
+    allowedHosts: 'all', // 允许所有主机访问
+    hot: false, //禁用热更新
+    liveReload: false, //禁用实时加载
+
+    client: {
+      webSocketURL: 'ws://192.168.3.31:8080/ws', // 使用 WebSocket 的 URL
+    },
+
+    proxy: {
+      '/api': {
+        target: 'http://192.168.3.31:3000', // 改为本地地址
+        changeOrigin: true,
+        pathRewrite: { '^/api': '/api' }  // 修改这里,保留 /api 前缀
+      },
+      '/relay': {  // 添加这个配置来处理继电器相关的请求
+        target: 'http://192.168.3.31:3000', // 改为本地地址
+        changeOrigin: true,
+        pathRewrite: { '^/relay': '/relay' }
+      }
+    }
+  }
+});