Ver código fonte

迎宾巡逻安防机器人机身屏交互系统初始化

zmj 1 semana atrás
commit
233e93a850
45 arquivos alterados com 11125 adições e 0 exclusões
  1. 7 0
      .gitignore
  2. 75 0
      README.md
  3. 13 0
      index.html
  4. 1161 0
      package-lock.json
  5. 20 0
      package.json
  6. 7 0
      public/favicon.svg
  7. 18 0
      src/App.vue
  8. 326 0
      src/api/screen.js
  9. BIN
      src/assets/images/default-welcome-bg 1.png
  10. BIN
      src/assets/images/default-welcome-bg.png
  11. BIN
      src/assets/media/play-plan/images/cola.jpg
  12. BIN
      src/assets/media/play-plan/videos/1-鍏冩皵妫灄骞垮憡鈥斺€旂櫧妗冨懗-480P 鏍囨竻-AVC.mp4
  13. 536 0
      src/assets/styles/main.css
  14. 206 0
      src/components/BroadcastOverlay.vue
  15. 226 0
      src/components/CameraPreview.vue
  16. 169 0
      src/components/GlobalAlert.vue
  17. 971 0
      src/components/IdlePlayer.vue
  18. 304 0
      src/components/MainMenu.vue
  19. 243 0
      src/components/NumericKeyboard.vue
  20. 284 0
      src/components/StatusBar.vue
  21. 145 0
      src/layouts/ScreenLayout.vue
  22. 12 0
      src/main.js
  23. 406 0
      src/mock/screen.js
  24. 100 0
      src/router/index.js
  25. 177 0
      src/stores/navigation.js
  26. 255 0
      src/stores/screen.js
  27. 162 0
      src/stores/visitor.js
  28. 175 0
      src/utils/device.js
  29. 123 0
      src/utils/time.js
  30. 236 0
      src/views/call-staff/Index.vue
  31. 33 0
      src/views/idle/Index.vue
  32. 243 0
      src/views/menu/Index.vue
  33. 222 0
      src/views/navigation/Index.vue
  34. 326 0
      src/views/navigation/Status.vue
  35. 260 0
      src/views/notice/Index.vue
  36. 258 0
      src/views/recognition/Result.vue
  37. 249 0
      src/views/system/Info.vue
  38. 268 0
      src/views/visitor/Appointment.vue
  39. 199 0
      src/views/visitor/AppointmentConfirm.vue
  40. 128 0
      src/views/visitor/Index.vue
  41. 209 0
      src/views/visitor/Success.vue
  42. 498 0
      src/views/visitor/WalkIn.vue
  43. 22 0
      vite.config.js
  44. 1158 0
      杩庡宸¢€诲畨闃叉満鍣ㄤ汉杩愮淮绔疻eb绠$悊绯荤粺璇︾粏璁捐寮€鍙戞枃妗V2.1.html
  45. 695 0
      杩庡宸¢€诲畨闃叉満鍣ㄤ汉鏈鸿韩灞忎氦浜掔郴缁熻缁嗚璁″紑鍙戞枃妗o紙涓€鏈燂級.html

+ 7 - 0
.gitignore

@@ -0,0 +1,7 @@
+node_modules
+dist
+.DS_Store
+*.local
+*.log
+.vscode
+.idea

+ 75 - 0
README.md

@@ -0,0 +1,75 @@
+# 迎宾巡逻安防机器人机身屏前端项目
+
+基于 Vue3 + Vite 构建的机器人屏幕端交互系统。
+
+## 技术栈
+
+- Vue 3
+- Vite 5
+- Vue Router 4
+- Pinia 2
+
+## 快速开始
+
+```bash
+# 安装依赖
+npm install
+
+# 开发模式
+npm run dev
+
+# 构建生产版本
+npm run build
+
+# 预览构建结果
+npm run preview
+```
+
+## 项目结构
+
+```
+src/
+├── api/              # API 封装层
+├── assets/           # 静态资源
+│   └── styles/      # 全局样式
+├── components/       # 通用组件
+├── layouts/         # 页面布局
+├── mock/            # Mock 数据
+├── router/          # 路由配置
+├── stores/          # Pinia 状态管理
+├── utils/           # 工具函数
+├── views/           # 页面视图
+│   ├── idle/        # 待机展示
+│   ├── menu/        # 主菜单
+│   ├── visitor/     # 访客登记
+│   ├── navigation/  # 路线引导
+│   ├── notice/      # 通知公告
+│   ├── call-staff/  # 呼叫工作人员
+│   ├── recognition/ # 识别结果
+│   └── system/      # 系统信息
+├── App.vue
+└── main.js
+```
+
+## 页面路由
+
+| 路径 | 页面 |
+|------|------|
+| / | 重定向到 /idle |
+| /idle | 待机展示页 |
+| /menu | 主菜单页 |
+| /visitor | 访客登记首页 |
+| /visitor/appointment | 预约核验页 |
+| /visitor/appointment-confirm | 预约确认页 |
+| /visitor/walk-in | 现场登记页 |
+| /visitor/success | 登记成功页 |
+| /recognition/result | 识别结果页 |
+| /navigation | 路线引导页 |
+| /navigation/status | 导航状态页 |
+| /notice | 通知公告页 |
+| /call-staff | 呼叫工作人员页 |
+| /system-info | 系统信息页(长按 Logo 进入) |
+
+## 设计参考
+
+详见 `robot_screen_design_doc.html`

+ 13 - 0
index.html

@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
+    <title>迎宾机器人</title>
+    <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
+  </head>
+  <body>
+    <div id="app"></div>
+    <script type="module" src="/src/main.js"></script>
+  </body>
+</html>

+ 1161 - 0
package-lock.json

@@ -0,0 +1,1161 @@
+{
+  "name": "robot-screen",
+  "version": "1.0.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "robot-screen",
+      "version": "1.0.0",
+      "dependencies": {
+        "pinia": "^2.1.7",
+        "vue": "^3.4.21",
+        "vue-router": "^4.3.0"
+      },
+      "devDependencies": {
+        "@vitejs/plugin-vue": "^5.0.4",
+        "vite": "^5.2.0"
+      }
+    },
+    "node_modules/@babel/helper-string-parser": {
+      "version": "7.27.1",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-validator-identifier": {
+      "version": "7.28.5",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/parser": {
+      "version": "7.29.3",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/types": "^7.29.0"
+      },
+      "bin": {
+        "parser": "bin/babel-parser.js"
+      },
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@babel/types": {
+      "version": "7.29.0",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-string-parser": "^7.27.1",
+        "@babel/helper-validator-identifier": "^7.28.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@esbuild/darwin-arm64": {
+      "version": "0.21.5",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@jridgewell/sourcemap-codec": {
+      "version": "1.5.5",
+      "license": "MIT"
+    },
+    "node_modules/@rollup/rollup-darwin-arm64": {
+      "version": "4.60.3",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@types/estree": {
+      "version": "1.0.8",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@vitejs/plugin-vue": {
+      "version": "5.2.4",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": "^18.0.0 || >=20.0.0"
+      },
+      "peerDependencies": {
+        "vite": "^5.0.0 || ^6.0.0",
+        "vue": "^3.2.25"
+      }
+    },
+    "node_modules/@vue/compiler-core": {
+      "version": "3.5.34",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/parser": "^7.29.3",
+        "@vue/shared": "3.5.34",
+        "entities": "^7.0.1",
+        "estree-walker": "^2.0.2",
+        "source-map-js": "^1.2.1"
+      }
+    },
+    "node_modules/@vue/compiler-dom": {
+      "version": "3.5.34",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/compiler-core": "3.5.34",
+        "@vue/shared": "3.5.34"
+      }
+    },
+    "node_modules/@vue/compiler-sfc": {
+      "version": "3.5.34",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/parser": "^7.29.3",
+        "@vue/compiler-core": "3.5.34",
+        "@vue/compiler-dom": "3.5.34",
+        "@vue/compiler-ssr": "3.5.34",
+        "@vue/shared": "3.5.34",
+        "estree-walker": "^2.0.2",
+        "magic-string": "^0.30.21",
+        "postcss": "^8.5.14",
+        "source-map-js": "^1.2.1"
+      }
+    },
+    "node_modules/@vue/compiler-ssr": {
+      "version": "3.5.34",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/compiler-dom": "3.5.34",
+        "@vue/shared": "3.5.34"
+      }
+    },
+    "node_modules/@vue/devtools-api": {
+      "version": "6.6.4",
+      "license": "MIT"
+    },
+    "node_modules/@vue/reactivity": {
+      "version": "3.5.34",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/shared": "3.5.34"
+      }
+    },
+    "node_modules/@vue/runtime-core": {
+      "version": "3.5.34",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/reactivity": "3.5.34",
+        "@vue/shared": "3.5.34"
+      }
+    },
+    "node_modules/@vue/runtime-dom": {
+      "version": "3.5.34",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/reactivity": "3.5.34",
+        "@vue/runtime-core": "3.5.34",
+        "@vue/shared": "3.5.34",
+        "csstype": "^3.2.3"
+      }
+    },
+    "node_modules/@vue/server-renderer": {
+      "version": "3.5.34",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/compiler-ssr": "3.5.34",
+        "@vue/shared": "3.5.34"
+      },
+      "peerDependencies": {
+        "vue": "3.5.34"
+      }
+    },
+    "node_modules/@vue/shared": {
+      "version": "3.5.34",
+      "license": "MIT"
+    },
+    "node_modules/csstype": {
+      "version": "3.2.3",
+      "license": "MIT"
+    },
+    "node_modules/entities": {
+      "version": "7.0.1",
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=0.12"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/entities?sponsor=1"
+      }
+    },
+    "node_modules/esbuild": {
+      "version": "0.21.5",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "bin": {
+        "esbuild": "bin/esbuild"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "optionalDependencies": {
+        "@esbuild/aix-ppc64": "0.21.5",
+        "@esbuild/android-arm": "0.21.5",
+        "@esbuild/android-arm64": "0.21.5",
+        "@esbuild/android-x64": "0.21.5",
+        "@esbuild/darwin-arm64": "0.21.5",
+        "@esbuild/darwin-x64": "0.21.5",
+        "@esbuild/freebsd-arm64": "0.21.5",
+        "@esbuild/freebsd-x64": "0.21.5",
+        "@esbuild/linux-arm": "0.21.5",
+        "@esbuild/linux-arm64": "0.21.5",
+        "@esbuild/linux-ia32": "0.21.5",
+        "@esbuild/linux-loong64": "0.21.5",
+        "@esbuild/linux-mips64el": "0.21.5",
+        "@esbuild/linux-ppc64": "0.21.5",
+        "@esbuild/linux-riscv64": "0.21.5",
+        "@esbuild/linux-s390x": "0.21.5",
+        "@esbuild/linux-x64": "0.21.5",
+        "@esbuild/netbsd-x64": "0.21.5",
+        "@esbuild/openbsd-x64": "0.21.5",
+        "@esbuild/sunos-x64": "0.21.5",
+        "@esbuild/win32-arm64": "0.21.5",
+        "@esbuild/win32-ia32": "0.21.5",
+        "@esbuild/win32-x64": "0.21.5"
+      }
+    },
+    "node_modules/esbuild/node_modules/@esbuild/aix-ppc64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
+      "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "aix"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/esbuild/node_modules/@esbuild/android-arm": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
+      "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/esbuild/node_modules/@esbuild/android-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
+      "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/esbuild/node_modules/@esbuild/android-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
+      "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/esbuild/node_modules/@esbuild/darwin-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
+      "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/esbuild/node_modules/@esbuild/freebsd-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
+      "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/esbuild/node_modules/@esbuild/freebsd-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
+      "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/esbuild/node_modules/@esbuild/linux-arm": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
+      "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/esbuild/node_modules/@esbuild/linux-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
+      "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/esbuild/node_modules/@esbuild/linux-ia32": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
+      "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/esbuild/node_modules/@esbuild/linux-loong64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
+      "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/esbuild/node_modules/@esbuild/linux-mips64el": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
+      "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
+      "cpu": [
+        "mips64el"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/esbuild/node_modules/@esbuild/linux-ppc64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
+      "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/esbuild/node_modules/@esbuild/linux-riscv64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
+      "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/esbuild/node_modules/@esbuild/linux-s390x": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
+      "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/esbuild/node_modules/@esbuild/linux-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
+      "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/esbuild/node_modules/@esbuild/netbsd-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
+      "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/esbuild/node_modules/@esbuild/openbsd-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
+      "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/esbuild/node_modules/@esbuild/sunos-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
+      "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "sunos"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/esbuild/node_modules/@esbuild/win32-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
+      "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/esbuild/node_modules/@esbuild/win32-ia32": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
+      "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/esbuild/node_modules/@esbuild/win32-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
+      "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/estree-walker": {
+      "version": "2.0.2",
+      "license": "MIT"
+    },
+    "node_modules/fsevents": {
+      "version": "2.3.3",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/magic-string": {
+      "version": "0.30.21",
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/sourcemap-codec": "^1.5.5"
+      }
+    },
+    "node_modules/nanoid": {
+      "version": "3.3.12",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "bin": {
+        "nanoid": "bin/nanoid.cjs"
+      },
+      "engines": {
+        "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+      }
+    },
+    "node_modules/picocolors": {
+      "version": "1.1.1",
+      "license": "ISC"
+    },
+    "node_modules/pinia": {
+      "version": "2.3.1",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/devtools-api": "^6.6.3",
+        "vue-demi": "^0.14.10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/posva"
+      },
+      "peerDependencies": {
+        "typescript": ">=4.4.4",
+        "vue": "^2.7.0 || ^3.5.11"
+      },
+      "peerDependenciesMeta": {
+        "typescript": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/postcss": {
+      "version": "8.5.14",
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/postcss/"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/postcss"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "nanoid": "^3.3.11",
+        "picocolors": "^1.1.1",
+        "source-map-js": "^1.2.1"
+      },
+      "engines": {
+        "node": "^10 || ^12 || >=14"
+      }
+    },
+    "node_modules/rollup": {
+      "version": "4.60.3",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree": "1.0.8"
+      },
+      "bin": {
+        "rollup": "dist/bin/rollup"
+      },
+      "engines": {
+        "node": ">=18.0.0",
+        "npm": ">=8.0.0"
+      },
+      "optionalDependencies": {
+        "@rollup/rollup-android-arm-eabi": "4.60.3",
+        "@rollup/rollup-android-arm64": "4.60.3",
+        "@rollup/rollup-darwin-arm64": "4.60.3",
+        "@rollup/rollup-darwin-x64": "4.60.3",
+        "@rollup/rollup-freebsd-arm64": "4.60.3",
+        "@rollup/rollup-freebsd-x64": "4.60.3",
+        "@rollup/rollup-linux-arm-gnueabihf": "4.60.3",
+        "@rollup/rollup-linux-arm-musleabihf": "4.60.3",
+        "@rollup/rollup-linux-arm64-gnu": "4.60.3",
+        "@rollup/rollup-linux-arm64-musl": "4.60.3",
+        "@rollup/rollup-linux-loong64-gnu": "4.60.3",
+        "@rollup/rollup-linux-loong64-musl": "4.60.3",
+        "@rollup/rollup-linux-ppc64-gnu": "4.60.3",
+        "@rollup/rollup-linux-ppc64-musl": "4.60.3",
+        "@rollup/rollup-linux-riscv64-gnu": "4.60.3",
+        "@rollup/rollup-linux-riscv64-musl": "4.60.3",
+        "@rollup/rollup-linux-s390x-gnu": "4.60.3",
+        "@rollup/rollup-linux-x64-gnu": "4.60.3",
+        "@rollup/rollup-linux-x64-musl": "4.60.3",
+        "@rollup/rollup-openbsd-x64": "4.60.3",
+        "@rollup/rollup-openharmony-arm64": "4.60.3",
+        "@rollup/rollup-win32-arm64-msvc": "4.60.3",
+        "@rollup/rollup-win32-ia32-msvc": "4.60.3",
+        "@rollup/rollup-win32-x64-gnu": "4.60.3",
+        "@rollup/rollup-win32-x64-msvc": "4.60.3",
+        "fsevents": "~2.3.2"
+      }
+    },
+    "node_modules/rollup/node_modules/@rollup/rollup-android-arm-eabi": {
+      "version": "4.60.3",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.3.tgz",
+      "integrity": "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/rollup/node_modules/@rollup/rollup-android-arm64": {
+      "version": "4.60.3",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.3.tgz",
+      "integrity": "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/rollup/node_modules/@rollup/rollup-darwin-x64": {
+      "version": "4.60.3",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.3.tgz",
+      "integrity": "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/rollup/node_modules/@rollup/rollup-freebsd-arm64": {
+      "version": "4.60.3",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.3.tgz",
+      "integrity": "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "freebsd"
+      ]
+    },
+    "node_modules/rollup/node_modules/@rollup/rollup-freebsd-x64": {
+      "version": "4.60.3",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.3.tgz",
+      "integrity": "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "freebsd"
+      ]
+    },
+    "node_modules/rollup/node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+      "version": "4.60.3",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.3.tgz",
+      "integrity": "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/rollup/node_modules/@rollup/rollup-linux-arm-musleabihf": {
+      "version": "4.60.3",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.3.tgz",
+      "integrity": "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/rollup/node_modules/@rollup/rollup-linux-arm64-gnu": {
+      "version": "4.60.3",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.3.tgz",
+      "integrity": "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/rollup/node_modules/@rollup/rollup-linux-arm64-musl": {
+      "version": "4.60.3",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.3.tgz",
+      "integrity": "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/rollup/node_modules/@rollup/rollup-linux-loong64-gnu": {
+      "version": "4.60.3",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.3.tgz",
+      "integrity": "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/rollup/node_modules/@rollup/rollup-linux-loong64-musl": {
+      "version": "4.60.3",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.3.tgz",
+      "integrity": "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/rollup/node_modules/@rollup/rollup-linux-ppc64-gnu": {
+      "version": "4.60.3",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.3.tgz",
+      "integrity": "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/rollup/node_modules/@rollup/rollup-linux-ppc64-musl": {
+      "version": "4.60.3",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.3.tgz",
+      "integrity": "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/rollup/node_modules/@rollup/rollup-linux-riscv64-gnu": {
+      "version": "4.60.3",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.3.tgz",
+      "integrity": "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/rollup/node_modules/@rollup/rollup-linux-riscv64-musl": {
+      "version": "4.60.3",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.3.tgz",
+      "integrity": "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/rollup/node_modules/@rollup/rollup-linux-s390x-gnu": {
+      "version": "4.60.3",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.3.tgz",
+      "integrity": "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/rollup/node_modules/@rollup/rollup-linux-x64-gnu": {
+      "version": "4.60.3",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.3.tgz",
+      "integrity": "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/rollup/node_modules/@rollup/rollup-linux-x64-musl": {
+      "version": "4.60.3",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.3.tgz",
+      "integrity": "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/rollup/node_modules/@rollup/rollup-openbsd-x64": {
+      "version": "4.60.3",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.3.tgz",
+      "integrity": "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "openbsd"
+      ]
+    },
+    "node_modules/rollup/node_modules/@rollup/rollup-openharmony-arm64": {
+      "version": "4.60.3",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.3.tgz",
+      "integrity": "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "openharmony"
+      ]
+    },
+    "node_modules/rollup/node_modules/@rollup/rollup-win32-arm64-msvc": {
+      "version": "4.60.3",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.3.tgz",
+      "integrity": "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/rollup/node_modules/@rollup/rollup-win32-ia32-msvc": {
+      "version": "4.60.3",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.3.tgz",
+      "integrity": "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/rollup/node_modules/@rollup/rollup-win32-x64-gnu": {
+      "version": "4.60.3",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.3.tgz",
+      "integrity": "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/rollup/node_modules/@rollup/rollup-win32-x64-msvc": {
+      "version": "4.60.3",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.3.tgz",
+      "integrity": "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/source-map-js": {
+      "version": "1.2.1",
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/vite": {
+      "version": "5.4.21",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "esbuild": "^0.21.3",
+        "postcss": "^8.4.43",
+        "rollup": "^4.20.0"
+      },
+      "bin": {
+        "vite": "bin/vite.js"
+      },
+      "engines": {
+        "node": "^18.0.0 || >=20.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/vitejs/vite?sponsor=1"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.3"
+      },
+      "peerDependencies": {
+        "@types/node": "^18.0.0 || >=20.0.0",
+        "less": "*",
+        "lightningcss": "^1.21.0",
+        "sass": "*",
+        "sass-embedded": "*",
+        "stylus": "*",
+        "sugarss": "*",
+        "terser": "^5.4.0"
+      },
+      "peerDependenciesMeta": {
+        "@types/node": {
+          "optional": true
+        },
+        "less": {
+          "optional": true
+        },
+        "lightningcss": {
+          "optional": true
+        },
+        "sass": {
+          "optional": true
+        },
+        "sass-embedded": {
+          "optional": true
+        },
+        "stylus": {
+          "optional": true
+        },
+        "sugarss": {
+          "optional": true
+        },
+        "terser": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/vue": {
+      "version": "3.5.34",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/compiler-dom": "3.5.34",
+        "@vue/compiler-sfc": "3.5.34",
+        "@vue/runtime-dom": "3.5.34",
+        "@vue/server-renderer": "3.5.34",
+        "@vue/shared": "3.5.34"
+      },
+      "peerDependencies": {
+        "typescript": "*"
+      },
+      "peerDependenciesMeta": {
+        "typescript": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/vue-demi": {
+      "version": "0.14.10",
+      "hasInstallScript": true,
+      "license": "MIT",
+      "bin": {
+        "vue-demi-fix": "bin/vue-demi-fix.js",
+        "vue-demi-switch": "bin/vue-demi-switch.js"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      },
+      "peerDependencies": {
+        "@vue/composition-api": "^1.0.0-rc.1",
+        "vue": "^3.0.0-0 || ^2.6.0"
+      },
+      "peerDependenciesMeta": {
+        "@vue/composition-api": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/vue-router": {
+      "version": "4.6.4",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/devtools-api": "^6.6.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/posva"
+      },
+      "peerDependencies": {
+        "vue": "^3.5.0"
+      }
+    }
+  }
+}

+ 20 - 0
package.json

@@ -0,0 +1,20 @@
+{
+  "name": "robot-screen",
+  "version": "1.0.0",
+  "description": "迎宾巡逻安防机器人机身屏交互系统",
+  "type": "module",
+  "scripts": {
+    "dev": "vite",
+    "build": "vite build",
+    "preview": "vite preview"
+  },
+  "dependencies": {
+    "vue": "^3.4.21",
+    "vue-router": "^4.3.0",
+    "pinia": "^2.1.7"
+  },
+  "devDependencies": {
+    "@vitejs/plugin-vue": "^5.0.4",
+    "vite": "^5.2.0"
+  }
+}

+ 7 - 0
public/favicon.svg

@@ -0,0 +1,7 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
+  <circle cx="50" cy="50" r="45" fill="#2f8ee5"/>
+  <circle cx="50" cy="35" r="18" fill="#fff"/>
+  <rect x="32" y="55" width="36" height="30" rx="8" fill="#fff"/>
+  <circle cx="42" cy="32" r="3" fill="#2f8ee5"/>
+  <circle cx="58" cy="32" r="3" fill="#2f8ee5"/>
+</svg>

+ 18 - 0
src/App.vue

@@ -0,0 +1,18 @@
+<template>
+  <router-view />
+  <GlobalAlert />
+  <BroadcastOverlay />
+</template>
+
+<script setup>
+import GlobalAlert from '@/components/GlobalAlert.vue'
+import BroadcastOverlay from '@/components/BroadcastOverlay.vue'
+</script>
+
+<style>
+#app {
+  width: 100vw;
+  height: 100vh;
+  overflow: hidden;
+}
+</style>

+ 326 - 0
src/api/screen.js

@@ -0,0 +1,326 @@
+/**
+ * API 封装层 - 迎宾机器人屏幕端
+ *
+ * 所有接口统一在此文件封装,一期使用 Mock 数据返回 Promise
+ * 后续替换为真实接口时,只需修改本文件中对应的方法实现
+ */
+
+import {
+  mockRobotStatus,
+  mockScreenConfig,
+  mockPlayPlan,
+  mockWelcomeContent,
+  mockBroadcastState,
+  mockBroadcastContent,
+  mockCommands,
+  mockAppointments,
+  mockIdCardResult,
+  mockRecognitionResult,
+  mockDestinations,
+  mockNotices,
+  mockNavigationStatus,
+  mockSystemInfo,
+  mockScreenTheme
+} from '@/mock/screen'
+
+// API 基础地址(后续替换为真实后端地址)
+const BASE_URL = '/api'
+
+// 模拟网络延迟
+const delay = (ms = 300) => new Promise(resolve => setTimeout(resolve, ms))
+
+// ============================================
+// 屏幕配置与状态
+// ============================================
+
+/**
+ * 获取屏幕配置
+ */
+export async function getScreenConfig() {
+  await delay()
+  return { ...mockScreenConfig }
+}
+
+/**
+ * 获取机器人状态
+ */
+export async function getRobotStatus() {
+  await delay()
+  return { ...mockRobotStatus }
+}
+
+/**
+ * 获取播放方案
+ */
+export async function getPlayPlan() {
+  await delay()
+  return { ...mockPlayPlan }
+}
+
+/**
+ * 获取欢迎页兜底内容
+ */
+export async function getWelcomeContent() {
+  await delay()
+  return { ...mockWelcomeContent }
+}
+
+/**
+ * 获取屏幕主题配置
+ */
+export async function getScreenTheme() {
+  await delay()
+  return { ...mockScreenTheme }
+}
+
+/**
+ * 获取播报状态
+ */
+export async function getBroadcastState() {
+  await delay()
+  return { ...mockBroadcastState }
+}
+
+/**
+ * 获取播报内容
+ */
+export async function getBroadcastContent() {
+  await delay()
+  return { ...mockBroadcastContent }
+}
+
+// ============================================
+// 语音指令
+// ============================================
+
+/**
+ * 获取最新指令
+ */
+export async function getLatestCommand() {
+  await delay(100)
+  // 模拟随机返回指令
+  if (Math.random() > 0.9) {
+    return mockCommands[Math.floor(Math.random() * mockCommands.length)]
+  }
+  return null
+}
+
+/**
+ * 指令回执
+ */
+export async function ackCommand(commandId) {
+  await delay()
+  return { success: true, commandId }
+}
+
+// ============================================
+// 访客登记
+// ============================================
+
+/**
+ * 读取身份证
+ */
+export async function readIdCard() {
+  await delay(1000)
+  return { ...mockIdCardResult }
+}
+
+/**
+ * 预约查询
+ */
+export async function queryAppointment(params) {
+  await delay(800)
+  const { mobile, idCardNo } = params
+  // 根据手机号或身份证号查询
+  const appointment = mockAppointments.find(
+    a => a.mobile === mobile || a.idCardNo === idCardNo
+  )
+  if (appointment) {
+    return appointment
+  }
+  throw new Error('未查询到预约记录')
+}
+
+/**
+ * 提交访客登记
+ */
+export async function submitVisitorRegistration(data) {
+  await delay(1000)
+  // 模拟成功返回
+  return {
+    success: true,
+    visitorId: 'V' + Date.now(),
+    registrationNo: 'REG' + Date.now(),
+    visitorName: data.visitorName,
+    registrationTime: new Date().toISOString()
+  }
+}
+
+/**
+ * 获取最新识别结果
+ */
+export async function getRecognitionResult() {
+  await delay(500)
+  return { ...mockRecognitionResult }
+}
+
+// ============================================
+// 路线引导
+// ============================================
+
+/**
+ * 获取目的地列表
+ */
+export async function getDestinations() {
+  await delay()
+  return [...mockDestinations]
+}
+
+/**
+ * 开始导航
+ */
+export async function startNavigation(params) {
+  await delay(500)
+  return {
+    taskId: 'nav_' + Date.now(),
+    destinationId: params.destinationId,
+    status: 'starting'
+  }
+}
+
+/**
+ * 获取导航状态
+ */
+export async function getNavigationStatus(params) {
+  await delay()
+  return { ...mockNavigationStatus, taskId: params.taskId }
+}
+
+/**
+ * 取消导航
+ */
+export async function cancelNavigation(params) {
+  await delay(300)
+  return { success: true, taskId: params.taskId }
+}
+
+// ============================================
+// 通知公告
+// ============================================
+
+/**
+ * 获取通知公告列表
+ */
+export async function getNotices() {
+  await delay()
+  return [...mockNotices]
+}
+
+/**
+ * 获取通知详情
+ */
+export async function getNoticeDetail(id) {
+  await delay()
+  const notice = mockNotices.find(n => n.id === id)
+  if (notice) {
+    return { ...notice }
+  }
+  throw new Error('公告不存在')
+}
+
+// ============================================
+// 呼叫工作人员
+// ============================================
+
+/**
+ * 呼叫工作人员
+ */
+export async function callStaff(params) {
+  await delay(1000)
+  return {
+    success: true,
+    callId: 'CALL' + Date.now(),
+    message: '已通知工作人员,请稍候'
+  }
+}
+
+// ============================================
+// 系统信息
+// ============================================
+
+/**
+ * 获取系统信息
+ */
+export async function getSystemInfo() {
+  await delay()
+  return { ...mockSystemInfo }
+}
+
+// ============================================
+// 摄像头
+// ============================================
+
+/**
+ * 获取摄像头预览地址
+ */
+export async function getCameraPreviewUrl() {
+  await delay()
+  return {
+    streamUrl: 'rtsp://localhost:8554/camera',
+    streamType: 'rtsp',
+    expireTime: Date.now() + 300000
+  }
+}
+
+// ============================================
+// 音量控制
+// ============================================
+
+/**
+ * 音量控制
+ */
+export async function setAudioControl(params) {
+  await delay()
+  return { success: true, ...params }
+}
+
+// ============================================
+// 事件上报
+// ============================================
+
+/**
+ * 上报屏幕事件
+ */
+export async function reportEvent(data) {
+  await delay(100)
+  console.log('[Event Report]', data)
+  return { success: true }
+}
+
+// 导出所有 API 方法
+export default {
+  getScreenConfig,
+  getRobotStatus,
+  getPlayPlan,
+  getWelcomeContent,
+  getScreenTheme,
+  getBroadcastState,
+  getBroadcastContent,
+  getLatestCommand,
+  ackCommand,
+  readIdCard,
+  queryAppointment,
+  submitVisitorRegistration,
+  getRecognitionResult,
+  getDestinations,
+  startNavigation,
+  getNavigationStatus,
+  cancelNavigation,
+  getNotices,
+  getNoticeDetail,
+  callStaff,
+  getSystemInfo,
+  getCameraPreviewUrl,
+  setAudioControl,
+  reportEvent
+}

BIN
src/assets/images/default-welcome-bg 1.png


BIN
src/assets/images/default-welcome-bg.png


BIN
src/assets/media/play-plan/images/cola.jpg


BIN
src/assets/media/play-plan/videos/1-鍏冩皵妫灄骞垮憡鈥斺€旂櫧妗冨懗-480P 鏍囨竻-AVC.mp4


+ 536 - 0
src/assets/styles/main.css

@@ -0,0 +1,536 @@
+/* ============================================
+   迎宾机器人屏幕端 - 全局样式
+   温和迎宾风 + 轻科技感
+   ============================================ */
+
+/* CSS 变量定义 */
+:root {
+  /* 主色调 */
+  --primary: #2f8ee5;
+  --primary-light: #5ba4ed;
+  --primary-dark: #1a6fc9;
+  --primary-soft: #e8f4fd;
+
+  /* 辅助色 */
+  --secondary: #20b7c7;
+  --secondary-light: #6dd5db;
+  --secondary-soft: #e6f7f8;
+
+  /* 状态色 */
+  --success: #10b981;
+  --success-soft: #e6f7f2;
+  --warning: #f59e0b;
+  --warning-soft: #fef3e2;
+  --danger: #ef4444;
+  --danger-soft: #fef2f2;
+  --info: #0ea5e9;
+  --info-soft: #e0f2fe;
+
+  /* 文字色 */
+  --text-primary: #1f2937;
+  --text-secondary: #4b5563;
+  --text-muted: #64748b;
+  --text-light: #94a3b8;
+
+  /* 背景色 */
+  --bg-page: #f5f8fb;
+  --bg-card: #ffffff;
+  --bg-header: rgba(255, 255, 255, 0.95);
+  --bg-hover: #f0f7ff;
+  --bg-active: #e5efff;
+
+  /* 边框 */
+  --border-light: #e5e7eb;
+  --border-color: #d1d5db;
+
+  /* 阴影 */
+  --shadow-sm: 0 2px 8px rgba(47, 142, 229, 0.08);
+  --shadow-md: 0 4px 16px rgba(47, 142, 229, 0.12);
+  --shadow-lg: 0 8px 32px rgba(47, 142, 229, 0.16);
+  --shadow-xl: 0 12px 48px rgba(47, 142, 229, 0.2);
+
+  /* 圆角 */
+  --radius-sm: 8px;
+  --radius-md: 12px;
+  --radius-lg: 16px;
+  --radius-xl: 24px;
+  --radius-full: 9999px;
+
+  /* 间距 */
+  --space-xs: 4px;
+  --space-sm: 8px;
+  --space-md: 16px;
+  --space-lg: 24px;
+  --space-xl: 32px;
+  --space-2xl: 48px;
+
+  /* 字体大小 */
+  --font-xs: 14px;
+  --font-sm: 16px;
+  --font-md: 18px;
+  --font-lg: 22px;
+  --font-xl: 28px;
+  --font-2xl: 36px;
+  --font-3xl: 44px;
+
+  /* 行高 */
+  --leading-tight: 1.2;
+  --leading-normal: 1.5;
+  --leading-relaxed: 1.75;
+
+  /* 按钮高度 */
+  --btn-height-sm: 48px;
+  --btn-height-md: 56px;
+  --btn-height-lg: 72px;
+  --btn-height-xl: 88px;
+
+  /* 状态栏高度 */
+  --status-bar-height: 76px;
+
+  /* 底部操作区高度 */
+  --action-bar-height: 92px;
+
+  /* 过渡 */
+  --transition-fast: 150ms ease;
+  --transition-normal: 250ms ease;
+  --transition-slow: 400ms ease;
+}
+
+/* 重置样式 */
+*,
+*::before,
+*::after {
+  box-sizing: border-box;
+  margin: 0;
+  padding: 0;
+}
+
+html {
+  font-size: 16px;
+  -webkit-text-size-adjust: 100%;
+  -webkit-tap-highlight-color: transparent;
+}
+
+body {
+  font-family: 'PingFang SC', 'Microsoft YaHei', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+  font-size: var(--font-md);
+  line-height: var(--leading-normal);
+  color: var(--text-primary);
+  background-color: var(--bg-page);
+  overflow: hidden;
+  -webkit-font-smoothing: antialiased;
+  -moz-osx-font-smoothing: grayscale;
+}
+
+/* 全屏适配 */
+html, body, #app {
+  width: 100%;
+  height: 100%;
+  overflow: hidden;
+}
+
+/* 链接 */
+a {
+  color: var(--primary);
+  text-decoration: none;
+  transition: color var(--transition-fast);
+}
+
+a:hover {
+  color: var(--primary-dark);
+}
+
+/* 按钮重置 */
+button {
+  font-family: inherit;
+  font-size: inherit;
+  border: none;
+  background: none;
+  cursor: pointer;
+  outline: none;
+}
+
+button:disabled {
+  cursor: not-allowed;
+  opacity: 0.6;
+}
+
+/* 输入框重置 */
+input,
+textarea {
+  font-family: inherit;
+  font-size: inherit;
+  border: none;
+  outline: none;
+  background: none;
+}
+
+/* 图片 */
+img,
+svg {
+  display: block;
+  max-width: 100%;
+}
+
+/* 列表 */
+ul,
+ol {
+  list-style: none;
+}
+
+/* 滚动条 */
+::-webkit-scrollbar {
+  width: 6px;
+  height: 6px;
+}
+
+::-webkit-scrollbar-track {
+  background: transparent;
+}
+
+::-webkit-scrollbar-thumb {
+  background: var(--border-color);
+  border-radius: var(--radius-full);
+}
+
+::-webkit-scrollbar-thumb:hover {
+  background: var(--text-muted);
+}
+
+/* ============================================
+   通用工具类
+   ============================================ */
+
+/* 文本 */
+.text-primary { color: var(--text-primary); }
+.text-secondary { color: var(--text-secondary); }
+.text-muted { color: var(--text-muted); }
+.text-light { color: var(--text-light); }
+.text-success { color: var(--success); }
+.text-warning { color: var(--warning); }
+.text-danger { color: var(--danger); }
+.text-center { text-align: center; }
+.text-left { text-align: left; }
+.text-right { text-align: right; }
+
+/* 字体大小 */
+.text-xs { font-size: var(--font-xs); }
+.text-sm { font-size: var(--font-sm); }
+.text-md { font-size: var(--font-md); }
+.text-lg { font-size: var(--font-lg); }
+.text-xl { font-size: var(--font-xl); }
+.text-2xl { font-size: var(--font-2xl); }
+.text-3xl { font-size: var(--font-3xl); }
+
+/* 字体粗细 */
+.font-normal { font-weight: 400; }
+.font-medium { font-weight: 500; }
+.font-semibold { font-weight: 600; }
+.font-bold { font-weight: 700; }
+
+/* flex */
+.flex { display: flex; }
+.flex-col { flex-direction: column; }
+.flex-wrap { flex-wrap: wrap; }
+.items-center { align-items: center; }
+.items-start { align-items: flex-start; }
+.items-end { align-items: flex-end; }
+.justify-center { justify-content: center; }
+.justify-between { justify-content: space-between; }
+.justify-around { justify-content: space-around; }
+.justify-end { justify-content: flex-end; }
+.flex-1 { flex: 1; }
+.flex-shrink-0 { flex-shrink: 0; }
+
+/* 间距 */
+.gap-xs { gap: var(--space-xs); }
+.gap-sm { gap: var(--space-sm); }
+.gap-md { gap: var(--space-md); }
+.gap-lg { gap: var(--space-lg); }
+.gap-xl { gap: var(--space-xl); }
+
+/* 边距 */
+.m-auto { margin: auto; }
+.mx-auto { margin-left: auto; margin-right: auto; }
+.mt-sm { margin-top: var(--space-sm); }
+.mt-md { margin-top: var(--space-md); }
+.mt-lg { margin-top: var(--space-lg); }
+.mt-xl { margin-top: var(--space-xl); }
+.mb-sm { margin-bottom: var(--space-sm); }
+.mb-md { margin-bottom: var(--space-md); }
+.mb-lg { margin-bottom: var(--space-lg); }
+.mb-xl { margin-bottom: var(--space-xl); }
+
+/* 宽度 */
+.w-full { width: 100%; }
+.h-full { height: 100%; }
+
+/* 圆角 */
+.rounded-sm { border-radius: var(--radius-sm); }
+.rounded-md { border-radius: var(--radius-md); }
+.rounded-lg { border-radius: var(--radius-lg); }
+.rounded-xl { border-radius: var(--radius-xl); }
+.rounded-full { border-radius: var(--radius-full); }
+
+/* 阴影 */
+.shadow-sm { box-shadow: var(--shadow-sm); }
+.shadow-md { box-shadow: var(--shadow-md); }
+.shadow-lg { box-shadow: var(--shadow-lg); }
+
+/* 溢出 */
+.overflow-hidden { overflow: hidden; }
+.overflow-auto { overflow: auto; }
+
+/* 定位 */
+.relative { position: relative; }
+.absolute { position: absolute; }
+.fixed { position: fixed; }
+
+/* 层级 */
+.z-10 { z-index: 10; }
+.z-20 { z-index: 20; }
+.z-30 { z-index: 30; }
+.z-40 { z-index: 40; }
+.z-50 { z-index: 50; }
+
+/* 透明度 */
+.opacity-0 { opacity: 0; }
+.opacity-50 { opacity: 0.5; }
+.opacity-100 { opacity: 1; }
+
+/* 指针事件 */
+.pointer-events-none { pointer-events: none; }
+.pointer-events-auto { pointer-events: auto; }
+
+/* 过渡 */
+.transition { transition: all var(--transition-normal); }
+.transition-fast { transition: all var(--transition-fast); }
+.transition-slow { transition: all var(--transition-slow); }
+
+/* ============================================
+   组件样式
+   ============================================ */
+
+/* 主按钮 */
+.btn {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  gap: var(--space-sm);
+  height: var(--btn-height-md);
+  padding: 0 var(--space-xl);
+  font-size: var(--font-lg);
+  font-weight: 500;
+  border-radius: var(--radius-lg);
+  transition: all var(--transition-fast);
+  cursor: pointer;
+  user-select: none;
+}
+
+.btn-primary {
+  background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
+  color: #fff;
+  box-shadow: var(--shadow-md);
+}
+
+.btn-primary:hover {
+  transform: translateY(-2px);
+  box-shadow: var(--shadow-lg);
+}
+
+.btn-primary:active {
+  transform: translateY(0);
+}
+
+.btn-secondary {
+  background: var(--bg-card);
+  color: var(--text-primary);
+  border: 2px solid var(--border-light);
+}
+
+.btn-secondary:hover {
+  border-color: var(--primary);
+  color: var(--primary);
+}
+
+.btn-lg {
+  height: var(--btn-height-lg);
+  font-size: var(--font-xl);
+  padding: 0 var(--space-2xl);
+}
+
+.btn-sm {
+  height: var(--btn-height-sm);
+  font-size: var(--font-sm);
+  padding: 0 var(--space-lg);
+}
+
+/* 卡片 */
+.card {
+  background: var(--bg-card);
+  border-radius: var(--radius-xl);
+  box-shadow: var(--shadow-md);
+  padding: var(--space-xl);
+}
+
+/* 输入框 */
+.input {
+  width: 100%;
+  height: var(--btn-height-md);
+  padding: 0 var(--space-lg);
+  font-size: var(--font-lg);
+  background: var(--bg-card);
+  border: 2px solid var(--border-light);
+  border-radius: var(--radius-lg);
+  transition: border-color var(--transition-fast);
+}
+
+.input:focus {
+  border-color: var(--primary);
+}
+
+.input::placeholder {
+  color: var(--text-light);
+}
+
+/* 标签 */
+.tag {
+  display: inline-flex;
+  align-items: center;
+  padding: var(--space-xs) var(--space-md);
+  font-size: var(--font-sm);
+  background: var(--primary-soft);
+  color: var(--primary);
+  border-radius: var(--radius-full);
+}
+
+/* 分割线 */
+.divider {
+  width: 100%;
+  height: 1px;
+  background: var(--border-light);
+  margin: var(--space-lg) 0;
+}
+
+/* ============================================
+   动画
+   ============================================ */
+
+/* 淡入 */
+@keyframes fadeIn {
+  from {
+    opacity: 0;
+  }
+  to {
+    opacity: 1;
+  }
+}
+
+/* 淡入上浮 */
+@keyframes fadeInUp {
+  from {
+    opacity: 0;
+    transform: translateY(20px);
+  }
+  to {
+    opacity: 1;
+    transform: translateY(0);
+  }
+}
+
+/* 缩放淡入 */
+@keyframes scaleIn {
+  from {
+    opacity: 0;
+    transform: scale(0.9);
+  }
+  to {
+    opacity: 1;
+    transform: scale(1);
+  }
+}
+
+/* 脉冲 */
+@keyframes pulse {
+  0%, 100% {
+    opacity: 1;
+  }
+  50% {
+    opacity: 0.5;
+  }
+}
+
+/* 呼吸 */
+@keyframes breathe {
+  0%, 100% {
+    transform: scale(1);
+  }
+  50% {
+    transform: scale(1.02);
+  }
+}
+
+/* 旋转 */
+@keyframes spin {
+  from {
+    transform: rotate(0deg);
+  }
+  to {
+    transform: rotate(360deg);
+  }
+}
+
+/* 动画类 */
+.animate-fadeIn {
+  animation: fadeIn var(--transition-normal) ease-out;
+}
+
+.animate-fadeInUp {
+  animation: fadeInUp var(--transition-slow) ease-out;
+}
+
+.animate-scaleIn {
+  animation: scaleIn var(--transition-normal) ease-out;
+}
+
+.animate-pulse {
+  animation: pulse 2s ease-in-out infinite;
+}
+
+.animate-breathe {
+  animation: breathe 3s ease-in-out infinite;
+}
+
+.animate-spin {
+  animation: spin 1s linear infinite;
+}
+
+/* ============================================
+   页面容器
+   ============================================ */
+
+.page-container {
+  width: 100vw;
+  height: 100vh;
+  display: flex;
+  flex-direction: column;
+  background: var(--bg-page);
+  overflow: hidden;
+}
+
+.page-main {
+  flex: 1;
+  overflow: hidden;
+  padding: var(--space-xl);
+}
+
+/* ============================================
+   响应式(预留竖屏适配)
+   ============================================ */
+
+@media (orientation: portrait) {
+  :root {
+    --status-bar-height: 58px;
+    --action-bar-height: 70px;
+  }
+}

+ 206 - 0
src/components/BroadcastOverlay.vue

@@ -0,0 +1,206 @@
+<template>
+  <Teleport to="body">
+    <Transition name="broadcast">
+      <div v-if="showBroadcast" class="broadcast-overlay">
+        <div class="broadcast-card">
+          <div class="broadcast-header">
+            <div class="broadcast-icon">
+              <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+                <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
+                <path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07" />
+              </svg>
+            </div>
+            <span class="broadcast-label">正在播报</span>
+          </div>
+
+          <div v-if="broadcastState.title" class="broadcast-title">
+            {{ broadcastState.title }}
+          </div>
+
+          <div class="broadcast-content">
+            {{ broadcastState.content }}
+          </div>
+
+          <div class="broadcast-wave">
+            <span class="wave-bar"></span>
+            <span class="wave-bar"></span>
+            <span class="wave-bar"></span>
+            <span class="wave-bar"></span>
+            <span class="wave-bar"></span>
+          </div>
+        </div>
+      </div>
+    </Transition>
+  </Teleport>
+</template>
+
+<script setup>
+import { computed } from 'vue'
+import { useScreenStore } from '@/stores/screen'
+
+const screenStore = useScreenStore()
+
+const broadcastState = computed(() => screenStore.broadcastState)
+
+const showBroadcast = computed(() => {
+  return broadcastState.value.broadcasting
+})
+</script>
+
+<style scoped>
+.broadcast-overlay {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: rgba(0, 0, 0, 0.6);
+  z-index: 9998;
+  backdrop-filter: blur(4px);
+}
+
+.broadcast-card {
+  width: 90%;
+  max-width: 700px;
+  padding: 40px;
+  background: var(--bg-card);
+  border-radius: var(--radius-xl);
+  box-shadow: var(--shadow-xl);
+  animation: scaleIn 0.3s ease-out;
+}
+
+.broadcast-header {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  margin-bottom: 24px;
+}
+
+.broadcast-icon {
+  width: 36px;
+  height: 36px;
+  color: var(--primary);
+  animation: pulse 1.5s ease-in-out infinite;
+}
+
+.broadcast-icon svg {
+  width: 100%;
+  height: 100%;
+}
+
+.broadcast-label {
+  font-size: 18px;
+  font-weight: 500;
+  color: var(--primary);
+  padding: 6px 16px;
+  background: var(--primary-soft);
+  border-radius: var(--radius-full);
+}
+
+.broadcast-title {
+  font-size: 28px;
+  font-weight: 600;
+  color: var(--text-primary);
+  margin-bottom: 16px;
+  text-align: center;
+}
+
+.broadcast-content {
+  font-size: 24px;
+  line-height: 1.8;
+  color: var(--text-secondary);
+  text-align: center;
+  padding: 24px;
+  background: var(--bg-page);
+  border-radius: var(--radius-lg);
+  margin-bottom: 32px;
+}
+
+.broadcast-wave {
+  display: flex;
+  align-items: flex-end;
+  justify-content: center;
+  gap: 6px;
+  height: 40px;
+}
+
+.wave-bar {
+  width: 6px;
+  background: var(--primary);
+  border-radius: 3px;
+  animation: wave 1s ease-in-out infinite;
+}
+
+.wave-bar:nth-child(1) {
+  height: 12px;
+  animation-delay: 0s;
+}
+
+.wave-bar:nth-child(2) {
+  height: 24px;
+  animation-delay: 0.1s;
+}
+
+.wave-bar:nth-child(3) {
+  height: 36px;
+  animation-delay: 0.2s;
+}
+
+.wave-bar:nth-child(4) {
+  height: 24px;
+  animation-delay: 0.3s;
+}
+
+.wave-bar:nth-child(5) {
+  height: 12px;
+  animation-delay: 0.4s;
+}
+
+@keyframes wave {
+  0%, 100% {
+    transform: scaleY(1);
+  }
+  50% {
+    transform: scaleY(0.5);
+  }
+}
+
+@keyframes scaleIn {
+  from {
+    opacity: 0;
+    transform: scale(0.9);
+  }
+  to {
+    opacity: 1;
+    transform: scale(1);
+  }
+}
+
+@keyframes pulse {
+  0%, 100% {
+    opacity: 1;
+  }
+  50% {
+    opacity: 0.6;
+  }
+}
+
+/* 动画 */
+.broadcast-enter-active,
+.broadcast-leave-active {
+  transition: all 0.3s ease;
+}
+
+.broadcast-enter-from,
+.broadcast-leave-to {
+  opacity: 0;
+}
+
+.broadcast-enter-from .broadcast-card,
+.broadcast-leave-to .broadcast-card {
+  transform: scale(0.9);
+}
+</style>

+ 226 - 0
src/components/CameraPreview.vue

@@ -0,0 +1,226 @@
+<template>
+  <div class="camera-preview" :class="{ loading: loading, error: hasError }">
+    <!-- 加载中 -->
+    <div v-if="loading" class="preview-loading">
+      <div class="loading-spinner"></div>
+      <span>摄像头加载中...</span>
+    </div>
+
+    <!-- 错误状态 -->
+    <div v-else-if="hasError" class="preview-error">
+      <div class="error-icon">
+        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+          <path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z" />
+          <circle cx="12" cy="13" r="4" />
+          <line x1="1" y1="1" x2="23" y2="23" />
+        </svg>
+      </div>
+      <span>摄像头不可用</span>
+    </div>
+
+    <!-- 预览画面 -->
+    <video
+      v-else-if="streamUrl"
+      ref="videoRef"
+      class="preview-video"
+      autoplay
+      playsinline
+      muted
+    >
+      <source :src="streamUrl" type="application/x-mpegURL" />
+    </video>
+
+    <!-- 默认状态 -->
+    <div v-else class="preview-placeholder">
+      <div class="placeholder-icon">
+        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+          <path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z" />
+          <circle cx="12" cy="13" r="4" />
+        </svg>
+      </div>
+      <span>摄像头预览</span>
+    </div>
+
+    <!-- 边框效果 -->
+    <div class="preview-frame">
+      <span class="frame-corner top-left"></span>
+      <span class="frame-corner top-right"></span>
+      <span class="frame-corner bottom-left"></span>
+      <span class="frame-corner bottom-right"></span>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted, onUnmounted } from 'vue'
+
+const props = defineProps({
+  streamUrl: {
+    type: String,
+    default: ''
+  },
+  autoPlay: {
+    type: Boolean,
+    default: true
+  }
+})
+
+const emit = defineEmits(['ready', 'error'])
+
+const videoRef = ref(null)
+const loading = ref(true)
+const hasError = ref(false)
+
+let mediaStream = null
+
+const initCamera = async () => {
+  try {
+    // 尝试获取本地摄像头用于预览
+    if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
+      mediaStream = await navigator.mediaDevices.getUserMedia({
+        video: {
+          width: { ideal: 640 },
+          height: { ideal: 480 },
+          facingMode: 'user'
+        }
+      })
+      if (videoRef.value) {
+        videoRef.value.srcObject = mediaStream
+      }
+    }
+    loading.value = false
+    emit('ready')
+  } catch (error) {
+    console.error('Camera error:', error)
+    loading.value = false
+    hasError.value = true
+    emit('error', error)
+  }
+}
+
+onMounted(() => {
+  if (props.autoPlay) {
+    initCamera()
+  }
+})
+
+onUnmounted(() => {
+  if (mediaStream) {
+    mediaStream.getTracks().forEach(track => track.stop())
+  }
+})
+</script>
+
+<style scoped>
+.camera-preview {
+  position: relative;
+  width: 100%;
+  height: 100%;
+  min-height: 200px;
+  background: var(--bg-page);
+  border-radius: var(--radius-lg);
+  overflow: hidden;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.preview-video {
+  width: 100%;
+  height: 100%;
+  object-fit: cover;
+}
+
+.preview-loading,
+.preview-error,
+.preview-placeholder {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 12px;
+  color: var(--text-muted);
+  font-size: 14px;
+}
+
+.loading-spinner {
+  width: 40px;
+  height: 40px;
+  border: 3px solid var(--border-light);
+  border-top-color: var(--primary);
+  border-radius: 50%;
+  animation: spin 1s linear infinite;
+}
+
+@keyframes spin {
+  to {
+    transform: rotate(360deg);
+  }
+}
+
+.error-icon,
+.placeholder-icon {
+  width: 48px;
+  height: 48px;
+  color: var(--text-light);
+}
+
+.error-icon svg,
+.placeholder-icon svg {
+  width: 100%;
+  height: 100%;
+}
+
+.preview-error {
+  color: var(--danger);
+}
+
+.preview-error .error-icon {
+  color: var(--danger);
+}
+
+/* 边框效果 */
+.preview-frame {
+  position: absolute;
+  top: 8px;
+  left: 8px;
+  right: 8px;
+  bottom: 8px;
+  pointer-events: none;
+}
+
+.frame-corner {
+  position: absolute;
+  width: 24px;
+  height: 24px;
+  border-color: var(--primary);
+  border-style: solid;
+}
+
+.frame-corner.top-left {
+  top: 0;
+  left: 0;
+  border-width: 3px 0 0 3px;
+  border-radius: 4px 0 0 0;
+}
+
+.frame-corner.top-right {
+  top: 0;
+  right: 0;
+  border-width: 3px 3px 0 0;
+  border-radius: 0 4px 0 0;
+}
+
+.frame-corner.bottom-left {
+  bottom: 0;
+  left: 0;
+  border-width: 0 0 3px 3px;
+  border-radius: 0 0 0 4px;
+}
+
+.frame-corner.bottom-right {
+  bottom: 0;
+  right: 0;
+  border-width: 0 3px 3px 0;
+  border-radius: 0 0 4px 0;
+}
+</style>

+ 169 - 0
src/components/GlobalAlert.vue

@@ -0,0 +1,169 @@
+<template>
+  <Teleport to="body">
+    <Transition name="alert">
+      <div v-if="alert.show" class="global-alert" :class="alertClass">
+        <div class="alert-icon">
+          <svg v-if="alert.type === 'success'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+            <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
+            <polyline points="22 4 12 14.01 9 11.01" />
+          </svg>
+          <svg v-else-if="alert.type === 'error'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+            <circle cx="12" cy="12" r="10" />
+            <line x1="15" y1="9" x2="9" y2="15" />
+            <line x1="9" y1="9" x2="15" y2="15" />
+          </svg>
+          <svg v-else-if="alert.type === 'warning'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+            <path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
+            <line x1="12" y1="9" x2="12" y2="13" />
+            <line x1="12" y1="17" x2="12.01" y2="17" />
+          </svg>
+          <svg v-else viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+            <circle cx="12" cy="12" r="10" />
+            <line x1="12" y1="16" x2="12" y2="12" />
+            <line x1="12" y1="8" x2="12.01" y2="8" />
+          </svg>
+        </div>
+        <div class="alert-content">
+          <div v-if="alert.title" class="alert-title">{{ alert.title }}</div>
+          <div class="alert-message">{{ alert.message }}</div>
+        </div>
+        <button class="alert-close" @click="handleClose">
+          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+            <line x1="18" y1="6" x2="6" y2="18" />
+            <line x1="6" y1="6" x2="18" y2="18" />
+          </svg>
+        </button>
+      </div>
+    </Transition>
+  </Teleport>
+</template>
+
+<script setup>
+import { computed } from 'vue'
+import { useScreenStore } from '@/stores/screen'
+
+const screenStore = useScreenStore()
+
+const alert = computed(() => screenStore.globalAlert)
+
+const alertClass = computed(() => {
+  return `alert-${alert.value.type || 'info'}`
+})
+
+const handleClose = () => {
+  screenStore.hideAlert()
+}
+</script>
+
+<style scoped>
+.global-alert {
+  position: fixed;
+  top: 80px;
+  left: 50%;
+  transform: translateX(-50%);
+  display: flex;
+  align-items: flex-start;
+  gap: 16px;
+  padding: 20px 24px;
+  background: var(--bg-card);
+  border-radius: var(--radius-xl);
+  box-shadow: var(--shadow-xl);
+  z-index: 9999;
+  max-width: 500px;
+  min-width: 320px;
+}
+
+.alert-success {
+  border-left: 6px solid var(--success);
+}
+
+.alert-error {
+  border-left: 6px solid var(--danger);
+}
+
+.alert-warning {
+  border-left: 6px solid var(--warning);
+}
+
+.alert-info {
+  border-left: 6px solid var(--primary);
+}
+
+.alert-icon {
+  width: 32px;
+  height: 32px;
+  flex-shrink: 0;
+}
+
+.alert-success .alert-icon {
+  color: var(--success);
+}
+
+.alert-error .alert-icon {
+  color: var(--danger);
+}
+
+.alert-warning .alert-icon {
+  color: var(--warning);
+}
+
+.alert-info .alert-icon {
+  color: var(--primary);
+}
+
+.alert-icon svg {
+  width: 100%;
+  height: 100%;
+}
+
+.alert-content {
+  flex: 1;
+}
+
+.alert-title {
+  font-size: 18px;
+  font-weight: 600;
+  color: var(--text-primary);
+  margin-bottom: 4px;
+}
+
+.alert-message {
+  font-size: 16px;
+  color: var(--text-secondary);
+  line-height: 1.5;
+}
+
+.alert-close {
+  width: 32px;
+  height: 32px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: var(--text-muted);
+  border-radius: var(--radius-md);
+  transition: all var(--transition-fast);
+  flex-shrink: 0;
+}
+
+.alert-close:hover {
+  background: var(--bg-page);
+  color: var(--text-primary);
+}
+
+.alert-close svg {
+  width: 20px;
+  height: 20px;
+}
+
+/* 动画 */
+.alert-enter-active,
+.alert-leave-active {
+  transition: all 0.3s ease;
+}
+
+.alert-enter-from,
+.alert-leave-to {
+  opacity: 0;
+  transform: translateX(-50%) translateY(-20px);
+}
+</style>

+ 971 - 0
src/components/IdlePlayer.vue

@@ -0,0 +1,971 @@
+<template>
+  <div class="idle-player">
+    <!-- 调试切换按钮(showDebugSwitch 为 true 时显示) -->
+    <button
+      v-if="theme.showDebugSwitch !== false"
+      class="debug-toggle"
+      @click.stop="onToggleMode"
+      title="开发调试:切换待机页显示模式"
+    >
+      {{ actualMode === 'welcome' ? '切换播放方案' : '切换欢迎页' }}
+    </button>
+
+    <!-- ================================================
+         欢迎模式(默认兜底页)
+         ================================================ -->
+    <div
+      v-if="actualMode === 'welcome'"
+      class="mode-welcome"
+      :class="`fit-${theme.backgroundFit || 'stretch'}`"
+      :style="{ '--welcome-bg-url': welcomeBgUrl }"
+      @click="onScreenClick"
+    >
+      <!-- 欢迎语柔光承托层 -->
+      <div v-if="theme.backgroundOverlay !== false" class="welcome-glow"></div>
+
+      <!-- 左上角品牌区 -->
+      <div class="brand-area">
+        <div class="brand-logo">
+          <img
+            v-if="theme.logoUrl"
+            :src="theme.logoUrl"
+            class="brand-logo-img"
+            alt=""
+          />
+          <svg v-else viewBox="0 0 56 56" fill="none" xmlns="http://www.w3.org/2000/svg">
+            <circle cx="28" cy="20" r="12" fill="currentColor"/>
+            <rect x="14" y="34" width="28" height="18" rx="7" fill="currentColor"/>
+            <circle cx="23" cy="18" r="3" fill="white"/>
+            <circle cx="33" cy="18" r="3" fill="white"/>
+            <rect x="9" y="38" width="6" height="10" rx="3" fill="currentColor" opacity="0.7"/>
+            <rect x="41" y="38" width="6" height="10" rx="3" fill="currentColor" opacity="0.7"/>
+          </svg>
+        </div>
+        <div class="brand-text">
+          <span class="brand-name">{{ theme.robotName }}</span>
+          <span class="brand-subtitle">{{ theme.brandSubtitle }}</span>
+        </div>
+      </div>
+
+      <!-- 页面主体:居中大欢迎视觉 -->
+      <div class="main-central">
+        <h1 class="main-title">{{ theme.welcomeTitle }}</h1>
+        <p class="main-subtitle">{{ theme.welcomeSubtitle }}</p>
+      </div>
+
+      <!-- 底部操作区 -->
+      <div class="footer-bar">
+
+        <!-- 左下角:状态 -->
+        <div v-if="theme.showStatusBar !== false" class="footer-status">
+          <span class="status-dot"></span>
+          <span>待机中</span>
+          <span class="sep">/</span>
+          <span>电量 {{ batteryLevel }}%</span>
+          <span class="sep">/</span>
+          <span>{{ networkOk ? '网络正常' : '网络异常' }}</span>
+        </div>
+
+        <!-- 中间:触摸进入按钮 -->
+        <div class="footer-cta" @click.stop>
+          <svg class="cta-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
+            <path d="M15 15l-2 5L9 9l11 4-5 2zm0 0l5 5"/>
+          </svg>
+          <span>{{ theme.touchText }}</span>
+        </div>
+
+        <!-- 右下角:时间日期 -->
+        <div v-if="theme.showTime !== false" class="footer-time">
+          <span class="time-str">{{ currentTime }}</span>
+          <span class="date-str">{{ currentDate }}</span>
+        </div>
+      </div>
+    </div>
+
+    <!-- ================================================
+         播放方案模式(广告 / 宣传 / 通知播放页)
+         ================================================ -->
+    <div
+      v-else-if="actualMode === 'playlist'"
+      class="mode-playlist"
+      :class="playlistBgClass"
+      @click="onScreenClick"
+    >
+      <!-- 素材层 -->
+      <div v-if="currentMedia" class="playlist-media-wrap" :class="{ 'is-transitioning': isTransitioning }">
+        <!-- 图片 -->
+        <img
+          v-if="currentMedia.type === 'image'"
+          :src="currentMedia.url"
+          :alt="currentMedia.title"
+          class="media-img"
+          :style="mediaFitStyle"
+          @load="onMediaLoad"
+          @error="onMediaError"
+        />
+        <!-- 视频 -->
+        <video
+          v-else-if="currentMedia.type === 'video'"
+          ref="videoEl"
+          :src="currentMedia.url"
+          class="media-video"
+          :style="mediaFitStyle"
+          :muted="currentMedia.muted !== false"
+          autoplay
+          playsinline
+          preload="auto"
+          @canplay="onMediaLoad"
+          @error="onMediaError"
+          @ended="onVideoEnded"
+        />
+      </div>
+
+      <!-- 无素材时的兜底 -->
+      <div v-else class="playlist-empty">
+        <span>暂无播放内容</span>
+      </div>
+
+      <!-- 左上角轻量品牌浮层 -->
+      <div class="playlist-brand">
+        <span class="playlist-brand-name">{{ theme.robotName }}</span>
+        <span class="playlist-brand-status">
+          <span class="playlist-status-dot"></span>
+          待机中
+        </span>
+      </div>
+
+      <!-- 底部轻量信息浮层 -->
+      <div class="playlist-bottom-bar">
+        <!-- 左:素材标题(仅当 showTitle === true 时显示) -->
+        <div
+          class="playlist-media-title"
+          v-if="currentMedia && currentMedia.showTitle === true && currentMedia.title"
+        >
+          <span>{{ currentMedia.title }}</span>
+        </div>
+
+        <!-- 中:轮播指示器 + 触摸提示(始终水平居中) -->
+        <div class="playlist-center-group">
+          <div class="playlist-indicators" v-if="mediaCount > 1">
+            <span
+              v-for="i in mediaCount"
+              :key="i"
+              class="indicator"
+              :class="{ active: i - 1 === currentIndex }"
+            />
+          </div>
+          <div class="playlist-touch-hint">
+            <svg class="hint-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+              <path d="M15 15l-2 5L9 9l11 4-5 2zm0 0l5 5"/>
+            </svg>
+            <span>触摸屏幕进入服务</span>
+          </div>
+        </div>
+
+        <!-- 右:当前时间(固定右下角) -->
+        <div class="playlist-time">
+          <span class="playlist-time-str">{{ currentTime }}</span>
+          <span class="playlist-date-str">{{ currentDate }}</span>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { computed, ref, watch, onMounted, onUnmounted } from 'vue'
+import { useScreenStore } from '@/stores/screen'
+import { formatTimeShort, formatDateCN } from '@/utils/time'
+import defaultWelcomeBg from '@/assets/images/default-welcome-bg.png'
+
+const emit = defineEmits(['click'])
+
+const screenStore = useScreenStore()
+
+const currentIndex = ref(0)
+const currentTime = ref(formatTimeShort())
+const currentDate = ref(formatDateCN())
+
+// 播放方案相关 ref
+const videoEl = ref(null)
+const isTransitioning = ref(false)
+const isVideoPlaying = ref(false)
+
+const actualMode = computed(() => screenStore.actualIdleMode)
+
+const hasMedia = computed(() => {
+  return screenStore.playPlan && Array.isArray(screenStore.playPlan.items) && screenStore.playPlan.items.length > 0
+})
+
+const mediaCount = computed(() => {
+  return hasMedia.value ? screenStore.playPlan.items.length : 0
+})
+
+const currentMedia = computed(() => {
+  if (!hasMedia.value) return null
+  return screenStore.playPlan.items[currentIndex.value] || null
+})
+
+const batteryLevel = computed(() => screenStore.robotStatus.batteryLevel)
+const networkOk = computed(() => screenStore.networkOk)
+const workStatus = computed(() => screenStore.robotStatus.workStatus)
+
+const workStatusText = computed(() => {
+  const map = { idle: '待机中', working: '工作中', charging: '充电中', error: '异常' }
+  return map[workStatus.value] || '待机中'
+})
+
+// 主题配置兜底默认值
+const theme = computed(() => {
+  const t = screenStore.screenTheme
+  const defaults = {
+    robotName: '迎宾巡逻机器人',
+    brandSubtitle: '智能接待 · 路线引导 · 信息服务',
+    logoUrl: '',
+    welcomeTitle: '您好,欢迎光临',
+    welcomeSubtitle: '我可以为您提供访客登记、路线引导、通知公告查询与现场帮助服务',
+    primaryColor: '#2f8ee5',
+    secondaryColor: '#20b7c7',
+    backgroundType: 'image',
+    backgroundImage: '',
+    backgroundFit: 'stretch',
+    backgroundOverlay: true,
+    showDecorations: false,
+    touchText: '触摸屏幕进入服务',
+    showServicePanel: false,
+    showStatusBar: true,
+    showTime: true,
+    showDebugSwitch: true,
+    serviceItems: [
+      { code: '01', title: '访客登记', desc: '预约到访 / 现场登记' },
+      { code: '02', title: '路线引导', desc: '大厅、展厅、会议室等' },
+      { code: '03', title: '通知公告', desc: '查看最新通知信息' },
+      { code: '04', title: '呼叫工作人员', desc: '需要帮助时快速联系' }
+    ]
+  }
+
+  if (!t) return defaults
+
+  return {
+    robotName: t.robotName || defaults.robotName,
+    brandSubtitle: t.brandSubtitle || defaults.brandSubtitle,
+    logoUrl: t.logoUrl || defaults.logoUrl,
+    welcomeTitle: t.welcomeTitle || defaults.welcomeTitle,
+    welcomeSubtitle: t.welcomeSubtitle || defaults.welcomeSubtitle,
+    primaryColor: t.primaryColor || defaults.primaryColor,
+    secondaryColor: t.secondaryColor || defaults.secondaryColor,
+    backgroundType: t.backgroundType || defaults.backgroundType,
+    backgroundImage: t.backgroundImage || defaults.backgroundImage,
+    backgroundFit: t.backgroundFit || defaults.backgroundFit,
+    backgroundOverlay: t.backgroundOverlay !== false,
+    showDecorations: t.showDecorations !== false,
+    touchText: t.touchText || defaults.touchText,
+    showServicePanel: t.showServicePanel === true,
+    showStatusBar: t.showStatusBar !== false,
+    showTime: t.showTime !== false,
+    showDebugSwitch: t.showDebugSwitch !== false,
+    serviceItems: t.serviceItems || defaults.serviceItems
+  }
+})
+
+// 背景图 URL
+const welcomeBgUrl = computed(() => {
+  const bg = theme.value.backgroundImage || defaultWelcomeBg
+  return `url("${bg}")`
+})
+
+// 播放方案:当前素材适配方式
+const currentFitMode = computed(() => {
+  if (!currentMedia.value) return 'cover'
+  return currentMedia.value.fitMode || screenStore.playPlan?.defaultFitMode || 'cover'
+})
+
+// 播放方案:背景 class(contain 模式用深色背景兜底)
+const playlistBgClass = computed(() => {
+  const mode = currentFitMode.value
+  if (mode === 'contain') return 'playlist-bg-contain'
+  if (mode === 'stretch') return 'playlist-bg-stretch'
+  return 'playlist-bg-cover'
+})
+
+// 播放方案:素材适配样式
+const mediaFitStyle = computed(() => {
+  const mode = currentFitMode.value
+  if (mode === 'contain') return { 'object-fit': 'contain' }
+  if (mode === 'stretch') return { 'object-fit': 'fill' }
+  return { 'object-fit': 'cover' }
+})
+
+const onToggleMode = () => {
+  screenStore.toggleIdleMode()
+}
+
+const onScreenClick = () => {
+  emit('click')
+}
+
+// 播放方案:切换下一条素材(带淡入淡出)
+const goNextMedia = () => {
+  if (mediaCount.value <= 1) return
+  isTransitioning.value = true
+  setTimeout(() => {
+    screenStore.nextMedia()
+    currentIndex.value = screenStore.currentMediaIndex
+    isVideoPlaying.value = false
+    setTimeout(() => {
+      isTransitioning.value = false
+    }, 50)
+  }, 200)
+}
+
+// 播放方案:图片定时切换
+let imageTimer = null
+
+const startImageTimer = () => {
+  clearImageTimer()
+  if (!currentMedia.value || currentMedia.value.type !== 'image') return
+  const duration = currentMedia.value.duration || 8000
+  imageTimer = setTimeout(() => {
+    goNextMedia()
+    startImageTimer()
+  }, duration)
+}
+
+const clearImageTimer = () => {
+  if (imageTimer) {
+    clearTimeout(imageTimer)
+    imageTimer = null
+  }
+}
+
+// 播放方案:视频播放结束
+const onVideoEnded = () => {
+  goNextMedia()
+  startImageTimer()
+}
+
+// 播放方案:素材加载成功
+const onMediaLoad = () => {
+  // nothing
+}
+
+// 播放方案:素材加载失败,跳下一条
+const onMediaError = () => {
+  console.warn('[IdlePlayer] Media load error, skipping:', currentMedia.value?.url)
+  goNextMedia()
+  startImageTimer()
+}
+
+// 播放方案:监听 currentMedia 变化,重启图片定时器
+watch(() => currentMedia.value, (newVal) => {
+  if (!newVal) return
+  if (newVal.type === 'image') {
+    startImageTimer()
+  } else {
+    clearImageTimer()
+  }
+})
+
+// 播放方案:监听 currentMediaIndex,重置状态
+watch(() => screenStore.currentMediaIndex, (newIdx) => {
+  currentIndex.value = newIdx
+  isVideoPlaying.value = false
+})
+
+const updateClock = () => {
+  currentTime.value = formatTimeShort()
+  currentDate.value = formatDateCN()
+}
+
+let clockTimer = null
+
+onMounted(() => {
+  clockTimer = setInterval(updateClock, 1000)
+  updateClock()
+  if (actualMode.value === 'playlist') {
+    startImageTimer()
+  }
+})
+
+onUnmounted(() => {
+  if (clockTimer) clearInterval(clockTimer)
+  clearImageTimer()
+})
+
+// 播放方案:mode 切换时管理定时器
+watch(actualMode, (newMode) => {
+  if (newMode === 'playlist') {
+    currentIndex.value = screenStore.currentMediaIndex
+    startImageTimer()
+  } else {
+    clearImageTimer()
+  }
+})
+</script>
+
+<style scoped>
+.idle-player {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  background: #f0f6fc;
+  cursor: pointer;
+  position: relative;
+  overflow: hidden;
+}
+
+/* ============================================
+   调试切换按钮(极弱化)
+   ============================================ */
+.debug-toggle {
+  position: absolute;
+  top: 10px;
+  right: 10px;
+  z-index: 100;
+  padding: 3px 9px;
+  font-size: 11px;
+  color: rgba(0, 0, 0, 0.2);
+  background: rgba(0, 0, 0, 0.03);
+  border: 1px dashed rgba(0, 0, 0, 0.08);
+  border-radius: 10px;
+  cursor: pointer;
+  transition: background 0.2s, color 0.2s;
+  font-family: inherit;
+  letter-spacing: 0.2px;
+  white-space: nowrap;
+}
+
+.debug-toggle:hover {
+  background: rgba(0, 0, 0, 0.06);
+  color: rgba(0, 0, 0, 0.35);
+}
+
+/* ============================================
+   欢迎模式
+   ============================================ */
+.mode-welcome {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  position: relative;
+  background-image:
+    linear-gradient(rgba(246, 251, 255, 0.10), rgba(246, 251, 255, 0.10)),
+    var(--welcome-bg-url);
+  background-position: center;
+  background-repeat: no-repeat;
+  background-color: #e2edf8;
+}
+
+.fit-stretch { background-size: 100% 100%; }
+.fit-cover   { background-size: cover; }
+.fit-contain { background-size: contain; background-color: #1a2a3a; }
+
+.mode-welcome::after {
+  content: '';
+  position: absolute;
+  inset: 0;
+  background: linear-gradient(
+    to bottom,
+    rgba(255, 255, 255, 0.04) 0%,
+    transparent 18%,
+    transparent 65%,
+    rgba(255, 255, 255, 0.35) 100%
+  );
+  pointer-events: none;
+  z-index: 1;
+}
+
+.welcome-glow {
+  position: absolute;
+  top: 40%;
+  left: 50%;
+  transform: translate(-50%, -50%);
+  width: 860px;
+  height: 440px;
+  background: radial-gradient(
+    ellipse at center,
+    rgba(255, 255, 255, 0.50) 0%,
+    rgba(255, 255, 255, 0.22) 35%,
+    rgba(255, 255, 255, 0.05) 55%,
+    transparent 72%
+  );
+  pointer-events: none;
+  z-index: 2;
+  filter: blur(2px);
+}
+
+.brand-area {
+  position: relative;
+  z-index: 10;
+  display: flex;
+  align-items: center;
+  gap: 20px;
+  padding: 38px 60px 0;
+  flex-shrink: 0;
+  animation: fadeInDown 0.5s ease-out;
+}
+
+.brand-logo {
+  width: 62px;
+  height: 62px;
+  color: var(--primary);
+  flex-shrink: 0;
+  filter: drop-shadow(0 3px 10px rgba(47, 142, 229, 0.28));
+  position: relative;
+}
+
+.brand-logo-img {
+  position: absolute;
+  inset: 0;
+  width: 100%;
+  height: 100%;
+  object-fit: contain;
+}
+
+.brand-logo svg { width: 100%; height: 100%; }
+
+.brand-text {
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+}
+
+.brand-name {
+  font-size: 32px;
+  font-weight: 800;
+  color: var(--text-primary);
+  letter-spacing: 3px;
+}
+
+.brand-subtitle {
+  font-size: 19px;
+  color: var(--text-muted);
+  letter-spacing: 1px;
+}
+
+.main-central {
+  position: relative;
+  z-index: 10;
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+  padding: 0 60px;
+  gap: 36px;
+  animation: fadeInUp 0.7s ease-out 0.1s backwards;
+}
+
+.main-title {
+  font-size: 104px;
+  font-weight: 900;
+  color: var(--text-primary);
+  margin: 0;
+  line-height: 1.02;
+  letter-spacing: 7px;
+  text-align: center;
+  text-shadow:
+    0 4px 30px rgba(47, 142, 229, 0.14),
+    0 8px 60px rgba(47, 142, 229, 0.08);
+}
+
+.main-subtitle {
+  font-size: 36px;
+  color: var(--text-secondary);
+  margin: 0;
+  line-height: 1.55;
+  max-width: 860px;
+  text-align: center;
+  font-weight: 400;
+  letter-spacing: 0.8px;
+}
+
+.footer-bar {
+  position: relative;
+  z-index: 10;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 0 60px 40px;
+  flex-shrink: 0;
+  height: 124px;
+  animation: fadeInUp 0.6s ease-out 0.25s backwards;
+}
+
+.footer-status {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  flex: 1;
+}
+
+.status-dot {
+  width: 11px;
+  height: 11px;
+  border-radius: 50%;
+  background: #10b981;
+  flex-shrink: 0;
+  box-shadow: 0 0 10px rgba(16, 185, 129, 0.6);
+  animation: pulse-dot 2.5s ease-in-out infinite;
+}
+
+@keyframes pulse-dot {
+  0%, 100% { opacity: 1; box-shadow: 0 0 10px rgba(16, 185, 129, 0.6); }
+  50% { opacity: 0.7; box-shadow: 0 0 18px rgba(16, 185, 129, 0.85); }
+}
+
+.footer-status .sep { color: var(--text-light); margin: 0 6px; }
+.footer-status span { font-size: 22px; font-weight: 600; color: var(--text-secondary); }
+
+.footer-cta {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 18px;
+  padding: 0 56px;
+  height: 92px;
+  min-width: 460px;
+  background: linear-gradient(135deg, #3b96f0 0%, #2170d0 50%, #1a5db8 100%);
+  color: #fff;
+  border-radius: 46px;
+  box-shadow:
+    0 10px 40px rgba(47, 142, 229, 0.45),
+    0 4px 16px rgba(26, 93, 184, 0.3),
+    0 0 0 1px rgba(255, 255, 255, 0.12) inset;
+  cursor: pointer;
+  flex-shrink: 0;
+  transition: transform 0.28s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.28s ease;
+  animation: breathe 4.5s ease-in-out infinite;
+  user-select: none;
+}
+
+.footer-cta:hover {
+  transform: scale(1.05);
+  box-shadow:
+    0 14px 56px rgba(47, 142, 229, 0.55),
+    0 6px 20px rgba(26, 93, 184, 0.35),
+    0 0 0 1px rgba(255, 255, 255, 0.18) inset;
+}
+
+.footer-cta:active {
+  transform: scale(0.97);
+  box-shadow:
+    0 6px 24px rgba(47, 142, 229, 0.35),
+    0 2px 10px rgba(26, 93, 184, 0.25),
+    0 0 0 1px rgba(255, 255, 255, 0.08) inset;
+}
+
+.cta-icon { width: 30px; height: 30px; flex-shrink: 0; }
+.footer-cta span { font-size: 30px; font-weight: 800; white-space: nowrap; letter-spacing: 3px; }
+
+.footer-time {
+  display: flex;
+  flex-direction: column;
+  align-items: flex-end;
+  gap: 7px;
+  flex: 1;
+}
+
+.time-str {
+  font-size: 76px;
+  font-weight: 800;
+  color: var(--text-primary);
+  letter-spacing: 4px;
+  line-height: 1;
+}
+
+.date-str { font-size: 24px; color: var(--text-muted); letter-spacing: 1.5px; }
+
+/* ============================================
+   播放方案模式(广告屏风格)
+   ============================================ */
+.mode-playlist {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  position: relative;
+  background: #0e1a28;
+}
+
+.playlist-bg-cover { background: #0e1a28; }
+.playlist-bg-contain { background: #111820; }
+.playlist-bg-stretch { background: #0e1a28; }
+
+/* 素材层 */
+.playlist-media-wrap {
+  position: absolute;
+  inset: 0;
+  transition: opacity 0.2s ease;
+}
+
+.playlist-media-wrap.is-transitioning {
+  opacity: 0;
+}
+
+ .media-img {
+  position: absolute;
+  inset: 0;
+  width: 100%;
+  height: 100%;
+}
+
+.media-video {
+  position: absolute;
+  inset: 0;
+  width: 100%;
+  height: 100%;
+}
+
+/* 无素材兜底 */
+.playlist-empty {
+  position: absolute;
+  inset: 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: rgba(255, 255, 255, 0.3);
+  font-size: 20px;
+  letter-spacing: 2px;
+}
+
+/* 左上角品牌浮层 */
+.playlist-brand {
+  position: absolute;
+  top: 26px;
+  left: 26px;
+  z-index: 10;
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+  padding: 14px 20px;
+  background: rgba(0, 0, 0, 0.34);
+  border: 1px solid rgba(255, 255, 255, 0.16);
+  border-radius: 16px;
+  backdrop-filter: blur(12px);
+  -webkit-backdrop-filter: blur(12px);
+}
+
+.playlist-brand-name {
+  font-size: 25px;
+  font-weight: 800;
+  color: rgba(255, 255, 255, 0.95);
+  letter-spacing: 2px;
+  white-space: nowrap;
+  text-shadow: 0 1px 6px rgba(0, 0, 0, 0.5);
+}
+
+.playlist-brand-status {
+  display: flex;
+  align-items: center;
+  gap: 7px;
+  font-size: 16px;
+  font-weight: 500;
+  color: rgba(255, 255, 255, 0.72);
+  text-shadow: 0 1px 4px rgba(0, 0, 0, 0.42);
+}
+
+.playlist-status-dot {
+  width: 10px;
+  height: 10px;
+  border-radius: 50%;
+  background: #10b981;
+  flex-shrink: 0;
+  animation: pulse-dot 2.5s ease-in-out infinite;
+}
+
+/* 底部浮层(绝对定位,不参与 flex 挤压) */
+.playlist-bottom-bar {
+  position: absolute;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  z-index: 10;
+  height: 132px;
+  pointer-events: none;
+  background: linear-gradient(
+    to top,
+    rgba(0, 0, 0, 0.76) 0%,
+    rgba(0, 0, 0, 0.48) 48%,
+    rgba(0, 0, 0, 0) 100%
+  );
+}
+
+/* 左:素材标题(绝对定位左下角) */
+.playlist-media-title {
+  position: absolute;
+  left: 30px;
+  bottom: 28px;
+  overflow: hidden;
+  pointer-events: none;
+}
+
+.playlist-media-title span {
+  font-size: 20px;
+  font-weight: 600;
+  color: rgba(255, 255, 255, 0.90);
+  letter-spacing: 1px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  display: block;
+  max-width: 260px;
+  text-shadow: 0 1px 6px rgba(0, 0, 0, 0.55);
+}
+
+/* 中:轮播指示器 + 触摸提示(绝对定位,始终水平居中) */
+.playlist-center-group {
+  position: absolute;
+  left: 50%;
+  bottom: 22px;
+  transform: translateX(-50%);
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 10px;
+  pointer-events: none;
+}
+
+.playlist-indicators {
+  display: flex;
+  align-items: center;
+  gap: 5px;
+}
+
+.indicator {
+  width: 7px;
+  height: 7px;
+  border-radius: 50%;
+  background: rgba(255, 255, 255, 0.38);
+  transition: all 0.3s ease;
+  flex-shrink: 0;
+}
+
+.indicator.active {
+  width: 24px;
+  border-radius: 4px;
+  background: rgba(255, 255, 255, 0.92);
+}
+
+/* 触摸提示(可触摸,pointer-events 恢复) */
+.playlist-touch-hint {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 14px;
+  height: 80px;
+  min-width: 420px;
+  padding: 0 46px;
+  background: rgba(0, 0, 0, 0.42);
+  border: 1px solid rgba(255, 255, 255, 0.24);
+  border-radius: 40px;
+  backdrop-filter: blur(10px);
+  -webkit-backdrop-filter: blur(10px);
+  box-shadow: 0 14px 42px rgba(0, 0, 0, 0.38);
+  cursor: pointer;
+  pointer-events: auto;
+}
+
+.hint-icon {
+  width: 30px;
+  height: 30px;
+  color: rgba(255, 255, 255, 0.92);
+  flex-shrink: 0;
+}
+
+.playlist-touch-hint span {
+  font-size: 29px;
+  font-weight: 800;
+  color: rgba(255, 255, 255, 0.92);
+  white-space: nowrap;
+  letter-spacing: 2px;
+  text-shadow: 0 1px 6px rgba(0, 0, 0, 0.48);
+}
+
+/* 右:当前时间(绝对定位右下角) */
+.playlist-time {
+  position: absolute;
+  right: 34px;
+  bottom: 30px;
+  display: flex;
+  flex-direction: column;
+  align-items: flex-end;
+  gap: 8px;
+}
+
+.playlist-time-str {
+  font-size: 64px;
+  font-weight: 800;
+  color: #ffffff;
+  letter-spacing: 3px;
+  line-height: 1;
+  text-shadow: 0 2px 10px rgba(0, 0, 0, 0.50);
+}
+
+.playlist-date-str {
+  font-size: 24px;
+  font-weight: 600;
+  color: rgba(255, 255, 255, 0.9);
+  letter-spacing: 1px;
+  line-height: 1;
+  text-shadow: 0 2px 8px rgba(0, 0, 0, 0.46);
+}
+
+/* ============================================
+   动画
+   ============================================ */
+@keyframes fadeInDown {
+  from { opacity: 0; transform: translateY(-16px); }
+  to { opacity: 1; transform: translateY(0); }
+}
+
+@keyframes fadeInUp {
+  from { opacity: 0; transform: translateY(22px); }
+  to { opacity: 1; transform: translateY(0); }
+}
+
+@keyframes breathe {
+  0%, 100% { transform: scale(1); }
+  50% { transform: scale(1.025); }
+}
+
+/* ============================================
+   响应式适配(8寸 1024x768 横屏)
+   ============================================ */
+@media (max-width: 980px), (max-height: 720px) {
+  .brand-area { padding: 34px 52px 0; }
+  .brand-logo { width: 58px; height: 58px; }
+  .brand-name { font-size: 30px; }
+  .brand-subtitle { font-size: 18px; }
+  .main-title { font-size: 98px; }
+  .main-subtitle { font-size: 34px; max-width: 820px; }
+  .footer-bar { padding: 0 52px 34px; height: 118px; }
+  .footer-cta { min-width: 460px; height: 88px; }
+  .footer-cta span { font-size: 29px; }
+  .time-str { font-size: 72px; }
+  .date-str { font-size: 23px; }
+
+  .playlist-brand { top: 24px; left: 24px; padding: 12px 18px; }
+  .playlist-brand-name { font-size: 24px; }
+  .playlist-brand-status { font-size: 15px; }
+  .playlist-status-dot { width: 9px; height: 9px; }
+
+  .playlist-bottom-bar { height: 126px; }
+  .playlist-media-title { left: 28px; bottom: 26px; }
+  .playlist-media-title span { font-size: 18px; max-width: 240px; }
+
+  .playlist-center-group { bottom: 22px; gap: 10px; }
+  .indicator { width: 7px; height: 7px; }
+  .indicator.active { width: 22px; }
+
+  .playlist-touch-hint { height: 76px; min-width: 400px; padding: 0 40px; border-radius: 38px; }
+  .playlist-touch-hint span { font-size: 27px; }
+  .hint-icon { width: 28px; height: 28px; }
+
+  .playlist-time { right: 30px; bottom: 28px; }
+  .playlist-time-str { font-size: 60px; }
+  .playlist-date-str { font-size: 21px; }
+}
+</style>

+ 304 - 0
src/components/MainMenu.vue

@@ -0,0 +1,304 @@
+<template>
+  <div class="main-menu">
+    <!-- 背景网格 -->
+    <div class="bg-grid"></div>
+
+    <div class="menu-grid">
+      <div
+        v-for="(item, index) in menuItems"
+        :key="item.path"
+        class="menu-card"
+        :class="`card-${index}`"
+        :style="{ animationDelay: `${index * 100}ms` }"
+        @click="handleClick(item)"
+      >
+        <!-- 卡片背景光效 -->
+        <div class="card-glow" :style="{ background: item.bgGradient }"></div>
+
+        <!-- 顶部主题色条 -->
+
+        <!-- 图标区 -->
+        <div class="card-icon-wrap" :style="{ '--accent': item.accent }">
+          <div class="card-icon" :style="{ background: item.bgGradient }">
+            <div class="icon-graphic" v-html="item.iconSvg"></div>
+          </div>
+        </div>
+
+        <!-- 文字区 -->
+        <div class="card-body">
+          <h3 class="card-title">{{ item.title }}</h3>
+          <p class="card-desc">{{ item.description }}</p>
+        </div>
+
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { useRouter } from 'vue-router'
+
+const router = useRouter()
+
+const menuItems = [
+  {
+    title: '访客登记',
+    description: '预约到访 / 现场登记',
+    path: '/visitor',
+    bgGradient: 'linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)',
+    accent: '#2f8ee5',
+  iconSvg: `<svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
+  <rect x="7" y="10" width="34" height="28" rx="6" stroke="white" stroke-width="3" fill="none"/>
+  <circle cx="20" cy="21" r="4.8" stroke="white" stroke-width="3" fill="none"/>
+  <path d="M13.5 32c1.4-4 4.2-6 6.5-6s5.1 2 6.5 6" stroke="white" stroke-width="3" stroke-linecap="round"/>
+  <path d="M30 28.5l3.2 3.2 7-8" stroke="white" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>`
+  },
+  {
+    title: '路线引导',
+    description: '选择目的地,路线指引',
+    path: '/navigation',
+    bgGradient: 'linear-gradient(135deg, #06b6d4 0%, #0891b2 100%)',
+    accent: '#20b7c7',
+    iconSvg: `<svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
+  <path
+    d="M24 6C15.7 6 9 12.7 9 21c0 11.2 15 21 15 21s15-9.8 15-21C39 12.7 32.3 6 24 6z"
+    stroke="white"
+    stroke-width="4"
+    stroke-linecap="round"
+    stroke-linejoin="round"
+  />
+  <circle
+    cx="24"
+    cy="21"
+    r="5.5"
+    stroke="white"
+    stroke-width="4"
+  />
+</svg>`
+  },
+  {
+    title: '通知公告',
+    description: '查看最新通知公告',
+    path: '/notice',
+    bgGradient: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
+    accent: '#f59e0b',
+   iconSvg: `<svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
+  <path d="M10 27h7l13 8V13L17 21h-7v6z" stroke="white" stroke-width="3" stroke-linejoin="round"/>
+  <path d="M17 27v9" stroke="white" stroke-width="3" stroke-linecap="round"/>
+  <path d="M34.5 20c2 2.2 2 5.8 0 8" stroke="white" stroke-width="3" stroke-linecap="round"/>
+  <path d="M39 16c4 5 4 12 0 17" stroke="white" stroke-width="3" stroke-linecap="round"/>
+</svg>`
+  },
+  {
+    title: '呼叫工作人员',
+    description: '需要帮助时快速联系',
+    path: '/call-staff',
+    bgGradient: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
+    accent: '#10b981',
+    iconSvg: `<svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
+  <path d="M31.8 36.2c-8.7-3.6-15.4-10.3-19-19" stroke="white" stroke-width="4.2" stroke-linecap="round"/>
+  <path d="M13.4 8.8l5.3 7.1c.8 1.1.7 2.7-.3 3.7l-2.5 2.5c2.2 4.2 5.8 7.8 10 10l2.5-2.5c1-1 2.6-1.1 3.7-.3l7.1 5.3c1.3 1 1.6 2.8.6 4.1l-2.4 3.2c-.9 1.2-2.4 1.8-3.9 1.5C19.3 40.7 7.3 28.7 4.6 14.5c-.3-1.5.3-3 1.5-3.9l3.2-2.4c1.3-1 3.1-.7 4.1.6z" stroke="white" stroke-width="4.2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>`
+  }
+]
+
+const handleClick = (item) => {
+  router.push(item.path)
+}
+</script>
+
+<style scoped>
+.main-menu {
+  width: 100%;
+  max-width: 930px;
+  position: relative;
+}
+
+/* 背景网格 */
+.bg-grid {
+  position: absolute;
+  inset: -20px;
+  background-image:
+    linear-gradient(rgba(47, 142, 229, 0.03) 1px, transparent 1px),
+    linear-gradient(90deg, rgba(47, 142, 229, 0.03) 1px, transparent 1px);
+  background-size: 40px 40px;
+  border-radius: 20px;
+  pointer-events: none;
+}
+
+.menu-grid {
+  display: grid;
+  grid-template-columns: repeat(2, 1fr);
+  gap: 26px;
+  width: 100%;
+  position: relative;
+  z-index: 1;
+}
+
+/* 卡片容器 */
+.menu-card {
+  position: relative;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  min-height: 210px;
+  padding: 28px 30px 26px;
+  background: rgba(255, 255, 255, 0.90);
+  border-radius: 32px;
+  cursor: pointer;
+  overflow: hidden;
+  animation: cardIn 0.55s cubic-bezier(0.34, 1.4, 0.64, 1) backwards;
+  transition:
+    transform 0.22s cubic-bezier(0.34, 1.56, 0.64, 1),
+    box-shadow 0.22s ease;
+  box-shadow:
+    0 18px 48px rgba(47, 142, 229, 0.11),
+    0 3px 10px rgba(15, 23, 42, 0.04),
+    inset 0 1px 0 rgba(255, 255, 255, 0.92);
+  border: 1px solid rgba(255, 255, 255, 0.85);
+}
+
+/* 卡片光效 */
+/* 卡片光效 */
+.card-glow {
+  position: absolute;
+  top: -40px;
+  left: 50%;
+  transform: translateX(-50%);
+  width: 200px;
+  height: 200px;
+  border-radius: 50%;
+  opacity: 0.06;
+  filter: blur(48px);
+  pointer-events: none;
+  transition: opacity 0.3s ease;
+}
+
+.menu-card:active {
+  transform: scale(0.978);
+  box-shadow:
+    0 10px 28px rgba(47, 142, 229, 0.10),
+    0 2px 8px rgba(15, 23, 42, 0.04);
+}
+
+/* 图标 */
+/* 图标 */
+.card-icon-wrap {
+  position: relative;
+  width: 96px;
+  height: 96px;
+  margin: 0 auto 18px;
+  flex-shrink: 0;
+}
+
+.card-icon-wrap::before {
+  content: '';
+  position: absolute;
+  inset: -10px;
+  border-radius: 34px;
+  background: color-mix(in srgb, var(--accent) 18%, transparent);
+  filter: blur(14px);
+  opacity: 0.55;
+}
+
+.card-icon {
+  width: 96px;
+  height: 96px;
+  min-width: 96px;
+  min-height: 96px;
+  max-width: 96px;
+  max-height: 96px;
+  aspect-ratio: 1 / 1;
+  border-radius: 30px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  box-shadow:
+    0 14px 30px rgba(15, 23, 42, 0.14),
+    inset 0 1px 0 rgba(255, 255, 255, 0.25);
+  transition: transform 0.3s ease, box-shadow 0.3s ease;
+  position: relative;
+  z-index: 1;
+}
+
+.icon-graphic {
+  width: 58px;
+  height: 58px;
+  color: white;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.icon-graphic :deep(svg) {
+  width: 100%;
+  height: 100%;
+  display: block;
+}
+
+/* 文字区 */
+/* 文字区 */
+.card-body {
+  text-align: center;
+  margin-bottom: 16px;
+}
+
+.card-title {
+  font-size: 38px;
+  font-weight: 900;
+  color: var(--text-primary);
+  margin: 0 0 12px;
+  letter-spacing: 1px;
+  line-height: 1;
+}
+
+.card-desc {
+  font-size: 22px;
+  color: var(--text-secondary);
+  margin: 0;
+  line-height: 1.35;
+  font-weight: 400;
+}
+
+
+/* 入场动画 */
+@keyframes cardIn {
+  from {
+    opacity: 0;
+    transform: translateY(40px) scale(0.92);
+  }
+  to {
+    opacity: 1;
+    transform: translateY(0) scale(1);
+  }
+}
+
+/* 响应式 */
+@media (max-width: 900px), (max-height: 660px) {
+  .menu-grid { gap: 22px; }
+  .menu-card {
+    min-height: 185px;
+    padding: 24px 24px 22px;
+    border-radius: 26px;
+  }
+  .card-icon-wrap {
+    width: 84px;
+    height: 84px;
+    margin-bottom: 16px;
+  }
+  .card-icon {
+    width: 84px;
+    height: 84px;
+    min-width: 84px;
+    min-height: 84px;
+    max-width: 84px;
+    max-height: 84px;
+    border-radius: 26px;
+  }
+  .icon-graphic { width: 50px; height: 50px; }
+  .card-title { font-size: 32px; margin-bottom: 9px; }
+  .card-desc { font-size: 19px; }
+}
+</style>

+ 243 - 0
src/components/NumericKeyboard.vue

@@ -0,0 +1,243 @@
+<template>
+  <div class="numeric-keyboard">
+    <!-- 输入框显示 -->
+    <div class="keyboard-input" @click="$emit('focus')">
+      <span class="input-label">{{ label }}</span>
+      <div class="input-value">
+        <span class="value-text">{{ displayValue || placeholder }}</span>
+        <span v-if="showCursor" class="cursor">|</span>
+      </div>
+    </div>
+
+    <!-- 键盘 -->
+    <div class="keyboard-keys">
+      <button
+        v-for="key in keys"
+        :key="key"
+        class="key-btn"
+        :class="{
+          'key-action': isActionKey(key),
+          'key-delete': key === 'DEL',
+          'key-confirm': key === '确认'
+        }"
+        @click="handleKeyClick(key)"
+      >
+        <template v-if="key === 'DEL'">
+          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+            <path d="M21 4H8l-7 8 7 8h13a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2z" />
+            <line x1="18" y1="9" x2="12" y2="15" />
+            <line x1="12" y1="9" x2="18" y2="15" />
+          </svg>
+        </template>
+        <template v-else>
+          {{ key }}
+        </template>
+      </button>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
+
+const props = defineProps({
+  modelValue: {
+    type: String,
+    default: ''
+  },
+  maxLength: {
+    type: Number,
+    default: 11
+  },
+  label: {
+    type: String,
+    default: '请输入'
+  },
+  placeholder: {
+    type: String,
+    default: ''
+  },
+  type: {
+    type: String,
+    default: 'phone' // 'phone' | 'idcard'
+  }
+})
+
+const emit = defineEmits(['update:modelValue', 'confirm', 'focus'])
+
+const keys = computed(() => {
+  if (props.type === 'idcard') {
+    return ['1', '2', '3', '4', '5', '6', '7', '8', '9', 'X', '0', 'DEL']
+  }
+  return ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0', 'DEL']
+})
+
+const displayValue = computed(() => {
+  return props.modelValue
+})
+
+const showCursor = ref(true)
+
+// 光标闪烁
+let cursorTimer = null
+
+onMounted(() => {
+  cursorTimer = setInterval(() => {
+    showCursor.value = !showCursor.value
+  }, 500)
+})
+
+onUnmounted(() => {
+  if (cursorTimer) {
+    clearInterval(cursorTimer)
+  }
+})
+
+const isActionKey = (key) => {
+  return ['DEL'].includes(key)
+}
+
+const handleKeyClick = (key) => {
+  if (key === 'DEL') {
+    // 删除
+    const newValue = props.modelValue.slice(0, -1)
+    emit('update:modelValue', newValue)
+  } else if (key === '确认') {
+    // 确认
+    emit('confirm', props.modelValue)
+  } else {
+    // 输入
+    if (props.modelValue.length < props.maxLength) {
+      let newValue = props.modelValue + key
+      // X 要大写
+      if (key === 'X') {
+        newValue = props.modelValue + 'X'
+      }
+      emit('update:modelValue', newValue)
+
+      // 自动触发确认(身份证号18位、手机号11位)
+      if (newValue.length === props.maxLength) {
+        setTimeout(() => {
+          emit('confirm', newValue)
+        }, 200)
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.numeric-keyboard {
+  width: 100%;
+  max-width: 400px;
+  margin: 0 auto;
+}
+
+.keyboard-input {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+  padding: 16px 20px;
+  background: var(--bg-card);
+  border: 2px solid var(--border-light);
+  border-radius: var(--radius-lg);
+  margin-bottom: 20px;
+  cursor: pointer;
+  transition: border-color var(--transition-fast);
+}
+
+.keyboard-input:focus-within {
+  border-color: var(--primary);
+}
+
+.input-label {
+  font-size: 14px;
+  color: var(--text-muted);
+}
+
+.input-value {
+  display: flex;
+  align-items: center;
+  min-height: 40px;
+}
+
+.value-text {
+  font-size: 28px;
+  font-weight: 500;
+  color: var(--text-primary);
+  letter-spacing: 2px;
+}
+
+.placeholder {
+  color: var(--text-light);
+}
+
+.cursor {
+  font-size: 28px;
+  color: var(--primary);
+  animation: blink 1s step-end infinite;
+}
+
+@keyframes blink {
+  50% {
+    opacity: 0;
+  }
+}
+
+.keyboard-keys {
+  display: grid;
+  grid-template-columns: repeat(3, 1fr);
+  gap: 12px;
+}
+
+.key-btn {
+  height: 72px;
+  font-size: 28px;
+  font-weight: 500;
+  color: var(--text-primary);
+  background: var(--bg-card);
+  border: 1px solid var(--border-light);
+  border-radius: var(--radius-lg);
+  cursor: pointer;
+  transition: all var(--transition-fast);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.key-btn:hover {
+  background: var(--primary-soft);
+  border-color: var(--primary);
+  color: var(--primary);
+}
+
+.key-btn:active {
+  transform: scale(0.95);
+  background: var(--primary);
+  color: white;
+}
+
+.key-btn svg {
+  width: 28px;
+  height: 28px;
+}
+
+.key-action {
+  background: var(--bg-page);
+}
+
+.key-delete {
+  color: var(--text-secondary);
+}
+
+.key-confirm {
+  background: var(--primary);
+  color: white;
+  border-color: var(--primary);
+}
+
+.key-confirm:hover {
+  background: var(--primary-dark);
+  color: white;
+}
+</style>

+ 284 - 0
src/components/StatusBar.vue

@@ -0,0 +1,284 @@
+<template>
+  <header class="status-bar">
+    <!-- Logo 区域 -->
+    <div
+      class="status-logo"
+      @touchstart="handleTouchStart"
+      @touchend="handleTouchEnd"
+      @mousedown="handleMouseDown"
+      @mouseup="handleMouseUp"
+    >
+      <div class="logo-icon">
+        <svg viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
+          <circle cx="20" cy="14" r="8" fill="currentColor" />
+          <rect x="10" y="24" width="20" height="12" rx="4" fill="currentColor" />
+          <circle cx="15" cy="12" r="2" fill="white" />
+          <circle cx="25" cy="12" r="2" fill="white" />
+        </svg>
+      </div>
+      <span class="logo-text">{{ title }}</span>
+    </div>
+
+    <!-- 中央信息 -->
+    <div class="status-center">
+      <div class="status-time">{{ currentTime }}</div>
+      <div class="status-date">{{ currentDate }}</div>
+    </div>
+
+    <!-- 右侧状态 -->
+    <div class="status-right">
+      <!-- 网络状态 -->
+      <div class="status-item" :class="{ 'offline': !networkOk }">
+        <span class="status-icon icon-network"></span>
+        <span class="status-label">{{ networkOk ? '在线' : '离线' }}</span>
+      </div>
+
+      <!-- 充电状态 -->
+      <div v-if="isCharging" class="status-item charging">
+        <span class="status-icon icon-charging"></span>
+        <span class="status-label">充电中</span>
+      </div>
+
+      <!-- 电量 -->
+      <div class="status-item" :class="batteryClass">
+        <span class="status-icon icon-battery"></span>
+        <span class="status-label">{{ batteryLevel }}%</span>
+      </div>
+
+      <!-- 故障提示 -->
+      <div v-if="hasFault" class="status-item fault">
+        <span class="status-icon icon-fault"></span>
+        <span class="status-label">故障</span>
+      </div>
+    </div>
+  </header>
+</template>
+
+<script setup>
+import { ref, computed, onMounted, onUnmounted } from 'vue'
+import { useScreenStore } from '@/stores/screen'
+import { formatTimeShort, formatDateCN } from '@/utils/time'
+
+const props = defineProps({
+  logo: {
+    type: String,
+    default: ''
+  },
+  title: {
+    type: String,
+    default: '迎宾巡逻机器人'
+  }
+})
+
+const emit = defineEmits(['logo-long-press'])
+
+const screenStore = useScreenStore()
+
+// 时间
+const currentTime = ref(formatTimeShort())
+const currentDate = ref(formatDateCN())
+
+// 状态
+const batteryLevel = computed(() => screenStore.robotStatus.batteryLevel)
+const networkOk = computed(() => screenStore.networkOk)
+const isCharging = computed(() => screenStore.isCharging)
+const hasFault = computed(() => screenStore.hasFault)
+
+const batteryClass = computed(() => {
+  const level = batteryLevel.value
+  if (level <= 20) return 'battery-low'
+  if (level <= 50) return 'battery-mid'
+  return 'battery-high'
+})
+
+// 长按检测
+let pressTimer = null
+let pressStartTime = 0
+const LONG_PRESS_DURATION = 5000 // 5秒
+
+const handleTouchStart = () => {
+  pressStartTime = Date.now()
+  pressTimer = setTimeout(() => {
+    emit('logo-long-press')
+  }, LONG_PRESS_DURATION)
+}
+
+const handleTouchEnd = () => {
+  if (pressTimer) {
+    clearTimeout(pressTimer)
+    pressTimer = null
+  }
+}
+
+const handleMouseDown = () => {
+  handleTouchStart()
+}
+
+const handleMouseUp = () => {
+  handleTouchEnd()
+}
+
+// 更新时间
+let timer = null
+
+const updateTime = () => {
+  currentTime.value = formatTimeShort()
+  currentDate.value = formatDateCN()
+}
+
+onMounted(() => {
+  timer = setInterval(updateTime, 1000)
+  updateTime()
+})
+
+onUnmounted(() => {
+  if (timer) {
+    clearInterval(timer)
+  }
+})
+</script>
+
+<style scoped>
+.status-bar {
+  height: var(--status-bar-height);
+  padding: 0 30px;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  background: var(--bg-header);
+  border-bottom: 1px solid var(--border-light);
+  flex-shrink: 0;
+  backdrop-filter: blur(10px);
+}
+
+.status-logo {
+  display: flex;
+  align-items: center;
+  gap: 14px;
+  cursor: pointer;
+  user-select: none;
+  -webkit-user-select: none;
+}
+
+.logo-icon {
+  width: 48px;
+  height: 48px;
+  color: var(--primary);
+}
+
+.logo-icon svg {
+  width: 100%;
+  height: 100%;
+}
+
+.logo-text {
+  font-size: 24px;
+  font-weight: 700;
+  color: var(--text-primary);
+  letter-spacing: 1px;
+}
+
+.status-center {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 2px;
+}
+
+.status-time {
+  font-size: 34px;
+  font-weight: 600;
+  color: var(--text-primary);
+  letter-spacing: 1.5px;
+  line-height: 1;
+}
+
+.status-date {
+  font-size: 17px;
+  color: var(--text-muted);
+  letter-spacing: 0.5px;
+}
+
+.status-right {
+  display: flex;
+  align-items: center;
+  gap: 16px;
+}
+
+.status-item {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 8px 16px;
+  border-radius: var(--radius-full);
+  background: var(--bg-page);
+  font-size: 17px;
+  color: var(--text-secondary);
+}
+
+.status-icon {
+  width: 20px;
+  height: 20px;
+}
+
+.icon-network {
+  background: currentColor;
+  mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2'%3E%3Cpath d='M5 12.55a11 11 0 0 1 14.08 0'/%3E%3Cpath d='M1.42 9a16 16 0 0 1 21.16 0'/%3E%3Cpath d='M8.53 16.11a6 6 0 0 1 6.95 0'/%3E%3Ccircle cx='12' cy='20' r='1'/%3E%3C/svg%3E") center/contain no-repeat;
+  -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2'%3E%3Cpath d='M5 12.55a11 11 0 0 1 14.08 0'/%3E%3Cpath d='M1.42 9a16 16 0 0 1 21.16 0'/%3E%3Cpath d='M8.53 16.11a6 6 0 0 1 6.95 0'/%3E%3Ccircle cx='12' cy='20' r='1'/%3E%3C/svg%3E") center/contain no-repeat;
+}
+
+.icon-battery {
+  background: currentColor;
+  mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2'%3E%3Crect x='2' y='7' width='18' height='10' rx='2'/%3E%3Cpath d='M22 11v2'/%3E%3C/svg%3E") center/contain no-repeat;
+  -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2'%3E%3Crect x='2' y='7' width='18' height='10' rx='2'/%3E%3Cpath d='M22 11v2'/%3E%3C/svg%3E") center/contain no-repeat;
+}
+
+.icon-charging {
+  background: var(--success);
+  mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2'%3E%3Cpath d='M5 18H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h3.19M15 6h2a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-3.19'/%3E%3Cline x1='23' y1='13' x2='23' y2='11'/%3E%3Cpolyline points='11 6 7 12 13 12 9 18'/%3E%3C/svg%3E") center/contain no-repeat;
+  -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2'%3E%3Cpath d='M5 18H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h3.19M15 6h2a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-3.19'/%3E%3Cline x1='23' y1='13' x2='23' y2='11'/%3E%3Cpolyline points='11 6 7 12 13 12 9 18'/%3E%3C/svg%3E") center/contain no-repeat;
+  animation: pulse 1.5s ease-in-out infinite;
+}
+
+.icon-fault {
+  background: var(--danger);
+  mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2'%3E%3Ccircle cx='12' cy='12' r='10'/%3E%3Cline x1='12' y1='8' x2='12' y2='12'/%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'/%3E%3C/svg%3E") center/contain no-repeat;
+  -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2'%3E%3Ccircle cx='12' cy='12' r='10'/%3E%3Cline x1='12' y1='8' x2='12' y2='12'/%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'/%3E%3C/svg%3E") center/contain no-repeat;
+}
+
+/* 电量状态 */
+.battery-high {
+  color: var(--success);
+}
+
+.battery-mid {
+  color: var(--warning);
+}
+
+.battery-low {
+  color: var(--danger);
+  animation: pulse 1s ease-in-out infinite;
+}
+
+.offline {
+  color: var(--danger);
+}
+
+.charging {
+  color: var(--success);
+}
+
+.fault {
+  color: var(--danger);
+  background: var(--danger-soft);
+}
+
+@keyframes pulse {
+  0%, 100% {
+    opacity: 1;
+  }
+  50% {
+    opacity: 0.5;
+  }
+}
+</style>

+ 145 - 0
src/layouts/ScreenLayout.vue

@@ -0,0 +1,145 @@
+<template>
+  <div class="screen-layout">
+    <!-- 顶部状态栏 -->
+    <StatusBar
+      v-if="showStatusBar"
+      :logo="logo"
+      :title="title"
+      @logo-long-press="onLogoLongPress"
+    />
+
+    <!-- 主内容区域 -->
+    <main class="layout-main" :style="mainStyle">
+      <slot />
+    </main>
+
+    <!-- 底部操作区 -->
+    <div v-if="showActionBar" class="layout-action">
+      <slot name="action">
+        <button v-if="showBackBtn" class="btn-action-back" @click="handleBack">
+          <span class="icon-back"></span>
+          <span>{{ backText }}</span>
+        </button>
+      </slot>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { computed, ref } from 'vue'
+import { useRouter } from 'vue-router'
+import StatusBar from '@/components/StatusBar.vue'
+
+const props = defineProps({
+  showStatusBar: {
+    type: Boolean,
+    default: true
+  },
+  showActionBar: {
+    type: Boolean,
+    default: true
+  },
+  showBackBtn: {
+    type: Boolean,
+    default: true
+  },
+  backText: {
+    type: String,
+    default: '返回首页'
+  },
+  backTarget: {
+    type: String,
+    default: '/idle'
+  },
+  logo: {
+    type: String,
+    default: ''
+  },
+  title: {
+    type: String,
+    default: '迎宾巡逻机器人'
+  },
+  mainPadding: {
+    type: String,
+    default: '24px'
+  }
+})
+
+const emit = defineEmits(['back', 'logo-long-press'])
+
+const router = useRouter()
+
+const mainStyle = computed(() => ({
+  padding: props.mainPadding
+}))
+
+const handleBack = () => {
+  emit('back')
+  router.push(props.backTarget)
+}
+
+const onLogoLongPress = () => {
+  emit('logo-long-press')
+}
+</script>
+
+<style scoped>
+.screen-layout {
+  width: 100vw;
+  height: 100vh;
+  display: flex;
+  flex-direction: column;
+  background: var(--bg-page);
+  overflow: hidden;
+}
+
+.layout-main {
+  flex: 1;
+  overflow: hidden;
+  display: flex;
+  flex-direction: column;
+}
+
+.layout-action {
+  height: 104px;
+  padding: 14px 28px;
+  display: flex;
+  align-items: center;
+  background: var(--bg-card);
+  border-top: 1px solid var(--border-light);
+  flex-shrink: 0;
+}
+
+.btn-action-back {
+  display: inline-flex;
+  align-items: center;
+  gap: 10px;
+  height: 76px;
+  padding: 0 42px;
+  font-size: 25px;
+  font-weight: 600;
+  color: var(--text-secondary);
+  background: var(--bg-page);
+  border: 2px solid var(--border-light);
+  border-radius: 35px;
+  cursor: pointer;
+  transition: all var(--transition-fast);
+  letter-spacing: 1px;
+}
+
+.btn-action-back:hover {
+  color: var(--primary);
+  border-color: var(--primary);
+  background: var(--primary-soft);
+}
+
+.icon-back {
+  width: 22px;
+  height: 22px;
+  border: 2.5px solid currentColor;
+  border-top: none;
+  border-right: none;
+  transform: rotate(45deg);
+  flex-shrink: 0;
+}
+</style>

+ 12 - 0
src/main.js

@@ -0,0 +1,12 @@
+import { createApp } from 'vue'
+import { createPinia } from 'pinia'
+import router from './router'
+import App from './App.vue'
+import './assets/styles/main.css'
+
+const app = createApp(App)
+const pinia = createPinia()
+
+app.use(pinia)
+app.use(router)
+app.mount('#app')

+ 406 - 0
src/mock/screen.js

@@ -0,0 +1,406 @@
+// Mock 数据 - 迎宾机器人屏幕端
+// 后续可替换为真实接口
+
+// 延迟模拟
+const delay = (ms = 300) => new Promise(resolve => setTimeout(resolve, ms))
+
+// ============================================
+// 1. 机器人状态
+// ============================================
+export const mockRobotStatus = {
+  batteryLevel: 85,
+  networkStatus: 'online',
+  workStatus: 'idle',
+  chargeStatus: 'not_charging',
+  faultFlag: false
+}
+
+// ============================================
+// 2. 屏幕配置
+// ============================================
+export const mockScreenConfig = {
+  robotName: '智能迎宾机器人',
+  logoUrl: '',
+  idleTimeout: 60,
+  theme: 'default',
+  version: '1.0.0'
+}
+
+// ============================================
+// 3. 播放方案与素材(本地测试素材)
+// ============================================
+
+// 本地测试素材 import(后续替换为后端接口返回的 url 即可)
+// 注意:import.meta.glob 这里必须使用相对路径,不能使用 @ 别名。
+// 当前文件位于 src/mock/screen.js,所以到 assets 的路径是 ../assets/...
+const imageModules = import.meta.glob('../assets/media/play-plan/images/*.{png,jpg,jpeg,webp}', {
+  eager: true,
+  import: 'default'
+})
+
+const videoModules = import.meta.glob('../assets/media/play-plan/videos/*.{mp4,webm}', {
+  eager: true,
+  import: 'default'
+})
+
+const getFileTitle = (filePath, fallback) => {
+  const fileName = filePath.split('/').pop() || fallback
+  return decodeURIComponent(fileName).replace(/\.[^.]+$/, '')
+}
+
+const imageItems = Object.entries(imageModules).map(([path, url], index) => ({
+  id: `local_img_${String(index + 1).padStart(3, '0')}`,
+  type: 'image',
+  title: getFileTitle(path, `图片素材 ${index + 1}`),
+  subtitle: '',
+  url,
+  duration: 8000,
+  fitMode: 'cover',
+  showTitle: false
+}))
+
+const videoItems = Object.entries(videoModules).map(([path, url], index) => ({
+  id: `local_video_${String(index + 1).padStart(3, '0')}`,
+  type: 'video',
+  title: getFileTitle(path, `视频素材 ${index + 1}`),
+  subtitle: '',
+  url,
+  duration: 30000,
+  fitMode: 'cover',
+  // 开发测试阶段默认静音,避免 Chromium 因未交互而拦截自动播放;后续可由播放方案字段控制
+  muted: true,
+  showTitle: false
+}))
+
+const localPlayItems = [
+  ...imageItems,
+  ...videoItems
+]
+
+export const mockPlayPlan = {
+  enabled: localPlayItems.length > 0,
+  planId: 'plan_local_test_001',
+  planName: '本地测试播放方案',
+  playMode: 'loop',
+  defaultFitMode: 'cover',
+  items: localPlayItems
+}
+
+// 欢迎页兜底内容
+export const mockWelcomeContent = {
+  title: '欢迎光临',
+  subtitle: '您好,请问有什么可以帮您?',
+  hint: '触摸屏幕开启服务',
+  logo: ''
+}
+
+// ============================================
+// 4. 播报内容
+// ============================================
+export const mockBroadcastState = {
+  broadcasting: false,
+  title: '',
+  content: '',
+  startTime: null,
+  endTime: null
+}
+
+// 示例播报内容
+export const mockBroadcastContent = {
+  title: '重要通知',
+  content: '各位访客请注意,本周日下午两点将在会议中心举办新产品发布会,欢迎各位参加。'
+}
+
+// ============================================
+// 5. 语音指令
+// ============================================
+export const mockCommands = [
+  { commandId: 'cmd_001', type: 'OPEN_MENU', action: 'menu', payload: {}, timestamp: Date.now() },
+  { commandId: 'cmd_002', type: 'OPEN_PAGE', action: 'visitor', payload: { page: '/visitor' }, timestamp: Date.now() },
+  { commandId: 'cmd_003', type: 'SHOW_ALERT', action: 'alert', payload: { title: '低电量', message: '机器人电量较低,请及时充电' }, timestamp: Date.now() }
+]
+
+// ============================================
+// 6. 预约访客数据
+// ============================================
+export const mockAppointments = [
+  {
+    appointmentNo: 'AP202401150001',
+    visitorName: '张先生',
+    mobile: '13812345678',
+    idCardNo: '310101199001011234',
+    visitedPerson: '李经理',
+    visitedDepartment: '市场部',
+    appointmentTime: '2024-01-15 14:00',
+    visitPurpose: '商务洽谈',
+    status: 'confirmed'
+  },
+  {
+    appointmentNo: 'AP202401150002',
+    visitorName: '王女士',
+    mobile: '13987654321',
+    idCardNo: '320101199002022345',
+    visitedPerson: '张主管',
+    visitedDepartment: '人事部',
+    appointmentTime: '2024-01-15 15:30',
+    visitPurpose: '面试应聘',
+    status: 'confirmed'
+  },
+  {
+    appointmentNo: 'AP202401160001',
+    visitorName: '陈先生',
+    mobile: '13611112222',
+    idCardNo: '330101199003033456',
+    visitedPerson: '财务部',
+    visitedDepartment: '财务部',
+    appointmentTime: '2024-01-16 10:00',
+    visitPurpose: '咨询业务',
+    status: 'confirmed'
+  }
+]
+
+// ============================================
+// 7. 身份证读取结果 Mock
+// ============================================
+export const mockIdCardResult = {
+  name: '张伟',
+  idCardNo: '310101198805051234',
+  gender: '男',
+  nation: '汉族',
+  address: '上海市浦东新区张江镇',
+  birthDate: '1988-05-05',
+  photoUrl: ''
+}
+
+// ============================================
+// 8. 人脸识别结果 Mock
+// ============================================
+export const mockRecognitionResult = {
+  personType: 'appointment',
+  matchStatus: 'matched',
+  visitorName: '张先生',
+  appointmentNo: 'AP202401150001',
+  confidence: 0.95,
+  photoUrl: '',
+  visitedPerson: '李经理',
+  appointmentTime: '2024-01-15 14:00'
+}
+
+// ============================================
+// 9. 目的地列表
+// ============================================
+export const mockDestinations = [
+  {
+    destinationId: 'dest_001',
+    name: '前台',
+    category: '服务',
+    floor: '1楼',
+    description: '综合服务前台',
+    estimatedTime: '2分钟',
+    icon: 'reception'
+  },
+  {
+    destinationId: 'dest_002',
+    name: '大厅',
+    category: '公共',
+    floor: '1楼',
+    description: '主楼大厅',
+    estimatedTime: '1分钟',
+    icon: 'lobby'
+  },
+  {
+    destinationId: 'dest_003',
+    name: '会议室A',
+    category: '会议',
+    floor: '2楼',
+    description: '可容纳20人',
+    estimatedTime: '3分钟',
+    icon: 'meeting'
+  },
+  {
+    destinationId: 'dest_004',
+    name: '会议室B',
+    category: '会议',
+    floor: '2楼',
+    description: '可容纳10人',
+    estimatedTime: '3分钟',
+    icon: 'meeting'
+  },
+  {
+    destinationId: 'dest_005',
+    name: '展厅',
+    category: '展示',
+    floor: '1楼',
+    description: '产品展示厅',
+    estimatedTime: '2分钟',
+    icon: 'exhibition'
+  },
+  {
+    destinationId: 'dest_006',
+    name: '休息区',
+    category: '公共',
+    floor: '1楼',
+    description: '访客休息区域',
+    estimatedTime: '1分钟',
+    icon: 'rest'
+  },
+  {
+    destinationId: 'dest_007',
+    name: '卫生间',
+    category: '设施',
+    floor: '1楼',
+    description: '男女卫生间',
+    estimatedTime: '1分钟',
+    icon: 'toilet'
+  },
+  {
+    destinationId: 'dest_008',
+    name: '茶水间',
+    category: '设施',
+    floor: '2楼',
+    description: '员工茶水间',
+    estimatedTime: '2分钟',
+    icon: 'kitchen'
+  }
+]
+
+// ============================================
+// 10. 通知公告列表
+// ============================================
+export const mockNotices = [
+  {
+    id: 'notice_001',
+    title: '关于本周产品发布会通知',
+    content: '各位同事、访客:\n\n本周日下午两点将在会议中心举办新产品发布会,届时将有公司领导致辞、新产品展示和互动环节。欢迎各位参加。\n\n请提前15分钟入场签到。\n\n综合管理部\n2024年1月10日',
+    publishTime: '2024-01-10 09:00',
+    publisher: '综合管理部',
+    contentType: 'notice',
+    priority: 'high'
+  },
+  {
+    id: 'notice_002',
+    title: '春节期间放假安排',
+    content: '各位员工:\n\n根据国家相关规定,结合公司实际情况,现将2024年春节期间放假安排通知如下:\n\n放假时间:2月9日至2月17日(共9天)\n2月4日(周日)、2月18日(周日)正常上班。\n\n请各部门做好工作交接,确保节日期间安全。\n\n人力资源部\n2024年1月8日',
+    publishTime: '2024-01-08 14:00',
+    publisher: '人力资源部',
+    contentType: 'notice',
+    priority: 'normal'
+  },
+  {
+    id: 'notice_003',
+    title: '办公区域消防演习通知',
+    content: '各部门:\n\n为提高员工消防安全意识,公司将于1月20日上午10点进行消防演习。届时将鸣响警报,请各部门配合疏散。\n\n演习结束后将在广场集合并进行灭火器使用培训。\n\n安全保障部\n2024年1月12日',
+    publishTime: '2024-01-12 10:00',
+    publisher: '安全保障部',
+    contentType: 'notice',
+    priority: 'normal'
+  },
+  {
+    id: 'notice_004',
+    title: '茶水间使用温馨提示',
+    content: '尊敬的员工:\n\n为保持茶水间整洁,请大家在使用后:\n1. 将自己的餐具带离\n2. 清理台面积水\n3. 将垃圾分类投放\n4. 节约用水用电\n\n感谢配合!',
+    publishTime: '2024-01-05 08:00',
+    publisher: '行政部',
+    contentType: 'tips',
+    priority: 'low'
+  }
+]
+
+// ============================================
+// 11. 导航状态 Mock
+// ============================================
+export const mockNavigationStatus = {
+  taskId: 'nav_001',
+  status: 'navigating',
+  currentPoint: '当前位置:大堂',
+  targetName: '会议室A',
+  progress: 45,
+  estimatedTime: '约2分钟'
+}
+
+// ============================================
+// 12. 系统信息 Mock
+// ============================================
+export const mockSystemInfo = {
+  deviceId: 'ROBOT-2024-001',
+  robotName: '智能迎宾机器人-01号',
+  ipAddress: '192.168.1.100',
+  macAddress: '00:11:22:33:44:55',
+  frontendVersion: '1.0.0',
+  backendVersion: '1.0.0',
+  screenResolution: '1024×768',
+  lastUpdate: '2024-01-15 10:00:00',
+  uptime: '72小时35分',
+  services: [
+    { name: '后端服务', status: 'online', latency: '12ms' },
+    { name: '语音服务', status: 'online', latency: '25ms' },
+    { name: '导航服务', status: 'online', latency: '8ms' },
+    { name: '人脸识别', status: 'online', latency: '45ms' }
+  ]
+}
+
+// ============================================
+// 13. 待机页主题配置(屏幕主题)
+// ============================================
+export const mockScreenTheme = {
+  robotName: '迎宾巡逻机器人',
+  brandSubtitle: '智能接待 · 路线引导 · 信息服务',
+  logoUrl: '',
+  welcomeTitle: '您好,欢迎光临',
+  welcomeSubtitle: '我可以为您提供访客登记、路线引导、通知公告查询与现场帮助服务',
+  primaryColor: '#2f8ee5',
+  secondaryColor: '#20b7c7',
+  backgroundType: 'image',
+  backgroundImage: '',
+  backgroundFit: 'stretch',
+  backgroundOverlay: true,
+  showDecorations: false,
+  touchText: '触摸屏幕进入服务',
+  showServicePanel: false,
+  showStatusBar: true,
+  showTime: true,
+  showDebugSwitch: true,
+  serviceItems: [
+    {
+      code: '01',
+      title: '访客登记',
+      desc: '预约到访 / 现场登记'
+    },
+    {
+      code: '02',
+      title: '路线引导',
+      desc: '大厅、展厅、会议室等'
+    },
+    {
+      code: '03',
+      title: '通知公告',
+      desc: '查看最新通知信息'
+    },
+    {
+      code: '04',
+      title: '呼叫工作人员',
+      desc: '需要帮助时快速联系'
+    }
+  ]
+}
+
+// ============================================
+// 导出所有 Mock 数据
+// ============================================
+export default {
+  mockRobotStatus,
+  mockScreenConfig,
+  mockPlayPlan,
+  mockWelcomeContent,
+  mockBroadcastState,
+  mockBroadcastContent,
+  mockCommands,
+  mockAppointments,
+  mockIdCardResult,
+  mockRecognitionResult,
+  mockDestinations,
+  mockNotices,
+  mockNavigationStatus,
+  mockSystemInfo,
+  mockScreenTheme
+}

+ 100 - 0
src/router/index.js

@@ -0,0 +1,100 @@
+import { createRouter, createWebHistory } from 'vue-router'
+
+const routes = [
+  {
+    path: '/',
+    redirect: '/idle'
+  },
+  {
+    path: '/idle',
+    name: 'Idle',
+    component: () => import('@/views/idle/Index.vue'),
+    meta: { title: '待机展示' }
+  },
+  {
+    path: '/menu',
+    name: 'Menu',
+    component: () => import('@/views/menu/Index.vue'),
+    meta: { title: '主菜单' }
+  },
+  {
+    path: '/visitor',
+    name: 'Visitor',
+    component: () => import('@/views/visitor/Index.vue'),
+    meta: { title: '访客登记' }
+  },
+  {
+    path: '/visitor/appointment',
+    name: 'VisitorAppointment',
+    component: () => import('@/views/visitor/Appointment.vue'),
+    meta: { title: '预约核验' }
+  },
+  {
+    path: '/visitor/appointment-confirm',
+    name: 'VisitorAppointmentConfirm',
+    component: () => import('@/views/visitor/AppointmentConfirm.vue'),
+    meta: { title: '预约确认' }
+  },
+  {
+    path: '/visitor/walk-in',
+    name: 'VisitorWalkIn',
+    component: () => import('@/views/visitor/WalkIn.vue'),
+    meta: { title: '现场登记' }
+  },
+  {
+    path: '/visitor/success',
+    name: 'VisitorSuccess',
+    component: () => import('@/views/visitor/Success.vue'),
+    meta: { title: '登记成功' }
+  },
+  {
+    path: '/recognition/result',
+    name: 'RecognitionResult',
+    component: () => import('@/views/recognition/Result.vue'),
+    meta: { title: '识别结果' }
+  },
+  {
+    path: '/navigation',
+    name: 'Navigation',
+    component: () => import('@/views/navigation/Index.vue'),
+    meta: { title: '路线引导' }
+  },
+  {
+    path: '/navigation/status',
+    name: 'NavigationStatus',
+    component: () => import('@/views/navigation/Status.vue'),
+    meta: { title: '导航状态' }
+  },
+  {
+    path: '/notice',
+    name: 'Notice',
+    component: () => import('@/views/notice/Index.vue'),
+    meta: { title: '通知公告' }
+  },
+  {
+    path: '/call-staff',
+    name: 'CallStaff',
+    component: () => import('@/views/call-staff/Index.vue'),
+    meta: { title: '呼叫工作人员' }
+  },
+  {
+    path: '/system-info',
+    name: 'SystemInfo',
+    component: () => import('@/views/system/Info.vue'),
+    meta: { title: '系统信息' }
+  }
+]
+
+const router = createRouter({
+  history: createWebHistory(),
+  routes
+})
+
+router.beforeEach((to, from, next) => {
+  if (to.meta.title) {
+    document.title = to.meta.title + ' - 迎宾机器人'
+  }
+  next()
+})
+
+export default router

+ 177 - 0
src/stores/navigation.js

@@ -0,0 +1,177 @@
+import { defineStore } from 'pinia'
+import { ref, computed } from 'vue'
+import * as api from '@/api/screen'
+
+export const useNavigationStore = defineStore('navigation', () => {
+  // 目的地列表
+  const destinations = ref([])
+
+  // 当前导航任务
+  const currentTask = ref(null)
+
+  // 导航状态
+  const navigationStatus = ref({
+    taskId: '',
+    status: 'idle',
+    currentPoint: '',
+    targetName: '',
+    progress: 0,
+    estimatedTime: ''
+  })
+
+  // 导航历史
+  const navigationHistory = ref([])
+
+  // 计算属性
+  const isNavigating = computed(() => {
+    return ['starting', 'navigating', 'arriving'].includes(navigationStatus.value.status)
+  })
+
+  const hasActiveTask = computed(() => !!currentTask.value)
+
+  // 按分类分组的目的地
+  const destinationsByCategory = computed(() => {
+    const grouped = {}
+    destinations.value.forEach(dest => {
+      if (!grouped[dest.category]) {
+        grouped[dest.category] = []
+      }
+      grouped[dest.category].push(dest)
+    })
+    return grouped
+  })
+
+  // 方法
+  async function fetchDestinations() {
+    try {
+      const res = await api.getDestinations()
+      destinations.value = res
+    } catch (e) {
+      console.error('Failed to fetch destinations:', e)
+      destinations.value = []
+    }
+  }
+
+  async function startNavigation(destination) {
+    try {
+      const res = await api.startNavigation({ destinationId: destination.destinationId })
+      currentTask.value = {
+        ...destination,
+        taskId: res.taskId,
+        startTime: Date.now()
+      }
+      navigationStatus.value = {
+        taskId: res.taskId,
+        status: 'starting',
+        currentPoint: '起点',
+        targetName: destination.name,
+        progress: 0,
+        estimatedTime: destination.estimatedTime || '3分钟'
+      }
+      return res
+    } catch (e) {
+      throw e
+    }
+  }
+
+  async function fetchNavigationStatus() {
+    if (!currentTask.value) return null
+    try {
+      const res = await api.getNavigationStatus({ taskId: currentTask.value.taskId })
+      navigationStatus.value = res
+      return res
+    } catch (e) {
+      console.error('Failed to fetch navigation status:', e)
+      return null
+    }
+  }
+
+  async function cancelNavigation() {
+    if (!currentTask.value) return
+    try {
+      await api.cancelNavigation({ taskId: currentTask.value.taskId })
+      addToHistory(currentTask.value)
+      currentTask.value = null
+      navigationStatus.value = {
+        taskId: '',
+        status: 'idle',
+        currentPoint: '',
+        targetName: '',
+        progress: 0,
+        estimatedTime: ''
+      }
+    } catch (e) {
+      console.error('Failed to cancel navigation:', e)
+      throw e
+    }
+  }
+
+  function addToHistory(task) {
+    navigationHistory.value.unshift({
+      ...task,
+      endTime: Date.now(),
+      duration: task.startTime ? Date.now() - task.startTime : 0
+    })
+    if (navigationHistory.value.length > 10) {
+      navigationHistory.value.pop()
+    }
+  }
+
+  function clearNavigation() {
+    currentTask.value = null
+    navigationStatus.value = {
+      taskId: '',
+      status: 'idle',
+      currentPoint: '',
+      targetName: '',
+      progress: 0,
+      estimatedTime: ''
+    }
+  }
+
+  // 模拟导航进度(用于演示)
+  function simulateNavigation() {
+    if (!currentTask.value) return
+
+    const steps = [
+      { status: 'starting', progress: 10, message: '正在规划路线...' },
+      { status: 'navigating', progress: 30, message: '开始带路' },
+      { status: 'navigating', progress: 50, message: '请跟我来' },
+      { status: 'navigating', progress: 70, message: '前方转弯' },
+      { status: 'navigating', progress: 85, message: '即将到达' },
+      { status: 'arriving', progress: 100, message: '已到达目的地' }
+    ]
+
+    let stepIndex = 0
+    const interval = setInterval(() => {
+      if (stepIndex < steps.length) {
+        const step = steps[stepIndex]
+        navigationStatus.value = {
+          ...navigationStatus.value,
+          status: step.status,
+          progress: step.progress
+        }
+        stepIndex++
+      } else {
+        clearInterval(interval)
+      }
+    }, 2000)
+  }
+
+  return {
+    destinations,
+    currentTask,
+    navigationStatus,
+    navigationHistory,
+    isNavigating,
+    hasActiveTask,
+    destinationsByCategory,
+    fetchDestinations,
+    startNavigation,
+    fetchNavigationStatus,
+    cancelNavigation,
+    addToHistory,
+    clearNavigation,
+    simulateNavigation
+  }
+})

+ 255 - 0
src/stores/screen.js

@@ -0,0 +1,255 @@
+import { defineStore } from 'pinia'
+import { ref, computed } from 'vue'
+import * as api from '@/api/screen'
+
+export const useScreenStore = defineStore('screen', () => {
+  // 机器人状态
+  const robotStatus = ref({
+    batteryLevel: 85,
+    networkStatus: 'online',
+    workStatus: 'idle',
+    chargeStatus: 'not_charging',
+    faultFlag: false
+  })
+
+  // 播放方案
+  const playPlan = ref(null)
+  const currentMediaIndex = ref(0)
+  const isPlaying = ref(true)
+
+  // 待机页模式: 'welcome' | 'playlist'
+  const idleMode = ref('welcome')
+
+  // 是否有播放方案(计算属性 + 手动覆盖)
+  const hasPlayPlan = ref(false)
+
+  // 强制模式覆盖(开发调试用)
+  const forceMode = ref(null)
+
+  // 播报状态
+  const broadcastState = ref({
+    broadcasting: false,
+    title: '',
+    content: '',
+    startTime: null,
+    endTime: null
+  })
+
+  // 语音指令
+  const latestCommand = ref(null)
+
+  // 全局提示
+  const globalAlert = ref({
+    show: false,
+    type: 'info',
+    title: '',
+    message: '',
+    duration: 3000
+  })
+
+  // 音量
+  const volume = ref(80)
+  const muted = ref(false)
+
+  // 屏幕配置
+  const screenConfig = ref({
+    robotName: '智能迎宾机器人',
+    logoUrl: '',
+    idleTimeout: 60,
+    theme: 'default'
+  })
+
+  // 待机页主题配置
+  const screenTheme = ref(null)
+
+  // 计算属性
+  const isIdle = computed(() => robotStatus.value.workStatus === 'idle')
+  const isCharging = computed(() => robotStatus.value.chargeStatus === 'charging')
+  const hasFault = computed(() => robotStatus.value.faultFlag)
+  const networkOk = computed(() => robotStatus.value.networkStatus === 'online')
+
+  const currentMedia = computed(() => {
+    if (!playPlan.value || !playPlan.value.items || playPlan.value.items.length === 0) {
+      return null
+    }
+    return playPlan.value.items[currentMediaIndex.value] || null
+  })
+
+  // 实际的待机页展示模式
+  const actualIdleMode = computed(() => {
+    if (forceMode.value !== null) {
+      return forceMode.value
+    }
+    return hasPlayPlan.value ? 'playlist' : 'welcome'
+  })
+
+  // 方法
+  async function fetchScreenConfig() {
+    try {
+      const res = await api.getScreenConfig()
+      screenConfig.value = res
+    } catch (e) {
+      console.error('Failed to fetch screen config:', e)
+    }
+  }
+
+  async function fetchScreenTheme() {
+    try {
+      const res = await api.getScreenTheme()
+      screenTheme.value = res
+    } catch (e) {
+      console.error('Failed to fetch screen theme:', e)
+    }
+  }
+
+  async function fetchRobotStatus() {
+    try {
+      const res = await api.getRobotStatus()
+      robotStatus.value = res
+    } catch (e) {
+      console.error('Failed to fetch robot status:', e)
+    }
+  }
+
+  async function fetchPlayPlan() {
+    try {
+      const res = await api.getPlayPlan()
+      playPlan.value = res
+      currentMediaIndex.value = 0
+      // 判断是否有有效的播放方案:enabled !== false 且 items 非空
+      hasPlayPlan.value = !!(
+        res &&
+        res.enabled !== false &&
+        Array.isArray(res.items) &&
+        res.items.length > 0
+      )
+    } catch (e) {
+      console.error('Failed to fetch play plan:', e)
+      playPlan.value = null
+      hasPlayPlan.value = false
+    }
+  }
+
+  async function fetchBroadcastState() {
+    try {
+      const res = await api.getBroadcastState()
+      broadcastState.value = res
+    } catch (e) {
+      console.error('Failed to fetch broadcast state:', e)
+    }
+  }
+
+  async function fetchLatestCommand() {
+    try {
+      const res = await api.getLatestCommand()
+      if (res && res.commandId !== latestCommand.value?.commandId) {
+        latestCommand.value = res
+        return res
+      }
+    } catch (e) {
+      console.error('Failed to fetch latest command:', e)
+    }
+    return null
+  }
+
+  async function ackCommand(commandId) {
+    try {
+      await api.ackCommand(commandId)
+    } catch (e) {
+      console.error('Failed to ack command:', e)
+    }
+  }
+
+  function nextMedia() {
+    if (playPlan.value && playPlan.value.items && playPlan.value.items.length > 0) {
+      currentMediaIndex.value = (currentMediaIndex.value + 1) % playPlan.value.items.length
+    }
+  }
+
+  function showAlert(options) {
+    const { type = 'info', title = '', message = '', duration = 3000 } = options
+    globalAlert.value = { show: true, type, title, message, duration }
+    if (duration > 0) {
+      setTimeout(() => {
+        globalAlert.value.show = false
+      }, duration)
+    }
+  }
+
+  function hideAlert() {
+    globalAlert.value.show = false
+  }
+
+  function setVolume(val) {
+    volume.value = Math.max(0, Math.min(100, val))
+  }
+
+  function toggleMute() {
+    muted.value = !muted.value
+  }
+
+  function pausePlay() {
+    isPlaying.value = false
+  }
+
+  function resumePlay() {
+    isPlaying.value = true
+  }
+
+  function resetToIdle() {
+    robotStatus.value.workStatus = 'idle'
+  }
+
+  // 切换待机页模式(开发调试用)
+  function toggleIdleMode() {
+    forceMode.value = actualIdleMode.value === 'welcome' ? 'playlist' : 'welcome'
+  }
+
+  // 重置强制模式
+  function resetForceMode() {
+    forceMode.value = null
+  }
+
+  return {
+    // 状态
+    robotStatus,
+    playPlan,
+    currentMediaIndex,
+    isPlaying,
+    broadcastState,
+    latestCommand,
+    globalAlert,
+    volume,
+    muted,
+    screenConfig,
+    screenTheme,
+    idleMode,
+    hasPlayPlan,
+    forceMode,
+    // 计算属性
+    isIdle,
+    isCharging,
+    hasFault,
+    networkOk,
+    currentMedia,
+    actualIdleMode,
+    // 方法
+    fetchScreenConfig,
+    fetchScreenTheme,
+    fetchRobotStatus,
+    fetchPlayPlan,
+    fetchBroadcastState,
+    fetchLatestCommand,
+    ackCommand,
+    nextMedia,
+    showAlert,
+    hideAlert,
+    setVolume,
+    toggleMute,
+    pausePlay,
+    resumePlay,
+    resetToIdle,
+    toggleIdleMode,
+    resetForceMode
+  }
+})

+ 162 - 0
src/stores/visitor.js

@@ -0,0 +1,162 @@
+import { defineStore } from 'pinia'
+import { ref } from 'vue'
+import * as api from '@/api/screen'
+
+export const useVisitorStore = defineStore('visitor', () => {
+  // 当前访客信息
+  const currentVisitor = ref({
+    name: '',
+    mobile: '',
+    idCardNo: '',
+    gender: '',
+    nation: '',
+    address: '',
+    photoUrl: ''
+  })
+
+  // 预约信息
+  const appointmentInfo = ref(null)
+
+  // 登记信息
+  const registrationInfo = ref({
+    visitorName: '',
+    mobile: '',
+    idCardNo: '',
+    visitType: 'walk_in',
+    registerType: 'manual',
+    visitorSource: '',
+    visitReason: '',
+    visitedPerson: '',
+    appointmentNo: ''
+  })
+
+  // 登记表单验证状态
+  const formErrors = ref({})
+
+  // 来访事由选项
+  const visitReasonOptions = [
+    { value: 'business', label: '商务洽谈' },
+    { value: 'interview', label: '面试应聘' },
+    { value: 'delivery', label: '快递/外卖' },
+    { value: 'maintenance', label: '设备维修' },
+    { value: 'visit_friend', label: '探亲访友' },
+    { value: 'consultation', label: '咨询业务' },
+    { value: 'other', label: '其他事宜' }
+  ]
+
+  // 方法
+  function setIdCardInfo(info) {
+    currentVisitor.value = {
+      ...currentVisitor.value,
+      name: info.name || '',
+      idCardNo: info.idCardNo || '',
+      gender: info.gender || '',
+      nation: info.nation || '',
+      address: info.address || '',
+      photoUrl: info.photoUrl || ''
+    }
+    // 自动填充登记信息
+    registrationInfo.value.visitorName = info.name || ''
+    registrationInfo.value.idCardNo = info.idCardNo || ''
+  }
+
+  function setMobile(mobile) {
+    currentVisitor.value.mobile = mobile
+    registrationInfo.value.mobile = mobile
+  }
+
+  function setFormField(field, value) {
+    registrationInfo.value[field] = value
+    if (formErrors.value[field]) {
+      delete formErrors.value[field]
+    }
+  }
+
+  function validateForm() {
+    const errors = {}
+    if (!registrationInfo.value.visitorName?.trim()) {
+      errors.visitorName = '请输入访客姓名'
+    }
+    if (!registrationInfo.value.mobile?.trim()) {
+      errors.mobile = '请输入手机号'
+    } else if (!/^1[3-9]\d{9}$/.test(registrationInfo.value.mobile)) {
+      errors.mobile = '手机号格式不正确'
+    }
+    formErrors.value = errors
+    return Object.keys(errors).length === 0
+  }
+
+  async function queryAppointment(mobile) {
+    try {
+      const res = await api.queryAppointment({ mobile })
+      appointmentInfo.value = res
+      return res
+    } catch (e) {
+      appointmentInfo.value = null
+      throw e
+    }
+  }
+
+  async function submitRegistration() {
+    if (!validateForm()) {
+      throw new Error('表单验证失败')
+    }
+    try {
+      const res = await api.submitVisitorRegistration(registrationInfo.value)
+      return res
+    } catch (e) {
+      throw e
+    }
+  }
+
+  async function readIdCard() {
+    try {
+      const res = await api.readIdCard()
+      setIdCardInfo(res)
+      return res
+    } catch (e) {
+      throw e
+    }
+  }
+
+  function clearVisitorData() {
+    currentVisitor.value = {
+      name: '',
+      mobile: '',
+      idCardNo: '',
+      gender: '',
+      nation: '',
+      address: '',
+      photoUrl: ''
+    }
+    appointmentInfo.value = null
+    registrationInfo.value = {
+      visitorName: '',
+      mobile: '',
+      idCardNo: '',
+      visitType: 'walk_in',
+      registerType: 'manual',
+      visitorSource: '',
+      visitReason: '',
+      visitedPerson: '',
+      appointmentNo: ''
+    }
+    formErrors.value = {}
+  }
+
+  return {
+    currentVisitor,
+    appointmentInfo,
+    registrationInfo,
+    formErrors,
+    visitReasonOptions,
+    setIdCardInfo,
+    setMobile,
+    setFormField,
+    validateForm,
+    queryAppointment,
+    submitRegistration,
+    readIdCard,
+    clearVisitorData
+  }
+})

+ 175 - 0
src/utils/device.js

@@ -0,0 +1,175 @@
+/**
+ * 设备相关工具函数
+ */
+
+/**
+ * 获取屏幕分辨率
+ */
+export function getScreenResolution() {
+  return {
+    width: window.screen.width,
+    height: window.screen.height,
+    availWidth: window.screen.availWidth,
+    availHeight: window.screen.availHeight
+  }
+}
+
+/**
+ * 获取视口尺寸
+ */
+export function getViewportSize() {
+  return {
+    width: window.innerWidth,
+    height: window.innerHeight
+  }
+}
+
+/**
+ * 获取设备信息
+ */
+export function getDeviceInfo() {
+  const ua = navigator.userAgent
+
+  const info = {
+    platform: navigator.platform,
+    language: navigator.language,
+    userAgent: ua,
+    isMobile: /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(ua),
+    isTouch: 'ontouchstart' in window || navigator.maxTouchPoints > 0,
+    screenWidth: window.screen.width,
+    screenHeight: window.screen.height
+  }
+
+  return info
+}
+
+/**
+ * 获取网络状态
+ */
+export function getNetworkStatus() {
+  const connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection
+  if (connection) {
+    return {
+      effectiveType: connection.effectiveType,
+      downlink: connection.downlink,
+      rtt: connection.rtt,
+      saveData: connection.saveData
+    }
+  }
+  return {
+    online: navigator.onLine
+  }
+}
+
+/**
+ * 防抖函数
+ */
+export function debounce(fn, delay = 300) {
+  let timer = null
+  return function (...args) {
+    if (timer) clearTimeout(timer)
+    timer = setTimeout(() => {
+      fn.apply(this, args)
+    }, delay)
+  }
+}
+
+/**
+ * 节流函数
+ */
+export function throttle(fn, delay = 300) {
+  let last = 0
+  return function (...args) {
+    const now = Date.now()
+    if (now - last >= delay) {
+      last = now
+      fn.apply(this, args)
+    }
+  }
+}
+
+/**
+ * 深拷贝
+ */
+export function deepClone(obj) {
+  if (obj === null || typeof obj !== 'object') return obj
+  if (Array.isArray(obj)) {
+    return obj.map(item => deepClone(item))
+  }
+  const clone = {}
+  for (const key in obj) {
+    if (obj.hasOwnProperty(key)) {
+      clone[key] = deepClone(obj[key])
+    }
+  }
+  return clone
+}
+
+/**
+ * 生成唯一ID
+ */
+export function generateId(prefix = '') {
+  const id = Date.now().toString(36) + Math.random().toString(36).substring(2)
+  return prefix ? `${prefix}_${id}` : id
+}
+
+/**
+ * 格式化文件大小
+ */
+export function formatFileSize(bytes) {
+  if (bytes === 0) return '0 B'
+  const k = 1024
+  const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
+  const i = Math.floor(Math.log(bytes) / Math.log(k))
+  return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
+}
+
+/**
+ * 手机号脱敏
+ */
+export function maskMobile(mobile) {
+  if (!mobile || mobile.length !== 11) return mobile
+  return mobile.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')
+}
+
+/**
+ * 身份证号脱敏
+ */
+export function maskIdCard(idCard) {
+  if (!idCard || idCard.length < 10) return idCard
+  return idCard.replace(/(\d{6})\d{8}(\d{4})/, '$1********$2')
+}
+
+/**
+ * 随机颜色
+ */
+export function randomColor() {
+  const colors = [
+    '#2f8ee5', '#20b7c7', '#10b981', '#f59e0b',
+    '#ef4444', '#8b5cf6', '#ec4899', '#06b6d4'
+  ]
+  return colors[Math.floor(Math.random() * colors.length)]
+}
+
+/**
+ * 睡眠函数
+ */
+export function sleep(ms) {
+  return new Promise(resolve => setTimeout(resolve, ms))
+}
+
+export default {
+  getScreenResolution,
+  getViewportSize,
+  getDeviceInfo,
+  getNetworkStatus,
+  debounce,
+  throttle,
+  deepClone,
+  generateId,
+  formatFileSize,
+  maskMobile,
+  maskIdCard,
+  randomColor,
+  sleep
+}

+ 123 - 0
src/utils/time.js

@@ -0,0 +1,123 @@
+/**
+ * 时间格式化工具
+ */
+
+/**
+ * 格式化时间为 HH:mm:ss
+ */
+export function formatTime(date = new Date()) {
+  const d = typeof date === 'number' ? new Date(date) : date
+  const hours = String(d.getHours()).padStart(2, '0')
+  const minutes = String(d.getMinutes()).padStart(2, '0')
+  const seconds = String(d.getSeconds()).padStart(2, '0')
+  return `${hours}:${minutes}:${seconds}`
+}
+
+/**
+ * 格式化时间为 HH:mm
+ */
+export function formatTimeShort(date = new Date()) {
+  const d = typeof date === 'number' ? new Date(date) : date
+  const hours = String(d.getHours()).padStart(2, '0')
+  const minutes = String(d.getMinutes()).padStart(2, '0')
+  return `${hours}:${minutes}`
+}
+
+/**
+ * 格式化日期为 YYYY-MM-DD
+ */
+export function formatDate(date = new Date()) {
+  const d = typeof date === 'number' ? new Date(date) : date
+  const year = d.getFullYear()
+  const month = String(d.getMonth() + 1).padStart(2, '0')
+  const day = String(d.getDate()).padStart(2, '0')
+  return `${year}-${month}-${day}`
+}
+
+/**
+ * 格式化日期时间为 YYYY-MM-DD HH:mm:ss
+ */
+export function formatDateTime(date = new Date()) {
+  return `${formatDate(date)} ${formatTime(date)}`
+}
+
+/**
+ * 格式化日期为中文显示
+ */
+export function formatDateCN(date = new Date()) {
+  const d = typeof date === 'number' ? new Date(date) : date
+  const year = d.getFullYear()
+  const month = d.getMonth() + 1
+  const day = d.getDate()
+  const weekdays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
+  const weekday = weekdays[d.getDay()]
+  return `${year}年${month}月${day}日 ${weekday}`
+}
+
+/**
+ * 格式化相对时间
+ */
+export function formatRelativeTime(timestamp) {
+  const now = Date.now()
+  const diff = now - timestamp
+
+  if (diff < 60000) {
+    return '刚刚'
+  } else if (diff < 3600000) {
+    return Math.floor(diff / 60000) + '分钟前'
+  } else if (diff < 86400000) {
+    return Math.floor(diff / 3600000) + '小时前'
+  } else if (diff < 604800000) {
+    return Math.floor(diff / 86400000) + '天前'
+  } else {
+    return formatDate(timestamp)
+  }
+}
+
+/**
+ * 格式化时长为 mm:ss 或 HH:mm:ss
+ */
+export function formatDuration(seconds) {
+  if (seconds < 0) return '00:00'
+
+  const h = Math.floor(seconds / 3600)
+  const m = Math.floor((seconds % 3600) / 60)
+  const s = seconds % 60
+
+  if (h > 0) {
+    return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
+  }
+  return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
+}
+
+/**
+ * 获取当前时间戳
+ */
+export function getTimestamp() {
+  return Date.now()
+}
+
+/**
+ * 判断是否为今天
+ */
+export function isToday(date) {
+  const d = typeof date === 'number' ? new Date(date) : date
+  const today = new Date()
+  return (
+    d.getFullYear() === today.getFullYear() &&
+    d.getMonth() === today.getMonth() &&
+    d.getDate() === today.getDate()
+  )
+}
+
+export default {
+  formatTime,
+  formatTimeShort,
+  formatDate,
+  formatDateTime,
+  formatDateCN,
+  formatRelativeTime,
+  formatDuration,
+  getTimestamp,
+  isToday
+}

+ 236 - 0
src/views/call-staff/Index.vue

@@ -0,0 +1,236 @@
+<template>
+  <ScreenLayout :show-back-btn="true" back-text="返回菜单" back-target="/menu">
+    <div class="page-call-staff">
+      <div class="call-content">
+        <!-- 状态图标 -->
+        <div class="call-icon" :class="{ calling: calling, success: callSuccess }">
+          <svg v-if="callSuccess" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+            <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
+            <polyline points="22 4 12 14.01 9 11.01" />
+          </svg>
+          <svg v-else-if="calling" class="animate-pulse" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+            <path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
+            <path d="M13.73 21a2 2 0 0 1-3.46 0" />
+          </svg>
+          <svg v-else viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+            <path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
+            <path d="M13.73 21a2 2 0 0 1-3.46 0" />
+          </svg>
+        </div>
+
+        <!-- 状态文字 -->
+        <h1 class="call-title">{{ callTitle }}</h1>
+        <p class="call-message">{{ callMessage }}</p>
+
+        <!-- 操作按钮 -->
+        <div v-if="!callSuccess" class="call-actions">
+          <button class="btn-call" @click="handleCall" :disabled="calling">
+            <span v-if="calling" class="loading-spinner"></span>
+            <span v-else>呼叫工作人员</span>
+          </button>
+        </div>
+
+        <div v-else class="call-actions">
+          <button class="btn-return" @click="goBack">
+            返回首页
+          </button>
+        </div>
+      </div>
+    </div>
+  </ScreenLayout>
+</template>
+
+<script setup>
+import { ref, computed } from 'vue'
+import { useRouter } from 'vue-router'
+import { useScreenStore } from '@/stores/screen'
+import * as api from '@/api/screen'
+import ScreenLayout from '@/layouts/ScreenLayout.vue'
+
+const router = useRouter()
+const screenStore = useScreenStore()
+
+const calling = ref(false)
+const callSuccess = ref(false)
+
+const callTitle = computed(() => {
+  if (callSuccess.value) return '已通知'
+  if (calling.value) return '正在呼叫...'
+  return '需要帮助?'
+})
+
+const callMessage = computed(() => {
+  if (callSuccess.value) return '工作人员即将到达,请稍候'
+  if (calling.value) return '正在为您联系工作人员'
+  return '点击按钮呼叫工作人员为您提供帮助'
+})
+
+const handleCall = async () => {
+  if (calling.value) return
+  calling.value = true
+
+  try {
+    await api.callStaff({})
+    callSuccess.value = true
+    screenStore.showAlert({
+      type: 'success',
+      message: '已通知工作人员,请稍候'
+    })
+  } catch {
+    screenStore.showAlert({
+      type: 'error',
+      message: '呼叫失败,请重试'
+    })
+  } finally {
+    calling.value = false
+  }
+}
+
+const goBack = () => {
+  router.push('/idle')
+}
+</script>
+
+<style scoped>
+.page-call-staff {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: linear-gradient(135deg, #e9f8ff 0%, #dff8f5 50%, #f7fbff 100%);
+}
+
+.call-content {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 24px;
+  text-align: center;
+  padding: 40px;
+  animation: fadeInUp 0.6s ease-out;
+}
+
+.call-icon {
+  width: 120px;
+  height: 120px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: var(--bg-card);
+  border-radius: 50%;
+  color: var(--primary);
+  box-shadow: var(--shadow-lg);
+}
+
+.call-icon svg {
+  width: 60px;
+  height: 60px;
+}
+
+.call-icon.calling {
+  background: var(--primary-soft);
+}
+
+.call-icon.success {
+  background: var(--success-soft);
+  color: var(--success);
+}
+
+.call-title {
+  font-size: 36px;
+  font-weight: 600;
+  color: var(--text-primary);
+  margin: 0;
+}
+
+.call-message {
+  font-size: 20px;
+  color: var(--text-secondary);
+  margin: 0;
+  max-width: 400px;
+}
+
+.call-actions {
+  margin-top: 16px;
+}
+
+.btn-call,
+.btn-return {
+  min-width: 280px;
+  height: 64px;
+  padding: 0 48px;
+  font-size: 22px;
+  font-weight: 500;
+  border-radius: var(--radius-lg);
+  cursor: pointer;
+  transition: all var(--transition-fast);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.btn-call {
+  background: var(--primary);
+  color: white;
+  border: none;
+  box-shadow: var(--shadow-md);
+}
+
+.btn-call:hover:not(:disabled) {
+  background: var(--primary-dark);
+  transform: translateY(-2px);
+  box-shadow: var(--shadow-lg);
+}
+
+.btn-call:disabled {
+  opacity: 0.7;
+  cursor: not-allowed;
+}
+
+.btn-return {
+  background: var(--bg-card);
+  color: var(--primary);
+  border: 2px solid var(--primary);
+}
+
+.btn-return:hover {
+  background: var(--primary);
+  color: white;
+}
+
+.loading-spinner {
+  width: 24px;
+  height: 24px;
+  border: 3px solid rgba(255, 255, 255, 0.3);
+  border-top-color: white;
+  border-radius: 50%;
+  animation: spin 1s linear infinite;
+}
+
+@keyframes spin {
+  to {
+    transform: rotate(360deg);
+  }
+}
+
+@keyframes fadeInUp {
+  from {
+    opacity: 0;
+    transform: translateY(30px);
+  }
+  to {
+    opacity: 1;
+    transform: translateY(0);
+  }
+}
+
+.animate-pulse {
+  animation: pulse 1.5s ease-in-out infinite;
+}
+
+@keyframes pulse {
+  0%, 100% { transform: scale(1); }
+  50% { transform: scale(1.1); }
+}
+</style>

+ 33 - 0
src/views/idle/Index.vue

@@ -0,0 +1,33 @@
+<template>
+  <div class="page-idle" @click="goToMenu">
+    <IdlePlayer @click="goToMenu" />
+  </div>
+</template>
+
+<script setup>
+import { onMounted } from 'vue'
+import { useRouter } from 'vue-router'
+import { useScreenStore } from '@/stores/screen'
+import IdlePlayer from '@/components/IdlePlayer.vue'
+
+const router = useRouter()
+const screenStore = useScreenStore()
+
+const goToMenu = () => {
+  router.push('/menu')
+}
+
+onMounted(() => {
+  screenStore.fetchScreenTheme()
+  screenStore.fetchPlayPlan()
+  screenStore.fetchRobotStatus()
+})
+</script>
+
+<style scoped>
+.page-idle {
+  width: 100vw;
+  height: 100vh;
+  overflow: hidden;
+}
+</style>

+ 243 - 0
src/views/menu/Index.vue

@@ -0,0 +1,243 @@
+<template>
+  <div class="screen-layout">
+    <!-- 顶部状态栏 -->
+    <StatusBar :title="robotName" />
+
+    <!-- 主内容区 -->
+    <div class="layout-main">
+      <div class="menu-page">
+        <!-- 动态背景层 -->
+        <div class="bg-layer">
+          <div class="bg-orb orb-1"></div>
+          <div class="bg-orb orb-2"></div>
+          <div class="bg-orb orb-3"></div>
+          <div class="bg-grid-overlay"></div>
+        </div>
+
+        <!-- 欢迎引导区 -->
+        <div class="menu-hero">
+          <h1 class="hero-title">请选择需要的服务</h1>
+          <p class="hero-sub">触摸下方入口办理业务,也可以直接说出您的需求</p>
+        </div>
+
+        <!-- 服务按钮区 -->
+        <div class="menu-service-area">
+          <MainMenu />
+        </div>
+
+        <!-- 悬浮返回按钮 -->
+        <button class="btn-back" @click="goBack">
+          <svg class="back-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
+            <path d="M15 18l-6-6 6-6" />
+          </svg>
+          <span>返回待机</span>
+        </button>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { computed } from 'vue'
+import { useRouter } from 'vue-router'
+import StatusBar from '@/components/StatusBar.vue'
+import MainMenu from '@/components/MainMenu.vue'
+import { useScreenStore } from '@/stores/screen'
+
+const router = useRouter()
+const screenStore = useScreenStore()
+
+const robotName = computed(() => screenStore.screenTheme?.robotName || '迎宾巡逻机器人')
+
+const goBack = () => {
+  router.push('/idle')
+}
+</script>
+
+<style scoped>
+.screen-layout {
+  width: 100vw;
+  height: 100vh;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+}
+
+/* 动态背景 */
+.bg-layer {
+  position: absolute;
+  inset: 0;
+  background: linear-gradient(160deg, #e8f1fd 0%, #dbeeff 30%, #eef5ff 60%, #e5f0f8 100%);
+  overflow: hidden;
+}
+
+/* 光晕球 */
+.bg-orb {
+  position: absolute;
+  border-radius: 50%;
+  filter: blur(80px);
+  pointer-events: none;
+}
+
+.orb-1 {
+  top: -180px;
+  right: -120px;
+  width: 600px;
+  height: 600px;
+  background: radial-gradient(ellipse, rgba(59, 130, 246, 0.14) 0%, transparent 65%);
+  animation: orbFloat 18s ease-in-out infinite;
+}
+
+.orb-2 {
+  bottom: -140px;
+  left: -100px;
+  width: 480px;
+  height: 480px;
+  background: radial-gradient(ellipse, rgba(6, 182, 212, 0.11) 0%, transparent 65%);
+  animation: orbFloat 22s ease-in-out infinite reverse;
+}
+
+.orb-3 {
+  top: 40%;
+  left: 45%;
+  transform: translate(-50%, -50%);
+  width: 320px;
+  height: 320px;
+  background: radial-gradient(ellipse, rgba(47, 142, 229, 0.06) 0%, transparent 65%);
+  animation: orbFloat 15s ease-in-out infinite;
+  animation-delay: -5s;
+}
+
+/* 网格叠加 */
+.bg-grid-overlay {
+  position: absolute;
+  inset: 0;
+  background-image:
+    linear-gradient(rgba(47, 142, 229, 0.025) 1px, transparent 1px),
+    linear-gradient(90deg, rgba(47, 142, 229, 0.025) 1px, transparent 1px);
+  background-size: 48px 48px;
+  pointer-events: none;
+}
+
+@keyframes orbFloat {
+  0%, 100% { transform: translate(0, 0) scale(1); }
+  33% { transform: translate(20px, -15px) scale(1.05); }
+  66% { transform: translate(-15px, 20px) scale(0.97); }
+}
+
+/* 主内容 */
+.layout-main {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+  position: relative;
+}
+
+.menu-page {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  position: relative;
+  overflow: hidden;
+  padding: 54px 0 24px;
+}
+
+/* 欢迎引导区 */
+.menu-hero {
+  text-align: center;
+  flex-shrink: 0;
+  position: relative;
+  z-index: 1;
+  padding-top: 0;
+  margin-bottom: 0;
+}
+
+.hero-title {
+  font-size: 54px;
+  font-weight: 900;
+  color: var(--text-primary);
+  letter-spacing: 3px;
+  margin: 0 0 6px;
+  line-height: 1.05;
+}
+
+.hero-sub {
+  font-size: 22px;
+  color: var(--text-secondary);
+  margin: 0;
+  font-weight: 500;
+  line-height: 1.25;
+}
+
+/* 服务按钮区 */
+.menu-service-area {
+  flex: 1;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 100%;
+  max-width: 950px;
+  margin-top: 0;
+  transform: translateY(-34px);
+  position: relative;
+  z-index: 1;
+}
+
+/* 悬浮返回按钮 */
+.btn-back {
+  position: absolute;
+  left: 42px;
+  bottom: 30px;
+  display: inline-flex;
+  align-items: center;
+  gap: 8px;
+  height: 68px;
+  padding: 0 32px;
+  background: rgba(255, 255, 255, 0.88);
+  border: 1px solid rgba(47, 142, 229, 0.20);
+  border-radius: 999px;
+  box-shadow:
+    0 12px 36px rgba(47, 142, 229, 0.14),
+    0 2px 8px rgba(0, 0, 0, 0.06);
+  color: var(--text-secondary);
+  font-size: 23px;
+  font-weight: 800;
+  cursor: pointer;
+  transition: all 0.22s ease;
+  letter-spacing: 1.5px;
+  backdrop-filter: blur(12px);
+  -webkit-backdrop-filter: blur(12px);
+  z-index: 10;
+}
+
+.btn-back:hover {
+  background: rgba(255, 255, 255, 0.97);
+  color: var(--primary);
+  box-shadow:
+    0 18px 48px rgba(47, 142, 229, 0.22),
+    0 4px 12px rgba(0, 0, 0, 0.08);
+  transform: translateY(-3px);
+}
+
+.btn-back:active {
+  transform: scale(0.97);
+  box-shadow: 0 8px 24px rgba(47, 142, 229, 0.12);
+}
+
+.back-icon {
+  width: 22px;
+  height: 22px;
+  flex-shrink: 0;
+}
+
+/* 响应式 */
+@media (max-width: 900px), (max-height: 660px) {
+  .menu-page { padding: 42px 0 22px; }
+  .hero-title { font-size: 46px; letter-spacing: 2px; margin-bottom: 5px; }
+  .hero-sub { font-size: 20px; line-height: 1.25; }
+  .menu-service-area { transform: translateY(-24px); }
+  .btn-back { left: 30px; bottom: 24px; height: 60px; padding: 0 26px; font-size: 21px; }
+}
+</style>

+ 222 - 0
src/views/navigation/Index.vue

@@ -0,0 +1,222 @@
+<template>
+  <ScreenLayout :show-back-btn="true" back-text="返回菜单" back-target="/menu">
+    <div class="page-navigation">
+      <h1 class="page-title">路线引导</h1>
+      <p class="page-subtitle">请选择您要去的地方</p>
+
+      <div class="destinations-grid">
+        <div
+          v-for="dest in destinations"
+          :key="dest.destinationId"
+          class="destination-card"
+          @click="selectDestination(dest)"
+        >
+          <div class="dest-icon" :style="{ background: getCategoryColor(dest.category) }">
+            <component :is="getCategoryIcon(dest.category)" />
+          </div>
+          <div class="dest-info">
+            <h3>{{ dest.name }}</h3>
+            <p>{{ dest.floor }} · {{ dest.description }}</p>
+          </div>
+          <div class="dest-time">
+            <span>约{{ dest.estimatedTime }}</span>
+          </div>
+        </div>
+      </div>
+    </div>
+  </ScreenLayout>
+</template>
+
+<script setup>
+import { ref, onMounted, h } from 'vue'
+import { useRouter } from 'vue-router'
+import { useNavigationStore } from '@/stores/navigation'
+import ScreenLayout from '@/layouts/ScreenLayout.vue'
+
+const router = useRouter()
+const navigationStore = useNavigationStore()
+
+const destinations = ref([])
+
+// 图标组件
+const ReceptionIcon = () => h('svg', { viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': '2' }, [
+  h('rect', { x: '2', y: '3', width: '20', height: '18', rx: '2' }),
+  h('line', { x1: '8', y1: '12', x2: '16', y2: '12' })
+])
+
+const LobbyIcon = () => h('svg', { viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': '2' }, [
+  h('path', { d: 'M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z' }),
+  h('polyline', { points: '9 22 9 12 15 12 15 22' })
+])
+
+const MeetingIcon = () => h('svg', { viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': '2' }, [
+  h('path', { d: 'M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2' }),
+  h('circle', { cx: '9', cy: '7', r: '4' }),
+  h('path', { d: 'M23 21v-2a4 4 0 0 0-3-3.87' }),
+  h('path', { d: 'M16 3.13a4 4 0 0 1 0 7.75' })
+])
+
+const ExhibitionIcon = () => h('svg', { viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': '2' }, [
+  h('rect', { x: '3', y: '3', width: '18', height: '18', rx: '2' }),
+  h('circle', { cx: '8.5', cy: '8.5', r: '1.5' }),
+  h('polyline', { points: '21 15 16 10 5 21' })
+])
+
+const RestIcon = () => h('svg', { viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': '2' }, [
+  h('path', { d: 'M18 8h1a4 4 0 0 1 0 8h-1' }),
+  h('path', { d: 'M2 8h16v9a4 4 0 0 1-4 4H6a4 4 0 0 1-4-4V8z' }),
+  h('line', { x1: '6', y1: '1', x2: '6', y2: '4' }),
+  h('line', { x1: '10', y1: '1', x2: '10', y2: '4' }),
+  h('line', { x1: '14', y1: '1', x2: '14', y2: '4' })
+])
+
+const ToiletIcon = () => h('svg', { viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': '2' }, [
+  h('circle', { cx: '12', cy: '4', r: '2' }),
+  h('path', { d: 'M12 6v6' }),
+  h('path', { d: 'M8 22v-6a4 4 0 0 1 8 0v6' })
+])
+
+const OtherIcon = () => h('svg', { viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': '2' }, [
+  h('circle', { cx: '12', cy: '12', r: '10' }),
+  h('line', { x1: '12', y1: '8', x2: '12', y2: '12' }),
+  h('line', { x1: '12', y1: '16', x2: '12.01', y2: '16' })
+])
+
+const getCategoryIcon = (category) => {
+  const icons = {
+    '服务': ReceptionIcon,
+    '公共': LobbyIcon,
+    '会议': MeetingIcon,
+    '展示': ExhibitionIcon,
+    '设施': RestIcon,
+    '卫生间': ToiletIcon
+  }
+  return icons[category] || OtherIcon
+}
+
+const getCategoryColor = (category) => {
+  const colors = {
+    '服务': 'linear-gradient(135deg, #2f8ee5 0%, #1a6fc9 100%)',
+    '公共': 'linear-gradient(135deg, #20b7c7 0%, #0ea5a5 100%)',
+    '会议': 'linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%)',
+    '展示': 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
+    '设施': 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
+    '卫生间': 'linear-gradient(135deg, #ec4899 0%, #db2777 100%)'
+  }
+  return colors[category] || colors['公共']
+}
+
+const selectDestination = async (dest) => {
+  await navigationStore.startNavigation(dest)
+  router.push('/navigation/status')
+}
+
+onMounted(async () => {
+  await navigationStore.fetchDestinations()
+  destinations.value = navigationStore.destinations
+})
+</script>
+
+<style scoped>
+.page-navigation {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  padding: 20px 0;
+}
+
+.page-title {
+  font-size: 32px;
+  font-weight: 600;
+  color: var(--text-primary);
+  text-align: center;
+  margin: 0;
+}
+
+.page-subtitle {
+  font-size: 18px;
+  color: var(--text-muted);
+  text-align: center;
+  margin: 8px 0 32px;
+}
+
+.destinations-grid {
+  flex: 1;
+  display: grid;
+  grid-template-columns: repeat(2, 1fr);
+  gap: 20px;
+  overflow-y: auto;
+  padding: 0 16px;
+}
+
+.destination-card {
+  display: flex;
+  align-items: center;
+  gap: 16px;
+  padding: 20px;
+  background: var(--bg-card);
+  border-radius: var(--radius-lg);
+  box-shadow: var(--shadow-sm);
+  cursor: pointer;
+  transition: all var(--transition-fast);
+}
+
+.destination-card:hover {
+  transform: translateY(-4px);
+  box-shadow: var(--shadow-lg);
+}
+
+.destination-card:active {
+  transform: translateY(-2px);
+}
+
+.dest-icon {
+  width: 56px;
+  height: 56px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  border-radius: 14px;
+  color: white;
+  flex-shrink: 0;
+}
+
+.dest-icon svg {
+  width: 28px;
+  height: 28px;
+}
+
+.dest-info {
+  flex: 1;
+  min-width: 0;
+}
+
+.dest-info h3 {
+  font-size: 20px;
+  font-weight: 600;
+  color: var(--text-primary);
+  margin: 0 0 4px;
+}
+
+.dest-info p {
+  font-size: 14px;
+  color: var(--text-muted);
+  margin: 0;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.dest-time {
+  flex-shrink: 0;
+}
+
+.dest-time span {
+  font-size: 14px;
+  color: var(--primary);
+  padding: 6px 12px;
+  background: var(--primary-soft);
+  border-radius: var(--radius-full);
+}
+</style>

+ 326 - 0
src/views/navigation/Status.vue

@@ -0,0 +1,326 @@
+<template>
+  <ScreenLayout :show-back-btn="false">
+    <div class="page-status">
+      <!-- 导航状态 -->
+      <div class="nav-container">
+        <div class="nav-header">
+          <h1>导航中</h1>
+          <p>目的地:{{ targetName }}</p>
+        </div>
+
+        <!-- 机器人动画 -->
+        <div class="robot-animation">
+          <div class="robot-icon" :class="{ moving: isMoving }">
+            <svg viewBox="0 0 80 80" fill="none" xmlns="http://www.w3.org/2000/svg">
+              <circle cx="40" cy="28" r="16" fill="currentColor" />
+              <rect x="20" y="48" width="40" height="24" rx="8" fill="currentColor" />
+              <circle cx="32" cy="24" r="3" fill="white" class="eye-left" />
+              <circle cx="48" cy="24" r="3" fill="white" class="eye-right" />
+              <rect x="60" y="56" width="16" height="4" rx="2" fill="currentColor" class="wheel" />
+              <rect x="4" y="56" width="16" height="4" rx="2" fill="currentColor" class="wheel" />
+            </svg>
+          </div>
+
+          <div class="path-line">
+            <div class="path-progress" :style="{ width: progress + '%' }"></div>
+          </div>
+
+          <div class="target-icon">
+            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+              <circle cx="12" cy="12" r="10" />
+              <circle cx="12" cy="12" r="6" />
+              <circle cx="12" cy="12" r="2" />
+            </svg>
+          </div>
+        </div>
+
+        <!-- 状态消息 -->
+        <div class="status-message" :class="statusClass">
+          <span class="message-text">{{ statusMessage }}</span>
+        </div>
+
+        <!-- 进度 -->
+        <div class="progress-info">
+          <div class="progress-bar">
+            <div class="progress-fill" :style="{ width: progress + '%' }"></div>
+          </div>
+          <span class="progress-text">{{ progress }}%</span>
+        </div>
+
+        <!-- 取消按钮 -->
+        <button class="btn-cancel" @click="handleCancel">
+          取消导航
+        </button>
+      </div>
+    </div>
+  </ScreenLayout>
+</template>
+
+<script setup>
+import { ref, computed, onMounted, onUnmounted } from 'vue'
+import { useRouter } from 'vue-router'
+import { useNavigationStore } from '@/stores/navigation'
+import { useScreenStore } from '@/stores/screen'
+import ScreenLayout from '@/layouts/ScreenLayout.vue'
+
+const router = useRouter()
+const navigationStore = useNavigationStore()
+const screenStore = useScreenStore()
+
+const progress = ref(0)
+const isMoving = ref(true)
+
+const targetName = computed(() => navigationStore.navigationStatus.targetName || '未知地点')
+
+const statusMessage = computed(() => {
+  const p = progress.value
+  if (p === 0) return '正在规划路线...'
+  if (p < 30) return '开始带路,请跟我来'
+  if (p < 60) return '前方直行'
+  if (p < 90) return '即将到达'
+  if (p === 100) return '已到达目的地'
+  return '导航中'
+})
+
+const statusClass = computed(() => {
+  if (progress.value === 100) return 'status-arrived'
+  if (progress.value >= 90) return 'status-near'
+  return 'status-moving'
+})
+
+let timer = null
+
+const startNavigation = () => {
+  timer = setInterval(() => {
+    if (progress.value < 100) {
+      progress.value += Math.random() * 5 + 2
+      if (progress.value >= 100) {
+        progress.value = 100
+        isMoving.value = false
+        clearInterval(timer)
+        // 到达后延迟返回
+        setTimeout(() => {
+          screenStore.showAlert({
+            type: 'success',
+            message: `已到达${targetName.value}`,
+            duration: 3000
+          })
+          setTimeout(() => {
+            handleFinish()
+          }, 3000)
+        }, 1000)
+      }
+    }
+  }, 500)
+}
+
+const handleCancel = async () => {
+  if (timer) clearInterval(timer)
+  try {
+    await navigationStore.cancelNavigation()
+    router.push('/navigation')
+  } catch {
+    router.push('/navigation')
+  }
+}
+
+const handleFinish = () => {
+  navigationStore.clearNavigation()
+  router.push('/idle')
+}
+
+onMounted(() => {
+  if (!navigationStore.currentTask) {
+    router.push('/navigation')
+    return
+  }
+  startNavigation()
+})
+
+onUnmounted(() => {
+  if (timer) clearInterval(timer)
+})
+</script>
+
+<style scoped>
+.page-status {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: linear-gradient(135deg, #e9f8ff 0%, #dff8f5 50%, #f7fbff 100%);
+}
+
+.nav-container {
+  width: 100%;
+  max-width: 600px;
+  padding: 40px;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 32px;
+}
+
+.nav-header {
+  text-align: center;
+}
+
+.nav-header h1 {
+  font-size: 36px;
+  font-weight: 600;
+  color: var(--text-primary);
+  margin: 0 0 8px;
+}
+
+.nav-header p {
+  font-size: 18px;
+  color: var(--text-secondary);
+  margin: 0;
+}
+
+.robot-animation {
+  position: relative;
+  width: 100%;
+  height: 120px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.robot-icon {
+  width: 80px;
+  height: 80px;
+  color: var(--primary);
+  z-index: 2;
+  transition: transform 0.5s ease;
+}
+
+.robot-icon.moving {
+  animation: walkBounce 0.5s ease-in-out infinite;
+}
+
+.robot-icon svg {
+  width: 100%;
+  height: 100%;
+}
+
+.robot-icon .eye-left,
+.robot-icon .eye-right {
+  animation: blink 3s ease-in-out infinite;
+}
+
+@keyframes walkBounce {
+  0%, 100% { transform: translateY(0) rotate(0deg); }
+  25% { transform: translateY(-5px) rotate(-2deg); }
+  75% { transform: translateY(-5px) rotate(2deg); }
+}
+
+@keyframes blink {
+  0%, 90%, 100% { transform: scaleY(1); }
+  95% { transform: scaleY(0.1); }
+}
+
+.path-line {
+  position: absolute;
+  left: 10%;
+  right: 10%;
+  height: 8px;
+  background: var(--border-light);
+  border-radius: 4px;
+  overflow: hidden;
+}
+
+.path-progress {
+  height: 100%;
+  background: linear-gradient(90deg, var(--primary), var(--secondary));
+  border-radius: 4px;
+  transition: width 0.3s ease;
+}
+
+.target-icon {
+  position: absolute;
+  right: 8%;
+  width: 32px;
+  height: 32px;
+  color: var(--success);
+}
+
+.target-icon svg {
+  width: 100%;
+  height: 100%;
+  animation: pulse 1.5s ease-in-out infinite;
+}
+
+@keyframes pulse {
+  0%, 100% { transform: scale(1); opacity: 1; }
+  50% { transform: scale(1.2); opacity: 0.7; }
+}
+
+.status-message {
+  padding: 16px 32px;
+  background: var(--bg-card);
+  border-radius: var(--radius-full);
+  box-shadow: var(--shadow-md);
+}
+
+.message-text {
+  font-size: 20px;
+  font-weight: 500;
+  color: var(--text-primary);
+}
+
+.status-arrived .message-text {
+  color: var(--success);
+}
+
+.status-near .message-text {
+  color: var(--primary);
+}
+
+.progress-info {
+  width: 100%;
+  display: flex;
+  align-items: center;
+  gap: 16px;
+}
+
+.progress-bar {
+  flex: 1;
+  height: 12px;
+  background: var(--border-light);
+  border-radius: 6px;
+  overflow: hidden;
+}
+
+.progress-fill {
+  height: 100%;
+  background: linear-gradient(90deg, var(--primary), var(--secondary));
+  border-radius: 6px;
+  transition: width 0.3s ease;
+}
+
+.progress-text {
+  font-size: 18px;
+  font-weight: 600;
+  color: var(--primary);
+  min-width: 50px;
+  text-align: right;
+}
+
+.btn-cancel {
+  padding: 16px 48px;
+  font-size: 18px;
+  color: var(--text-secondary);
+  background: var(--bg-card);
+  border: 2px solid var(--border-light);
+  border-radius: var(--radius-full);
+  cursor: pointer;
+  transition: all var(--transition-fast);
+}
+
+.btn-cancel:hover {
+  border-color: var(--danger);
+  color: var(--danger);
+}
+</style>

+ 260 - 0
src/views/notice/Index.vue

@@ -0,0 +1,260 @@
+<template>
+  <ScreenLayout :show-back-btn="true" back-text="返回菜单" back-target="/menu">
+    <div class="page-notice">
+      <h1 class="page-title">通知公告</h1>
+
+      <!-- 公告列表 -->
+      <div v-if="!selectedNotice" class="notice-list">
+        <div
+          v-for="notice in notices"
+          :key="notice.id"
+          class="notice-card"
+          :class="{ 'notice-high': notice.priority === 'high' }"
+          @click="selectNotice(notice)"
+        >
+          <div class="notice-header">
+            <span v-if="notice.priority === 'high'" class="notice-tag">重要</span>
+            <span class="notice-type">{{ getTypeName(notice.contentType) }}</span>
+          </div>
+          <h3 class="notice-title">{{ notice.title }}</h3>
+          <div class="notice-meta">
+            <span>{{ notice.publisher }}</span>
+            <span>{{ notice.publishTime }}</span>
+          </div>
+          <div class="notice-arrow">
+            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+              <polyline points="9 18 15 12 9 6" />
+            </svg>
+          </div>
+        </div>
+      </div>
+
+      <!-- 公告详情 -->
+      <div v-else class="notice-detail">
+        <button class="btn-back-list" @click="selectedNotice = null">
+          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+            <line x1="19" y1="12" x2="5" y2="12" />
+            <polyline points="12 19 5 12 12 5" />
+          </svg>
+          返回列表
+        </button>
+
+        <div class="detail-content">
+          <h2 class="detail-title">{{ selectedNotice.title }}</h2>
+          <div class="detail-meta">
+            <span>{{ selectedNotice.publisher }}</span>
+            <span>{{ selectedNotice.publishTime }}</span>
+          </div>
+          <div class="detail-body">
+            <p v-for="(line, index) in noticeLines" :key="index">{{ line }}</p>
+          </div>
+        </div>
+      </div>
+    </div>
+  </ScreenLayout>
+</template>
+
+<script setup>
+import { ref, computed, onMounted } from 'vue'
+import { useScreenStore } from '@/stores/screen'
+import * as api from '@/api/screen'
+import ScreenLayout from '@/layouts/ScreenLayout.vue'
+
+const screenStore = useScreenStore()
+
+const notices = ref([])
+const selectedNotice = ref(null)
+
+const noticeLines = computed(() => {
+  if (!selectedNotice.value) return []
+  return selectedNotice.value.content.split('\n')
+})
+
+const getTypeName = (type) => {
+  const types = {
+    'notice': '通知',
+    'tips': '提示',
+    'announcement': '公告'
+  }
+  return types[type] || '通知'
+}
+
+const selectNotice = (notice) => {
+  selectedNotice.value = notice
+}
+
+onMounted(async () => {
+  try {
+    notices.value = await api.getNotices()
+  } catch {
+    screenStore.showAlert({
+      type: 'error',
+      message: '获取公告列表失败'
+    })
+  }
+})
+</script>
+
+<style scoped>
+.page-notice {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  padding: 20px 0;
+}
+
+.page-title {
+  font-size: 32px;
+  font-weight: 600;
+  color: var(--text-primary);
+  text-align: center;
+  margin: 0 0 24px;
+}
+
+.notice-list {
+  flex: 1;
+  overflow-y: auto;
+  padding: 0 16px;
+  display: flex;
+  flex-direction: column;
+  gap: 16px;
+}
+
+.notice-card {
+  position: relative;
+  padding: 20px;
+  background: var(--bg-card);
+  border-radius: var(--radius-lg);
+  box-shadow: var(--shadow-sm);
+  cursor: pointer;
+  transition: all var(--transition-fast);
+}
+
+.notice-card:hover {
+  transform: translateY(-4px);
+  box-shadow: var(--shadow-lg);
+}
+
+.notice-high {
+  border-left: 4px solid var(--danger);
+}
+
+.notice-header {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin-bottom: 8px;
+}
+
+.notice-tag {
+  padding: 2px 8px;
+  font-size: 12px;
+  font-weight: 500;
+  color: white;
+  background: var(--danger);
+  border-radius: var(--radius-sm);
+}
+
+.notice-type {
+  font-size: 13px;
+  color: var(--text-muted);
+}
+
+.notice-title {
+  font-size: 20px;
+  font-weight: 600;
+  color: var(--text-primary);
+  margin: 0 0 12px;
+  padding-right: 32px;
+}
+
+.notice-meta {
+  display: flex;
+  gap: 16px;
+  font-size: 14px;
+  color: var(--text-light);
+}
+
+.notice-arrow {
+  position: absolute;
+  right: 16px;
+  top: 50%;
+  transform: translateY(-50%);
+  width: 24px;
+  height: 24px;
+  color: var(--text-light);
+}
+
+.notice-arrow svg {
+  width: 100%;
+  height: 100%;
+}
+
+/* 详情页 */
+.notice-detail {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+}
+
+.btn-back-list {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 8px 16px;
+  font-size: 16px;
+  color: var(--primary);
+  background: none;
+  border: none;
+  cursor: pointer;
+  margin-bottom: 16px;
+  align-self: flex-start;
+}
+
+.btn-back-list:hover {
+  color: var(--primary-dark);
+}
+
+.btn-back-list svg {
+  width: 20px;
+  height: 20px;
+}
+
+.detail-content {
+  flex: 1;
+  background: var(--bg-card);
+  border-radius: var(--radius-xl);
+  padding: 32px;
+  overflow-y: auto;
+}
+
+.detail-title {
+  font-size: 28px;
+  font-weight: 600;
+  color: var(--text-primary);
+  margin: 0 0 16px;
+}
+
+.detail-meta {
+  display: flex;
+  gap: 16px;
+  font-size: 14px;
+  color: var(--text-muted);
+  padding-bottom: 24px;
+  border-bottom: 1px solid var(--border-light);
+  margin-bottom: 24px;
+}
+
+.detail-body {
+  font-size: 20px;
+  line-height: 2;
+  color: var(--text-secondary);
+}
+
+.detail-body p {
+  margin: 0;
+  white-space: pre-wrap;
+}
+</style>

+ 258 - 0
src/views/recognition/Result.vue

@@ -0,0 +1,258 @@
+<template>
+  <ScreenLayout :show-back-btn="true" back-text="返回" back-target="/idle">
+    <div class="page-recognition">
+      <div class="recognition-content">
+        <!-- 结果图标 -->
+        <div class="result-icon" :class="resultClass">
+          <svg v-if="isMatched" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+            <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
+            <polyline points="22 4 12 14.01 9 11.01" />
+          </svg>
+          <svg v-else viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+            <circle cx="12" cy="12" r="10" />
+            <line x1="12" y1="8" x2="12" y2="12" />
+            <line x1="12" y1="16" x2="12.01" y2="16" />
+          </svg>
+        </div>
+
+        <!-- 结果标题 -->
+        <h1 class="result-title">{{ resultTitle }}</h1>
+        <p class="result-subtitle">{{ resultSubtitle }}</p>
+
+        <!-- 详细信息 -->
+        <div v-if="isMatched && result" class="result-details">
+          <div class="detail-row">
+            <span class="detail-label">姓名</span>
+            <span class="detail-value">{{ result.visitorName }}</span>
+          </div>
+          <div class="detail-row">
+            <span class="detail-label">预约对象</span>
+            <span class="detail-value">{{ result.visitedPerson }}</span>
+          </div>
+          <div class="detail-row">
+            <span class="detail-label">预约时间</span>
+            <span class="detail-value">{{ result.appointmentTime }}</span>
+          </div>
+        </div>
+
+        <!-- 操作按钮 -->
+        <div class="result-actions">
+          <button v-if="isMatched" class="btn-primary" @click="handleConfirm">
+            确认登记
+          </button>
+          <button v-else class="btn-secondary" @click="goToVisitor">
+            前往登记
+          </button>
+        </div>
+      </div>
+    </div>
+  </ScreenLayout>
+</template>
+
+<script setup>
+import { ref, computed, onMounted } from 'vue'
+import { useRouter } from 'vue-router'
+import { useVisitorStore } from '@/stores/visitor'
+import { useScreenStore } from '@/stores/screen'
+import * as api from '@/api/screen'
+import ScreenLayout from '@/layouts/ScreenLayout.vue'
+
+const router = useRouter()
+const visitorStore = useVisitorStore()
+const screenStore = useScreenStore()
+
+const result = ref(null)
+const loading = ref(true)
+
+const isMatched = computed(() => {
+  return result.value && result.value.matchStatus === 'matched'
+})
+
+const resultClass = computed(() => {
+  if (isMatched.value) return 'success'
+  return 'warning'
+})
+
+const resultTitle = computed(() => {
+  if (!result.value) return '识别中...'
+  if (isMatched.value) return '身份已确认'
+  return '未匹配到预约'
+})
+
+const resultSubtitle = computed(() => {
+  if (!result.value) return '正在验证身份信息'
+  if (isMatched.value) return '欢迎您的到来'
+  return '请选择其他方式进行登记'
+})
+
+const handleConfirm = () => {
+  if (result.value) {
+    visitorStore.appointmentInfo = {
+      appointmentNo: result.value.appointmentNo,
+      visitorName: result.value.visitorName,
+      visitedPerson: result.value.visitedPerson,
+      appointmentTime: result.value.appointmentTime
+    }
+  }
+  router.push('/visitor/appointment-confirm')
+}
+
+const goToVisitor = () => {
+  router.push('/visitor')
+}
+
+onMounted(async () => {
+  try {
+    result.value = await api.getRecognitionResult()
+  } catch {
+    screenStore.showAlert({
+      type: 'error',
+      message: '获取识别结果失败'
+    })
+  } finally {
+    loading.value = false
+  }
+})
+</script>
+
+<style scoped>
+.page-recognition {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: linear-gradient(135deg, #e9f8ff 0%, #dff8f5 50%, #f7fbff 100%);
+}
+
+.recognition-content {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 20px;
+  text-align: center;
+  padding: 40px;
+  animation: fadeInUp 0.6s ease-out;
+}
+
+.result-icon {
+  width: 100px;
+  height: 100px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: var(--bg-card);
+  border-radius: 50%;
+  box-shadow: var(--shadow-lg);
+}
+
+.result-icon svg {
+  width: 60px;
+  height: 60px;
+}
+
+.result-icon.success {
+  color: var(--success);
+  background: var(--success-soft);
+}
+
+.result-icon.warning {
+  color: var(--warning);
+  background: var(--warning-soft);
+}
+
+.result-title {
+  font-size: 36px;
+  font-weight: 600;
+  color: var(--text-primary);
+  margin: 0;
+}
+
+.result-subtitle {
+  font-size: 20px;
+  color: var(--text-secondary);
+  margin: 0;
+}
+
+.result-details {
+  background: var(--bg-card);
+  border-radius: var(--radius-xl);
+  padding: 24px 40px;
+  box-shadow: var(--shadow-md);
+  margin: 16px 0;
+  min-width: 320px;
+}
+
+.detail-row {
+  display: flex;
+  justify-content: space-between;
+  gap: 40px;
+  padding: 12px 0;
+}
+
+.detail-row:not(:last-child) {
+  border-bottom: 1px solid var(--border-light);
+}
+
+.detail-label {
+  font-size: 16px;
+  color: var(--text-muted);
+}
+
+.detail-value {
+  font-size: 18px;
+  font-weight: 500;
+  color: var(--text-primary);
+}
+
+.result-actions {
+  margin-top: 16px;
+}
+
+.btn-primary,
+.btn-secondary {
+  min-width: 240px;
+  height: 64px;
+  padding: 0 48px;
+  font-size: 22px;
+  font-weight: 500;
+  border-radius: var(--radius-lg);
+  cursor: pointer;
+  transition: all var(--transition-fast);
+}
+
+.btn-primary {
+  background: var(--primary);
+  color: white;
+  border: none;
+  box-shadow: var(--shadow-md);
+}
+
+.btn-primary:hover {
+  background: var(--primary-dark);
+  transform: translateY(-2px);
+  box-shadow: var(--shadow-lg);
+}
+
+.btn-secondary {
+  background: var(--bg-card);
+  color: var(--primary);
+  border: 2px solid var(--primary);
+}
+
+.btn-secondary:hover {
+  background: var(--primary);
+  color: white;
+}
+
+@keyframes fadeInUp {
+  from {
+    opacity: 0;
+    transform: translateY(30px);
+  }
+  to {
+    opacity: 1;
+    transform: translateY(0);
+  }
+}
+</style>

+ 249 - 0
src/views/system/Info.vue

@@ -0,0 +1,249 @@
+<template>
+  <ScreenLayout :show-back-btn="true" back-text="返回待机" back-target="/idle">
+    <div class="page-system-info">
+      <h1 class="page-title">系统信息</h1>
+
+      <div class="info-card">
+        <div class="info-section">
+          <h2 class="section-title">设备信息</h2>
+          <div class="info-list">
+            <div class="info-row">
+              <span class="info-label">设备编号</span>
+              <span class="info-value">{{ systemInfo.deviceId }}</span>
+            </div>
+            <div class="info-row">
+              <span class="info-label">机器人名称</span>
+              <span class="info-value">{{ systemInfo.robotName }}</span>
+            </div>
+            <div class="info-row">
+              <span class="info-label">本地IP</span>
+              <span class="info-value">{{ systemInfo.ipAddress }}</span>
+            </div>
+            <div class="info-row">
+              <span class="info-label">MAC地址</span>
+              <span class="info-value">{{ systemInfo.macAddress }}</span>
+            </div>
+          </div>
+        </div>
+
+        <div class="info-section">
+          <h2 class="section-title">版本信息</h2>
+          <div class="info-list">
+            <div class="info-row">
+              <span class="info-label">前端版本</span>
+              <span class="info-value">{{ systemInfo.frontendVersion }}</span>
+            </div>
+            <div class="info-row">
+              <span class="info-label">后端版本</span>
+              <span class="info-value">{{ systemInfo.backendVersion }}</span>
+            </div>
+            <div class="info-row">
+              <span class="info-label">屏幕分辨率</span>
+              <span class="info-value">{{ systemInfo.screenResolution }}</span>
+            </div>
+            <div class="info-row">
+              <span class="info-label">最后更新</span>
+              <span class="info-value">{{ systemInfo.lastUpdate }}</span>
+            </div>
+            <div class="info-row">
+              <span class="info-label">运行时长</span>
+              <span class="info-value">{{ systemInfo.uptime }}</span>
+            </div>
+          </div>
+        </div>
+
+        <div class="info-section">
+          <h2 class="section-title">服务状态</h2>
+          <div class="services-grid">
+            <div
+              v-for="service in systemInfo.services"
+              :key="service.name"
+              class="service-item"
+              :class="{ online: service.status === 'online', offline: service.status !== 'online' }"
+            >
+              <div class="service-status"></div>
+              <span class="service-name">{{ service.name }}</span>
+              <span class="service-latency">{{ service.latency }}</span>
+            </div>
+          </div>
+        </div>
+      </div>
+
+      <div class="actions">
+        <button class="btn-refresh" @click="handleRefresh">
+          刷新页面
+        </button>
+      </div>
+    </div>
+  </ScreenLayout>
+</template>
+
+<script setup>
+import { ref, onMounted } from 'vue'
+import * as api from '@/api/screen'
+import ScreenLayout from '@/layouts/ScreenLayout.vue'
+
+const systemInfo = ref({
+  deviceId: '加载中...',
+  robotName: '-',
+  ipAddress: '-',
+  macAddress: '-',
+  frontendVersion: '-',
+  backendVersion: '-',
+  screenResolution: '-',
+  lastUpdate: '-',
+  uptime: '-',
+  services: []
+})
+
+const fetchSystemInfo = async () => {
+  try {
+    systemInfo.value = await api.getSystemInfo()
+  } catch (error) {
+    console.error('Failed to fetch system info:', error)
+  }
+}
+
+const handleRefresh = () => {
+  window.location.reload()
+}
+
+onMounted(() => {
+  fetchSystemInfo()
+
+  // 更新分辨率
+  systemInfo.value.screenResolution = `${window.screen.width}×${window.screen.height}`
+})
+</script>
+
+<style scoped>
+.page-system-info {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 20px 0;
+  overflow-y: auto;
+}
+
+.page-title {
+  font-size: 32px;
+  font-weight: 600;
+  color: var(--text-primary);
+  text-align: center;
+  margin: 0 0 24px;
+}
+
+.info-card {
+  width: 100%;
+  max-width: 600px;
+  display: flex;
+  flex-direction: column;
+  gap: 24px;
+  padding: 0 16px;
+}
+
+.info-section {
+  background: var(--bg-card);
+  border-radius: var(--radius-lg);
+  padding: 20px;
+  box-shadow: var(--shadow-sm);
+}
+
+.section-title {
+  font-size: 18px;
+  font-weight: 600;
+  color: var(--text-primary);
+  margin: 0 0 16px;
+  padding-bottom: 12px;
+  border-bottom: 1px solid var(--border-light);
+}
+
+.info-list {
+  display: flex;
+  flex-direction: column;
+}
+
+.info-row {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 10px 0;
+}
+
+.info-row:not(:last-child) {
+  border-bottom: 1px solid var(--border-light);
+}
+
+.info-label {
+  font-size: 15px;
+  color: var(--text-muted);
+}
+
+.info-value {
+  font-size: 15px;
+  color: var(--text-primary);
+  font-weight: 500;
+}
+
+.services-grid {
+  display: grid;
+  grid-template-columns: repeat(2, 1fr);
+  gap: 12px;
+}
+
+.service-item {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 12px;
+  background: var(--bg-page);
+  border-radius: var(--radius-md);
+}
+
+.service-status {
+  width: 8px;
+  height: 8px;
+  border-radius: 50%;
+}
+
+.service-item.online .service-status {
+  background: var(--success);
+}
+
+.service-item.offline .service-status {
+  background: var(--danger);
+}
+
+.service-name {
+  flex: 1;
+  font-size: 14px;
+  color: var(--text-primary);
+}
+
+.service-latency {
+  font-size: 12px;
+  color: var(--text-muted);
+}
+
+.actions {
+  margin-top: 24px;
+}
+
+.btn-refresh {
+  padding: 14px 40px;
+  font-size: 16px;
+  color: var(--text-secondary);
+  background: var(--bg-card);
+  border: 2px solid var(--border-light);
+  border-radius: var(--radius-lg);
+  cursor: pointer;
+  transition: all var(--transition-fast);
+}
+
+.btn-refresh:hover {
+  border-color: var(--primary);
+  color: var(--primary);
+}
+</style>

+ 268 - 0
src/views/visitor/Appointment.vue

@@ -0,0 +1,268 @@
+<template>
+  <ScreenLayout :show-back-btn="true" back-text="返回登记" back-target="/visitor">
+    <div class="page-appointment">
+      <h1 class="page-title">预约核验</h1>
+
+      <div class="verify-options">
+        <!-- 身份证读取 -->
+        <div class="verify-card" @click="handleIdCard">
+          <div class="verify-icon">
+            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+              <rect x="2" y="4" width="20" height="16" rx="2" />
+              <line x1="6" y1="8" x2="10" y2="8" />
+              <line x1="6" y1="12" x2="18" y2="12" />
+              <line x1="6" y1="16" x2="14" y2="16" />
+            </svg>
+          </div>
+          <h3>身份证读取</h3>
+          <p>请将身份证放置读卡区</p>
+        </div>
+
+        <!-- 手机号查询 -->
+        <div class="verify-card" @click="showPhoneInput = true">
+          <div class="verify-icon">
+            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+              <rect x="5" y="2" width="14" height="20" rx="2" ry="2" />
+              <line x1="12" y1="18" x2="12.01" y2="18" />
+            </svg>
+          </div>
+          <h3>手机号查询</h3>
+          <p>输入预约手机号查询</p>
+        </div>
+      </div>
+
+      <!-- 手机号输入弹窗 -->
+      <div v-if="showPhoneInput" class="phone-modal">
+        <div class="modal-content">
+          <h2>请输入预约手机号</h2>
+          <NumericKeyboard
+            v-model="phoneNumber"
+            label="手机号"
+            placeholder="请输入11位手机号"
+            :max-length="11"
+            type="phone"
+            @confirm="handlePhoneConfirm"
+          />
+          <div class="modal-actions">
+            <button class="btn-cancel" @click="showPhoneInput = false">取消</button>
+            <button class="btn-confirm" @click="handlePhoneConfirm(phoneNumber)">查询</button>
+          </div>
+        </div>
+      </div>
+    </div>
+  </ScreenLayout>
+</template>
+
+<script setup>
+import { ref } from 'vue'
+import { useRouter } from 'vue-router'
+import { useScreenStore } from '@/stores/screen'
+import { useVisitorStore } from '@/stores/visitor'
+import ScreenLayout from '@/layouts/ScreenLayout.vue'
+import NumericKeyboard from '@/components/NumericKeyboard.vue'
+
+const router = useRouter()
+const screenStore = useScreenStore()
+const visitorStore = useVisitorStore()
+
+const showPhoneInput = ref(false)
+const phoneNumber = ref('')
+
+const handleIdCard = async () => {
+  try {
+    const result = await visitorStore.readIdCard()
+    screenStore.showAlert({
+      type: 'success',
+      message: '身份证读取成功'
+    })
+    // 查询预约
+    if (result.idCardNo) {
+      try {
+        await visitorStore.queryAppointment({ idCardNo: result.idCardNo })
+        router.push('/visitor/appointment-confirm')
+      } catch {
+        screenStore.showAlert({
+          type: 'info',
+          message: '未查询到预约信息,请选择现场登记'
+        })
+      }
+    }
+  } catch {
+    screenStore.showAlert({
+      type: 'error',
+      message: '身份证读取失败,请重试或选择其他方式'
+    })
+  }
+}
+
+const handlePhoneConfirm = async (phone) => {
+  if (!phone || phone.length !== 11) {
+    screenStore.showAlert({
+      type: 'warning',
+      message: '请输入正确的11位手机号'
+    })
+    return
+  }
+
+  try {
+    await visitorStore.queryAppointment({ mobile: phone })
+    showPhoneInput.value = false
+    router.push('/visitor/appointment-confirm')
+  } catch {
+    screenStore.showAlert({
+      type: 'info',
+      message: '未查询到预约信息'
+    })
+  }
+}
+</script>
+
+<style scoped>
+.page-appointment {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 20px 0;
+}
+
+.page-title {
+  font-size: 32px;
+  font-weight: 600;
+  color: var(--text-primary);
+  margin: 0 0 40px;
+}
+
+.verify-options {
+  display: grid;
+  grid-template-columns: repeat(2, 1fr);
+  gap: 40px;
+  max-width: 700px;
+  width: 100%;
+}
+
+.verify-card {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 16px;
+  padding: 40px 24px;
+  background: var(--bg-card);
+  border-radius: var(--radius-xl);
+  box-shadow: var(--shadow-md);
+  cursor: pointer;
+  transition: all var(--transition-normal);
+}
+
+.verify-card:hover {
+  transform: translateY(-6px);
+  box-shadow: var(--shadow-xl);
+}
+
+.verify-icon {
+  width: 64px;
+  height: 64px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: var(--primary-soft);
+  border-radius: 16px;
+  color: var(--primary);
+}
+
+.verify-icon svg {
+  width: 36px;
+  height: 36px;
+}
+
+.verify-card h3 {
+  font-size: 24px;
+  font-weight: 600;
+  color: var(--text-primary);
+  margin: 0;
+}
+
+.verify-card p {
+  font-size: 15px;
+  color: var(--text-muted);
+  margin: 0;
+}
+
+/* 手机号输入弹窗 */
+.phone-modal {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background: rgba(0, 0, 0, 0.5);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: 100;
+  animation: fadeIn 0.2s ease-out;
+}
+
+.modal-content {
+  width: 90%;
+  max-width: 450px;
+  padding: 32px;
+  background: var(--bg-card);
+  border-radius: var(--radius-xl);
+  box-shadow: var(--shadow-xl);
+}
+
+.modal-content h2 {
+  font-size: 24px;
+  font-weight: 600;
+  color: var(--text-primary);
+  text-align: center;
+  margin: 0 0 24px;
+}
+
+.modal-actions {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 16px;
+  margin-top: 24px;
+}
+
+.btn-cancel,
+.btn-confirm {
+  height: 56px;
+  font-size: 18px;
+  border-radius: var(--radius-lg);
+  cursor: pointer;
+  transition: all var(--transition-fast);
+}
+
+.btn-cancel {
+  background: var(--bg-page);
+  color: var(--text-secondary);
+  border: 2px solid var(--border-light);
+}
+
+.btn-cancel:hover {
+  border-color: var(--text-muted);
+}
+
+.btn-confirm {
+  background: var(--primary);
+  color: white;
+  border: none;
+}
+
+.btn-confirm:hover {
+  background: var(--primary-dark);
+}
+
+@keyframes fadeIn {
+  from {
+    opacity: 0;
+  }
+  to {
+    opacity: 1;
+  }
+}
+</style>

+ 199 - 0
src/views/visitor/AppointmentConfirm.vue

@@ -0,0 +1,199 @@
+<template>
+  <ScreenLayout :show-back-btn="true" back-text="返回" back-target="/visitor/appointment">
+    <div class="page-confirm">
+      <h1 class="page-title">预约信息确认</h1>
+
+      <div v-if="appointment" class="info-card">
+        <div class="info-row">
+          <span class="info-label">访客姓名</span>
+          <span class="info-value">{{ appointment.visitorName }}</span>
+        </div>
+        <div class="info-row">
+          <span class="info-label">手机号码</span>
+          <span class="info-value">{{ maskMobile(appointment.mobile) }}</span>
+        </div>
+        <div class="info-row">
+          <span class="info-label">身份证号</span>
+          <span class="info-value">{{ maskIdCard(appointment.idCardNo) }}</span>
+        </div>
+        <div class="info-row">
+          <span class="info-label">被访人</span>
+          <span class="info-value">{{ appointment.visitedPerson }}</span>
+        </div>
+        <div class="info-row">
+          <span class="info-label">被访部门</span>
+          <span class="info-value">{{ appointment.visitedDepartment }}</span>
+        </div>
+        <div class="info-row">
+          <span class="info-label">预约时间</span>
+          <span class="info-value">{{ appointment.appointmentTime }}</span>
+        </div>
+        <div class="info-row">
+          <span class="info-label">来访事由</span>
+          <span class="info-value">{{ appointment.visitPurpose }}</span>
+        </div>
+      </div>
+
+      <div class="confirm-actions">
+        <button class="btn-back" @click="goBack">信息有误</button>
+        <button class="btn-confirm" @click="handleConfirm" :disabled="loading">
+          <span v-if="loading" class="loading-spinner"></span>
+          <span v-else>确认登记</span>
+        </button>
+      </div>
+    </div>
+  </ScreenLayout>
+</template>
+
+<script setup>
+import { ref } from 'vue'
+import { useRouter } from 'vue-router'
+import { useScreenStore } from '@/stores/screen'
+import { useVisitorStore } from '@/stores/visitor'
+import { maskMobile, maskIdCard } from '@/utils/device'
+import ScreenLayout from '@/layouts/ScreenLayout.vue'
+
+const router = useRouter()
+const screenStore = useScreenStore()
+const visitorStore = useVisitorStore()
+
+const loading = ref(false)
+
+const appointment = visitorStore.appointmentInfo
+
+const goBack = () => {
+  router.push('/visitor/walk-in')
+}
+
+const handleConfirm = async () => {
+  if (loading.value) return
+  loading.value = true
+
+  try {
+    await visitorStore.submitRegistration()
+    router.push('/visitor/success')
+  } catch (error) {
+    screenStore.showAlert({
+      type: 'error',
+      message: '登记失败,请重试'
+    })
+  } finally {
+    loading.value = false
+  }
+}
+</script>
+
+<style scoped>
+.page-confirm {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 20px 0;
+}
+
+.page-title {
+  font-size: 32px;
+  font-weight: 600;
+  color: var(--text-primary);
+  margin: 0 0 32px;
+}
+
+.info-card {
+  width: 100%;
+  max-width: 500px;
+  background: var(--bg-card);
+  border-radius: var(--radius-xl);
+  box-shadow: var(--shadow-md);
+  padding: 24px;
+  margin-bottom: 32px;
+}
+
+.info-row {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 16px 0;
+  border-bottom: 1px solid var(--border-light);
+}
+
+.info-row:last-child {
+  border-bottom: none;
+}
+
+.info-label {
+  font-size: 16px;
+  color: var(--text-muted);
+}
+
+.info-value {
+  font-size: 18px;
+  font-weight: 500;
+  color: var(--text-primary);
+}
+
+.confirm-actions {
+  display: grid;
+  grid-template-columns: 1fr 2fr;
+  gap: 20px;
+  width: 100%;
+  max-width: 500px;
+}
+
+.btn-back,
+.btn-confirm {
+  height: 64px;
+  font-size: 20px;
+  font-weight: 500;
+  border-radius: var(--radius-lg);
+  cursor: pointer;
+  transition: all var(--transition-fast);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.btn-back {
+  background: var(--bg-card);
+  color: var(--text-secondary);
+  border: 2px solid var(--border-light);
+}
+
+.btn-back:hover {
+  border-color: var(--text-muted);
+}
+
+.btn-confirm {
+  background: var(--primary);
+  color: white;
+  border: none;
+  box-shadow: var(--shadow-md);
+}
+
+.btn-confirm:hover:not(:disabled) {
+  background: var(--primary-dark);
+  transform: translateY(-2px);
+  box-shadow: var(--shadow-lg);
+}
+
+.btn-confirm:disabled {
+  opacity: 0.7;
+  cursor: not-allowed;
+}
+
+.loading-spinner {
+  width: 24px;
+  height: 24px;
+  border: 3px solid rgba(255, 255, 255, 0.3);
+  border-top-color: white;
+  border-radius: 50%;
+  animation: spin 1s linear infinite;
+}
+
+@keyframes spin {
+  to {
+    transform: rotate(360deg);
+  }
+}
+</style>

+ 128 - 0
src/views/visitor/Index.vue

@@ -0,0 +1,128 @@
+<template>
+  <ScreenLayout :show-back-btn="true" back-text="返回菜单" back-target="/menu">
+    <div class="page-visitor">
+      <h1 class="page-title">访客登记</h1>
+
+      <div class="visitor-options">
+        <!-- 预约到访 -->
+        <div class="option-card" @click="goToAppointment">
+          <div class="option-icon" style="background: linear-gradient(135deg, #2f8ee5 0%, #1a6fc9 100%);">
+            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+              <rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
+              <line x1="16" y1="2" x2="16" y2="6" />
+              <line x1="8" y1="2" x2="8" y2="6" />
+              <line x1="3" y1="10" x2="21" y2="10" />
+            </svg>
+          </div>
+          <h3>预约到访</h3>
+          <p>请出示预约信息</p>
+        </div>
+
+        <!-- 现场登记 -->
+        <div class="option-card" @click="goToWalkIn">
+          <div class="option-icon" style="background: linear-gradient(135deg, #20b7c7 0%, #0ea5a5 100%);">
+            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+              <path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
+              <circle cx="9" cy="7" r="4" />
+              <line x1="19" y1="8" x2="19" y2="14" />
+              <line x1="22" y1="11" x2="16" y2="11" />
+            </svg>
+          </div>
+          <h3>现场登记</h3>
+          <p>填写个人信息</p>
+        </div>
+      </div>
+    </div>
+  </ScreenLayout>
+</template>
+
+<script setup>
+import { useRouter } from 'vue-router'
+import ScreenLayout from '@/layouts/ScreenLayout.vue'
+
+const router = useRouter()
+
+const goToAppointment = () => {
+  router.push('/visitor/appointment')
+}
+
+const goToWalkIn = () => {
+  router.push('/visitor/walk-in')
+}
+</script>
+
+<style scoped>
+.page-visitor {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 20px 0;
+}
+
+.page-title {
+  font-size: 32px;
+  font-weight: 600;
+  color: var(--text-primary);
+  margin: 0 0 40px;
+}
+
+.visitor-options {
+  display: grid;
+  grid-template-columns: repeat(2, 1fr);
+  gap: 40px;
+  max-width: 700px;
+  width: 100%;
+}
+
+.option-card {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 16px;
+  padding: 48px 32px;
+  background: var(--bg-card);
+  border-radius: var(--radius-xl);
+  box-shadow: var(--shadow-md);
+  cursor: pointer;
+  transition: all var(--transition-normal);
+}
+
+.option-card:hover {
+  transform: translateY(-8px);
+  box-shadow: var(--shadow-xl);
+}
+
+.option-card:active {
+  transform: translateY(-4px);
+}
+
+.option-icon {
+  width: 80px;
+  height: 80px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  border-radius: 20px;
+  color: white;
+}
+
+.option-icon svg {
+  width: 44px;
+  height: 44px;
+}
+
+.option-card h3 {
+  font-size: 26px;
+  font-weight: 600;
+  color: var(--text-primary);
+  margin: 0;
+}
+
+.option-card p {
+  font-size: 16px;
+  color: var(--text-muted);
+  margin: 0;
+}
+</style>

+ 209 - 0
src/views/visitor/Success.vue

@@ -0,0 +1,209 @@
+<template>
+  <ScreenLayout :show-back-btn="false">
+    <div class="page-success">
+      <div class="success-content">
+        <div class="success-icon">
+          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+            <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
+            <polyline points="22 4 12 14.01 9 11.01" />
+          </svg>
+        </div>
+
+        <h1 class="success-title">登记成功</h1>
+        <p class="success-message">欢迎您的到来</p>
+
+        <div class="visitor-info">
+          <div class="info-item">
+            <span class="info-label">访客姓名</span>
+            <span class="info-value">{{ visitorName }}</span>
+          </div>
+          <div class="info-item">
+            <span class="info-label">登记时间</span>
+            <span class="info-value">{{ currentTime }}</span>
+          </div>
+        </div>
+
+        <div class="next-hint">
+          <p>请前往{{ visitedPerson || '被访人' }}处</p>
+          <p>感谢您的配合,祝您访问愉快</p>
+        </div>
+      </div>
+
+      <button class="btn-return" @click="goToIdle">
+        返回首页
+      </button>
+    </div>
+  </ScreenLayout>
+</template>
+
+<script setup>
+import { ref, computed, onMounted } from 'vue'
+import { useRouter } from 'vue-router'
+import { useVisitorStore } from '@/stores/visitor'
+import { formatDateTime } from '@/utils/time'
+import ScreenLayout from '@/layouts/ScreenLayout.vue'
+
+const router = useRouter()
+const visitorStore = useVisitorStore()
+
+const currentTime = ref(formatDateTime())
+
+const visitorName = computed(() => visitorStore.registrationInfo.visitorName || '访客')
+const visitedPerson = computed(() => visitorStore.registrationInfo.visitedPerson || '')
+
+const goToIdle = () => {
+  visitorStore.clearVisitorData()
+  router.push('/idle')
+}
+
+// 更新时间
+let timer = null
+
+onMounted(() => {
+  timer = setInterval(() => {
+    currentTime.value = formatDateTime()
+  }, 1000)
+
+  // 3秒后自动返回待机页
+  setTimeout(() => {
+    goToIdle()
+  }, 10000)
+})
+</script>
+
+<style scoped>
+.page-success {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  background: linear-gradient(135deg, #e9f8ff 0%, #dff8f5 50%, #f7fbff 100%);
+  padding: 40px;
+}
+
+.success-content {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  gap: 20px;
+  text-align: center;
+  animation: fadeInUp 0.6s ease-out;
+}
+
+.success-icon {
+  width: 100px;
+  height: 100px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: var(--success);
+  border-radius: 50%;
+  color: white;
+  animation: scaleIn 0.5s ease-out 0.2s backwards;
+}
+
+.success-icon svg {
+  width: 60px;
+  height: 60px;
+}
+
+.success-title {
+  font-size: 48px;
+  font-weight: 700;
+  color: var(--text-primary);
+  margin: 0;
+}
+
+.success-message {
+  font-size: 24px;
+  color: var(--text-secondary);
+  margin: 0;
+}
+
+.visitor-info {
+  background: var(--bg-card);
+  border-radius: var(--radius-xl);
+  padding: 24px 40px;
+  box-shadow: var(--shadow-md);
+  margin: 20px 0;
+}
+
+.info-item {
+  display: flex;
+  justify-content: space-between;
+  gap: 40px;
+  padding: 12px 0;
+}
+
+.info-item:not(:last-child) {
+  border-bottom: 1px solid var(--border-light);
+}
+
+.info-label {
+  font-size: 16px;
+  color: var(--text-muted);
+}
+
+.info-value {
+  font-size: 18px;
+  font-weight: 500;
+  color: var(--text-primary);
+}
+
+.next-hint {
+  margin-top: 20px;
+}
+
+.next-hint p {
+  font-size: 18px;
+  color: var(--text-secondary);
+  margin: 8px 0;
+}
+
+.btn-return {
+  width: 280px;
+  height: 64px;
+  font-size: 22px;
+  font-weight: 500;
+  background: var(--bg-card);
+  color: var(--primary);
+  border: 2px solid var(--primary);
+  border-radius: var(--radius-lg);
+  cursor: pointer;
+  transition: all var(--transition-fast);
+  box-shadow: var(--shadow-md);
+}
+
+.btn-return:hover {
+  background: var(--primary);
+  color: white;
+  transform: translateY(-2px);
+  box-shadow: var(--shadow-lg);
+}
+
+@keyframes fadeInUp {
+  from {
+    opacity: 0;
+    transform: translateY(30px);
+  }
+  to {
+    opacity: 1;
+    transform: translateY(0);
+  }
+}
+
+@keyframes scaleIn {
+  from {
+    opacity: 0;
+    transform: scale(0.5);
+  }
+  to {
+    opacity: 1;
+    transform: scale(1);
+  }
+}
+</style>

+ 498 - 0
src/views/visitor/WalkIn.vue

@@ -0,0 +1,498 @@
+<template>
+  <ScreenLayout :show-back-btn="true" back-text="返回登记" back-target="/visitor">
+    <div class="page-walk-in">
+      <h1 class="page-title">现场登记</h1>
+
+      <div class="form-container">
+        <!-- 身份证读取 -->
+        <div class="idcard-section">
+          <button class="btn-idcard" @click="handleReadIdCard" :disabled="reading">
+            <div class="idcard-icon">
+              <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+                <rect x="2" y="4" width="20" height="16" rx="2" />
+                <line x1="6" y1="8" x2="10" y2="8" />
+                <line x1="6" y1="12" x2="18" y2="12" />
+                <line x1="6" y1="16" x2="14" y2="16" />
+              </svg>
+            </div>
+            <span v-if="reading">读取中...</span>
+            <span v-else>读取身份证</span>
+          </button>
+        </div>
+
+        <div class="form-divider">
+          <span>或手动填写</span>
+        </div>
+
+        <!-- 表单 -->
+        <div class="form-fields">
+          <!-- 姓名 -->
+          <div class="form-field">
+            <label>访客姓名</label>
+            <input
+              type="text"
+              v-model="formData.visitorName"
+              placeholder="请输入姓名"
+              class="input"
+            />
+          </div>
+
+          <!-- 手机号 -->
+          <div class="form-field">
+            <label>手机号码</label>
+            <div class="input-with-keyboard" @click="showKeyboard('mobile')">
+              <span class="input-value">{{ formData.mobile || '请输入手机号' }}</span>
+            </div>
+          </div>
+
+          <!-- 身份证号 -->
+          <div class="form-field">
+            <label>身份证号</label>
+            <div class="input-with-keyboard" @click="showKeyboard('idCardNo')">
+              <span class="input-value">{{ formData.idCardNo || '请输入身份证号' }}</span>
+            </div>
+          </div>
+
+          <!-- 被访人 -->
+          <div class="form-field">
+            <label>被访人</label>
+            <input
+              type="text"
+              v-model="formData.visitedPerson"
+              placeholder="请输入被访人姓名"
+              class="input"
+            />
+          </div>
+
+          <!-- 来访事由 -->
+          <div class="form-field">
+            <label>来访事由</label>
+            <div class="reason-options">
+              <button
+                v-for="option in visitReasons"
+                :key="option.value"
+                class="reason-btn"
+                :class="{ active: formData.visitReason === option.value }"
+                @click="formData.visitReason = option.value"
+              >
+                {{ option.label }}
+              </button>
+            </div>
+          </div>
+        </div>
+
+        <!-- 提交按钮 -->
+        <button class="btn-submit" @click="handleSubmit" :disabled="submitting">
+          <span v-if="submitting" class="loading-spinner"></span>
+          <span v-else>提交登记</span>
+        </button>
+      </div>
+
+      <!-- 数字键盘弹窗 -->
+      <div v-if="showKeyboardFlag" class="keyboard-modal" @click.self="hideKeyboard">
+        <div class="keyboard-content">
+          <div class="keyboard-header">
+            <span>{{ keyboardLabel }}</span>
+            <button class="keyboard-close" @click="hideKeyboard">完成</button>
+          </div>
+          <NumericKeyboard
+            v-model="currentInput"
+            :label="keyboardLabel"
+            :max-length="keyboardMaxLength"
+            :type="keyboardType"
+            @confirm="handleKeyboardConfirm"
+          />
+        </div>
+      </div>
+    </div>
+  </ScreenLayout>
+</template>
+
+<script setup>
+import { ref, reactive } from 'vue'
+import { useRouter } from 'vue-router'
+import { useScreenStore } from '@/stores/screen'
+import { useVisitorStore } from '@/stores/visitor'
+import ScreenLayout from '@/layouts/ScreenLayout.vue'
+import NumericKeyboard from '@/components/NumericKeyboard.vue'
+
+const router = useRouter()
+const screenStore = useScreenStore()
+const visitorStore = useVisitorStore()
+
+const reading = ref(false)
+const submitting = ref(false)
+
+// 键盘相关
+const showKeyboardFlag = ref(false)
+const currentInput = ref('')
+const currentField = ref('')
+const keyboardLabel = ref('')
+const keyboardMaxLength = ref(11)
+const keyboardType = ref('phone')
+
+const visitReasons = visitorStore.visitReasonOptions
+
+const formData = reactive({
+  visitorName: '',
+  mobile: '',
+  idCardNo: '',
+  visitedPerson: '',
+  visitReason: ''
+})
+
+const showKeyboard = (field) => {
+  currentField.value = field
+  currentInput.value = formData[field] || ''
+
+  if (field === 'mobile') {
+    keyboardLabel.value = '手机号'
+    keyboardMaxLength.value = 11
+    keyboardType.value = 'phone'
+  } else if (field === 'idCardNo') {
+    keyboardLabel.value = '身份证号'
+    keyboardMaxLength.value = 18
+    keyboardType.value = 'idcard'
+  }
+
+  showKeyboardFlag.value = true
+}
+
+const hideKeyboard = () => {
+  showKeyboardFlag.value = false
+}
+
+const handleKeyboardConfirm = (value) => {
+  formData[currentField.value] = value
+  hideKeyboard()
+}
+
+const handleReadIdCard = async () => {
+  reading.value = true
+  try {
+    const result = await visitorStore.readIdCard()
+    formData.visitorName = result.name || ''
+    formData.idCardNo = result.idCardNo || ''
+    screenStore.showAlert({
+      type: 'success',
+      message: '身份证读取成功'
+    })
+  } catch {
+    screenStore.showAlert({
+      type: 'error',
+      message: '身份证读取失败,请手动输入'
+    })
+  } finally {
+    reading.value = false
+  }
+}
+
+const handleSubmit = async () => {
+  // 验证
+  if (!formData.visitorName?.trim()) {
+    screenStore.showAlert({ type: 'warning', message: '请输入访客姓名' })
+    return
+  }
+  if (!formData.mobile?.trim()) {
+    screenStore.showAlert({ type: 'warning', message: '请输入手机号' })
+    return
+  }
+  if (!/^1[3-9]\d{9}$/.test(formData.mobile)) {
+    screenStore.showAlert({ type: 'warning', message: '手机号格式不正确' })
+    return
+  }
+
+  submitting.value = true
+  try {
+    // 更新 store
+    visitorStore.setFormField('visitorName', formData.visitorName)
+    visitorStore.setFormField('mobile', formData.mobile)
+    visitorStore.setFormField('idCardNo', formData.idCardNo)
+    visitorStore.setFormField('visitedPerson', formData.visitedPerson)
+    visitorStore.setFormField('visitReason', formData.visitReason)
+
+    await visitorStore.submitRegistration()
+    router.push('/visitor/success')
+  } catch {
+    screenStore.showAlert({
+      type: 'error',
+      message: '登记失败,请重试'
+    })
+  } finally {
+    submitting.value = false
+  }
+}
+</script>
+
+<style scoped>
+.page-walk-in {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 20px 0;
+  overflow-y: auto;
+}
+
+.page-title {
+  font-size: 32px;
+  font-weight: 600;
+  color: var(--text-primary);
+  margin: 0 0 24px;
+}
+
+.form-container {
+  width: 100%;
+  max-width: 500px;
+  padding: 0 16px;
+}
+
+.idcard-section {
+  margin-bottom: 24px;
+}
+
+.btn-idcard {
+  width: 100%;
+  height: 80px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 16px;
+  background: var(--primary-soft);
+  color: var(--primary);
+  border: 2px dashed var(--primary);
+  border-radius: var(--radius-lg);
+  font-size: 20px;
+  font-weight: 500;
+  cursor: pointer;
+  transition: all var(--transition-fast);
+}
+
+.btn-idcard:hover:not(:disabled) {
+  background: var(--primary);
+  color: white;
+  border-style: solid;
+}
+
+.btn-idcard:disabled {
+  opacity: 0.6;
+  cursor: not-allowed;
+}
+
+.idcard-icon {
+  width: 32px;
+  height: 32px;
+}
+
+.idcard-icon svg {
+  width: 100%;
+  height: 100%;
+}
+
+.form-divider {
+  display: flex;
+  align-items: center;
+  gap: 16px;
+  margin: 24px 0;
+}
+
+.form-divider::before,
+.form-divider::after {
+  content: '';
+  flex: 1;
+  height: 1px;
+  background: var(--border-light);
+}
+
+.form-divider span {
+  font-size: 14px;
+  color: var(--text-muted);
+}
+
+.form-fields {
+  display: flex;
+  flex-direction: column;
+  gap: 20px;
+  margin-bottom: 32px;
+}
+
+.form-field {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+}
+
+.form-field label {
+  font-size: 16px;
+  color: var(--text-secondary);
+}
+
+.input {
+  width: 100%;
+  height: 56px;
+  padding: 0 16px;
+  font-size: 18px;
+  background: var(--bg-card);
+  border: 2px solid var(--border-light);
+  border-radius: var(--radius-lg);
+  transition: border-color var(--transition-fast);
+}
+
+.input:focus {
+  border-color: var(--primary);
+}
+
+.input::placeholder {
+  color: var(--text-light);
+}
+
+.input-with-keyboard {
+  height: 56px;
+  padding: 0 16px;
+  display: flex;
+  align-items: center;
+  background: var(--bg-card);
+  border: 2px solid var(--border-light);
+  border-radius: var(--radius-lg);
+  cursor: pointer;
+  transition: border-color var(--transition-fast);
+}
+
+.input-with-keyboard:hover {
+  border-color: var(--primary);
+}
+
+.input-value {
+  font-size: 18px;
+  color: var(--text-light);
+}
+
+.input-with-keyboard:has(.input-value:not(:empty)) .input-value {
+  color: var(--text-primary);
+}
+
+.reason-options {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 10px;
+}
+
+.reason-btn {
+  padding: 10px 18px;
+  font-size: 15px;
+  background: var(--bg-card);
+  border: 2px solid var(--border-light);
+  border-radius: var(--radius-full);
+  color: var(--text-secondary);
+  cursor: pointer;
+  transition: all var(--transition-fast);
+}
+
+.reason-btn:hover {
+  border-color: var(--primary);
+  color: var(--primary);
+}
+
+.reason-btn.active {
+  background: var(--primary);
+  border-color: var(--primary);
+  color: white;
+}
+
+.btn-submit {
+  width: 100%;
+  height: 64px;
+  font-size: 22px;
+  font-weight: 600;
+  background: var(--primary);
+  color: white;
+  border: none;
+  border-radius: var(--radius-lg);
+  cursor: pointer;
+  box-shadow: var(--shadow-md);
+  transition: all var(--transition-fast);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.btn-submit:hover:not(:disabled) {
+  background: var(--primary-dark);
+  transform: translateY(-2px);
+  box-shadow: var(--shadow-lg);
+}
+
+.btn-submit:disabled {
+  opacity: 0.7;
+  cursor: not-allowed;
+}
+
+.loading-spinner {
+  width: 24px;
+  height: 24px;
+  border: 3px solid rgba(255, 255, 255, 0.3);
+  border-top-color: white;
+  border-radius: 50%;
+  animation: spin 1s linear infinite;
+}
+
+@keyframes spin {
+  to {
+    transform: rotate(360deg);
+  }
+}
+
+/* 键盘弹窗 */
+.keyboard-modal {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background: rgba(0, 0, 0, 0.5);
+  display: flex;
+  align-items: flex-end;
+  z-index: 100;
+  animation: fadeIn 0.2s ease-out;
+}
+
+.keyboard-content {
+  width: 100%;
+  background: var(--bg-card);
+  border-radius: var(--radius-xl) var(--radius-xl) 0 0;
+  padding: 20px;
+  padding-bottom: 40px;
+}
+
+.keyboard-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 16px;
+}
+
+.keyboard-header span {
+  font-size: 18px;
+  font-weight: 500;
+  color: var(--text-primary);
+}
+
+.keyboard-close {
+  padding: 8px 20px;
+  font-size: 16px;
+  color: var(--primary);
+  background: none;
+  border: none;
+  cursor: pointer;
+}
+
+@keyframes fadeIn {
+  from {
+    opacity: 0;
+  }
+  to {
+    opacity: 1;
+  }
+}
+</style>

+ 22 - 0
vite.config.js

@@ -0,0 +1,22 @@
+import { defineConfig } from 'vite'
+import vue from '@vitejs/plugin-vue'
+import { fileURLToPath, URL } from 'node:url'
+
+export default defineConfig({
+  plugins: [vue()],
+  resolve: {
+    alias: {
+      '@': fileURLToPath(new URL('./src', import.meta.url))
+    }
+  },
+  server: {
+    port: 5173,
+    host: true
+  },
+  build: {
+    outDir: 'dist',
+    assetsDir: 'assets',
+    sourcemap: false,
+    chunkSizeWarningLimit: 1000
+  }
+})

+ 1158 - 0
杩庡宸¢€诲畨闃叉満鍣ㄤ汉杩愮淮绔疻eb绠$悊绯荤粺璇︾粏璁捐寮€鍙戞枃妗V2.1.html

@@ -0,0 +1,1158 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+  <meta charset="UTF-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+  <title>迎宾巡逻安防机器人运维端 Web 管理系统详细设计开发文档(一期)</title>
+  <style>
+    :root{--primary:#1f7aec;--green:#10b981;--orange:#f59e0b;--red:#ef4444;--purple:#7c3aed;--text:#1f2937;--sub:#4b5563;--line:#e5e7eb;--bg:#f6f8fb;--card:#fff;--tag:#eef4ff;--code:#f8fafc}
+    *{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;font-family:"PingFang SC","Microsoft YaHei",Arial,sans-serif;color:var(--text);background:var(--bg);line-height:1.72}
+    .container{width:1240px;max-width:calc(100vw - 48px);margin:24px auto 60px}.hero{background:linear-gradient(135deg,#0f172a 0%,#1d4ed8 52%,#0ea5e9 100%);color:#fff;border-radius:22px;padding:38px 42px;box-shadow:0 18px 50px rgba(17,24,39,.16)}
+    .hero h1{margin:0 0 12px;font-size:34px;line-height:1.25}.hero p{margin:8px 0;opacity:.96;font-size:15px}.meta{display:flex;gap:12px;flex-wrap:wrap;margin-top:18px}.chip{padding:6px 12px;border-radius:999px;background:rgba(255,255,255,.14);border:1px solid rgba(255,255,255,.18);font-size:13px}
+    .section{background:var(--card);border-radius:18px;padding:28px 30px;margin-top:20px;box-shadow:0 10px 30px rgba(15,23,42,.06)}h2{margin:0 0 14px;font-size:24px;color:#111827;padding-left:14px;border-left:5px solid var(--primary)}h3{margin:26px 0 10px;font-size:18px;color:#111827}h4{margin:20px 0 8px;font-size:16px;color:#111827}p,li{font-size:14px;color:var(--sub)}ul,ol{margin:8px 0 8px 22px;padding:0}
+    .grid-2{display:grid;grid-template-columns:1fr 1fr;gap:24px}.grid-3{display:grid;grid-template-columns:repeat(3,1fr);gap:16px}.card{border:1px solid var(--line);border-radius:14px;padding:16px 18px;background:#fff}.card strong{color:#111827}.scenario-grid{display:grid;grid-template-columns:1fr 1fr;gap:24px;margin-top:14px}.scenario-card{border:1px solid #d9dee7;border-radius:16px;padding:20px 24px 22px;background:#fff;min-height:210px}.scenario-card h4{margin:0 0 12px;font-size:16px;line-height:1.4;color:#111827}.scenario-card ul{margin:0 0 0 20px;padding:0}.scenario-card li{font-size:14px;line-height:1.75;color:#4b5563;margin:2px 0}.tag{display:inline-block;padding:3px 10px;margin-right:8px;margin-bottom:8px;border-radius:999px;background:var(--tag);color:var(--primary);font-size:12px;border:1px solid #dbeafe}
+    .warn{border-left:5px solid var(--orange);background:#fffaf0;padding:16px 18px;border-radius:12px;color:#7c5a10;font-size:14px}.danger{border-left:5px solid var(--red);background:#fff5f5;padding:16px 18px;border-radius:12px;color:#8f1d1d;font-size:14px}.ok{border-left:5px solid var(--green);background:#f0fdf4;padding:16px 18px;border-radius:12px;color:#166534;font-size:14px}.note{border-left:5px solid var(--purple);background:#faf5ff;padding:16px 18px;border-radius:12px;color:#6b21a8;font-size:14px}
+    table{width:100%;border-collapse:collapse;margin-top:10px;background:#fff;border:1px solid var(--line);table-layout:fixed}th,td{border:1px solid var(--line);padding:10px 12px;text-align:left;vertical-align:top;font-size:13px;color:var(--sub);word-break:break-word}th{background:#f8fafc;color:#111827;font-weight:600}
+    .toc{columns:2;gap:30px}.toc a{display:block;text-decoration:none;color:var(--primary);padding:3px 0;font-size:14px}.code{background:var(--code);border:1px solid var(--line);border-radius:12px;padding:14px 16px;font-family:Consolas,Monaco,monospace;font-size:12px;line-height:1.6;color:#0f172a;white-space:pre-wrap;overflow:auto}.footer{text-align:center;color:#94a3b8;margin-top:20px;font-size:12px}code.inline{background:#eff6ff;color:#1d4ed8;border:1px solid #dbeafe;padding:2px 6px;border-radius:6px;font-size:12px}
+    @media(max-width:900px){.grid-2,.grid-3,.scenario-grid{grid-template-columns:1fr}.scenario-card{padding:18px 20px}.scenario-card h4{font-size:16px}.scenario-card li{font-size:14px}.toc{columns:1}.container{max-width:calc(100vw - 24px)}.hero{padding:24px}.section{padding:22px}table{display:block;overflow:auto}}
+  </style>
+</head>
+<body>
+<div class="container">
+  <div class="hero">
+    <h1>迎宾巡逻安防机器人运维端 Web 管理系统详细设计开发文档(一期)</h1>
+    <p>文档用途:作为运维端、机器人端、主控平台侧的统一开发基线文档,直接用于产品评审、数据库设计、接口开发、前后端开发与测试联调。</p>
+    <p>文档原则:本版以运维端需求为主导进行定义,未定事项按本文直接定版,后续由机器人侧与其他团队按本文配合实现。</p>
+    <div class="meta"><span class="chip">技术基线:RuoYi + Vue3</span><span class="chip">文档级别:详细设计开发版</span><span class="chip">单机器人本地部署</span><span class="chip">局域网 IP + 端口访问</span></div>
+  </div>
+
+  <div class="section"><h2>目录</h2><div class="toc">
+    <a href="#s1">1. 项目定位与约束</a><a href="#s2">2. 用户角色与使用目标</a><a href="#s3">3. 系统边界与设计原则</a><a href="#s4">4. 总体功能架构</a><a href="#s5">5. 菜单结构设计</a><a href="#s6">6. 页面详细设计</a><a href="#s7">7. 后端接口设计</a><a href="#s8">8. 数据库表设计</a><a href="#s9">9. 状态、日志与控制规则</a><a href="#s10">10. 权限与账号设计</a><a href="#s11">11. 开发优先级与实施顺序</a><a href="#s12">12. 其他团队配合要求</a><a href="#s13">13. 测试验收要点</a>
+  </div></div>
+
+  <div class="section" id="s1"><h2>1. 项目定位与约束</h2>
+    <p>本系统为<strong>迎宾巡逻安防机器人本地运维 Web 管理系统</strong>。系统部署在单台机器人本体,运维人员通过局域网访问机器人 IP + 端口进入后台,进行机器人状态查看、内容配置、参数设置、日志诊断、版本升级与基础维护。</p>
+    <div class="ok">本系统不是云端总控平台,不承担多机器人统一调度,也不承担上级业务平台的任务编排、访客预约发起、巡逻策略制定等核心业务能力。</div>
+    <h3>1.1 核心建设目标</h3><div class="grid-3"><div class="card"><strong>本地可维护</strong><br>不依赖云端也可完成机器人基础配置、升级、日志查看与简单控制。</div><div class="card"><strong>内容可配置</strong><br>欢迎语、问答库、多媒体、播报任务、展示主题等均在本地后台完成维护。</div><div class="card"><strong>运维可闭环</strong><br>首页看状态、模块看详情、日志可排查、版本可升级、问题可定位。</div></div>
+    <h3>1.2 一期定版原则</h3><ul><li>凡是前期沟通中已经提到或建议过的功能,一期直接定版,不因外部团队尚未实现而弱化设计。</li><li>对机器人侧、主控平台侧、展示端存在依赖的地方,本文先定义目标能力,再要求对方配合提供接口或实现。</li><li>后续若因为硬件、算法或平台限制产生调整,应基于本文迭代版本,而不是回退为概要需求。</li></ul>
+  </div>
+
+  <div class="section" id="s2"><h2>2. 用户角色与使用目标</h2>
+    <table><thead><tr><th>角色</th><th>主要职责</th><th>核心使用模块</th></tr></thead><tbody><tr><td>管理员</td><td>系统初始化、账号维护、全量配置、升级控制</td><td>全部模块</td></tr><tr><td>运维人员</td><td>日常维护、配置调整、排障处理、日志查看</td><td>首页、内容管理、监控管理、运维管理</td></tr><tr><td>交付实施人员</td><td>现场交付、基础内容录入、播放方案配置、主题设置</td><td>首页、内容管理、系统设置</td></tr><tr><td>售后支持人员</td><td>远程辅助定位问题、指导升级、查看状态</td><td>首页、监控管理、运维管理</td></tr><tr><td>查看人员</td><td>仅查看运行状态和记录,不执行配置和控制操作</td><td>首页、访客记录、对话日志、告警日志</td></tr></tbody></table>
+    <h3>2.1 典型使用目标</h3>
+    <div class="scenario-grid">
+      <div class="scenario-card">
+        <h4>场景 A:现场交付初始化</h4>
+        <ul>
+          <li>接入机器人局域网</li>
+          <li>通过 IP + 端口访问后台</li>
+          <li>登录管理员账号</li>
+          <li>配置欢迎语、问答库、展示主题</li>
+          <li>上传多媒体素材并配置播放方案</li>
+          <li>配置播报内容与定时任务</li>
+        </ul>
+      </div>
+      <div class="scenario-card">
+        <h4>场景 B:日常运维排查</h4>
+        <ul>
+          <li>查看机器人电量、运行状态、告警信息</li>
+          <li>查看摄像头画面并进行远程喊话</li>
+          <li>查看对话日志、访客记录、安防告警记录</li>
+          <li>导出需要的记录或日志</li>
+          <li>按需进行重启、充电等简单操作</li>
+        </ul>
+      </div>
+    </div>
+  </div>
+
+  <div class="section" id="s3"><h2>3. 系统边界与设计原则</h2>
+    <h3>3.1 本系统负责内容</h3><div><span class="tag">本地登录</span><span class="tag">机器人状态查看</span><span class="tag">设备简单控制</span><span class="tag">欢迎语配置</span><span class="tag">问答库管理</span><span class="tag">素材管理</span><span class="tag">播放方案管理</span><span class="tag">播报内容管理</span><span class="tag">播报任务管理</span><span class="tag">展示主题配置</span><span class="tag">访客记录查看导出</span><span class="tag">预约记录查看</span><span class="tag">白名单管理</span><span class="tag">视频预览</span><span class="tag">远程喊话</span><span class="tag">对话日志</span><span class="tag">安防告警日志</span><span class="tag">参数配置</span><span class="tag">日志中心</span><span class="tag">系统诊断</span><span class="tag">版本管理与 OTA</span></div>
+    <h3>3.2 本系统不负责内容</h3><ul><li>多机器人管理和云端总控。</li><li>建图、路径编辑、巡逻任务编排等导航管理页面。</li><li>安防算法逻辑本身,例如跌倒检测、越界检测的算法实现。</li><li>访客预约发起、被访人确认、通行权限下发等上层业务流程。</li></ul><div class="warn">地图与路径功能虽然属于机器人能力的重要组成部分,但本期由其他团队负责,不纳入本 HTML 文档页面和表设计范围。</div>
+    <h3>3.3 设计原则</h3><ul><li>页面先按清晰可开发的业务模块定型,不采用“待定”“后续再说”的写法作为主体内容。</li><li>对动态参数、动态日志等场景,采用“页面结构固定 + 数据内容动态”的设计方式。</li><li>列表页统一遵循:查询区 + 操作区 + 表格区 + 分页区 + 详情弹窗 / 编辑弹窗。</li><li>高风险动作统一弹窗二次确认,并写入操作日志。</li></ul>
+  </div>
+
+  <div class="section" id="s4"><h2>4. 总体功能架构</h2><div class="grid-3">
+    <div class="card"><strong>首页</strong><ul><li>运行总览</li><li>统计信息</li><li>异常告警</li><li>快捷操作</li></ul></div><div class="card"><strong>内容管理</strong><ul><li>欢迎语配置</li><li>问答库管理</li><li>素材管理</li><li>播放方案管理</li><li>播报内容管理</li><li>播报任务管理</li><li>展示主题配置</li></ul></div><div class="card"><strong>访客管理</strong><ul><li>访客记录</li><li>预约记录</li><li>白名单管理</li></ul></div><div class="card"><strong>监控管理</strong><ul><li>视频预览</li><li>远程喊话</li><li>对话日志</li><li>安防告警日志</li></ul></div><div class="card"><strong>运维管理</strong><ul><li>设备状态</li><li>设备控制</li><li>运行参数配置</li><li>系统诊断</li><li>日志中心</li><li>软件版本 / OTA 升级</li></ul></div><div class="card"><strong>系统设置</strong><ul><li>账号管理</li><li>修改密码</li><li>基础设置</li></ul></div>
+  </div></div>
+
+  <div class="section" id="s5"><h2>5. 菜单结构设计</h2>
+    <table><thead><tr><th>一级菜单</th><th>二级菜单</th><th>页面职责</th><th>一期优先级</th></tr></thead><tbody>
+      <tr><td>首页</td><td>首页总览</td><td>展示机器人实时状态、摘要统计、异常告警与快捷操作入口。</td><td>P0</td></tr>
+      <tr><td rowspan="7">内容管理</td><td>欢迎语配置</td><td>维护机器人默认欢迎语和触发控制参数。</td><td>P0</td></tr><tr><td>问答库管理</td><td>维护 FAQ 问答数据,支持字典分类、相似问、导出;一期暂不支持导入。问答分类使用 RuoYi 字典,不单独建设问答分类管理菜单。</td><td>P0</td></tr><tr><td>素材管理</td><td>维护图片、视频素材。</td><td>P0</td></tr><tr><td>播放方案管理</td><td>维护素材播放编排关系、时长、顺序和当前播放方案。</td><td>P0</td></tr><tr><td>播报内容管理</td><td>维护可被播报任务引用的播报文本模板。</td><td>P0</td></tr><tr><td>播报任务管理</td><td>维护播报时间策略、频率、启停状态。</td><td>P0</td></tr><tr><td>展示主题配置</td><td>维护机器人对外展示界面的品牌与主题风格。</td><td>P1</td></tr>
+      <tr><td rowspan="3">访客管理</td><td>访客记录</td><td>查看访客登记记录,支持查询、详情、导出。</td><td>P1</td></tr><tr><td>预约记录</td><td>查看主控平台同步的预约记录。</td><td>P1</td></tr><tr><td>白名单管理</td><td>维护人员白名单数据,支持通过人脸照片、身份证号、手机号进行白名单匹配。</td><td>P1</td></tr>
+      <tr><td rowspan="4">监控管理</td><td>视频预览</td><td>查看机器人摄像头实时画面。</td><td>P1</td></tr><tr><td>远程喊话</td><td>下发喊话内容、查看执行结果。</td><td>P1</td></tr><tr><td>对话日志</td><td>查看人机交互日志。</td><td>P1</td></tr><tr><td>安防告警日志</td><td>查看机器人侧安防告警记录。</td><td>P1</td></tr>
+      <tr><td rowspan="6">运维管理</td><td>设备状态</td><td>查看详细设备状态、资源占用、模块状态。</td><td>P0</td></tr><tr><td>设备控制</td><td>提供一键充电、停止充电、重启、关机、服务重启等操作。</td><td>P0</td></tr><tr><td>运行参数配置</td><td>动态读取参数分组与字段,支持编辑与保存。</td><td>P0</td></tr><tr><td>系统诊断</td><td>查看诊断检查结果、自检结果与关键资源状态。</td><td>P1</td></tr><tr><td>日志中心</td><td>统一查看系统、设备、升级、操作等日志。</td><td>P0</td></tr><tr><td>软件版本 / OTA 升级</td><td>查看版本、上传安装包、执行升级、查看升级记录。</td><td>P0</td></tr>
+      <tr><td rowspan="3">系统设置</td><td>账号管理</td><td>维护后台账号、角色、状态。</td><td>P1</td></tr><tr><td>修改密码</td><td>当前登录账号修改密码。</td><td>P0</td></tr><tr><td>基础设置</td><td>维护系统名称、Logo、页脚信息等后台基础配置。</td><td>P2</td></tr>
+    </tbody></table>
+
+    <h3>5.1 RuoYi 代码生成与定制开发划分</h3>
+    <div class="note">本项目基于 RuoYi + Vue3 开发。RuoYi 代码生成器适合根据数据库表快速生成常规列表页、表单页、Controller、Service、Mapper 等基础 CRUD 代码;但对于首页大屏式总览、实时视频、远程控制、OTA 进度、动态参数表单、播放编排等交互复杂页面,仍需要 Cursor 或开发人员进行定制化开发。</div>
+    <table>
+      <thead>
+        <tr>
+          <th>一级菜单</th>
+          <th>页面/模块</th>
+          <th>开发方式建议</th>
+          <th>是否适合 RuoYi 代码生成</th>
+          <th>说明</th>
+        </tr>
+      </thead>
+      <tbody>
+        <tr><td>首页</td><td>首页总览</td><td>定制开发</td><td>否</td><td>首页涉及机器人实时状态、统计卡片、告警摘要、快捷操作入口,属于聚合看板页面,不能直接按单表 CRUD 生成。</td></tr>
+        <tr><td rowspan="7">内容管理</td><td>欢迎语配置</td><td>定制开发</td><td>不建议生成前端列表页</td><td>欢迎语配置为单配置页,不是多条数据 CRUD 页面。数据库通过 config_key=default 定位默认配置;前端建议自定义表单页,直接加载和保存默认配置,不提供列表、新增、删除。</td></tr>
+        <tr><td>问答库管理</td><td>RuoYi 主子表生成后定制</td><td>部分适合</td><td>可基于 robot_ops_faq、robot_ops_faq_similar 生成基础 CRUD;问题分类使用 RuoYi 字典 robot_faq_category,不单独生成问答分类管理页面;前端需将主子表明细表格调整为“相似问多行输入,一行一个”的交互方式;sortNo 作为保留字段,不在页面展示和编辑;启用/停用、导出、分类字典回显需按业务微调。一期暂不支持问答库导入,后续如运营需要批量维护再扩展。</td></tr>
+        <tr><td>素材管理</td><td>RuoYi 生成后定制</td><td>部分适合</td><td>可基于 robot_ops_media_asset 生成基础列表、查询、详情、编辑、删除、导出接口和页面;当前前端已基于 RuoYi 接口和 /common/uploadMediaFile 上传接口完成定制。前端只允许用户维护素材名称、启用状态和备注,文件信息由上传接口返回或系统维护;缩略图展示、图片/视频预览、引用状态展示、删除引用保护提示均需定制。</td></tr>
+        <tr><td>播放方案管理</td><td>RuoYi 主子表生成后定制</td><td>部分适合</td><td>播放方案由主表 robot_ops_play_plan 和子表 robot_ops_play_plan_item 组成,适合先使用 RuoYi 主子表生成基础列表、表单、Controller、Service、Mapper,再进行定制。前端需将原始子表表格调整为“选择素材 + 素材编排”交互,支持素材选择、顺序调整、图片停留时长、视频默认播完切换、播放状态切换、预览方案。播放状态 status=1 表示当前播放方案,status=0 表示备用方案;同一时间只允许一个播放方案处于当前播放状态,该规则需由后端事务控制。素材 quotedFlag 维护需由后端补充业务逻辑。</td></tr>
+        <tr><td>播报内容管理</td><td>RuoYi 生成后微调</td><td>适合</td><td>典型单表 CRUD,可基于 robot_ops_broadcast_content 生成,再补充测试播报按钮。</td></tr>
+        <tr><td>播报任务管理</td><td>RuoYi 生成后定制</td><td>部分适合</td><td>基础 CRUD 可生成;时间段、频率、循环规则、复制任务等需要定制表单校验和交互。</td></tr>
+        <tr><td>展示主题配置</td><td>RuoYi 生成后定制</td><td>部分适合</td><td>基础 CRUD 可生成;Logo/背景上传、颜色选择器、主题预览、设为启用需要定制。</td></tr>
+        <tr><td rowspan="3">访客管理</td><td>访客记录</td><td>RuoYi 生成后定制</td><td>部分适合</td><td>可基于 robot_ops_visitor_record 生成只读列表和详情页面;需去掉新增、编辑、删除,补充到访类型、登记方式、访客来源、来访事由、照片预览、时间范围查询、详情弹窗和导出字段优化。列表中访客照片前置到身份证号后,便于快速识别访客身份。</td></tr>
+        <tr><td>预约记录</td><td>RuoYi 生成后微调</td><td>适合</td><td>典型查询列表和详情页面,可基于 robot_ops_appointment_record 生成,数据来源为主控平台同步。</td></tr>
+        <tr><td>白名单管理</td><td>RuoYi 生成后定制</td><td>部分适合</td><td>可基于 robot_ops_whitelist 生成基础 CRUD;需补充身份证号、人脸照片上传/预览、有效期、人员类型、启用/停用、导入导出,以及手机号/身份证号/人脸照片至少填写一种的表单校验。</td></tr>
+        <tr><td rowspan="4">监控管理</td><td>视频预览</td><td>定制开发</td><td>否</td><td>实时视频播放、重连、全屏、状态提示依赖视频流接口和播放器组件,需要定制开发。</td></tr>
+        <tr><td>远程喊话</td><td>半定制开发</td><td>部分适合</td><td>喊话记录列表可基于 robot_ops_shout_record 生成;喊话输入、预置短语、立即喊话操作需要定制。</td></tr>
+        <tr><td>对话日志</td><td>RuoYi 生成后微调</td><td>适合</td><td>典型日志查询和详情页面,可基于 robot_ops_dialogue_log 生成,详情页需展示原始请求和原始响应。</td></tr>
+        <tr><td>安防告警日志</td><td>RuoYi 生成后微调</td><td>适合</td><td>典型日志查询页面,可基于 robot_ops_alarm_log 生成,再补充确认告警、忽略告警、抓拍图预览。</td></tr>
+        <tr><td rowspan="6">运维管理</td><td>设备状态</td><td>定制开发</td><td>否</td><td>数据来自机器人侧实时接口,不落库;页面为状态面板和模块状态展示,不能按数据库表生成。</td></tr>
+        <tr><td>设备控制</td><td>半定制开发</td><td>部分适合</td><td>控制记录列表可基于 robot_ops_control_record 生成;一键充电、重启、关机、服务重启等控制按钮和二次确认需要定制。</td></tr>
+        <tr><td>运行参数配置</td><td>定制开发</td><td>否</td><td>虽然有 robot_ops_param 表,但页面需要根据参数分组和值类型动态渲染表单控件,不能直接按固定 CRUD 页面生成。</td></tr>
+        <tr><td>系统诊断</td><td>半定制开发</td><td>部分适合</td><td>诊断项列表可基于 robot_ops_diagnosis_item 生成;立即诊断、诊断总览和诊断结果聚合需要定制。</td></tr>
+        <tr><td>日志中心</td><td>RuoYi 生成后定制</td><td>部分适合</td><td>单表日志可生成,但日志中心需要整合 robot_ops_sys_log 和 robot_ops_operate_log,并通过 sourceType 区分来源,需要定制查询逻辑。</td></tr>
+        <tr><td>软件版本 / OTA 升级</td><td>定制开发</td><td>否</td><td>升级包列表和升级记录可参考生成代码,但上传安装包、执行升级、进度刷新、结果展示属于流程型页面,需要定制开发。</td></tr>
+        <tr><td rowspan="3">系统设置</td><td>账号管理</td><td>优先复用 RuoYi</td><td>视权限方案而定</td><td>如果使用 RuoYi 原生权限体系,优先复用系统用户、角色、菜单管理;如果采用本文简化账号表,可基于 robot_ops_user 生成后微调。</td></tr>
+        <tr><td>修改密码</td><td>复用/定制均可</td><td>否</td><td>修改密码通常复用 RuoYi 用户中心能力,不建议按表生成单独 CRUD 页面。</td></tr>
+        <tr><td>基础设置</td><td>半定制开发</td><td>部分适合</td><td>可基于 robot_ops_system_config 生成基础表单,但页面更接近单配置页,需定制保存逻辑。</td></tr>
+      </tbody>
+    </table>
+    <div class="warn">开发建议:先使用 RuoYi 代码生成器生成“适合/部分适合”的基础列表、表单和后端 CRUD,再由 Cursor 或开发人员根据第 6 章页面设计、第 7 章接口设计进行二次调整。标记为“定制开发”的页面不要强行套用单表 CRUD 模板,否则后续返工成本较高。</div>
+  </div>
+
+  <div class="section" id="s6"><h2>6. 页面详细设计</h2>
+    <h3>6.1 登录页</h3><table><thead><tr><th>项</th><th>详细设计</th></tr></thead><tbody><tr><td>页面目标</td><td>本地后台登录入口,控制未授权用户访问。</td></tr><tr><td>展示字段</td><td>账号、密码、登录按钮、错误提示文案、系统标题、系统 Logo。</td></tr><tr><td>操作按钮</td><td>登录。</td></tr><tr><td>校验规则</td><td>账号不能为空;密码不能为空;连续输错密码 5 次锁定 10 分钟。</td></tr><tr><td>登录成功动作</td><td>写入 token / session,跳转首页。</td></tr><tr><td>登录失败动作</td><td>显示失败原因;账号或密码错误时不暴露具体哪个字段错误。</td></tr></tbody></table>
+
+    <h3>6.2 首页 / 工作台</h3><p>首页按<strong>顶部状态区 + 统计区 + 告警区 + 快捷操作区 + 最近记录区</strong>进行设计。</p><div class="note">首页中的机器人基础信息、实时运行状态、资源占用状态、模块状态等数据由机器人侧接口实时返回,运维端一期不建设机器人基础信息表和设备状态快照表。</div>
+    <h4>6.2.1 顶部状态区字段</h4><table><thead><tr><th>字段</th><th>说明</th><th>展示形式</th></tr></thead><tbody><tr><td>机器人名称(robotName)</td><td>机器人名称</td><td>文本</td></tr><tr><td>机器人编号(robotCode)</td><td>机器人编号</td><td>文本</td></tr><tr><td>在线状态(onlineStatus)</td><td>在线状态:在线/离线</td><td>状态标签</td></tr><tr><td>工作状态(workStatus)</td><td>工作状态:空闲/接待中/播报中/充电中/异常</td><td>状态标签</td></tr><tr><td>电量百分比(batteryLevel)</td><td>电量百分比</td><td>进度条 + 文本</td></tr><tr><td>充电状态(chargeStatus)</td><td>充电状态:未充电/充电中/充满</td><td>状态标签</td></tr><tr><td>网络状态(networkStatus)</td><td>网络状态:正常/异常</td><td>状态标签</td></tr><tr><td>设备本地 IP(ipAddress)</td><td>设备本地 IP</td><td>文本</td></tr><tr><td>存储占用情况(storageUsed)</td><td>存储占用情况</td><td>文本,例如 18.3GB / 64GB</td></tr><tr><td>系统主版本号(currentVersion)</td><td>系统主版本号</td><td>文本</td></tr></tbody></table>
+    <h4>6.2.2 统计区字段</h4><table><thead><tr><th>字段</th><th>说明</th><th>点击跳转</th></tr></thead><tbody><tr><td>今日访客登记数量(todayVisitorCount)</td><td>今日访客登记数量</td><td>访客记录</td></tr><tr><td>今日预约记录数量(todayAppointmentCount)</td><td>今日预约记录数量</td><td>预约记录</td></tr><tr><td>今日对话次数(todayDialogueCount)</td><td>今日对话次数</td><td>对话日志</td></tr><tr><td>今日播报次数(todayBroadcastCount)</td><td>今日播报次数</td><td>播报任务</td></tr><tr><td>今日安防告警数量(todayAlarmCount)</td><td>今日安防告警数量</td><td>安防告警日志</td></tr><tr><td>今日运维操作次数(todayOperateCount)</td><td>今日运维操作次数</td><td>日志中心</td></tr></tbody></table>
+    <h4>6.2.3 告警与快捷操作</h4><table><thead><tr><th>区块</th><th>内容</th></tr></thead><tbody><tr><td>最近系统异常</td><td>最近 5 条系统异常摘要,点击跳日志中心。</td></tr><tr><td>最近安防告警</td><td>最近 5 条安防告警摘要,点击跳安防告警日志。</td></tr><tr><td>最近升级失败</td><td>最近 5 条升级失败摘要,点击跳 OTA 升级页。</td></tr><tr><td>快捷按钮</td><td>查看摄像头、远程喊话、一键充电、停止充电、重启机器人、进入 OTA。</td></tr></tbody></table>
+
+    <h3>6.3 内容管理</h3>
+    <h4>6.3.1 欢迎语配置页面</h4><table><thead><tr><th>字段/功能</th><th>类型</th><th>详细设计</th></tr></thead><tbody><tr><td>页面目标</td><td>-</td><td>维护机器人默认欢迎语配置。该页面为单配置页,不提供列表、新增、删除;页面打开后直接加载 config_key=default 的默认配置,保存时更新该配置。</td></tr><tr><td>欢迎语文本(welcomeText)</td><td>textarea</td><td>欢迎语文本,最大 200 字。</td></tr><tr><td>语音播报(voiceEnabled)</td><td>switch</td><td>控制欢迎语触发时是否进行语音播报。启用后,机器人检测到访客时可语音播报欢迎语;关闭后,欢迎语可仅用于屏幕展示。</td></tr><tr><td>语音播报冷却时间(cooldownSeconds)</td><td>number</td><td>控制语音欢迎语的重复播报间隔,单位秒,默认 30。机器人语音播报欢迎语后,在冷却时间内再次检测到访客时,不重复语音播报,可仅进行屏幕展示。</td></tr><tr><td>启用欢迎语(status)</td><td>switch</td><td>控制欢迎语功能整体是否启用。停用后,机器人检测到访客时不触发欢迎语。</td></tr><tr><td>备注(remark)</td><td>input</td><td>备注说明。</td></tr><tr><td>保存</td><td>button</td><td>保存当前配置(更新 config_key=default 的配置)。</td></tr><tr><td>恢复默认</td><td>button</td><td>前端本地恢复系统默认欢迎语配置,不直接写入数据库;用户需再次点击“保存配置”后才更新 config_key=default 的配置。</td></tr><tr><td>测试播报</td><td>button</td><td>下发测试播报指令,用于验证当前欢迎语文本和语音播报配置。测试播报不新增或修改配置数据。</td></tr></tbody></table>
+    <h4>6.3.2 问答库管理页面</h4>
+<table><thead><tr><th>模块</th><th>详细设计</th></tr></thead><tbody>
+<tr><td>页面目标</td><td>维护机器人 FAQ 问答内容,支持按问题分类管理标准问题、相似问和答案内容。页面基于 robot_ops_faq 主表和 robot_ops_faq_similar 相似问表实现,前端以一个“问答库管理”页面统一维护,不单独建设相似问管理菜单。</td></tr>
+<tr><td>查询条件</td><td>问题分类(categoryType,数据来源:RuoYi 字典 robot_faq_category)、标准问题关键字、启用状态。</td></tr>
+<tr><td>列表字段</td><td>问题分类、标准问题、相似问数量、答案摘要、启用状态、更新时间、操作。</td></tr>
+<tr><td>操作按钮</td><td>新增、编辑、删除、批量删除、启用/停用、导出。</td></tr>
+<tr><td>编辑弹窗字段</td><td>问题分类(categoryType,RuoYi 字典 robot_faq_category)、标准问题(question)、相似问(similarQuestions)、答案内容(answer)、启用状态(status)、备注(remark)。</td></tr>
+<tr><td>相似问交互</td><td>相似问使用多行文本框维护,一行一个相似问;保存时前端转换为相似问列表,后端写入 robot_ops_faq_similar 表。</td></tr>
+<tr><td>答案内容规则</td><td>答案内容最多 2000 字,列表中只展示答案摘要,完整答案在编辑弹窗中维护。</td></tr>
+<tr><td>排序规则</td><td>sortNo / sort_no 作为保留字段,数据库继续保留;前端页面一期不展示、不编辑。新增问答时默认 sortNo=0,编辑问答时保持原值。后续如需在机器人屏幕端展示推荐问答或人工排序,再重新设计推荐/置顶/排序能力。</td></tr>
+<tr><td>业务规则</td><td>同一分类下标准问题建议不重复,新增和编辑时由后端进行重复校验。删除主问答时,应同步删除其相似问数据。</td></tr>
+<tr><td>导入说明</td><td>问答库一期暂不支持导入。后续如运营确实需要批量维护问答内容,再单独扩展 Excel 导入模板、问题分类匹配、相似问拆分、重复校验和失败明细回显能力。</td></tr>
+</tbody></table>
+    <h4>6.3.3 素材管理页面</h4>
+<table><thead><tr><th>模块</th><th>详细设计</th></tr></thead><tbody>
+<tr><td>页面目标</td><td>维护机器人展示、播放方案等功能使用的图片和视频素材。素材管理可基于 RuoYi 生成基础列表和表单,再定制上传、缩略图展示、图片/视频预览、启用/停用、引用状态和删除保护能力。</td></tr>
+<tr><td>查询条件</td><td>素材名称、素材类型、启用状态、上传时间范围。</td></tr>
+<tr><td>列表字段</td><td>缩略图(thumbnailUrl)、素材名称(assetName)、素材类型(assetType)、文件格式(fileFormat)、文件大小(fileSize)、视频时长(durationSeconds)、分辨率(resolution)、上传时间(createTime)、引用状态(quotedFlag)、启用状态(status)、操作。</td></tr>
+<tr><td>操作按钮</td><td>上传素材、预览、编辑、删除、启用/停用、批量删除。</td></tr>
+<tr><td>编辑字段</td><td>用户只允许维护素材名称(assetName)、启用状态(status)、备注(remark)。文件地址、缩略图、文件大小、文件格式、MIME 类型、视频时长、分辨率、引用状态等信息由上传接口返回或系统维护,前端只读展示,不允许用户手动填写。</td></tr>
+<tr><td>素材类型</td><td>素材类型 assetType 为业务类型,建议使用 RuoYi 字典 media_asset_type,字典项:image=图片,video=视频。素材类型可由上传接口根据文件 MIME 类型或后缀自动判断。</td></tr>
+<tr><td>文件信息</td><td>fileUrl 为素材文件访问地址;thumbnailUrl 为缩略图地址,图片可等于 fileUrl,视频只有后端返回 thumbnailUrl 时才展示封面,否则前端显示默认视频图标;fileSize 为文件大小,单位字节;fileFormat 为文件格式/后缀;mimeType 为文件 MIME 类型;resolution 为分辨率;durationSeconds 为视频时长,图片素材为空。resolution、durationSeconds 允许为空。</td></tr>
+<tr><td>上传规则</td><td>图片支持 jpg/png/webp;视频支持 mp4;单文件大小默认上限 500MB。当前前端实际调用 /common/uploadMediaFile 上传素材文件,上传成功后回填 fileUrl、thumbnailUrl、fileSize、fileFormat、mimeType、durationSeconds、resolution、assetType 等字段。若部分字段为空,前端按默认图标或“-”兜底展示。</td></tr>
+<tr><td>引用状态</td><td>quotedFlag 表示素材是否被播放方案引用,由系统维护,前端只读展示,不允许编辑。删除素材时,后端必须检查播放方案明细表中的实际引用关系,不应仅依赖 quotedFlag。</td></tr>
+<tr><td>引用保护</td><td>被播放方案引用的素材不可直接删除,需先解除引用。接口应返回明确提示,例如“该素材已被 2 个播放方案引用,请先解除引用后再删除”。</td></tr>
+<tr><td>预览规则</td><td>图片弹窗预览;视频弹窗播放器预览;视频无缩略图时,列表展示默认视频图标;无法播放时提示格式不支持。</td></tr>
+<tr><td>业务规则</td><td>启用状态为启用的素材可被新播放方案选择;停用素材不可被新播放方案选择,但历史播放方案已引用的停用素材仍需支持回显。</td></tr>
+</tbody></table>
+    <h4>6.3.4 播放方案管理页面</h4>
+<table><thead><tr><th>模块</th><th>详细设计</th></tr></thead><tbody>
+<tr><td>页面目标</td><td>维护机器人屏幕播放使用的素材编排方案。一个播放方案由主表信息和多个素材明细组成,素材来源于素材管理模块。</td></tr>
+<tr><td>开发方式</td><td>基于 RuoYi 主子表生成基础页面和接口,主表 robot_ops_play_plan,子表 robot_ops_play_plan_item,关联字段 plan_id。生成后前端需要定制为“选择素材 + 素材编排”的交互方式。</td></tr>
+<tr><td>查询条件</td><td>方案名称、循环方式(loopMode)、播放状态(status)。</td></tr>
+<tr><td>列表字段</td><td>方案名称(planName)、循环方式(loopMode)、素材数量(assetCount)、播放状态(status)、更新时间(updateTime)、操作。</td></tr>
+<tr><td>主表编辑字段</td><td>方案名称(planName)、循环方式(loopMode)、播放状态(status)、备注(remark)。播放状态 status=1 表示当前播放,status=0 表示备用方案。</td></tr>
+<tr><td>素材明细字段</td><td>素材ID(assetId)、播放顺序(playOrder)、停留时长(staySeconds)、转场方式(transitionType,预留字段,默认 none)。素材名称、素材类型、文件地址、缩略图、视频时长、分辨率等展示字段不在明细表中冗余保存,由后端根据 assetId 关联 robot_ops_media_asset 返回。</td></tr>
+<tr><td>素材选择</td><td>新增素材明细时,通过“选择素材”弹窗从素材库选择启用状态的素材。前端保存时只提交 assetId、playOrder、staySeconds、transitionType 等播放编排字段;素材名称、素材类型、文件地址、缩略图、视频时长等信息由后端查询详情或预览时关联素材表返回。</td></tr>
+<tr><td>素材编排</td><td>播放方案明细支持上移、下移或拖拽排序。保存时根据当前顺序重新生成 playOrder。</td></tr>
+<tr><td>图片播放规则</td><td>当 assetType=image 时,staySeconds 必填,建议默认 10 秒,取值范围 1-3600 秒。</td></tr>
+<tr><td>视频播放规则</td><td>当 assetType=video 时,staySeconds 可为空,表示按视频自身播放。一期不做视频截断播放逻辑。</td></tr>
+<tr><td>转场方式</td><td>transitionType 作为预留字段,一期前端不展示、不编辑,默认值为 none。后续车端屏幕支持转场效果后,再开放配置,例如 none=无转场、fade=淡入淡出。</td></tr>
+<tr><td>操作按钮</td><td>新增、编辑、删除、启用播放、设为备用、预览。</td></tr>
+<tr><td>播放状态规则</td><td>status=1 表示当前播放方案,status=0 表示备用方案。同一时间只允许一个播放方案 status=1。启用某个方案为当前播放时,后端应在事务中将其他方案 status 置为 0,将当前方案 status 置为 1。车端只读取 status=1 的播放方案。</td></tr>
+<tr><td>删除规则</td><td>删除方案时需要同步删除其素材明细,并重新计算相关素材的 quotedFlag。如删除当前播放方案,可能导致车端无可播放方案,建议后端根据实际业务策略进行限制或提示。</td></tr>
+<tr><td>素材引用规则</td><td>播放方案新增、编辑、删除后,后端需要重新计算相关素材的 quotedFlag。只要素材被任意播放方案明细引用,则 quotedFlag=1;不再被任何播放方案引用,则 quotedFlag=0。</td></tr>
+<tr><td>预览规则</td><td>一期前端已基于播放方案详情接口实现预览弹窗。点击"预览"后,前端调用详情接口获取方案信息和素材明细,按 playOrder 顺序展示播放清单;左侧展示当前素材的大图或视频播放器,右侧展示播放清单,支持上一个、下一个和点击清单切换。图片按 staySeconds 展示停留时长;视频使用 fileUrl 播放,默认播完后切换到下一个素材。若后续后端提供独立 preview 接口,可仅替换预览数据源,页面结构无需大改。</td></tr>
+<tr><td>业务规则</td><td>新增/编辑时至少选择一个素材。停用素材不可被新播放方案选择,但历史方案中已引用的停用素材仍需支持回显。</td></tr>
+<tr><td>素材信息回显</td><td>播放方案详情、编辑回显和预览接口需要关联 robot_ops_media_asset 返回素材展示信息,包括 assetName、assetType、fileUrl、thumbnailUrl、durationSeconds、resolution、assetStatus 等。明细表本身不保存这些快照字段。</td></tr>
+</tbody></table>
+    <div class="note">字典建议:loopMode 可使用 RuoYi 字典 play_plan_loop_mode,字典项:loop=循环播放,once=播放一次。transitionType 为预留字段,一期不开放配置。</div>
+    <h4>6.3.5 播报内容管理页面</h4><table><thead><tr><th>模块</th><th>详细设计</th></tr></thead><tbody><tr><td>查询条件</td><td>内容名称、内容分类、启用状态。</td></tr><tr><td>列表字段</td><td>内容名称、内容分类、播报文本摘要、启用状态、更新时间、操作。</td></tr><tr><td>编辑字段</td><td>内容名称(contentName)、内容分类(contentType)、播报文本(broadcastText)、启用状态(status)、备注(remark)。</td></tr><tr><td>操作按钮</td><td>新增、编辑、删除、启用/停用、测试播报。</td></tr><tr><td>内容分类</td><td>通知、宣传、提示、安防提醒、自定义。</td></tr></tbody></table>
+    <h4>6.3.6 播报任务管理页面</h4><table><thead><tr><th>模块</th><th>详细设计</th></tr></thead><tbody><tr><td>查询条件</td><td>任务名称、循环类型、启用状态。</td></tr><tr><td>列表字段</td><td>任务名称、关联内容名称、开始时间、结束时间、播报频率(分钟)、循环类型、循环取值、启用状态、操作。</td></tr><tr><td>编辑字段</td><td>任务名称(taskName)、关联播报内容(contentId,页面显示内容名称)、开始时间(startTime)、结束时间(endTime)、播报频率分钟数(frequencyMinutes,单位:分钟)、循环类型(cycleType)、循环取值(cycleValue)、启用状态(status)、备注(remark)。</td></tr><tr><td>cycleType</td><td>使用 RuoYi 字典 <code class="inline">broadcast_task_cycle_type</code>,字典值:1=按星期,2=按日期。</td></tr><tr><td>cycleValue</td><td>当 cycleType=1(按星期)时,保存星期值,1=星期一、2=星期二、3=星期三、4=星期四、5=星期五、6=星期六、7=星期日,多个值用英文逗号分隔,例如 1,2,3,4,5。当 cycleType=2(按日期)时,保存指定日期,多个日期用英文逗号分隔,例如 2026-03-20,2026-03-21。</td></tr><tr><td>操作按钮</td><td>新增、编辑、复制、删除、启用/停用。</td></tr><tr><td>校验规则</td><td>结束时间必须大于开始时间;frequencyMinutes 必须大于 0,单位为分钟;当循环类型为按星期时,至少选择一个星期;当循环类型为按日期时,至少选择一个指定日期。</td></tr><tr><td>交互规则</td><td>关联播报内容列表显示内容名称;新增/编辑时仅允许选择启用状态的播报内容;历史任务关联的播报内容如已停用,编辑时仍需可回显,并显示“已停用”提示,但不可重新选择停用内容。</td></tr></tbody></table>
+    <h4>6.3.7 展示主题配置页面</h4><table><thead><tr><th>模块</th><th>详细设计</th></tr></thead><tbody><tr><td>查询条件</td><td>主题名称、启用状态。</td></tr><tr><td>列表字段</td><td>主题名称、Logo、背景资源、主色、启用状态、当前启用标识、更新时间、操作。</td></tr><tr><td>编辑字段</td><td>主题名称(themeName)、Logo 地址(logoUrl)、背景类型(backgroundType)、背景资源地址(backgroundUrl)、主色(primaryColor)、辅助色(secondaryColor)、欢迎标题(welcomeTitle)、欢迎副标题(welcomeSubTitle)、启用状态(status)。</td></tr><tr><td>操作按钮</td><td>新增、编辑、删除、设为启用、预览。</td></tr><tr><td>业务规则</td><td>同一时刻仅允许一个主题处于“当前启用”状态。</td></tr></tbody></table>
+
+    <h3>6.4 访客管理</h3>
+    <h4>6.4.1 访客记录页面</h4>
+<table><thead><tr><th>模块</th><th>详细设计</th></tr></thead><tbody>
+<tr><td>页面目标</td><td>展示已完成登记的访客到访记录。访客记录由机器人屏幕登记流程或扫码 H5 登记流程产生,运维端仅负责查看、详情和导出,不提供新增、编辑、删除。</td></tr>
+<tr><td>查询条件</td><td>访客姓名、手机号、身份证号、到访类型、登记方式、被访对象、来访时间范围。</td></tr>
+<tr><td>列表字段</td><td>访客姓名、手机号、身份证号、访客照片、到访类型、登记方式、访客来源、来访事由、预约单号、被访对象、来访时间、操作。</td></tr>
+<tr><td>详情字段</td><td>访客姓名(visitorName)、手机号(mobile)、身份证号(idCardNo)、到访类型(visitType)、登记方式(registerType)、访客来源(visitorSource)、来访事由(visitReason)、访客照片(visitorPhoto)、预约单号(appointmentNo)、被访对象(visitedPerson)、来访时间(visitTime)、备注(remark)。</td></tr>
+<tr><td>到访类型</td><td>用于区分访客到访业务类型,建议字典项为:APPOINTMENT=预约到访,WALK_IN=现场登记。</td></tr>
+<tr><td>登记方式</td><td>用于区分访客完成登记的入口,建议字典项为:SCREEN=机器人端,H5=手机端。机器人端指访客在机器人屏幕完成登记;手机端指访客扫码后在 H5 页面完成登记。身份证读卡、手机号输入、人脸拍照等可作为机器人端登记流程中的信息采集方式,不单独作为登记方式。</td></tr>
+<tr><td>访客来源</td><td>用于描述访客来自哪里或属于什么来源,字段为 visitorSource / visitor_source,适配公司、酒店、小区、园区、展厅等多场景,例如公司名称、合作方、亲友、外卖、快递、供应商、施工单位、旅行团等。该字段建议选填。</td></tr>
+<tr><td>来访事由</td><td>用于描述访客本次到访目的,字段为 visitReason / visit_reason,例如业务接洽、走亲访友、酒店入住、外卖配送、快递投递、设备维修、参观接待、会议拜访、施工进场等。该字段建议选填,一期采用文本输入,不强制枚举。</td></tr>
+<tr><td>访客照片</td><td>由机器人端摄像头或登记流程采集上传,运维端只做展示和预览,不支持手动上传修改。列表中访客照片放在身份证号之后,便于运维人员优先查看访客身份信息;详情中可展示较大尺寸照片预览。</td></tr>
+<tr><td>预约关联</td><td>预约到访记录通过 appointmentNo 关联预约记录;现场登记记录 appointmentNo 可为空。</td></tr>
+<tr><td>操作按钮</td><td>查看详情、导出。</td></tr>
+<tr><td>业务规则</td><td>访客记录代表已经完成登记的到访记录,不设置登记结果字段。登记失败、身份证读取失败、扫码失败、预约匹配失败等过程异常,不进入访客记录,应进入日志中心或后续扩展的登记异常日志。</td></tr>
+<tr><td>与白名单关系</td><td>访客记录与白名单识别记录分开管理。命中白名单不作为访客登记结果;白名单命中属于识别或通行逻辑,可后续在识别日志、通行记录或对话/安防日志中体现。</td></tr>
+<tr><td>与预约记录关系</td><td>预约记录由主控平台同步,表示计划来访;访客到现场后通过机器人屏幕或扫码 H5 完成登记确认,并生成访客记录。</td></tr>
+<tr><td>导出字段</td><td>导出列表主要字段;访客照片建议导出“有照片/无照片”或照片链接,不直接导出图片文件。</td></tr>
+</tbody></table>
+    <h4>6.4.2 预约记录页面</h4><table><thead><tr><th>模块</th><th>详细设计</th></tr></thead><tbody><tr><td>查询条件</td><td>预约单号、访客姓名、手机号、预约状态、预约时间范围。</td></tr><tr><td>列表字段</td><td>预约单号、访客姓名、手机号、被访人、预约时间、状态、同步时间、操作。</td></tr><tr><td>详情字段</td><td>预约单号(appointmentNo)、访客姓名(visitorName)、手机号(mobile)、被访人(visitedPerson)、预约时间(appointmentTime)、预约状态(status)、同步时间(syncTime)、来源平台(sourcePlatform)、备注(remark)。</td></tr><tr><td>操作按钮</td><td>查看详情。</td></tr><tr><td>数据来源</td><td>主控平台同步;本地端仅展示,不发起预约流程。</td></tr></tbody></table>
+    <h4>6.4.3 白名单管理页面</h4>
+<table><thead><tr><th>模块</th><th>详细设计</th></tr></thead><tbody>
+<tr><td>页面目标</td><td>维护可被机器人识别为可信人员的白名单数据,支持通过人脸照片、身份证号、手机号等信息进行白名单匹配。</td></tr>
+<tr><td>查询条件</td><td>姓名、手机号、身份证号、人员类型、来源类型、启用状态。</td></tr>
+<tr><td>列表字段</td><td>姓名、手机号、身份证号、人员类型、是否有人脸照片、来源类型、有效期、启用状态、更新时间、操作。</td></tr>
+<tr><td>编辑字段</td><td>姓名(name)、人员类型(whitelistType,页面显示为“人员类型”)、手机号(mobile)、身份证号(idCardNo)、人脸照片(faceImageUrl)、来源类型(sourceType,只读展示,不允许人工修改)、有效开始时间(validStartTime)、有效结束时间(validEndTime)、启用状态(status)、备注(remark)。</td></tr>
+<tr><td>人员类型</td><td>人员类型用于描述白名单人员身份,建议字典项为:内部人员、访客、VIP、其他。不建议将“人脸白名单”作为人员类型,因为人脸识别属于匹配方式,不属于人员身份类型。</td></tr>
+<tr><td>操作按钮</td><td>新增、编辑、删除、导入、导出、启用/停用。</td></tr>
+<tr><td>表单校验</td><td>姓名、人员类型、来源类型、启用状态必填;手机号、身份证号、人脸照片三者至少填写一种;手机号填写时需符合大陆手机号格式;身份证号填写时需符合 18 位身份证基础格式;有效结束时间如填写,必须大于有效开始时间。</td></tr>
+<tr><td>人脸照片规则</td><td>一期仅保存人脸照片地址(faceImageUrl),不保存人脸特征ID。机器人侧按照片进行人脸比对,建议上传清晰正脸照片;前端上传限制为 2MB,支持 png/jpg/jpeg 格式。</td></tr>
+<tr><td>匹配规则</td><td>白名单不设置单一识别方式字段。机器人或后端根据当前采集到的身份信息进行匹配:人脸识别时通过人脸照片比对;身份证读取或输入时匹配身份证号;手机号输入时匹配手机号。任一方式匹配到启用且在有效期内的白名单人员,即视为白名单命中。</td></tr>
+<tr><td>业务规则</td><td>来源类型由系统自动识别,不允许用户手动修改。运维后台手动新增和导入的数据默认来源类型为 1=本地录入;主控平台同步数据由同步接口写入 2=平台同步;机器人端采集数据由机器人采集接口写入 3=机器人采集。列表中展示来源类型,查询区支持按来源类型筛选。</td></tr>
+<tr><td>有效期规则</td><td>有效开始时间为空表示立即生效;有效结束时间为空表示长期有效。列表中根据当前时间动态展示有效期状态:有效、未生效、已过期、长期有效。一期不提供有效期状态筛选条件。</td></tr>
+</tbody></table>
+
+    <h3>6.5 监控管理</h3>
+    <h4>6.5.1 视频预览页面</h4><table><thead><tr><th>区域</th><th>详细设计</th></tr></thead><tbody><tr><td>播放器区域</td><td>展示实时视频画面,支持播放、暂停、刷新、全屏。</td></tr><tr><td>状态区域</td><td>展示视频状态:未连接/连接中/播放中/失败。</td></tr><tr><td>辅助信息</td><td>显示码流类型、分辨率、最近更新时间。</td></tr><tr><td>操作按钮</td><td>刷新、全屏、重新连接。</td></tr><tr><td>异常提示</td><td>播放失败时展示错误码和建议操作:刷新、检查摄像头服务、检查网络。视频流地址、播放状态等由机器人侧实时接口返回,运维端一期不单独建设视频配置表。</td></tr></tbody></table>
+    <h4>6.5.2 远程喊话页面</h4><table><thead><tr><th>区域</th><th>详细设计</th></tr></thead><tbody><tr><td>输入区域</td><td>喊话文本输入框,最大 500 字。</td></tr><tr><td>参数区域</td><td>音量(0-100)、播放次数(1-5)、是否立即打断当前播报。</td></tr><tr><td>预置短语</td><td>展示常用喊话语句,点击自动填充。</td></tr><tr><td>结果区域</td><td>显示最近 10 次喊话记录与执行结果。</td></tr><tr><td>操作按钮</td><td>立即喊话、清空内容。</td></tr></tbody></table>
+    <h4>6.5.3 对话日志页面</h4><table><thead><tr><th>模块</th><th>详细设计</th></tr></thead><tbody><tr><td>查询条件</td><td>时间范围、会话 ID、用户问题关键字、命中状态、来源场景。</td></tr><tr><td>列表字段</td><td>时间、会话 ID、用户问题、机器人回答摘要、命中方式、来源场景、状态、操作。</td></tr><tr><td>详情字段</td><td>会话 ID(sessionId)、提问时间(askTime)、用户问题(question)、机器人回答(answer)、命中方式(hitType)、来源场景(sceneType)、结果状态(resultStatus)、原始请求(rawRequest)、原始响应(rawResponse)。</td></tr><tr><td>操作按钮</td><td>查看详情、导出。</td></tr></tbody></table>
+    <h4>6.5.4 安防告警日志页面</h4><table><thead><tr><th>模块</th><th>详细设计</th></tr></thead><tbody><tr><td>查询条件</td><td>告警类型、告警级别、处理状态、告警时间范围。</td></tr><tr><td>列表字段</td><td>告警时间、告警类型、告警级别、位置/来源、处理状态、描述摘要、操作。</td></tr><tr><td>详情字段</td><td>告警时间(alarmTime)、告警类型(alarmType)、告警级别(alarmLevel)、来源位置(sourcePosition)、处理状态(handleStatus)、描述(description)、抓拍图地址(snapshotUrl)、备注(remark)。</td></tr><tr><td>操作按钮</td><td>查看详情、确认告警、忽略告警、导出。</td></tr></tbody></table>
+
+    <h3>6.6 运维管理</h3>
+    <h4>6.6.1 设备状态页面</h4><table><thead><tr><th>区域</th><th>字段</th></tr></thead><tbody><tr><td>基础信息</td><td>机器人名称(robotName)、机器人编号(robotCode)、序列号(serialNo)、IP 地址(ipAddress)、MAC 地址(macAddress)、运行时长(uptime)。</td></tr><tr><td>运行信息</td><td>在线状态(onlineStatus)、工作状态(workStatus)、电量百分比(batteryLevel)、充电状态(chargeStatus)、网络状态(networkStatus)。</td></tr><tr><td>系统资源</td><td>CPU 使用率(cpuUsage)、内存使用率(memoryUsage)、磁盘使用率(diskUsage)、温度(temperature)。</td></tr><tr><td>模块状态</td><td>摄像头状态(cameraStatus)、麦克风状态(micStatus)、扬声器状态(speakerStatus)、屏幕状态(screenStatus)、主服务状态(mainServiceStatus)。</td></tr><tr><td>时间信息</td><td>系统时间(serverTime)、最近心跳时间(lastHeartbeatTime)。</td></tr></tbody></table><p>以上设备状态字段均由机器人侧实时接口返回,运维端一期不做本地数据库持久化。</p>
+    <h4>6.6.2 设备控制页面</h4><table><thead><tr><th>控制项</th><th>说明</th><th>返回字段</th></tr></thead><tbody><tr><td>一键充电</td><td>触发机器人进入充电流程</td><td>任务ID(taskId)、执行状态(resultStatus)、结果信息(resultMsg)</td></tr>
+<tr><td>停止充电</td><td>停止当前充电动作</td><td>任务ID(taskId)、执行状态(resultStatus)、结果信息(resultMsg)</td></tr>
+<tr><td>重启机器人</td><td>执行整机重启</td><td>任务ID(taskId)、执行状态(resultStatus)、结果信息(resultMsg)</td></tr>
+<tr><td>关机</td><td>执行安全关机</td><td>任务ID(taskId)、执行状态(resultStatus)、结果信息(resultMsg)</td></tr>
+<tr><td>重启应用服务</td><td>重启指定服务</td><td>任务ID(taskId)、服务名称(serviceName)、执行状态(resultStatus)</td></tr>
+<tr><td>音频测试</td><td>播放测试音频或测试播报</td><td>执行状态(resultStatus)</td></tr>
+<tr><td>屏幕测试</td><td>切换测试画面</td><td>执行状态(resultStatus)</td></tr>
+<tr><td>控制记录</td><td>所有控制动作统一写入设备控制记录表和操作日志,便于追踪 taskId、执行结果和失败原因。</td><td>控制记录ID(controlRecordId)、任务ID(taskId)、执行状态(resultStatus)、结果信息(resultMsg)</td></tr></tbody></table>
+    <h4>6.6.3 运行参数配置页面</h4><p>页面采用<strong>模块 Tab + 模块参数表</strong>形式。</p><table><thead><tr><th>字段</th><th>说明</th></tr></thead><tbody><tr><td>参数分组编码(groupCode)</td><td>参数分组编码,例如 audio、screen、system、device、service。</td></tr><tr><td>参数分组名称(groupName)</td><td>参数分组名称。</td></tr><tr><td>参数编码(paramCode)</td><td>参数编码。</td></tr><tr><td>参数名称(paramName)</td><td>参数名称。</td></tr><tr><td>参数值(paramValue)</td><td>参数值。</td></tr><tr><td>值类型(valueType)</td><td>string、int、float、boolean、enum。</td></tr><tr><td>单位(unit)</td><td>单位。</td></tr><tr><td>是否可编辑(editable)</td><td>是否可编辑。</td></tr><tr><td>是否必填(requiredFlag)</td><td>是否必填。</td></tr><tr><td>数值边界(minValue / maxValue)</td><td>数值边界。</td></tr><tr><td>枚举项(enumOptions)</td><td>枚举项 JSON。</td></tr><tr><td>参数说明(remark)</td><td>参数说明。</td></tr></tbody></table><p>操作按钮:刷新、保存、重置默认值。保存时需按 valueType 校验值类型和取值范围。</p>
+    <h4>6.6.4 系统诊断页面</h4><table><thead><tr><th>模块</th><th>设计说明</th></tr></thead><tbody><tr><td>诊断总览</td><td>显示诊断结果汇总:正常项数量、告警项数量、失败项数量。</td></tr><tr><td>诊断列表</td><td>项目名称、检查结果、详情描述、最后检查时间。</td></tr><tr><td>检查项</td><td>网络检查、摄像头检查、麦克风检查、扬声器检查、磁盘空间检查、服务状态检查。</td></tr><tr><td>操作按钮</td><td>立即诊断、刷新结果、导出诊断结果。</td></tr></tbody></table>
+    <h4>6.6.5 日志中心页面</h4><table><thead><tr><th>模块</th><th>详细设计</th></tr></thead><tbody><tr><td>查询条件</td><td>日志类型、关键字、时间范围、结果状态。</td></tr><tr><td>日志类型</td><td>系统日志、设备日志、操作日志、升级日志、服务日志。</td></tr><tr><td>列表字段</td><td>日志时间、日志类型、日志级别、模块名称、摘要、结果状态、操作。</td></tr><tr><td>详情字段</td><td>日志时间(logTime)、日志类型(logType)、日志级别(logLevel)、模块名称(moduleName)、日志内容(content)、结果状态(resultStatus)、追踪 ID(traceId)、备注(remark)。</td></tr><tr><td>操作按钮</td><td>查看详情、导出。</td></tr></tbody></table>
+    <h4>6.6.6 软件版本 / OTA 升级页面</h4><table><thead><tr><th>区块</th><th>详细设计</th></tr></thead><tbody><tr><td>软件版本列表</td><td>展示模块编码(moduleCode)、模块名称(moduleName)、当前版本(currentVersion)、安装时间(installTime)、运行状态(runStatus)。</td></tr><tr><td>安装包管理</td><td>展示安装包名称(packageName)、模块编码(moduleCode)、目标版本(targetVersion)、文件大小(fileSize)、上传时间(uploadTime)、上传人(uploadBy)。</td></tr><tr><td>升级操作</td><td>选择模块 + 安装包,执行升级,展示升级状态(resultStatus)、升级进度(progressPercent)与结果信息(resultMsg)。</td></tr><tr><td>升级记录</td><td>展示开始时间(startTime)、结束时间(endTime)、模块编码(moduleCode)、模块名称(moduleName)、原版本(currentVersion)、目标版本(targetVersion)、执行结果(resultStatus)、失败原因/结果信息(resultMsg)、升级进度(progressPercent)。</td></tr><tr><td>操作按钮</td><td>上传安装包、删除安装包、执行升级、刷新进度、查看升级详情。</td></tr></tbody></table>
+
+    <h3>6.7 系统设置</h3>
+    <h4>6.7.1 账号管理页面</h4><table><thead><tr><th>模块</th><th>详细设计</th></tr></thead><tbody><tr><td>查询条件</td><td>账号、姓名、状态。</td></tr><tr><td>列表字段</td><td>账号、姓名、角色、状态、最后登录时间、操作。</td></tr><tr><td>编辑字段</td><td>登录账号(username)、用户姓名(nickName)、登录密码(password)、角色编码(roleCode)、启用状态(status)、备注(remark)。</td></tr><tr><td>操作按钮</td><td>新增、编辑、重置密码、启用/停用、删除。</td></tr><tr><td>业务规则</td><td>admin 默认账号不可删除;可重置密码。</td></tr></tbody></table>
+    <h4>6.7.2 修改密码页面</h4><table><thead><tr><th>字段</th><th>说明</th></tr></thead><tbody><tr><td>原密码(oldPassword)</td><td>原密码。</td></tr><tr><td>新密码(newPassword)</td><td>新密码,建议 8-32 位。</td></tr><tr><td>确认新密码(confirmPassword)</td><td>确认新密码,必须与 newPassword 一致。</td></tr></tbody></table>
+    <h4>6.7.3 基础设置页面</h4><table><thead><tr><th>字段</th><th>说明</th></tr></thead><tbody><tr><td>后台系统名称(systemName)</td><td>后台系统名称。</td></tr><tr><td>后台 Logo(systemLogo)</td><td>后台 Logo。</td></tr><tr><td>页脚文案(footerText)</td><td>页脚文案。</td></tr><tr><td>备案号/版权信息(recordNo)</td><td>备案号/版权信息,可选。</td></tr></tbody></table>
+  </div>
+
+  <div class="section" id="s7"><h2>7. 后端接口设计</h2>
+    <div class="note">说明:接口统一以 <code class="inline">/robot-ops</code> 为前缀,返回结构统一为 <code class="inline">{ code, msg, data, timestamp }</code>。第 7 章接口字段采用 camelCase;第 8 章数据库字段采用 snake_case。列表接口统一支持 pageNum、pageSize。</div>
+    <h3>7.0 通用文件上传接口</h3>
+    <table><thead><tr><th>接口</th><th>方法</th><th>说明</th><th>请求参数</th><th>返回字段</th><th>适用场景</th></tr></thead><tbody>
+      <tr><td>/robot-ops/common/file/upload</td><td>POST</td><td>通用文件上传</td><td>文件(file)、业务类型(bizType)</td><td>文件地址(fileUrl)、文件名称(fileName)、文件大小(fileSize)、文件格式(fileFormat)</td><td>主题Logo、主题背景资源,普通附件等。素材库文件仍优先使用素材上传接口。</td></tr>
+      <tr><td>/common/uploadMediaFile</td><td>POST</td><td>素材文件上传</td><td>文件(file)</td><td>文件地址(fileUrl/url)、素材类型(assetType)、缩略图地址(thumbnailUrl)、文件大小(fileSize)、文件格式(fileFormat)、MIME 类型(mimeType)、视频时长(durationSeconds)、分辨率(resolution)等;具体字段以后端实际返回为准</td><td>当前素材管理前端实际使用的上传接口。上传后前端回填文件信息,再通过素材新增/编辑接口保存素材记录。</td></tr>
+    </tbody></table>
+    <div class="note">通用文件上传接口默认不单独建设文件记录表,上传后的 fileUrl 由具体业务表保存;如后续需要统一文件管理,再扩展附件表。</div>
+    <div class="note">当前素材管理前端实际使用 <code class="inline">/common/uploadMediaFile</code> 上传素材文件。上传接口负责保存文件并返回素材文件信息,前端回填 fileUrl、thumbnailUrl、fileSize、fileFormat、mimeType、durationSeconds、resolution、assetType 等字段;用户确认后再通过 RuoYi 生成的素材新增/编辑接口保存素材记录。</div>
+    <div class="note">开发设计中的 <code class="inline">/robot-ops/content/media/upload</code> 属于后续统一接口规划路径;本期前端已按当前项目实际接口 <code class="inline">/common/uploadMediaFile</code> 落地。</div>
+
+
+    <h3>7.1 通用接口规范</h3>
+    <h4>7.1.1 通用返回结构</h4>
+    <div class="code">{
+  "code": 200,
+  "msg": "操作成功",
+  "data": {},
+  "timestamp": "2026-04-24 10:00:00"
+}</div>
+    <h4>7.1.2 分页返回结构</h4>
+    <div class="code">{
+  "code": 200,
+  "msg": "查询成功",
+  "data": {
+    "total": 100,
+    "pageNum": 1,
+    "pageSize": 10,
+    "rows": []
+  },
+  "timestamp": "2026-04-24 10:00:00"
+}</div>
+    <h4>7.1.3 通用状态码</h4>
+    <table><thead><tr><th>状态码</th><th>说明</th><th>处理建议</th></tr></thead><tbody>
+      <tr><td>200</td><td>成功</td><td>正常处理 data。</td></tr>
+      <tr><td>400</td><td>请求参数错误</td><td>前端提示 msg。</td></tr>
+      <tr><td>401</td><td>未登录或登录已过期</td><td>跳转登录页。</td></tr>
+      <tr><td>403</td><td>无权限</td><td>提示无权限。</td></tr>
+      <tr><td>500</td><td>服务异常</td><td>提示系统异常,并记录日志。</td></tr>
+      <tr><td>10001</td><td>机器人侧接口不可用</td><td>提示机器人服务异常。</td></tr>
+      <tr><td>10002</td><td>机器人执行失败</td><td>展示 resultMsg。</td></tr>
+    </tbody></table>
+
+    <h3>7.2 认证与系统接口</h3>
+    <h4>7.2.1 登录</h4>
+    <table><thead><tr><th>项</th><th>内容</th></tr></thead><tbody>
+      <tr><td>接口地址</td><td>POST /robot-ops/auth/login</td></tr>
+      <tr><td>请求参数</td><td>登录账号(username)、登录密码(password)</td></tr>
+      <tr><td>返回字段</td><td>访问令牌(token)、用户ID(userId)、登录账号(username)、用户姓名(nickName)、角色编码(roleCode)</td></tr>
+      <tr><td>数据库表</td><td>robot_ops_user</td></tr>
+      <tr><td>业务规则</td><td>账号停用不可登录;密码连续错误 5 次锁定 10 分钟;登录成功更新 last_login_time。</td></tr>
+    </tbody></table>
+    <div class="code">请求示例:
+{
+  "username": "admin",
+  "password": "******"
+}
+
+返回示例:
+{
+  "token": "xxxxxx",
+  "userId": 1,
+  "username": "admin",
+  "nickName": "管理员",
+  "roleCode": "ADMIN"
+}</div>
+
+    <h4>7.2.2 退出登录</h4>
+    <table><thead><tr><th>项</th><th>内容</th></tr></thead><tbody>
+      <tr><td>接口地址</td><td>POST /robot-ops/auth/logout</td></tr>
+      <tr><td>请求参数</td><td>无</td></tr>
+      <tr><td>返回字段</td><td>无</td></tr>
+      <tr><td>业务规则</td><td>清理当前登录 token 或 session。</td></tr>
+    </tbody></table>
+
+    <h4>7.2.3 修改密码</h4>
+    <table><thead><tr><th>项</th><th>内容</th></tr></thead><tbody>
+      <tr><td>接口地址</td><td>POST /robot-ops/auth/change-password</td></tr>
+      <tr><td>请求参数</td><td>原密码(oldPassword)、新密码(newPassword)、确认新密码(confirmPassword)</td></tr>
+      <tr><td>返回字段</td><td>无</td></tr>
+      <tr><td>数据库表</td><td>robot_ops_user</td></tr>
+      <tr><td>业务规则</td><td>原密码校验通过后更新;新密码和确认密码必须一致。</td></tr>
+    </tbody></table>
+
+    <h4>7.2.4 当前登录用户</h4>
+    <table><thead><tr><th>项</th><th>内容</th></tr></thead><tbody>
+      <tr><td>接口地址</td><td>GET /robot-ops/auth/current-user</td></tr>
+      <tr><td>返回字段</td><td>用户ID(userId)、登录账号(username)、用户姓名(nickName)、角色编码(roleCode)、权限标识(permissions)</td></tr>
+      <tr><td>数据库表</td><td>robot_ops_user 或 RuoYi 原生权限表</td></tr>
+    </tbody></table>
+
+    <h4>7.2.5 基础设置查询/保存</h4>
+    <table><thead><tr><th>接口</th><th>方法</th><th>说明</th><th>请求/返回字段</th><th>数据库表</th></tr></thead><tbody>
+      <tr><td>/robot-ops/system/base-config</td><td>GET</td><td>获取基础设置</td><td>后台系统名称(systemName)、后台Logo(systemLogo)、页脚文案(footerText)、备案号(recordNo)</td><td>robot_ops_system_config</td></tr>
+      <tr><td>/robot-ops/system/base-config</td><td>PUT</td><td>保存基础设置</td><td>后台系统名称(systemName)、后台Logo(systemLogo)、页脚文案(footerText)、备案号(recordNo)</td><td>robot_ops_system_config</td></tr>
+    </tbody></table>
+
+    <h3>7.3 首页接口</h3>
+    <h4>7.3.1 首页总览</h4>
+    <table><thead><tr><th>项</th><th>内容</th></tr></thead><tbody>
+      <tr><td>接口地址</td><td>GET /robot-ops/home/overview</td></tr>
+      <tr><td>请求参数</td><td>无</td></tr>
+      <tr><td>返回字段</td><td>机器人名称(robotName)、机器人编号(robotCode)、在线状态(onlineStatus)、工作状态(workStatus)、电量百分比(batteryLevel)、充电状态(chargeStatus)、网络状态(networkStatus)、IP地址(ipAddress)、存储占用(storageUsed)、当前版本(currentVersion)、今日访客数(todayVisitorCount)、今日预约数(todayAppointmentCount)、今日对话数(todayDialogueCount)、今日播报数(todayBroadcastCount)、今日告警数(todayAlarmCount)、今日操作数(todayOperateCount)</td></tr>
+      <tr><td>数据来源</td><td>机器人实时接口 + 本地访客/对话/告警/操作记录聚合。</td></tr>
+      <tr><td>数据库表</td><td>robot_ops_visitor_record、robot_ops_appointment_record、robot_ops_dialogue_log、robot_ops_alarm_log、robot_ops_operate_log;机器人实时状态不落库。</td></tr>
+    </tbody></table>
+
+    <h4>7.3.2 首页最近告警/最近记录</h4>
+    <table><thead><tr><th>接口</th><th>说明</th><th>请求参数</th><th>返回字段</th><th>数据库表</th></tr></thead><tbody>
+      <tr><td>GET /robot-ops/home/alarms</td><td>获取首页最近告警</td><td>数量(limit,默认5)</td><td>告警ID(id)、告警时间(alarmTime)、告警类型(alarmType)、告警级别(alarmLevel)、描述(description)</td><td>robot_ops_alarm_log</td></tr>
+      <tr><td>GET /robot-ops/home/quick-records</td><td>获取首页最近记录</td><td>数量(limit,默认5)</td><td>记录类型(recordType)、时间(recordTime)、标题(title)、摘要(summary)、跳转目标(targetUrl)</td><td>robot_ops_operate_log、robot_ops_upgrade_record、robot_ops_sys_log</td></tr>
+    </tbody></table>
+
+    <h3>7.4 内容管理接口</h3>
+    <h4>7.4.1 欢迎语配置接口</h4>
+    <table><thead><tr><th>接口</th><th>方法</th><th>说明</th><th>请求/返回字段</th><th>数据库表</th></tr></thead><tbody>
+      <tr><td>/robot-ops/content/welcome-config</td><td>GET</td><td>获取欢迎语配置</td><td>返回:配置标识(configKey,固定 default)、欢迎语文本(welcomeText)、语音播报开关(voiceEnabled)、语音播报冷却时间(cooldownSeconds)、启用欢迎语状态(status)、备注(remark)。前端无需传参,后端以 config_key='default' 查询;如数据库暂无默认配置,可返回空数据,由前端加载默认配置。</td><td>robot_ops_welcome_config</td></tr>
+      <tr><td>/robot-ops/content/welcome-config</td><td>PUT</td><td>保存欢迎语配置</td><td>请求:配置标识(configKey,固定 default,可由前端传入,也可由后端默认处理)、欢迎语文本(welcomeText)、语音播报开关(voiceEnabled)、语音播报冷却时间(cooldownSeconds)、启用欢迎语状态(status)、备注(remark)。后端保存时应以 config_key='default' 作为定位条件,存在则更新,不存在则新增。</td><td>robot_ops_welcome_config</td></tr>
+      <tr><td>/robot-ops/content/welcome-config/test</td><td>POST</td><td>测试欢迎语播报</td><td>请求:配置标识(configKey,固定 default)、欢迎语文本(welcomeText)、启用欢迎语状态(status)、语音播报开关(voiceEnabled)。仅下发测试播报,不新增或修改配置数据。</td><td>不新增业务表,可写入 robot_ops_operate_log</td></tr>
+    </tbody></table>
+    <div class="note">欢迎语配置为单配置页,前端当前会在保存和测试播报时携带 configKey='default';后端也应支持不传 configKey 时默认按 default 处理。</div>
+    <div class="note">保存欢迎语配置时,后端建议按 config_key='default' 执行“有则更新,无则新增”的逻辑,避免初始化数据缺失时保存失败。</div>
+    <div class="note">恢复默认由前端本地重置表单完成,不单独调用后端恢复默认接口;用户点击“保存配置”后才真正写入数据库。</div>
+    <div class="note">status 控制欢迎语功能整体是否启用;voiceEnabled 控制欢迎语触发时是否进行语音播报;cooldownSeconds 表示语音播报冷却时间,用于避免短时间内重复语音播报。</div>
+
+    <h4>7.4.2 问答库接口</h4>
+    <table><thead><tr><th>接口</th><th>方法</th><th>说明</th><th>请求参数</th><th>返回/处理字段</th><th>数据库表</th></tr></thead><tbody>
+      <tr><td>/robot-ops/content/faq/page</td><td>GET</td><td>问答分页</td><td>问题分类(categoryType,字典值)、标准问题关键字(question)、启用状态(status)、pageNum、pageSize</td><td>问答ID(id)、问题分类(categoryType,按字典回显)、标准问题(question)、相似问数量(similarCount)、答案摘要(answerSummary)、启用状态(status)、更新时间(updateTime)</td><td>robot_ops_faq、robot_ops_faq_similar;分类名称通过 RuoYi 字典 robot_faq_category 回显</td></tr>
+      <tr><td>/robot-ops/content/faq/{id}</td><td>GET</td><td>问答详情</td><td>问答ID(id)</td><td>categoryType、question、similarQuestions 或 robotOpsFaqSimilarList、answer、sortNo、status、remark</td><td>robot_ops_faq、robot_ops_faq_similar</td></tr>
+      <tr><td>/robot-ops/content/faq</td><td>POST</td><td>新增问答</td><td>categoryType、question、similarQuestions 或 robotOpsFaqSimilarList、answer、status、remark;sortNo 由前端默认传 0 或后端默认写入 0</td><td>新增后的问答ID(id)</td><td>robot_ops_faq、robot_ops_faq_similar</td></tr>
+      <tr><td>/robot-ops/content/faq</td><td>PUT</td><td>编辑问答</td><td>id、categoryType、question、similarQuestions 或 robotOpsFaqSimilarList、answer、sortNo、status、remark;前端页面不展示 sortNo,编辑时保持原值</td><td>无</td><td>robot_ops_faq、robot_ops_faq_similar</td></tr>
+      <tr><td>/robot-ops/content/faq/{id}</td><td>DELETE</td><td>删除问答</td><td>问答ID(id)</td><td>无</td><td>robot_ops_faq、robot_ops_faq_similar</td></tr>
+      <tr><td>/robot-ops/content/faq/export</td><td>GET</td><td>导出问答</td><td>问题分类(categoryType)、标准问题关键字(question)、启用状态(status)</td><td>Excel文件;导出字段建议包括问题分类、标准问题、相似问、答案内容、启用状态、更新时间、备注,不导出 sortNo</td><td>robot_ops_faq、robot_ops_faq_similar;分类名称按 RuoYi 字典 robot_faq_category 回显</td></tr>
+    </tbody></table>
+
+    <div class="note">问答分类不单独建设业务表和管理页面,统一使用 RuoYi 字典维护,字典类型为 <code class="inline">robot_faq_category</code>。当前字典项为:1=问候寒暄,2=产品介绍,3=业务咨询,4=访客引导,5=场所引导,6=安防提示,7=设备使用,8=售后服务,9=常见问题,10=其他。</div>
+    <div class="note">问答库前端页面使用多行文本维护相似问,一行一个;提交时可同时携带 similarQuestions 和 robotOpsFaqSimilarList,以兼容主子表接口和后续简化接口。后端保存时需写入 robot_ops_faq_similar 表。</div>
+    <div class="note">问答库一期不提供导入接口。由于问答导入涉及分类字典匹配、标准问题重复校验、相似问拆分、主子表写入和失败明细回显,后续如运营需要批量维护,再单独扩展导入能力。</div>
+    <div class="note">sortNo / sort_no 为保留字段,前端页面一期不展示、不编辑。新增时默认 0,编辑时保持原值。后续如需推荐、置顶或屏幕端展示排序,再重新设计排序能力。</div>
+    <div class="note">删除问答时需同步删除 robot_ops_faq_similar 中对应 faq_id 的相似问数据;启用/停用仅影响主问答状态,相似问不单独设置状态。</div>
+
+    <h4>7.4.3 素材管理接口</h4>
+<table><thead><tr><th>接口</th><th>方法</th><th>说明</th><th>请求参数</th><th>返回/处理字段</th><th>数据库表</th></tr></thead><tbody>
+<tr><td>/base/mediAasset/list</td><td>GET</td><td>素材分页</td><td>素材名称(assetName)、素材类型(assetType)、启用状态(status)、引用状态(quotedFlag)、上传时间范围、pageNum、pageSize</td><td>id、assetName、assetType、fileFormat、fileSize、durationSeconds、resolution、thumbnailUrl、fileUrl、quotedFlag、status、createTime</td><td>robot_ops_media_asset</td></tr>
+<tr><td>/base/mediAasset/{id}</td><td>GET</td><td>素材详情</td><td>素材ID(id)</td><td>id、assetName、assetType、fileUrl、thumbnailUrl、fileSize、fileFormat、mimeType、durationSeconds、resolution、quotedFlag、status、remark、createTime、updateTime</td><td>robot_ops_media_asset</td></tr>
+<tr><td>/common/uploadMediaFile</td><td>POST</td><td>上传素材文件</td><td>文件(file)</td><td>返回文件信息:fileUrl/url、assetType、thumbnailUrl、fileSize、fileFormat、mimeType、durationSeconds、resolution 等。前端上传成功后回填文件信息。</td><td>不直接等同于普通 CRUD 接口;是否直接入库以后端实现为准。当前前端兼容返回 id 或不返回 id 两种情况。</td></tr>
+<tr><td>/base/mediAasset</td><td>POST</td><td>新增素材记录</td><td>assetName、assetType、fileUrl、thumbnailUrl、fileSize、fileFormat、mimeType、durationSeconds、resolution、status、quotedFlag、remark</td><td>新增后的素材ID</td><td>robot_ops_media_asset</td></tr>
+<tr><td>/base/mediAasset</td><td>PUT</td><td>编辑素材</td><td>id、assetName、assetType、fileUrl、thumbnailUrl、fileSize、fileFormat、mimeType、durationSeconds、resolution、status、quotedFlag、remark。页面仅允许用户维护 assetName、status、remark;其他字段由上传接口回填或系统维护。</td><td>无</td><td>robot_ops_media_asset</td></tr>
+<tr><td>/base/mediAasset/{ids}</td><td>DELETE</td><td>删除素材</td><td>素材ID或ID数组(ids)</td><td>无;如素材已被播放方案引用,应返回不可删除原因和引用数量</td><td>robot_ops_media_asset、robot_ops_play_plan_item</td></tr>
+<tr><td>/base/mediAasset/export</td><td>GET</td><td>导出素材</td><td>同分页查询条件</td><td>Excel文件;建议导出素材名称、素材类型、文件格式、文件大小、视频时长、分辨率、引用状态、启用状态、上传时间、备注</td><td>robot_ops_media_asset</td></tr>
+</tbody></table>
+<div class="note">当前素材管理前端基于 RuoYi 生成接口落地,列表、详情、新增、编辑、删除、导出使用 <code class="inline">/base/mediAasset</code> 相关接口;素材文件上传使用 <code class="inline">/common/uploadMediaFile</code>。</div>
+<div class="note">上传接口返回素材文件信息后,前端会回填 fileUrl、thumbnailUrl、fileSize、fileFormat、mimeType、durationSeconds、resolution、assetType 等字段;用户点击“确定”后,再通过新增或编辑接口保存素材记录。前端已兼容上传接口直接返回 id 的情况。</div>
+<div class="note">素材类型 assetType 是业务类型,用于页面筛选、播放方案选择和预览方式判断;mimeType 是文件 MIME 类型,用于技术校验、预览和文件响应处理;fileFormat 是文件后缀/格式,用于页面展示和导出。</div>
+<div class="note">thumbnailUrl 用于列表缩略图展示。图片素材没有独立缩略图时,前端可使用 fileUrl 作为缩略图;视频素材只有后端返回 thumbnailUrl 时才展示封面,否则前端展示默认视频图标,避免将 mp4 文件地址误作为图片缩略图。</div>
+<div class="note">durationSeconds、resolution 支持为空,前端以“-”兜底展示。若后端可解析图片/视频信息,可返回对应值;若暂时无法解析,不影响素材列表和预览功能。</div>
+<div class="note">quotedFlag 由后端根据播放方案明细引用关系维护,前端只读展示。删除素材时,后端必须检查 robot_ops_play_plan_item 是否存在引用,不能仅依赖 quotedFlag。</div>
+
+    <h4>7.4.4 播放方案接口</h4>
+    <table><thead><tr><th>接口</th><th>方法</th><th>说明</th><th>请求/返回字段</th><th>数据库表</th></tr></thead><tbody>
+      <tr><td>/robot-ops/content/play-plan/page</td><td>GET</td><td>播放方案分页</td><td>请求:方案名称(planName)、循环方式(loopMode)、播放状态(status)、pageNum、pageSize;返回:id、planName、assetCount、loopMode、status、updateTime</td><td>robot_ops_play_plan</td></tr>
+      <tr><td>/robot-ops/content/play-plan/{id}</td><td>GET</td><td>播放方案详情</td><td>返回:id、planName、assetCount、loopMode、status、remark、itemList;itemList 从明细表返回 assetId、playOrder、staySeconds、transitionType,并通过关联 robot_ops_media_asset 补充 assetName、assetType、fileUrl、thumbnailUrl、durationSeconds、resolution、assetStatus 等展示字段。</td><td>robot_ops_play_plan、robot_ops_play_plan_item、robot_ops_media_asset</td></tr>
+      <tr><td>/robot-ops/content/play-plan</td><td>POST</td><td>新增播放方案</td><td>请求:planName、loopMode、status、remark、itemList。itemList 只需提交 assetId、playOrder、staySeconds、transitionType;其中 transitionType 为预留字段,一期可不传,由后端默认写入 none;新增时至少包含一个素材明细;assetCount 由后端根据 itemList 数量计算。后端保存时需校验 assetId 对应素材是否存在且可用。</td><td>robot_ops_play_plan、robot_ops_play_plan_item、robot_ops_media_asset</td></tr>
+      <tr><td>/robot-ops/content/play-plan</td><td>PUT</td><td>编辑播放方案</td><td>请求:id、planName、loopMode、status、remark、itemList。itemList 只需提交 assetId、playOrder、staySeconds、transitionType;其中 transitionType 为预留字段,一期可不传,由后端默认写入 none;保存时以后端接收的 itemList 为准重建或更新明细;assetCount 由后端重新计算。后端保存时需校验 assetId 对应素材是否存在。</td><td>robot_ops_play_plan、robot_ops_play_plan_item、robot_ops_media_asset</td></tr>
+      <tr><td>/robot-ops/content/play-plan/{id}</td><td>DELETE</td><td>删除播放方案</td><td>方案ID(id)。删除时同步删除明细并重新计算相关素材 quotedFlag。如删除当前播放方案,可能导致车端无可播放方案,建议后端根据实际业务策略进行限制或提示。</td><td>robot_ops_play_plan、robot_ops_play_plan_item、robot_ops_media_asset</td></tr>
+      <tr><td>/robot-ops/content/play-plan/{id}/status</td><td>POST</td><td>切换播放状态</td><td>方案ID(id)、播放状态(status)。当 status=1 时,后端事务中将其他方案 status 置为 0,将当前方案 status 置为 1;当 status=0 时,需根据业务策略判断是否允许将当前播放方案设为备用。</td><td>robot_ops_play_plan</td></tr>
+      <tr><td>/robot-ops/content/play-plan/{id}/preview</td><td>GET</td><td>预览播放方案(后续规划)</td><td>方案ID(id)。返回方案基本信息和按 playOrder 排序后的 itemList;itemList 需关联素材表返回 assetName、assetType、fileUrl、thumbnailUrl、durationSeconds、resolution、assetStatus 等字段,用于前端预览。当前前端一期已先基于详情接口 getPlan(id) 实现预览弹窗,后续可切换为该接口。</td><td>robot_ops_play_plan、robot_ops_play_plan_item、robot_ops_media_asset</td></tr>
+    </tbody></table>
+    <div class="code">新增/编辑播放方案请求 itemList 示例:
+[
+  {
+    "assetId": 1,
+    "playOrder": 1,
+    "staySeconds": 10,
+    "transitionType": "none"
+  },
+  {
+    "assetId": 2,
+    "playOrder": 2,
+    "staySeconds": null,
+    "transitionType": "none"
+  }
+]
+播放方案详情/预览返回 itemList 示例:
+[
+  {
+    "assetId": 1,
+    "assetName": "大厅欢迎背景图",
+    "assetType": "image",
+    "fileUrl": "/profile/upload/media/welcome-bg.jpg",
+    "thumbnailUrl": "/profile/upload/media/welcome-bg.jpg",
+    "durationSeconds": null,
+    "resolution": "1920x1080",
+    "assetStatus": "1",
+    "playOrder": 1,
+    "staySeconds": 10,
+    "transitionType": "none"
+  },
+  {
+    "assetId": 2,
+    "assetName": "展厅轮播宣传视频",
+    "assetType": "video",
+    "fileUrl": "/profile/upload/media/showroom-video.mp4",
+    "thumbnailUrl": "/profile/upload/media/showroom-video.jpg",
+    "durationSeconds": 90,
+    "resolution": "1920x1080",
+    "assetStatus": "1",
+    "playOrder": 2,
+    "staySeconds": null,
+    "transitionType": "none"
+  }
+]</div>
+    <div class="note">播放方案保存时,后端应根据 itemList 数量更新主表 asset_count,并根据 itemList 顺序更新 play_order。</div>
+    <div class="note">说明:本节播放方案接口路径为规划接口路径。若本期基于 RuoYi 主子表生成播放方案代码,实际接口路径可能采用 RuoYi 生成风格,例如 /base/playPlan/list、/base/playPlan/{id}、/base/playPlan。前端开发和联调时应以实际生成的 API 文件路径为准,并在页面开发完成后同步本文档。</div>
+    <div class="note">播放方案明细表一期不保存素材快照字段,只保存 asset_id、play_order、stay_seconds、transition_type 等编排字段。播放方案详情、编辑回显和预览接口应根据 asset_id 关联 robot_ops_media_asset 返回 assetName、assetType、fileUrl、thumbnailUrl、durationSeconds、resolution、assetStatus 等展示字段。</div>
+    <div class="note">保存播放方案时,后端应根据 asset_id 校验素材是否存在。新增方案和新增明细时,仅允许选择启用状态的素材;历史方案中已引用的停用素材仍需支持详情回显和编辑回显,并提示素材已停用。</div>
+    <div class="note">新增、编辑、删除播放方案后,需要重新计算相关素材的 quoted_flag,确保素材管理列表中的引用状态准确。</div>
+    <div class="warn">播放状态唯一性由后端事务控制。status=1 表示当前播放方案,同一时间只允许一个播放方案 status=1。启用某个方案为当前播放时,后端需将其他方案 status 更新为 0,将当前方案 status 更新为 1。车端只读取 status=1 的播放方案。</div>
+    <div class="note">transitionType / transition_type 为播放转场预留字段,一期前端不展示、不编辑。保存播放方案时,如前端未传 transitionType,后端默认写入 none。后续车端屏幕支持转场效果后,再开放 fade 等配置项。</div>
+    <div class="note">字典建议:loopMode 可使用 RuoYi 字典 play_plan_loop_mode,字典项:loop=循环播放,once=播放一次。transitionType 为预留字段,一期不开放配置。</div>
+
+    <h4>7.4.5 播报内容与播报任务接口</h4>
+    <table><thead><tr><th>接口</th><th>方法</th><th>说明</th><th>主要字段</th><th>数据库表</th></tr></thead><tbody>
+      <tr><td>/robot-ops/content/broadcast-content/page</td><td>GET</td><td>播报内容分页</td><td>contentName、contentType、broadcastText、status、updateTime</td><td>robot_ops_broadcast_content</td></tr>
+<tr><td>/robot-ops/content/broadcast-content/{id}</td><td>GET</td><td>播报内容详情</td><td>内容ID(id);返回 contentName、contentType、broadcastText、status、remark</td><td>robot_ops_broadcast_content</td></tr>
+      <tr><td>/robot-ops/content/broadcast-content</td><td>POST</td><td>新增播报内容</td><td>contentName、contentType、broadcastText、status、remark</td><td>robot_ops_broadcast_content</td></tr>
+      <tr><td>/robot-ops/content/broadcast-content</td><td>PUT</td><td>编辑播报内容</td><td>id、contentName、contentType、broadcastText、status、remark</td><td>robot_ops_broadcast_content</td></tr>
+      <tr><td>/robot-ops/content/broadcast-content/{id}</td><td>DELETE</td><td>删除播报内容</td><td>内容ID(id)</td><td>robot_ops_broadcast_content</td></tr>
+      <tr><td>/robot-ops/content/broadcast-content/{id}/test</td><td>POST</td><td>测试播报内容</td><td>内容ID(id)</td><td>robot_ops_broadcast_content、robot_ops_operate_log</td></tr>
+      <tr><td>/robot-ops/content/broadcast-task/page</td><td>GET</td><td>播报任务分页</td><td>请求字段:任务名称(taskName)、播报内容名称(contentName)、循环类型(cycleType)、启用状态(status)、pageNum、pageSize;返回字段:任务ID(id)、任务名称(taskName)、播报内容ID(contentId)、播报内容名称(contentName)、播报内容状态(contentStatus,建议返回)、开始时间(startTime)、结束时间(endTime)、播报频率(frequencyMinutes,单位:分钟)、循环类型(cycleType:1按星期,2按日期)、循环取值(cycleValue)、启用状态(status)、更新时间(updateTime)。</td><td>robot_ops_broadcast_task、robot_ops_broadcast_content</td></tr>
+<tr><td>/robot-ops/content/broadcast-task/{id}</td><td>GET</td><td>播报任务详情</td><td>任务ID(id);返回 taskName、contentId、contentName、contentStatus、startTime、endTime、frequencyMinutes、cycleType、cycleValue、status、remark</td><td>robot_ops_broadcast_task、robot_ops_broadcast_content</td></tr>
+      <tr><td>/robot-ops/content/broadcast-task</td><td>POST</td><td>新增播报任务</td><td>taskName、contentId、startTime、endTime、frequencyMinutes、cycleType、cycleValue、status、remark;cycleType=1 时 cycleValue 保存星期值,cycleType=2 时 cycleValue 保存日期值</td><td>robot_ops_broadcast_task</td></tr>
+      <tr><td>/robot-ops/content/broadcast-task</td><td>PUT</td><td>编辑播报任务</td><td>id、taskName、contentId、startTime、endTime、frequencyMinutes、cycleType、cycleValue、status、remark;cycleType=1 时 cycleValue 保存星期值,cycleType=2 时 cycleValue 保存日期值</td><td>robot_ops_broadcast_task</td></tr>
+      <tr><td>/robot-ops/content/broadcast-task/{id}</td><td>DELETE</td><td>删除播报任务</td><td>任务ID(id)</td><td>robot_ops_broadcast_task</td></tr>
+      <tr><td>/robot-ops/content/broadcast-task/{id}/copy</td><td>POST</td><td>复制播报任务</td><td>任务ID(id)</td><td>robot_ops_broadcast_task</td></tr>
+    </tbody></table>
+    <div class="note">播报任务循环类型使用 RuoYi 字典 <code class="inline">broadcast_task_cycle_type</code>,字典值约定为:1=按星期,2=按日期。按星期时,cycleValue 保存 1-7 的星期值,多个值用英文逗号分隔;按日期时,cycleValue 保存 YYYY-MM-DD 日期值,多个日期用英文逗号分隔。</div>
+    <div class="note">播报任务列表和详情接口建议返回 contentName 和 contentStatus,便于前端展示关联播报内容名称,以及识别关联内容是否已停用。新增/编辑播报任务时,前端仅允许选择启用状态的播报内容;历史任务关联的停用内容需要支持回显。</div>
+
+    <h4>7.4.6 展示主题接口</h4>
+    <table><thead><tr><th>接口</th><th>方法</th><th>说明</th><th>请求/返回字段</th><th>数据库表</th></tr></thead><tbody>
+      <tr><td>/robot-ops/content/theme/page</td><td>GET</td><td>主题分页</td><td>themeName、status、pageNum、pageSize;返回 logoUrl、backgroundType、backgroundUrl、primaryColor、currentEnabled、updateTime</td><td>robot_ops_theme</td></tr>
+      <tr><td>/robot-ops/content/theme/{id}</td><td>GET</td><td>主题详情</td><td>id、themeName、logoUrl、backgroundType、backgroundUrl、primaryColor、secondaryColor、welcomeTitle、welcomeSubTitle、status、currentEnabled</td><td>robot_ops_theme</td></tr>
+      <tr><td>/robot-ops/content/theme</td><td>POST</td><td>新增主题</td><td>themeName、logoUrl、backgroundType、backgroundUrl、primaryColor、secondaryColor、welcomeTitle、welcomeSubTitle、status</td><td>robot_ops_theme</td></tr>
+      <tr><td>/robot-ops/content/theme</td><td>PUT</td><td>编辑主题</td><td>id、themeName、logoUrl、backgroundType、backgroundUrl、primaryColor、secondaryColor、welcomeTitle、welcomeSubTitle、status</td><td>robot_ops_theme</td></tr>
+      <tr><td>/robot-ops/content/theme/{id}</td><td>DELETE</td><td>删除主题</td><td>主题ID(id)</td><td>robot_ops_theme</td></tr>
+      <tr><td>/robot-ops/content/theme/{id}/enable</td><td>POST</td><td>启用主题</td><td>主题ID(id)</td><td>robot_ops_theme</td></tr>
+    </tbody></table>
+    <div class="note">主题 Logo、背景资源可通过通用文件上传接口获取 fileUrl 后,再写入 logoUrl、backgroundUrl;也可根据项目实现复用素材上传接口。</div>
+    <div class="warn">启用主题时需要将其他主题 current_enabled 置为 0,当前主题置为 1,保证同一时间只有一个主题生效。</div>
+
+    <h3>7.5 访客管理接口</h3>
+    <table><thead><tr><th>接口</th><th>方法</th><th>说明</th><th>请求参数</th><th>返回/处理字段</th><th>数据库表</th></tr></thead><tbody>
+      <tr><td>/robot-ops/visitor/record/page</td><td>GET</td><td>访客记录分页</td><td>visitorName、mobile、idCardNo、visitType、registerType、visitedPerson、visitTimeStart、visitTimeEnd、pageNum、pageSize</td><td>id、visitorName、mobile、idCardNo、visitorPhoto、visitType、registerType、visitorSource、visitReason、appointmentNo、visitedPerson、visitTime</td><td>robot_ops_visitor_record</td></tr>
+      <tr><td>/robot-ops/visitor/record/{id}</td><td>GET</td><td>访客详情</td><td>记录ID(id)</td><td>visitorName、mobile、idCardNo、visitType、registerType、visitorSource、visitReason、visitorPhoto、appointmentNo、visitedPerson、visitTime、remark</td><td>robot_ops_visitor_record</td></tr>
+      <tr><td>/robot-ops/visitor/record/export</td><td>GET</td><td>导出访客记录</td><td>同分页查询条件:visitorName、mobile、idCardNo、visitType、registerType、visitedPerson、visitTimeStart、visitTimeEnd</td><td>Excel文件;导出字段建议包括访客姓名、手机号、身份证号、到访类型、登记方式、访客来源、来访事由、预约单号、被访对象、来访时间、访客照片状态</td><td>robot_ops_visitor_record</td></tr>
+      <tr><td>/robot-ops/visitor/appointment/page</td><td>GET</td><td>预约记录分页</td><td>appointmentNo、visitorName、mobile、status、appointmentTimeStart、appointmentTimeEnd、pageNum、pageSize</td><td>appointmentNo、visitorName、mobile、visitedPerson、appointmentTime、status、syncTime</td><td>robot_ops_appointment_record</td></tr>
+      <tr><td>/robot-ops/visitor/appointment/{id}</td><td>GET</td><td>预约详情</td><td>预约记录ID(id)</td><td>appointmentNo、visitorName、mobile、visitedPerson、appointmentTime、status、syncTime、sourcePlatform、remark</td><td>robot_ops_appointment_record</td></tr>
+      <tr><td>/robot-ops/visitor/whitelist/page</td><td>GET</td><td>白名单分页</td><td>姓名(name)、手机号(mobile)、身份证号(idCardNo)、人员类型(whitelistType)、来源类型(sourceType)、启用状态(status)、pageNum、pageSize</td><td>白名单ID(id)、姓名(name)、手机号(mobile)、身份证号(idCardNo)、人员类型(whitelistType)、人脸照片地址(faceImageUrl)、是否有人脸照片(hasFaceImage)、来源类型(sourceType)、有效开始时间(validStartTime)、有效结束时间(validEndTime)、启用状态(status)、更新时间(updateTime)</td><td>robot_ops_whitelist</td></tr>
+      <tr><td>/robot-ops/visitor/whitelist/{id}</td><td>GET</td><td>白名单详情</td><td>白名单ID(id)</td><td>name、mobile、idCardNo、whitelistType、faceImageUrl、sourceType、validStartTime、validEndTime、status、remark</td><td>robot_ops_whitelist</td></tr>
+      <tr><td>/robot-ops/visitor/whitelist</td><td>POST</td><td>新增白名单</td><td>name、mobile、idCardNo、whitelistType、faceImageUrl、sourceType、validStartTime、validEndTime、status、remark;运维后台新增时 sourceType 默认传 1,表示本地录入</td><td>新增ID(id)</td><td>robot_ops_whitelist</td></tr>
+      <tr><td>/robot-ops/visitor/whitelist</td><td>PUT</td><td>编辑白名单</td><td>id、name、mobile、idCardNo、whitelistType、faceImageUrl、sourceType、validStartTime、validEndTime、status、remark;sourceType 由系统自动维护,前端只读展示,不允许用户手动修改</td><td>无</td><td>robot_ops_whitelist</td></tr>
+<tr><td>/robot-ops/visitor/whitelist/{id}/status</td><td>PUT</td><td>启用/停用白名单</td><td>白名单ID(id)、启用状态(status)</td><td>无</td><td>robot_ops_whitelist</td></tr>
+      <tr><td>/robot-ops/visitor/whitelist/{id}</td><td>DELETE</td><td>删除白名单</td><td>白名单ID(id)</td><td>无</td><td>robot_ops_whitelist</td></tr>
+      <tr><td>/robot-ops/visitor/whitelist/import</td><td>POST</td><td>导入白名单</td><td>Excel文件(file);导入字段包括姓名、人员类型、手机号、身份证号、人脸照片地址、有效开始时间、有效结束时间、启用状态、备注;运维后台导入的数据 sourceType 默认写入 1,表示本地录入</td><td>导入总数、成功数、失败数、失败明细;导入时需校验 mobile、idCardNo、faceImageUrl 三者至少填写一种;手机号和身份证号需校验格式</td><td>robot_ops_whitelist</td></tr>
+      <tr><td>/robot-ops/visitor/whitelist/export</td><td>GET</td><td>导出白名单</td><td>同分页查询条件</td><td>Excel文件;导出字段包括姓名、手机号、身份证号、人员类型、人脸照片地址、来源类型、有效开始时间、有效结束时间、启用状态、更新时间、备注</td><td>robot_ops_whitelist</td></tr>
+    </tbody></table>
+    <div class="note">访客记录不设置 resultStatus 和 sourceType 作为页面字段。访客记录代表已完成登记的到访记录;登记失败、扫码失败、读卡失败、预约匹配失败等异常应进入日志中心或后续扩展的登记异常日志。</div>
+    <div class="note">访客记录支持两类到访类型:APPOINTMENT=预约到访,WALK_IN=现场登记;登记方式支持 SCREEN=机器人端,H5=手机端。机器人端指访客在机器人屏幕完成登记;手机端指访客扫码后在 H5 页面完成登记。</div>
+    <div class="note">预约记录由主控平台同步;预约到访访客在现场完成登记后生成访客记录,并通过 appointmentNo 关联预约记录。现场登记访客可不关联预约单号。</div>
+    <div class="note">白名单中的 whitelistType 字段在页面上显示为“人员类型”,用于表示人员身份,如内部人员、访客、VIP、其他。人脸识别不作为人员类型,而是白名单匹配方式之一。</div>
+    <div class="note">白名单不单独设置识别方式字段。机器人侧可根据当前采集到的身份信息匹配白名单:人脸识别时通过 faceImageUrl 对应的人脸照片进行比对;刷身份证或输入身份证时匹配 idCardNo;输入手机号时匹配 mobile。任一方式匹配到启用且在有效期内的白名单人员,即视为白名单命中。</div>
+    <div class="note">新增或编辑白名单时,mobile、idCardNo、faceImageUrl 三者至少填写一种;手机号填写时需符合大陆手机号格式,身份证号填写时需符合 18 位身份证基础格式;一期不保存人脸特征ID。</div>
+    <div class="note">来源类型 sourceType 使用 RuoYi 字典 source_type,当前字典值为:1=本地录入,2=平台同步,3=机器人采集。sourceType 由系统自动赋值,运维后台新增和导入默认写入 1;主控平台同步写入 2;机器人采集写入 3。前端新增/编辑弹窗中只读展示来源类型,不允许人工选择。</div>
+    <div class="note">有效期状态由前端根据 validStartTime、validEndTime 和当前时间动态展示,不作为一期分页查询参数。</div>
+
+    <h3>7.6 监控管理接口</h3>
+    <table><thead><tr><th>接口</th><th>方法</th><th>说明</th><th>请求参数</th><th>返回/处理字段</th><th>数据库表</th></tr></thead><tbody>
+      <tr><td>/robot-ops/monitor/video/stream-info</td><td>GET</td><td>获取视频流信息</td><td>无</td><td>视频地址(streamUrl)、播放协议(streamProtocol)、码流类型(streamType)、分辨率(resolution)、过期时间(expireTime)</td><td>实时接口返回,不落库</td></tr>
+      <tr><td>/robot-ops/monitor/video/status</td><td>GET</td><td>获取视频状态</td><td>无</td><td>播放状态(videoStatus)、错误码(errorCode)、错误信息(errorMsg)、更新时间(updateTime)</td><td>实时接口返回,不落库</td></tr>
+      <tr><td>/robot-ops/monitor/voice/shout</td><td>POST</td><td>执行远程喊话</td><td>喊话文本(shoutText)、音量(volume)、播放次数(playTimes)、是否打断(interruptFlag)</td><td>记录ID(id)、执行状态(resultStatus)、结果信息(resultMsg)</td><td>robot_ops_shout_record、robot_ops_operate_log</td></tr>
+      <tr><td>/robot-ops/monitor/voice/shout-record/page</td><td>GET</td><td>喊话记录分页</td><td>shoutText、resultStatus、createTimeStart、createTimeEnd、pageNum、pageSize</td><td>id、shoutText、volume、playTimes、interruptFlag、resultStatus、resultMsg、operateBy、createTime</td><td>robot_ops_shout_record</td></tr>
+      <tr><td>/robot-ops/monitor/dialogue/page</td><td>GET</td><td>对话日志分页</td><td>sessionId、keyword、hitType、sceneType、askTimeStart、askTimeEnd、pageNum、pageSize</td><td>id、sessionId、askTime、question、answerSummary、hitType、sceneType、resultStatus</td><td>robot_ops_dialogue_log</td></tr>
+      <tr><td>/robot-ops/monitor/dialogue/{id}</td><td>GET</td><td>对话详情</td><td>日志ID(id)</td><td>sessionId、askTime、question、answer、hitType、sceneType、resultStatus、rawRequest、rawResponse</td><td>robot_ops_dialogue_log</td></tr>
+      <tr><td>/robot-ops/monitor/dialogue/export</td><td>GET</td><td>导出对话日志</td><td>同分页查询条件</td><td>Excel文件</td><td>robot_ops_dialogue_log</td></tr>
+      <tr><td>/robot-ops/monitor/alarm/page</td><td>GET</td><td>安防告警分页</td><td>alarmType、alarmLevel、handleStatus、alarmTimeStart、alarmTimeEnd、pageNum、pageSize</td><td>id、alarmTime、alarmType、alarmLevel、sourcePosition、handleStatus、description</td><td>robot_ops_alarm_log</td></tr>
+      <tr><td>/robot-ops/monitor/alarm/{id}</td><td>GET</td><td>安防告警详情</td><td>告警ID(id)</td><td>alarmTime、alarmType、alarmLevel、sourcePosition、handleStatus、description、snapshotUrl、remark</td><td>robot_ops_alarm_log</td></tr>
+      <tr><td>/robot-ops/monitor/alarm/{id}/confirm</td><td>PUT</td><td>确认告警</td><td>告警ID(id)、备注(remark)</td><td>无</td><td>robot_ops_alarm_log</td></tr>
+      <tr><td>/robot-ops/monitor/alarm/{id}/ignore</td><td>PUT</td><td>忽略告警</td><td>告警ID(id)、备注(remark)</td><td>无</td><td>robot_ops_alarm_log</td></tr>
+      <tr><td>/robot-ops/monitor/alarm/export</td><td>GET</td><td>导出告警日志</td><td>同分页查询条件</td><td>Excel文件</td><td>robot_ops_alarm_log</td></tr>
+    </tbody></table>
+
+    <h3>7.7 运维管理接口</h3>
+    <h4>7.7.1 设备状态与设备控制接口</h4>
+    <table><thead><tr><th>接口</th><th>方法</th><th>说明</th><th>请求参数</th><th>返回/处理字段</th><th>数据库表</th></tr></thead><tbody>
+      <tr><td>/robot-ops/ops/device-status/detail</td><td>GET</td><td>设备状态详情</td><td>无</td><td>robotName、robotCode、serialNo、ipAddress、macAddress、uptime、onlineStatus、workStatus、batteryLevel、chargeStatus、networkStatus、cpuUsage、memoryUsage、diskUsage、temperature、cameraStatus、micStatus、speakerStatus、screenStatus、mainServiceStatus、serverTime、lastHeartbeatTime</td><td>实时接口返回,不落库</td></tr>
+      <tr><td>/robot-ops/ops/control/charge-start</td><td>POST</td><td>一键充电</td><td>无或扩展参数</td><td>taskId、resultStatus、resultMsg</td><td>robot_ops_control_record、robot_ops_operate_log</td></tr>
+      <tr><td>/robot-ops/ops/control/charge-stop</td><td>POST</td><td>停止充电</td><td>无或扩展参数</td><td>taskId、resultStatus、resultMsg</td><td>robot_ops_control_record、robot_ops_operate_log</td></tr>
+      <tr><td>/robot-ops/ops/control/reboot</td><td>POST</td><td>重启机器人</td><td>确认标识(confirmFlag)</td><td>taskId、resultStatus、resultMsg</td><td>robot_ops_control_record、robot_ops_operate_log</td></tr>
+      <tr><td>/robot-ops/ops/control/shutdown</td><td>POST</td><td>关机</td><td>确认标识(confirmFlag)</td><td>taskId、resultStatus、resultMsg</td><td>robot_ops_control_record、robot_ops_operate_log</td></tr>
+      <tr><td>/robot-ops/ops/control/restart-service</td><td>POST</td><td>重启服务</td><td>服务名称(serviceName)</td><td>taskId、serviceName、resultStatus、resultMsg</td><td>robot_ops_control_record、robot_ops_operate_log</td></tr>
+      <tr><td>/robot-ops/ops/control/audio-test</td><td>POST</td><td>音频测试</td><td>测试文本(testText,可选)</td><td>resultStatus、resultMsg</td><td>robot_ops_control_record、robot_ops_operate_log</td></tr>
+      <tr><td>/robot-ops/ops/control/screen-test</td><td>POST</td><td>屏幕测试</td><td>测试类型(testType,可选)</td><td>resultStatus、resultMsg</td><td>robot_ops_control_record、robot_ops_operate_log</td></tr>
+      <tr><td>/robot-ops/ops/control/record/page</td><td>GET</td><td>设备控制记录分页</td><td>controlType、resultStatus、createTimeStart、createTimeEnd、pageNum、pageSize</td><td>id、controlType、controlName、taskId、resultStatus、resultMsg、operateBy、createTime、finishTime</td><td>robot_ops_control_record</td></tr>
+      <tr><td>/robot-ops/ops/control/record/{id}</td><td>GET</td><td>设备控制记录详情</td><td>控制记录ID(id)</td><td>controlType、controlName、taskId、requestParam、resultStatus、resultMsg、operateBy、createTime、finishTime</td><td>robot_ops_control_record</td></tr>
+    </tbody></table>
+
+    <h4>7.7.2 运行参数接口</h4>
+    <table><thead><tr><th>接口</th><th>方法</th><th>说明</th><th>请求/返回字段</th><th>数据库表</th></tr></thead><tbody>
+      <tr><td>/robot-ops/ops/param/groups</td><td>GET</td><td>参数分组列表</td><td>groupCode、groupName、sortNo、status</td><td>robot_ops_param_group</td></tr>
+      <tr><td>/robot-ops/ops/param/page</td><td>GET</td><td>参数分页/分组参数</td><td>groupCode、keyword、pageNum、pageSize;返回 paramCode、paramName、paramValue、valueType、unit、editable、requiredFlag、minValue、maxValue、enumOptions、remark</td><td>robot_ops_param</td></tr>
+      <tr><td>/robot-ops/ops/param</td><td>PUT</td><td>保存参数</td><td>参数列表(params:paramCode、paramValue)</td><td>robot_ops_param、robot_ops_operate_log</td></tr>
+      <tr><td>/robot-ops/ops/param/reset</td><td>POST</td><td>重置参数</td><td>groupCode 或 paramCodes</td><td>robot_ops_param、robot_ops_operate_log</td></tr>
+    </tbody></table>
+    <div class="warn">参数保存时必须根据 valueType、requiredFlag、minValue、maxValue、enumOptions 做后端校验。</div>
+
+    <h4>7.7.3 系统诊断与日志接口</h4>
+    <table><thead><tr><th>接口</th><th>方法</th><th>说明</th><th>请求/返回字段</th><th>数据库表</th></tr></thead><tbody>
+      <tr><td>/robot-ops/ops/diagnosis/overview</td><td>GET</td><td>诊断总览</td><td>normalCount、warnCount、failCount、lastCheckTime</td><td>robot_ops_diagnosis_item</td></tr>
+      <tr><td>/robot-ops/ops/diagnosis/page</td><td>GET</td><td>诊断项分页</td><td>itemName、resultStatus、pageNum、pageSize;返回 itemCode、itemName、resultStatus、detailMsg、lastCheckTime</td><td>robot_ops_diagnosis_item</td></tr>
+      <tr><td>/robot-ops/ops/diagnosis/run</td><td>POST</td><td>执行诊断</td><td>诊断项编码(itemCodes,可选)</td><td>normalCount、warnCount、failCount、itemList</td><td>robot_ops_diagnosis_item、robot_ops_sys_log</td></tr>
+      <tr><td>/robot-ops/ops/diagnosis/export</td><td>GET</td><td>导出诊断结果</td><td>resultStatus</td><td>Excel文件</td><td>robot_ops_diagnosis_item</td></tr>
+      <tr><td>/robot-ops/ops/log/page</td><td>GET</td><td>日志分页</td><td>日志来源类型(sourceType:SYS/OPERATE,可选)、logType、logLevel、keyword、resultStatus、logTimeStart、logTimeEnd、pageNum、pageSize</td><td>id、日志来源类型(sourceType)、logTime、logType、logLevel、moduleName、contentSummary、resultStatus、traceId</td><td>robot_ops_sys_log、robot_ops_operate_log</td></tr>
+      <tr><td>/robot-ops/ops/log/{id}</td><td>GET</td><td>日志详情</td><td>日志ID(id)、日志来源类型(sourceType)</td><td>logTime、logType、logLevel、moduleName、content、resultStatus、traceId、remark</td><td>robot_ops_sys_log、robot_ops_operate_log</td></tr>
+      <tr><td>/robot-ops/ops/log/export</td><td>GET</td><td>导出日志</td><td>同日志分页查询条件</td><td>Excel文件</td><td>robot_ops_sys_log、robot_ops_operate_log</td></tr>
+    </tbody></table>
+
+    <h4>7.7.4 软件版本与 OTA 升级接口</h4>
+    <table><thead><tr><th>接口</th><th>方法</th><th>说明</th><th>请求/返回字段</th><th>数据库表</th></tr></thead><tbody>
+      <tr><td>/robot-ops/ops/version/page</td><td>GET</td><td>软件版本分页</td><td>moduleCode、moduleName、runStatus、pageNum、pageSize;返回 moduleCode、moduleName、currentVersion、installTime、runStatus</td><td>robot_ops_version_info</td></tr>
+      <tr><td>/robot-ops/ops/upgrade/package/upload</td><td>POST</td><td>上传升级包</td><td>文件(file)、模块编码(moduleCode)、目标版本(targetVersion)、安装包名称(packageName)</td><td>packageId、packageName、moduleCode、targetVersion、fileUrl、fileSize、uploadBy、uploadTime</td><td>robot_ops_upgrade_package</td></tr>
+      <tr><td>/robot-ops/ops/upgrade/package/page</td><td>GET</td><td>升级包分页</td><td>packageName、moduleCode、targetVersion、pageNum、pageSize</td><td>packageId、packageName、moduleCode、targetVersion、fileSize、uploadBy、uploadTime</td><td>robot_ops_upgrade_package</td></tr>
+      <tr><td>/robot-ops/ops/upgrade/package/{id}</td><td>DELETE</td><td>删除升级包</td><td>升级包ID(id)</td><td>无</td><td>robot_ops_upgrade_package</td></tr>
+      <tr><td>/robot-ops/ops/upgrade/execute</td><td>POST</td><td>执行升级</td><td>模块编码(moduleCode)、升级包ID(packageId)、确认标识(confirmFlag)</td><td>升级记录ID(recordId)、升级状态(resultStatus)、升级进度(progressPercent)、结果信息(resultMsg)</td><td>robot_ops_upgrade_record、robot_ops_operate_log</td></tr>
+      <tr><td>/robot-ops/ops/upgrade/record/page</td><td>GET</td><td>升级记录分页</td><td>moduleCode、resultStatus、startTimeStart、startTimeEnd、pageNum、pageSize</td><td>recordId、moduleCode、moduleName、currentVersion、targetVersion、startTime、endTime、resultStatus、resultMsg、progressPercent</td><td>robot_ops_upgrade_record</td></tr>
+      <tr><td>/robot-ops/ops/upgrade/record/{id}</td><td>GET</td><td>升级详情</td><td>升级记录ID(id)</td><td>moduleCode、moduleName、currentVersion、targetVersion、packageId、executeBy、startTime、endTime、resultStatus、resultMsg、progressPercent</td><td>robot_ops_upgrade_record</td></tr>
+    </tbody></table>
+
+    <h3>7.8 账号管理接口</h3>
+    <table><thead><tr><th>接口</th><th>方法</th><th>说明</th><th>请求/返回字段</th><th>数据库表</th></tr></thead><tbody>
+      <tr><td>/robot-ops/system/user/page</td><td>GET</td><td>账号分页</td><td>username、nickName、status、pageNum、pageSize;返回 userId、username、nickName、roleCode、status、lastLoginTime</td><td>robot_ops_user</td></tr>
+      <tr><td>/robot-ops/system/user/{id}</td><td>GET</td><td>账号详情</td><td>用户ID(id)</td><td>userId、username、nickName、roleCode、status、remark、lastLoginTime</td><td>robot_ops_user</td></tr>
+      <tr><td>/robot-ops/system/user</td><td>POST</td><td>新增账号</td><td>username、nickName、password、roleCode、status、remark</td><td>新增用户ID(userId)</td><td>robot_ops_user</td></tr>
+      <tr><td>/robot-ops/system/user</td><td>PUT</td><td>编辑账号</td><td>userId、nickName、roleCode、status、remark</td><td>无</td><td>robot_ops_user</td></tr>
+      <tr><td>/robot-ops/system/user/{id}</td><td>DELETE</td><td>删除账号</td><td>用户ID(id)</td><td>无</td><td>robot_ops_user</td></tr>
+      <tr><td>/robot-ops/system/user/{id}/reset-password</td><td>POST</td><td>重置密码</td><td>用户ID(id)、新密码(newPassword)</td><td>无</td><td>robot_ops_user</td></tr>
+    </tbody></table>
+    <div class="warn">admin 默认账号不可删除;账号停用后不可登录;所有账号新增、编辑、删除、重置密码操作均需写入操作日志。</div>
+  </div>
+
+  <div class="section" id="s8"><h2>8. 数据库表设计</h2>
+    <div class="note">说明:以下表结构按运维端主库设计,命名采用 <code class="inline">robot_ops_</code> 前缀。第 6 章括号内字段为前端/接口字段,采用 camelCase;第 8 章字段为数据库字段,采用 snake_case,例如 robotName 对应 robot_name。机器人基础信息、实时运行状态、资源占用状态、模块状态、视频流状态等由机器人侧实时接口提供,一期不建设 robot_ops_robot_info 和 robot_ops_device_status_snapshot 两张表。</div>
+    <div class="warn">数据库建表语句以 MySQL 8.x 为基线,字段中文说明通过 COMMENT 标注。若项目实际使用 RuoYi 默认字段规范,可在开发时结合 create_by、create_time、update_by、update_time 等公共字段做统一封装;remark 作为业务备注字段,不要求所有表强制具备。</div>
+    <div class="note">公共字段约定:除日志流水类表外,内容管理类、访客管理类、运维配置类、系统配置类等业务表统一包含 create_by、create_time、update_by、update_time 四个公共字段,用于记录创建人、创建时间、更新人和更新时间。remark 为业务备注字段,不属于所有表强制必备字段;原则上用于后台可维护的主表、配置表和业务记录表。子表、明细表、日志流水表不强制添加 remark。日志类如需记录处理说明,应优先使用 result_msg、error_msg、handle_remark、description 等具备业务含义的字段。</div>
+    <div class="note">问答分类使用 RuoYi 系统字典能力维护,不单独建设 robot_ops_faq_category 表。RuoYi 字典类型为 <code class="inline">robot_faq_category</code>,当前字典项为:1=问候寒暄,2=产品介绍,3=业务咨询,4=访客引导,5=场所引导,6=安防提示,7=设备使用,8=售后服务,9=常见问题,10=其他。</div>
+
+    <h3>8.1 基础与权限表</h3>
+    <h4>8.1.1 本地账号表 robot_ops_user</h4>
+    <div class="code">CREATE TABLE `robot_ops_user` (
+  `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+  `username` VARCHAR(50) NOT NULL COMMENT '登录账号',
+  `password` VARCHAR(100) NOT NULL COMMENT '登录密码,加密存储',
+  `nick_name` VARCHAR(50) DEFAULT NULL COMMENT '用户姓名/昵称',
+  `role_code` VARCHAR(50) DEFAULT NULL COMMENT '角色编码:ADMIN、OPS、VIEWER',
+  `status` CHAR(1) NOT NULL DEFAULT '1' COMMENT '账号状态:0停用,1启用',
+  `remark` VARCHAR(500) DEFAULT NULL COMMENT '备注',
+  `last_login_time` DATETIME DEFAULT NULL COMMENT '最近登录时间',
+  `create_by` VARCHAR(64) DEFAULT NULL COMMENT '创建人',
+  `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `update_by` VARCHAR(64) DEFAULT NULL COMMENT '更新人',
+  `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_robot_ops_user_username` (`username`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='本地后台账号表';</div>
+
+    <h4>8.1.2 系统基础配置表 robot_ops_system_config</h4>
+    <div class="code">CREATE TABLE `robot_ops_system_config` (
+  `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+  `system_name` VARCHAR(100) DEFAULT NULL COMMENT '后台系统名称',
+  `system_logo` VARCHAR(255) DEFAULT NULL COMMENT '后台系统Logo地址',
+  `footer_text` VARCHAR(255) DEFAULT NULL COMMENT '页脚文案',
+  `record_no` VARCHAR(100) DEFAULT NULL COMMENT '备案号/版权信息',
+  `create_by` VARCHAR(64) DEFAULT NULL COMMENT '创建人',
+  `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `update_by` VARCHAR(64) DEFAULT NULL COMMENT '更新人',
+  `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统基础配置表';</div>
+
+    <h3>8.2 内容管理表</h3>
+    <h4>8.2.1 欢迎语配置表 robot_ops_welcome_config</h4>
+    <div class="code">CREATE TABLE `robot_ops_welcome_config` (
+  `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+  `config_key` VARCHAR(50) NOT NULL DEFAULT 'default' COMMENT '配置标识,默认配置固定为default',
+  `welcome_text` VARCHAR(500) NOT NULL COMMENT '欢迎语文本,前端限制最大200字,数据库预留500字',
+  `voice_enabled` CHAR(1) NOT NULL DEFAULT '1' COMMENT '是否启用语音播报:0否,1是',
+  `cooldown_seconds` INT NOT NULL DEFAULT 30 COMMENT '语音播报冷却时间,单位秒',
+  `status` CHAR(1) NOT NULL DEFAULT '1' COMMENT '启用欢迎语状态:0停用,1启用',
+  `remark` VARCHAR(500) DEFAULT NULL COMMENT '备注',
+  `create_by` VARCHAR(64) DEFAULT NULL COMMENT '创建人',
+  `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `update_by` VARCHAR(64) DEFAULT NULL COMMENT '更新人',
+  `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_robot_ops_welcome_config_key` (`config_key`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='欢迎语配置表';</div>
+    <div class="note">说明:欢迎语配置一期为单配置页,不作为多条数据列表管理。config_key 用于标识固定配置项,默认配置固定为 default;后端查询和保存时应以 config_key='default' 作为业务定位条件,避免因误插入多条数据导致无法判断当前配置。建议数据库初始化时预置一条 config_key='default' 的默认配置,同时保存接口应支持“有则更新,无则新增”。</div>
+    <div class="note">说明:welcome_text 数据库预留 500 字长度,前端页面按产品规则限制最大 200 字,便于后续扩展欢迎语内容。</div>
+    <div class="note">说明:status 控制欢迎语功能整体是否启用;voice_enabled 控制欢迎语触发后是否语音播报;cooldown_seconds 控制语音欢迎语的重复播报间隔。冷却期内再次检测到访客时,可仅做屏幕展示,不重复语音播报。</div>
+    <div class="code">INSERT INTO `robot_ops_welcome_config`
+(`config_key`, `welcome_text`, `voice_enabled`, `cooldown_seconds`, `status`, `remark`, `create_by`, `create_time`, `update_by`, `update_time`)
+VALUES
+('default', '您好,欢迎光临!我是迎宾巡逻安防机器人,很高兴为您服务。', '1', 30, '1', '系统默认欢迎语配置', 'system', NOW(), 'system', NOW());</div>
+
+    <h4>8.2.2 问答库表 robot_ops_faq</h4>
+    <div class="code">CREATE TABLE `robot_ops_faq` (
+  `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+  `category_type` VARCHAR(50) DEFAULT NULL COMMENT '问题分类字典值,字典类型:robot_faq_category',
+  `question` VARCHAR(500) NOT NULL COMMENT '标准问题',
+  `answer` TEXT NOT NULL COMMENT '答案内容',
+  `sort_no` INT DEFAULT 0 COMMENT '排序号,数字越小越靠前',
+  `status` CHAR(1) NOT NULL DEFAULT '1' COMMENT '启用状态:0停用,1启用',
+  `remark` VARCHAR(500) DEFAULT NULL COMMENT '备注',
+  `create_by` VARCHAR(64) DEFAULT NULL COMMENT '创建人',
+  `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `update_by` VARCHAR(64) DEFAULT NULL COMMENT '更新人',
+  `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  PRIMARY KEY (`id`),
+  KEY `idx_robot_ops_faq_category_type` (`category_type`),
+  KEY `idx_robot_ops_faq_status` (`status`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='问答库主表';</div>
+    <div class="note">说明:sort_no 为保留字段,前端页面一期不展示、不编辑。新增问答默认写入 0,编辑问答时保持原值。后续如果需要推荐问答、置顶问答或机器人屏幕端展示排序,再重新设计排序和推荐能力。</div>
+
+    <h4>8.2.3 相似问表 robot_ops_faq_similar</h4>
+    <div class="code">CREATE TABLE `robot_ops_faq_similar` (
+  `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+  `faq_id` BIGINT NOT NULL COMMENT '问答ID,关联robot_ops_faq.id',
+  `similar_question` VARCHAR(500) NOT NULL COMMENT '相似问文本',
+  `sort_no` INT DEFAULT 0 COMMENT '排序号,数字越小越靠前',
+  `create_by` VARCHAR(64) DEFAULT NULL COMMENT '创建人',
+  `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `update_by` VARCHAR(64) DEFAULT NULL COMMENT '更新人',
+  `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  PRIMARY KEY (`id`),
+  KEY `idx_robot_ops_faq_similar_faq_id` (`faq_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='问答相似问表';</div>
+
+    <h4>8.2.4 素材资源表 robot_ops_media_asset</h4>
+<div class="code">CREATE TABLE `robot_ops_media_asset` (
+  `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+  `asset_name` VARCHAR(100) NOT NULL COMMENT '素材名称',
+  `asset_type` VARCHAR(20) NOT NULL COMMENT '素材类型:image图片,video视频',
+  `file_url` VARCHAR(500) NOT NULL COMMENT '素材文件地址',
+  `thumbnail_url` VARCHAR(500) DEFAULT NULL COMMENT '缩略图地址,图片可等于file_url,视频可为封面图',
+  `file_size` BIGINT DEFAULT NULL COMMENT '文件大小,单位字节',
+  `file_format` VARCHAR(20) DEFAULT NULL COMMENT '文件格式,如jpg、png、webp、mp4',
+  `mime_type` VARCHAR(100) DEFAULT NULL COMMENT '文件MIME类型,如image/jpeg、video/mp4',
+  `duration_seconds` INT DEFAULT NULL COMMENT '视频时长,单位秒;图片为空',
+  `resolution` VARCHAR(50) DEFAULT NULL COMMENT '分辨率,如1920x1080',
+  `status` CHAR(1) NOT NULL DEFAULT '1' COMMENT '启用状态:0停用,1启用',
+  `quoted_flag` CHAR(1) NOT NULL DEFAULT '0' COMMENT '是否被播放方案引用:0否,1是,由系统维护',
+  `remark` VARCHAR(500) DEFAULT NULL COMMENT '备注',
+  `create_by` VARCHAR(64) DEFAULT NULL COMMENT '创建人',
+  `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间/上传时间',
+  `update_by` VARCHAR(64) DEFAULT NULL COMMENT '更新人',
+  `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  PRIMARY KEY (`id`),
+  KEY `idx_robot_ops_media_asset_type` (`asset_type`),
+  KEY `idx_robot_ops_media_asset_status` (`status`),
+  KEY `idx_robot_ops_media_asset_quoted_flag` (`quoted_flag`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='素材资源表';</div>
+<div class="note">说明:asset_type 为业务素材类型,用于页面筛选、播放方案选择和预览方式判断;mime_type 为文件标准 MIME 类型,用于文件校验、响应和预览处理;file_format 为文件后缀/格式,用于页面展示和导出。</div>
+<div class="note">说明:file_url、thumbnail_url、file_size、file_format、mime_type、duration_seconds、resolution 等字段由上传接口返回或后端解析生成,前端只读展示,不允许用户手动填写。duration_seconds 仅视频素材需要,图片素材为空;duration_seconds、resolution 允许为空,前端以“-”兜底展示。</div>
+<div class="note">说明:thumbnail_url 用于列表缩略图和视频封面展示。图片素材如未单独生成缩略图,可使用 file_url;视频素材只有后端返回 thumbnail_url 时才展示封面图,否则前端展示默认视频图标。</div>
+<div class="note">说明:quoted_flag 由系统维护,表示素材是否被播放方案引用。删除素材时,应以后端检查 robot_ops_play_plan_item 的实际引用关系为准。</div>
+
+    <h4>8.2.5 播放方案表 robot_ops_play_plan</h4>
+    <div class="code">CREATE TABLE `robot_ops_play_plan` (
+  `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+  `plan_name` VARCHAR(100) NOT NULL COMMENT '播放方案名称',
+  `loop_mode` VARCHAR(20) NOT NULL DEFAULT 'loop' COMMENT '循环方式:loop循环播放,once播放一次',
+  `asset_count` INT NOT NULL DEFAULT 0 COMMENT '素材数量',
+  `status` CHAR(1) NOT NULL DEFAULT '0' COMMENT '播放状态:0备用方案,1当前播放',
+  `remark` VARCHAR(500) DEFAULT NULL COMMENT '备注',
+  `create_by` VARCHAR(64) DEFAULT NULL COMMENT '创建人',
+  `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `update_by` VARCHAR(64) DEFAULT NULL COMMENT '更新人',
+  `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  PRIMARY KEY (`id`),
+  KEY `idx_robot_ops_play_plan_status` (`status`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='播放方案主表';</div>
+    <div class="note">说明:status 表示播放状态,0=备用方案,1=当前播放。车端只读取 status=1 的播放方案。</div>
+    <div class="note">说明:asset_count 为素材数量,由后端根据播放方案明细 itemList 数量维护,前端不允许手动填写。</div>
+    <div class="note">说明:同一时间仅允许一个播放方案 status=1,唯一性由后端事务控制,不依赖数据库唯一索引。</div>
+
+    <h4>8.2.6 播放方案素材明细表 robot_ops_play_plan_item</h4>
+    <div class="code">CREATE TABLE `robot_ops_play_plan_item` (
+  `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+  `plan_id` BIGINT NOT NULL COMMENT '播放方案ID,关联robot_ops_play_plan.id',
+  `asset_id` BIGINT NOT NULL COMMENT '素材ID,关联robot_ops_media_asset.id',
+  `play_order` INT NOT NULL DEFAULT 0 COMMENT '播放顺序,数字越小越靠前',
+  `stay_seconds` INT DEFAULT NULL COMMENT '停留时长,图片必填,视频可为空',
+  `transition_type` VARCHAR(50) NOT NULL DEFAULT 'none' COMMENT '转场方式,预留字段:none无转场,fade淡入淡出',
+  `create_by` VARCHAR(64) DEFAULT NULL COMMENT '创建人',
+  `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `update_by` VARCHAR(64) DEFAULT NULL COMMENT '更新人',
+  `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  PRIMARY KEY (`id`),
+  KEY `idx_robot_ops_play_plan_item_plan_id` (`plan_id`),
+  KEY `idx_robot_ops_play_plan_item_asset_id` (`asset_id`),
+  KEY `idx_robot_ops_play_plan_item_order` (`plan_id`, `play_order`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='播放方案素材明细表';</div>
+    <div class="note">说明:播放方案明细表一期只保存素材引用关系和播放编排字段,不保存素材名称、类型、文件地址、缩略图、视频时长等快照字段。</div>
+    <div class="note">说明:asset_id 为素材真实关联字段;播放方案详情、编辑回显、预览接口需要通过 asset_id 关联 robot_ops_media_asset 返回素材名称、素材类型、文件地址、缩略图、视频时长、分辨率、素材状态等展示信息。</div>
+    <div class="note">说明:图片素材 stay_seconds 必填;视频素材 stay_seconds 可为空,表示按视频自身播放。一期不做视频截断播放逻辑。</div>
+    <div class="note">说明:transition_type 为预留字段,一期前端不展示、不编辑,默认值为 none。后续车端屏幕支持转场动画后,再开放 fade 等转场配置。</div>
+    <div class="note">设计取舍:一期不做播放方案发布版本冻结,不要求保存素材快照。这样表结构更简单,后端维护成本更低;后续如果需要"方案发布后素材内容不随素材库变更而变化",再扩展素材快照字段或播放方案发布版本表。</div>
+
+    <h4>8.2.7 播报内容表 robot_ops_broadcast_content</h4>
+    <div class="code">CREATE TABLE `robot_ops_broadcast_content` (
+  `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+  `content_name` VARCHAR(100) NOT NULL COMMENT '播报内容名称',
+  `content_type` VARCHAR(50) DEFAULT NULL COMMENT '内容分类:通知、宣传、提示、安防提醒、自定义',
+  `broadcast_text` VARCHAR(2000) NOT NULL COMMENT '播报文本',
+  `status` CHAR(1) NOT NULL DEFAULT '1' COMMENT '启用状态:0停用,1启用',
+  `remark` VARCHAR(500) DEFAULT NULL COMMENT '备注',
+  `create_by` VARCHAR(64) DEFAULT NULL COMMENT '创建人',
+  `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `update_by` VARCHAR(64) DEFAULT NULL COMMENT '更新人',
+  `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  PRIMARY KEY (`id`),
+  KEY `idx_robot_ops_broadcast_content_type` (`content_type`),
+  KEY `idx_robot_ops_broadcast_content_status` (`status`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='播报内容表';</div>
+
+    <h4>8.2.8 播报任务表 robot_ops_broadcast_task</h4>
+    <div class="code">CREATE TABLE `robot_ops_broadcast_task` (
+  `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+  `task_name` VARCHAR(100) NOT NULL COMMENT '播报任务名称',
+  `content_id` BIGINT NOT NULL COMMENT '播报内容ID,关联robot_ops_broadcast_content.id',
+  `start_time` VARCHAR(8) DEFAULT NULL COMMENT '开始时间,格式HH:mm:ss',
+  `end_time` VARCHAR(8) DEFAULT NULL COMMENT '结束时间,格式HH:mm:ss',
+  `frequency_minutes` INT DEFAULT NULL COMMENT '播报频率,单位分钟',
+  `cycle_type` VARCHAR(20) DEFAULT NULL COMMENT '循环类型字典值,字典类型:broadcast_task_cycle_type,1按星期,2按日期',
+  `cycle_value` VARCHAR(255) DEFAULT NULL COMMENT '循环取值:cycle_type=1时保存星期值,如1,2,3,4,5;cycle_type=2时保存日期值,如2026-03-20,2026-03-21',
+  `status` CHAR(1) NOT NULL DEFAULT '1' COMMENT '启用状态:0停用,1启用',
+  `remark` VARCHAR(500) DEFAULT NULL COMMENT '备注',
+  `create_by` VARCHAR(64) DEFAULT NULL COMMENT '创建人',
+  `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `update_by` VARCHAR(64) DEFAULT NULL COMMENT '更新人',
+  `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  PRIMARY KEY (`id`),
+  KEY `idx_robot_ops_broadcast_task_content_id` (`content_id`),
+  KEY `idx_robot_ops_broadcast_task_status` (`status`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='播报任务表';
+    <div class="note">播报任务循环类型使用 RuoYi 字典 <code class="inline">broadcast_task_cycle_type</code>,字典项建议配置为:1=按星期,2=按日期。按星期时,cycle_value 中 1-7 分别代表星期一到星期日。</div></div>
+
+    <h4>8.2.9 展示主题表 robot_ops_theme</h4>
+    <div class="code">CREATE TABLE `robot_ops_theme` (
+  `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+  `theme_name` VARCHAR(100) NOT NULL COMMENT '主题名称',
+  `logo_url` VARCHAR(255) DEFAULT NULL COMMENT 'Logo地址',
+  `background_type` VARCHAR(20) DEFAULT NULL COMMENT '背景类型:image图片,video视频,color纯色',
+  `background_url` VARCHAR(255) DEFAULT NULL COMMENT '背景资源地址',
+  `primary_color` VARCHAR(20) DEFAULT NULL COMMENT '主题主色',
+  `secondary_color` VARCHAR(20) DEFAULT NULL COMMENT '辅助色',
+  `welcome_title` VARCHAR(200) DEFAULT NULL COMMENT '欢迎标题',
+  `welcome_sub_title` VARCHAR(500) DEFAULT NULL COMMENT '欢迎副标题',
+  `status` CHAR(1) NOT NULL DEFAULT '1' COMMENT '启用状态:0停用,1启用',
+  `current_enabled` CHAR(1) NOT NULL DEFAULT '0' COMMENT '是否当前启用主题:0否,1是',
+  `create_by` VARCHAR(64) DEFAULT NULL COMMENT '创建人',
+  `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `update_by` VARCHAR(64) DEFAULT NULL COMMENT '更新人',
+  `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  PRIMARY KEY (`id`),
+  KEY `idx_robot_ops_theme_current_enabled` (`current_enabled`),
+  KEY `idx_robot_ops_theme_status` (`status`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='机器人展示主题表';</div>
+
+    <h3>8.3 访客管理表</h3>
+    <h4>8.3.1 访客记录表 robot_ops_visitor_record</h4>
+    <div class="code">CREATE TABLE `robot_ops_visitor_record` (
+  `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+  `visitor_name` VARCHAR(100) NOT NULL COMMENT '访客姓名',
+  `mobile` VARCHAR(20) DEFAULT NULL COMMENT '手机号',
+  `id_card_no` VARCHAR(50) DEFAULT NULL COMMENT '身份证号',
+  `visit_type` VARCHAR(50) NOT NULL COMMENT '到访类型:APPOINTMENT预约到访,WALK_IN现场登记',
+  `register_type` VARCHAR(50) NOT NULL COMMENT '登记方式:SCREEN机器人端,H5手机端',
+  `visitor_source` VARCHAR(100) DEFAULT NULL COMMENT '访客来源,如公司、单位、亲友、外卖、快递、供应商等',
+  `visit_reason` VARCHAR(200) DEFAULT NULL COMMENT '来访事由,如业务接洽、走亲访友、酒店入住、配送、维修、参观等',
+  `visitor_photo` VARCHAR(255) DEFAULT NULL COMMENT '访客照片地址,由机器人端采集上传',
+  `appointment_no` VARCHAR(100) DEFAULT NULL COMMENT '关联预约单号,现场登记可为空',
+  `visited_person` VARCHAR(100) DEFAULT NULL COMMENT '被访对象,可为被访人、房号、部门、接待单位等',
+  `visit_time` DATETIME NOT NULL COMMENT '来访/登记时间',
+  `remark` VARCHAR(500) DEFAULT NULL COMMENT '备注',
+  `create_by` VARCHAR(64) DEFAULT NULL COMMENT '创建人',
+  `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `update_by` VARCHAR(64) DEFAULT NULL COMMENT '更新人',
+  `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  PRIMARY KEY (`id`),
+  KEY `idx_robot_ops_visitor_record_name` (`visitor_name`),
+  KEY `idx_robot_ops_visitor_record_mobile` (`mobile`),
+  KEY `idx_robot_ops_visitor_record_id_card_no` (`id_card_no`),
+  KEY `idx_robot_ops_visitor_record_visit_type` (`visit_type`),
+  KEY `idx_robot_ops_visitor_record_register_type` (`register_type`),
+  KEY `idx_robot_ops_visitor_record_appointment_no` (`appointment_no`),
+  KEY `idx_robot_ops_visitor_record_visit_time` (`visit_time`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='访客记录表';</div>
+    <div class="note">说明:访客记录表只保存已完成登记的到访记录,不保存登记失败结果。登记失败、扫码失败、身份证读取失败、预约匹配失败等异常,进入日志中心或后续扩展的登记异常日志。</div>
+    <div class="note">说明:visit_type 建议使用 RuoYi 字典 visitor_visit_type,字典项为 APPOINTMENT=预约到访,WALK_IN=现场登记。</div>
+    <div class="note">说明:register_type 建议使用 RuoYi 字典 visitor_register_type,字典项为 SCREEN=机器人端,H5=手机端。机器人端指访客在机器人屏幕完成登记;手机端指访客扫码后在 H5 页面完成登记。</div>
+    <div class="note">说明:visitor_source 和 visit_reason 为通用文本字段,用于适配公司、酒店、小区、园区、展厅等多种场景,不强制做枚举。</div>
+
+    <h4>8.3.2 预约记录表 robot_ops_appointment_record</h4>
+    <div class="code">CREATE TABLE `robot_ops_appointment_record` (
+  `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+  `appointment_no` VARCHAR(50) NOT NULL COMMENT '预约单号',
+  `visitor_name` VARCHAR(100) DEFAULT NULL COMMENT '访客姓名',
+  `mobile` VARCHAR(20) DEFAULT NULL COMMENT '访客手机号',
+  `visited_person` VARCHAR(100) DEFAULT NULL COMMENT '被访人/被访对象',
+  `appointment_time` DATETIME DEFAULT NULL COMMENT '预约到访时间',
+  `status` VARCHAR(20) DEFAULT NULL COMMENT '预约状态:待到访、已到访、已取消、已过期',
+  `sync_time` DATETIME DEFAULT NULL COMMENT '同步到本地时间',
+  `source_platform` VARCHAR(50) DEFAULT NULL COMMENT '来源平台',
+  `remark` VARCHAR(500) DEFAULT NULL COMMENT '备注',
+  `create_by` VARCHAR(64) DEFAULT NULL COMMENT '创建人',
+  `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `update_by` VARCHAR(64) DEFAULT NULL COMMENT '更新人',
+  `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_robot_ops_appointment_no` (`appointment_no`),
+  KEY `idx_robot_ops_appointment_time` (`appointment_time`),
+  KEY `idx_robot_ops_appointment_mobile` (`mobile`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='访客预约记录表';</div>
+
+    <h4>8.3.3 白名单表 robot_ops_whitelist</h4>
+    <div class="code">CREATE TABLE `robot_ops_whitelist` (
+  `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+  `name` VARCHAR(100) NOT NULL COMMENT '姓名',
+  `mobile` VARCHAR(20) DEFAULT NULL COMMENT '手机号',
+  `id_card_no` VARCHAR(50) DEFAULT NULL COMMENT '身份证号',
+  `whitelist_type` VARCHAR(50) DEFAULT NULL COMMENT '人员类型字典值,如internal内部人员、visitor访客、vip VIP、other其他',
+  `face_image_url` VARCHAR(255) DEFAULT NULL COMMENT '人脸照片地址,用于机器人侧照片比对',
+  `source_type` VARCHAR(50) DEFAULT NULL COMMENT '来源类型字典值:1本地录入,2平台同步,3机器人采集',
+  `valid_start_time` DATETIME DEFAULT NULL COMMENT '有效开始时间,不填表示立即生效',
+  `valid_end_time` DATETIME DEFAULT NULL COMMENT '有效结束时间,不填表示长期有效',
+  `status` CHAR(1) NOT NULL DEFAULT '1' COMMENT '启用状态:0停用,1启用',
+  `remark` VARCHAR(500) DEFAULT NULL COMMENT '备注',
+  `create_by` VARCHAR(64) DEFAULT NULL COMMENT '创建人',
+  `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `update_by` VARCHAR(64) DEFAULT NULL COMMENT '更新人',
+  `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  PRIMARY KEY (`id`),
+  KEY `idx_robot_ops_whitelist_mobile` (`mobile`),
+  KEY `idx_robot_ops_whitelist_id_card_no` (`id_card_no`),
+  KEY `idx_robot_ops_whitelist_type` (`whitelist_type`),
+  KEY `idx_robot_ops_whitelist_source_type` (`source_type`),
+  KEY `idx_robot_ops_whitelist_status` (`status`),
+  KEY `idx_robot_ops_whitelist_valid_time` (`valid_start_time`, `valid_end_time`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='白名单表';</div>
+    <div class="note">说明:whitelist_type 当前页面显示为“人员类型”,用于描述人员身份,不表示识别方式。建议字典项为 internal=内部人员、visitor=访客、vip=VIP、other=其他。</div>
+    <div class="note">说明:白名单不设置 recognition_type / auth_type 字段,因为同一人员可同时支持人脸照片、身份证号、手机号多种匹配方式。机器人侧根据实际采集到的信息选择对应字段进行匹配。</div>
+    <div class="note">说明:一期不建设 face_feature_id 字段。当前人脸白名单采用照片比对方式,仅保存 face_image_url。</div>
+    <div class="note">说明:source_type 使用 RuoYi 字典 source_type,当前字典项为 1=本地录入、2=平台同步、3=机器人采集。运维后台新增和导入的数据默认写入 1,平台同步和机器人采集数据由对应接口写入。</div>
+
+    <h3>8.4 监控与日志表</h3>
+    <h4>8.4.1 远程喊话记录表 robot_ops_shout_record</h4>
+    <div class="code">CREATE TABLE `robot_ops_shout_record` (
+  `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+  `shout_text` VARCHAR(1000) NOT NULL COMMENT '喊话文本',
+  `volume` INT DEFAULT NULL COMMENT '音量,0-100',
+  `play_times` INT DEFAULT 1 COMMENT '播放次数',
+  `interrupt_flag` CHAR(1) DEFAULT '0' COMMENT '是否打断当前播报:0否,1是',
+  `result_status` VARCHAR(20) DEFAULT NULL COMMENT '执行状态:SUCCESS成功,FAIL失败,RUNNING执行中',
+  `result_msg` VARCHAR(500) DEFAULT NULL COMMENT '执行结果信息/失败原因',
+  `operate_by` VARCHAR(64) DEFAULT NULL COMMENT '操作人',
+  `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间/喊话时间',
+  PRIMARY KEY (`id`),
+  KEY `idx_robot_ops_shout_record_create_time` (`create_time`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='远程喊话记录表';</div>
+
+    <h4>8.4.2 对话日志表 robot_ops_dialogue_log</h4>
+    <div class="code">CREATE TABLE `robot_ops_dialogue_log` (
+  `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+  `session_id` VARCHAR(100) DEFAULT NULL COMMENT '会话ID',
+  `ask_time` DATETIME DEFAULT NULL COMMENT '提问时间',
+  `question` VARCHAR(1000) DEFAULT NULL COMMENT '用户问题',
+  `answer` TEXT COMMENT '机器人回答',
+  `hit_type` VARCHAR(50) DEFAULT NULL COMMENT '命中方式:FAQ命中、未命中、其他',
+  `scene_type` VARCHAR(50) DEFAULT NULL COMMENT '来源场景:欢迎接待、咨询问答、其他',
+  `result_status` VARCHAR(20) DEFAULT NULL COMMENT '结果状态:SUCCESS成功,FAIL失败,TIMEOUT超时',
+  `raw_request` TEXT COMMENT '原始请求内容',
+  `raw_response` TEXT COMMENT '原始响应内容',
+  `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  PRIMARY KEY (`id`),
+  KEY `idx_robot_ops_dialogue_log_session_id` (`session_id`),
+  KEY `idx_robot_ops_dialogue_log_ask_time` (`ask_time`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='人机对话日志表';</div>
+
+    <h4>8.4.3 安防告警日志表 robot_ops_alarm_log</h4>
+    <div class="code">CREATE TABLE `robot_ops_alarm_log` (
+  `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+  `alarm_time` DATETIME DEFAULT NULL COMMENT '告警时间',
+  `alarm_type` VARCHAR(50) DEFAULT NULL COMMENT '告警类型',
+  `alarm_level` VARCHAR(20) DEFAULT NULL COMMENT '告警级别:LOW低,MEDIUM中,HIGH高,CRITICAL紧急',
+  `source_position` VARCHAR(100) DEFAULT NULL COMMENT '来源位置/区域/模块',
+  `handle_status` VARCHAR(20) DEFAULT 'UNHANDLED' COMMENT '处理状态:UNHANDLED未处理,CONFIRMED已确认,IGNORED已忽略',
+  `description` VARCHAR(1000) DEFAULT NULL COMMENT '告警描述',
+  `snapshot_url` VARCHAR(255) DEFAULT NULL COMMENT '抓拍图地址',
+  `remark` VARCHAR(500) DEFAULT NULL COMMENT '备注',
+  `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  PRIMARY KEY (`id`),
+  KEY `idx_robot_ops_alarm_log_time` (`alarm_time`),
+  KEY `idx_robot_ops_alarm_log_level` (`alarm_level`),
+  KEY `idx_robot_ops_alarm_log_status` (`handle_status`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='安防告警日志表';</div>
+
+    <h4>8.4.4 系统日志表 robot_ops_sys_log</h4>
+    <div class="code">CREATE TABLE `robot_ops_sys_log` (
+  `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+  `log_time` DATETIME DEFAULT NULL COMMENT '日志时间',
+  `log_type` VARCHAR(50) DEFAULT NULL COMMENT '日志类型:系统日志、设备日志、升级日志、服务日志',
+  `log_level` VARCHAR(20) DEFAULT NULL COMMENT '日志级别:INFO、WARN、ERROR',
+  `module_name` VARCHAR(100) DEFAULT NULL COMMENT '模块名称',
+  `content` TEXT COMMENT '日志内容',
+  `result_status` VARCHAR(20) DEFAULT NULL COMMENT '结果状态',
+  `trace_id` VARCHAR(100) DEFAULT NULL COMMENT '链路追踪ID',
+  `remark` VARCHAR(500) DEFAULT NULL COMMENT '备注',
+  `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  PRIMARY KEY (`id`),
+  KEY `idx_robot_ops_sys_log_time` (`log_time`),
+  KEY `idx_robot_ops_sys_log_type` (`log_type`),
+  KEY `idx_robot_ops_sys_log_level` (`log_level`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统日志表';</div>
+
+    <h4>8.4.5 操作日志表 robot_ops_operate_log</h4>
+    <div class="code">CREATE TABLE `robot_ops_operate_log` (
+  `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+  `operate_time` DATETIME DEFAULT NULL COMMENT '操作时间',
+  `operate_user` VARCHAR(64) DEFAULT NULL COMMENT '操作人',
+  `module_name` VARCHAR(100) DEFAULT NULL COMMENT '操作模块',
+  `operate_type` VARCHAR(50) DEFAULT NULL COMMENT '操作类型:新增、编辑、删除、控制、升级等',
+  `operate_content` VARCHAR(1000) DEFAULT NULL COMMENT '操作内容',
+  `result_status` VARCHAR(20) DEFAULT NULL COMMENT '操作结果:SUCCESS成功,FAIL失败',
+  `result_msg` VARCHAR(500) DEFAULT NULL COMMENT '结果信息/失败原因',
+  `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  PRIMARY KEY (`id`),
+  KEY `idx_robot_ops_operate_log_time` (`operate_time`),
+  KEY `idx_robot_ops_operate_log_user` (`operate_user`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='后台操作日志表';</div>
+
+    <h3>8.5 运维与升级表</h3>
+    <h4>8.5.1 参数分组表 robot_ops_param_group</h4>
+    <div class="code">CREATE TABLE `robot_ops_param_group` (
+  `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+  `group_code` VARCHAR(50) NOT NULL COMMENT '参数分组编码',
+  `group_name` VARCHAR(100) NOT NULL COMMENT '参数分组名称',
+  `sort_no` INT DEFAULT 0 COMMENT '排序号,数字越小越靠前',
+  `status` CHAR(1) NOT NULL DEFAULT '1' COMMENT '启用状态:0停用,1启用',
+  `remark` VARCHAR(500) DEFAULT NULL COMMENT '备注',
+  `create_by` VARCHAR(64) DEFAULT NULL COMMENT '创建人',
+  `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `update_by` VARCHAR(64) DEFAULT NULL COMMENT '更新人',
+  `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_robot_ops_param_group_code` (`group_code`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='运行参数分组表';</div>
+
+    <h4>8.5.2 设备控制记录表 robot_ops_control_record</h4>
+    <div class="code">CREATE TABLE `robot_ops_control_record` (
+  `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+  `control_type` VARCHAR(50) NOT NULL COMMENT '控制类型:充电、停止充电、重启、关机、重启服务、音频测试、屏幕测试等',
+  `control_name` VARCHAR(100) DEFAULT NULL COMMENT '控制名称',
+  `task_id` VARCHAR(100) DEFAULT NULL COMMENT '机器人侧返回的任务ID',
+  `request_param` TEXT COMMENT '请求参数JSON',
+  `result_status` VARCHAR(20) DEFAULT NULL COMMENT '执行状态:SUCCESS成功,FAIL失败,RUNNING执行中,PENDING等待中',
+  `result_msg` VARCHAR(500) DEFAULT NULL COMMENT '执行结果信息/失败原因',
+  `operate_by` VARCHAR(64) DEFAULT NULL COMMENT '操作人',
+  `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间/下发时间',
+  `finish_time` DATETIME DEFAULT NULL COMMENT '完成时间',
+  PRIMARY KEY (`id`),
+  KEY `idx_robot_ops_control_record_task_id` (`task_id`),
+  KEY `idx_robot_ops_control_record_type` (`control_type`),
+  KEY `idx_robot_ops_control_record_time` (`create_time`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='设备控制记录表';</div>
+
+    <h4>8.5.3 运行参数表 robot_ops_param</h4>
+    <div class="code">CREATE TABLE `robot_ops_param` (
+  `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+  `group_code` VARCHAR(50) NOT NULL COMMENT '参数分组编码,关联robot_ops_param_group.group_code',
+  `param_code` VARCHAR(100) NOT NULL COMMENT '参数编码',
+  `param_name` VARCHAR(100) NOT NULL COMMENT '参数名称',
+  `param_value` VARCHAR(2000) DEFAULT NULL COMMENT '参数值',
+  `value_type` VARCHAR(20) DEFAULT NULL COMMENT '值类型:string、int、float、boolean、enum、json',
+  `unit` VARCHAR(20) DEFAULT NULL COMMENT '单位',
+  `editable` CHAR(1) NOT NULL DEFAULT '1' COMMENT '是否可编辑:0否,1是',
+  `required_flag` CHAR(1) NOT NULL DEFAULT '0' COMMENT '是否必填:0否,1是',
+  `min_value` VARCHAR(50) DEFAULT NULL COMMENT '最小值',
+  `max_value` VARCHAR(50) DEFAULT NULL COMMENT '最大值',
+  `enum_options` VARCHAR(2000) DEFAULT NULL COMMENT '枚举项JSON',
+  `remark` VARCHAR(500) DEFAULT NULL COMMENT '参数说明/备注',
+  `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_robot_ops_param_code` (`group_code`,`param_code`),
+  KEY `idx_robot_ops_param_group_code` (`group_code`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='运行参数配置表';</div>
+
+    <h4>8.5.4 系统诊断项表 robot_ops_diagnosis_item</h4>
+    <div class="code">CREATE TABLE `robot_ops_diagnosis_item` (
+  `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+  `item_code` VARCHAR(50) NOT NULL COMMENT '诊断项编码',
+  `item_name` VARCHAR(100) NOT NULL COMMENT '诊断项名称',
+  `result_status` VARCHAR(20) DEFAULT NULL COMMENT '检查结果:NORMAL正常,WARN告警,FAIL失败',
+  `detail_msg` VARCHAR(1000) DEFAULT NULL COMMENT '详情描述',
+  `last_check_time` DATETIME DEFAULT NULL COMMENT '最后检查时间',
+  `create_by` VARCHAR(64) DEFAULT NULL COMMENT '创建人',
+  `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `update_by` VARCHAR(64) DEFAULT NULL COMMENT '更新人',
+  `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_robot_ops_diagnosis_item_code` (`item_code`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统诊断项表';</div>
+    <div class="note">说明:一期仅保留各诊断项的最新诊断结果;如后续需要追踪每次诊断历史,可在二期扩展诊断记录表和诊断明细表。</div>
+
+    <h4>8.5.5 软件版本信息表 robot_ops_version_info</h4>
+    <div class="code">CREATE TABLE `robot_ops_version_info` (
+  `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+  `module_code` VARCHAR(50) NOT NULL COMMENT '模块编码',
+  `module_name` VARCHAR(100) NOT NULL COMMENT '模块名称',
+  `current_version` VARCHAR(50) DEFAULT NULL COMMENT '当前版本号',
+  `install_time` DATETIME DEFAULT NULL COMMENT '安装时间',
+  `run_status` VARCHAR(20) DEFAULT NULL COMMENT '运行状态:RUNNING运行中,STOPPED已停止,ERROR异常',
+  `create_by` VARCHAR(64) DEFAULT NULL COMMENT '创建人',
+  `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `update_by` VARCHAR(64) DEFAULT NULL COMMENT '更新人',
+  `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_robot_ops_version_module_code` (`module_code`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='软件版本信息表';</div>
+
+    <h4>8.5.6 升级包表 robot_ops_upgrade_package</h4>
+    <div class="code">CREATE TABLE `robot_ops_upgrade_package` (
+  `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+  `package_name` VARCHAR(100) NOT NULL COMMENT '安装包名称',
+  `module_code` VARCHAR(50) NOT NULL COMMENT '升级模块编码',
+  `target_version` VARCHAR(50) NOT NULL COMMENT '目标版本号',
+  `file_url` VARCHAR(255) NOT NULL COMMENT '升级包文件地址',
+  `file_size` BIGINT DEFAULT NULL COMMENT '文件大小,单位字节',
+  `upload_by` VARCHAR(64) DEFAULT NULL COMMENT '上传人',
+  `upload_time` DATETIME DEFAULT NULL COMMENT '上传完成时间',
+  `create_by` VARCHAR(64) DEFAULT NULL COMMENT '创建人',
+  `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `update_by` VARCHAR(64) DEFAULT NULL COMMENT '更新人',
+  `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  PRIMARY KEY (`id`),
+  KEY `idx_robot_ops_upgrade_package_module` (`module_code`),
+  KEY `idx_robot_ops_upgrade_package_version` (`target_version`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OTA升级包表';</div>
+
+    <h4>8.5.7 升级记录表 robot_ops_upgrade_record</h4>
+    <div class="code">CREATE TABLE `robot_ops_upgrade_record` (
+  `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+  `module_code` VARCHAR(50) NOT NULL COMMENT '模块编码',
+  `module_name` VARCHAR(100) DEFAULT NULL COMMENT '模块名称',
+  `current_version` VARCHAR(50) DEFAULT NULL COMMENT '原版本号',
+  `target_version` VARCHAR(50) DEFAULT NULL COMMENT '目标版本号',
+  `package_id` BIGINT DEFAULT NULL COMMENT '升级包ID,关联robot_ops_upgrade_package.id',
+  `execute_by` VARCHAR(64) DEFAULT NULL COMMENT '执行人',
+  `start_time` DATETIME DEFAULT NULL COMMENT '升级开始时间',
+  `end_time` DATETIME DEFAULT NULL COMMENT '升级结束时间',
+  `result_status` VARCHAR(20) DEFAULT NULL COMMENT '升级状态:SUCCESS成功,FAIL失败,RUNNING升级中,PENDING等待中',
+  `result_msg` VARCHAR(1000) DEFAULT NULL COMMENT '升级结果信息/失败原因',
+  `progress_percent` INT DEFAULT 0 COMMENT '升级进度百分比,0-100',
+  `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  PRIMARY KEY (`id`),
+  KEY `idx_robot_ops_upgrade_record_module` (`module_code`),
+  KEY `idx_robot_ops_upgrade_record_status` (`result_status`),
+  KEY `idx_robot_ops_upgrade_record_start_time` (`start_time`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OTA升级记录表';</div>
+
+    <div class="note">一期不建设机器人基础信息表和设备状态快照表。首页总览、设备状态页、视频预览页的数据以机器人侧实时接口为准;如二期需要状态历史趋势、离线查看最近状态、故障追溯统计,再扩展设备状态快照表。</div>
+  </div>
+
+  <div class="section" id="s9"><h2>9. 状态、日志与控制规则</h2>
+    <h3>9.1 状态枚举建议</h3><table><thead><tr><th>字段</th><th>建议值</th></tr></thead><tbody><tr><td>onlineStatus</td><td>ONLINE / OFFLINE</td></tr><tr><td>workStatus</td><td>IDLE / RECEIVING / BROADCASTING / CHARGING / ERROR</td></tr><tr><td>chargeStatus</td><td>NOT_CHARGING / CHARGING / FULL</td></tr><tr><td>resultStatus</td><td>SUCCESS / FAIL / RUNNING / PENDING</td></tr><tr><td>alarmLevel</td><td>LOW / MEDIUM / HIGH / CRITICAL</td></tr><tr><td>status</td><td>0 停用 / 1 启用</td></tr></tbody></table>
+    <h3>9.2 控制规则</h3><ul><li>重启、关机、充电、停止充电、升级等操作必须记录操作日志。</li><li>重启、关机、升级操作必须弹窗二次确认。</li><li>当机器人处于升级中时,除查看类操作外,禁止执行关机、重启、参数保存等高风险动作。</li><li>当设备离线时,控制按钮置灰并显示“设备离线,无法执行”。</li></ul>
+    <h3>9.3 日志规则</h3><ul><li>所有关键业务操作必须写入操作日志。</li><li>机器人端回传的异常和诊断结果,统一映射到日志中心或诊断中心展示。</li><li>日志保留时长一期默认 180 天,可由系统参数配置。</li></ul>
+  </div>
+
+  <div class="section" id="s10"><h2>10. 权限与账号设计</h2><table><thead><tr><th>角色</th><th>默认权限</th></tr></thead><tbody><tr><td>ADMIN</td><td>全量权限,包括账号管理、参数配置、设备控制、OTA 升级。</td></tr><tr><td>OPS</td><td>首页、内容管理、访客管理、监控管理、运维管理(除账号管理)。</td></tr><tr><td>VIEWER</td><td>仅查看权限,不可执行新增、编辑、删除、升级、控制等动作。</td></tr></tbody></table><p>RuoYi 菜单权限与按钮权限均需保留,避免后期返工。即使一期只有 admin,也要按标准权限框架开发。</p></div>
+
+  <div class="section" id="s11"><h2>11. 开发优先级与实施顺序</h2><table><thead><tr><th>阶段</th><th>模块</th><th>说明</th></tr></thead><tbody><tr><td>阶段一</td><td>登录、首页、设备状态、设备控制、参数配置、日志中心、版本/OTA</td><td>先打通基础运维闭环。</td></tr><tr><td>阶段二</td><td>欢迎语、问答库、素材管理、播放方案、播报内容、播报任务、主题配置</td><td>打通内容配置闭环。</td></tr><tr><td>阶段三</td><td>访客记录、预约记录、白名单、视频预览、远程喊话、对话日志、安防告警日志</td><td>补齐业务查询和监控能力。</td></tr><tr><td>阶段四</td><td>优化、导入导出、性能提升、操作审计完善</td><td>稳定化阶段。</td></tr></tbody></table></div>
+
+  <div class="section" id="s12"><h2>12. 对其他团队的配合要求</h2><div class="danger">以下内容不再作为“待定事项”,而是作为其他团队必须按本文配合实现的内容。</div><ul><li><strong>机器人侧:</strong>需提供首页状态接口、设备状态接口、控制接口、视频流信息接口、喊话接口、参数接口、日志接口、版本与升级接口。</li><li><strong>主控平台侧:</strong>需提供预约记录同步接口、可选白名单同步接口。</li><li><strong>展示端:</strong>需支持欢迎语、播放方案、播报任务、展示主题配置的读取与应用。</li><li><strong>算法 / 安防侧:</strong>需向运维端提供安防告警记录标准数据结构。</li></ul><div class="ok">结论:本文件已经作为一期开发基线文档定版。后续若有调整,应基于本文迭代版本,而不是推翻本文重新回到需求澄清阶段。</div></div>
+
+  <div class="section" id="s13"><h2>13. 测试验收要点</h2><table><thead><tr><th>模块</th><th>验收要点</th></tr></thead><tbody><tr><td>登录</td><td>正确账号可登录,错误账号提示,退出后不可访问后台页面。</td></tr><tr><td>首页</td><td>状态、统计、告警、快捷入口可正常展示和跳转。</td></tr><tr><td>内容管理</td><td>欢迎语、问答库、素材、方案、播报任务、主题均可增删改查。</td></tr><tr><td>访客管理</td><td>访客记录、预约记录、白名单可查询,导出结果正确。</td></tr><tr><td>监控管理</td><td>视频可预览,喊话可执行,对话日志与告警日志可查询。</td></tr><tr><td>运维管理</td><td>设备状态可展示,控制操作有确认弹窗和结果反馈,参数保存有效,OTA 流程完整。</td></tr><tr><td>日志与权限</td><td>关键操作写入操作日志,不同角色权限生效。</td></tr></tbody></table></div>
+
+  <div class="footer">文档版本:V2.1(完整详细设计开发版)</div>
+</div>
+</body>
+</html>

+ 695 - 0
杩庡宸¢€诲畨闃叉満鍣ㄤ汉鏈鸿韩灞忎氦浜掔郴缁熻缁嗚璁″紑鍙戞枃妗o紙涓€鏈燂級.html

@@ -0,0 +1,695 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+  <meta charset="UTF-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+  <title>迎宾巡逻安防机器人机身屏交互系统详细设计开发文档(一期)</title>
+  <style>
+    :root{
+      --primary:#2f8ee5;--primary2:#20b7c7;--green:#10b981;--orange:#f59e0b;--red:#ef4444;--purple:#7c3aed;
+      --text:#1f2937;--sub:#4b5563;--muted:#64748b;--line:#e5e7eb;--bg:#f5f8fb;--card:#fff;--tag:#eef7ff;--code:#f8fafc
+    }
+    *{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;font-family:"PingFang SC","Microsoft YaHei",Arial,sans-serif;color:var(--text);background:var(--bg);line-height:1.75}
+    .container{width:1240px;max-width:calc(100vw - 48px);margin:24px auto 60px}.hero{background:linear-gradient(135deg,#e9f8ff 0%,#dff8f5 42%,#f7fbff 100%);color:#0f172a;border:1px solid rgba(47,142,229,.16);border-radius:24px;padding:38px 42px;box-shadow:0 18px 50px rgba(47,142,229,.12);position:relative;overflow:hidden}
+    .hero:after{content:"";position:absolute;right:-90px;top:-90px;width:260px;height:260px;border-radius:50%;background:rgba(32,183,199,.16)}.hero h1{margin:0 0 12px;font-size:34px;line-height:1.25;position:relative}.hero p{margin:8px 0;color:#334155;font-size:15px;position:relative}.meta{display:flex;gap:12px;flex-wrap:wrap;margin-top:18px;position:relative}.chip{padding:6px 12px;border-radius:999px;background:rgba(255,255,255,.78);border:1px solid rgba(47,142,229,.18);font-size:13px;color:#1e5f99}
+    .section{background:var(--card);border-radius:18px;padding:28px 30px;margin-top:20px;box-shadow:0 10px 30px rgba(15,23,42,.06)}h2{margin:0 0 14px;font-size:24px;color:#111827;padding-left:14px;border-left:5px solid var(--primary)}h3{margin:26px 0 10px;font-size:18px;color:#111827}h4{margin:20px 0 8px;font-size:16px;color:#111827}p,li{font-size:14px;color:var(--sub)}ul,ol{margin:8px 0 8px 22px;padding:0}
+    .grid-2{display:grid;grid-template-columns:1fr 1fr;gap:18px}.grid-3{display:grid;grid-template-columns:repeat(3,1fr);gap:16px}.grid-4{display:grid;grid-template-columns:repeat(4,1fr);gap:14px}.card{border:1px solid var(--line);border-radius:16px;padding:16px 18px;background:#fff}.card strong{color:#111827}.tag{display:inline-block;padding:3px 10px;margin-right:8px;margin-bottom:8px;border-radius:999px;background:var(--tag);color:#1f7ace;font-size:12px;border:1px solid #dbeafe}
+    .warn{border-left:5px solid var(--orange);background:#fffaf0;padding:16px 18px;border-radius:12px;color:#7c5a10;font-size:14px}.danger{border-left:5px solid var(--red);background:#fff5f5;padding:16px 18px;border-radius:12px;color:#8f1d1d;font-size:14px}.ok{border-left:5px solid var(--green);background:#f0fdf4;padding:16px 18px;border-radius:12px;color:#166534;font-size:14px}.note{border-left:5px solid var(--purple);background:#faf5ff;padding:16px 18px;border-radius:12px;color:#6b21a8;font-size:14px}.info{border-left:5px solid var(--primary2);background:#ecfeff;padding:16px 18px;border-radius:12px;color:#155e75;font-size:14px}
+    table{width:100%;border-collapse:collapse;margin-top:10px;background:#fff;border:1px solid var(--line);table-layout:fixed}th,td{border:1px solid var(--line);padding:10px 12px;text-align:left;vertical-align:top;font-size:13px;color:var(--sub);word-break:break-word}th{background:#f8fafc;color:#111827;font-weight:600}.toc{columns:2;gap:30px}.toc a{display:block;text-decoration:none;color:var(--primary);padding:3px 0;font-size:14px}.code{background:var(--code);border:1px solid var(--line);border-radius:12px;padding:14px 16px;font-family:Consolas,Monaco,monospace;font-size:12px;line-height:1.6;color:#0f172a;white-space:pre-wrap;overflow:auto}.footer{text-align:center;color:#94a3b8;margin-top:20px;font-size:12px}code.inline{background:#eff6ff;color:#1d4ed8;border:1px solid #dbeafe;padding:2px 6px;border-radius:6px;font-size:12px}.flow{background:#f8fafc;border:1px dashed #cbd5e1;border-radius:14px;padding:14px 16px;font-family:Consolas,Monaco,monospace;font-size:13px;color:#334155;white-space:pre-wrap}
+    @media(max-width:900px){.grid-2,.grid-3,.grid-4{grid-template-columns:1fr}.toc{columns:1}.container{max-width:calc(100vw - 24px)}.hero{padding:24px}.section{padding:22px}table{display:block;overflow:auto}}
+  </style>
+</head>
+<body>
+<div class="container">
+  <div class="hero">
+    <h1>迎宾巡逻安防机器人机身屏交互系统详细设计开发文档(一期)</h1>
+    <p>文档用途:作为机器人机身屏前端页面设计、交互流程、Mock 开发、后续接口联调和验收测试的统一开发基线。</p>
+    <p>文档原则:一期先完成屏幕端前端页面与交互流程,使用 Mock 数据,后续再逐步对接小主机后端、语音服务、身份识别、导航点位、访客登记等接口。</p>
+    <div class="meta"><span class="chip">技术基线:Vue3 + Vite</span><span class="chip">运行环境:Ubuntu + Chromium Kiosk</span><span class="chip">屏幕基准:1024×768 横屏 8 寸</span><span class="chip">交互方式:10 点触摸 + 语音指令</span></div>
+  </div>
+
+  <div class="section"><h2>目录</h2><div class="toc">
+    <a href="#s1">1. 项目定位与建设目标</a><a href="#s2">2. 运行环境与技术选型</a><a href="#s3">3. 系统边界与职责划分</a><a href="#s4">4. 总体功能架构</a><a href="#s5">5. 主菜单与页面结构</a><a href="#s6">6. 核心业务流程设计</a><a href="#s7">7. 页面详细设计</a><a href="#s8">8. 播放、播报与声音控制规则</a><a href="#s9">9. 语音指令与事件通信设计</a><a href="#s10">10. 输入方式与触摸交互规范</a><a href="#s11">11. UI 视觉与屏幕适配规范</a><a href="#s12">12. Mock 数据与前端开发建议</a><a href="#s13">13. 后续接口设计建议</a><a href="#s14">14. 开发优先级与实施顺序</a><a href="#s15">15. 测试验收要点</a><a href="#s16">16. 安全、隐私与异常兜底</a><a href="#s17">17. 运行部署与现场维护建议</a><a href="#s18">18. 待确认与后续扩展事项</a>
+  </div></div>
+
+  <div class="section" id="s1"><h2>1. 项目定位与建设目标</h2>
+    <p>本系统为<strong>迎宾巡逻安防机器人机身屏交互系统</strong>,运行在机器人本体小主机盒子中,通过 8 寸触摸屏面向访客、现场用户、前台/物业/工作人员提供迎宾展示、访客登记、预约核验、路线引导、通知公告、呼叫工作人员等交互能力。</p>
+    <div class="ok">屏幕端不是运维后台,不承担素材上传、问答库维护、播放方案配置、日志查询、OTA 升级、账号管理、白名单批量维护等后台管理能力。</div>
+    <h3>1.1 一期建设目标</h3>
+    <div class="grid-3">
+      <div class="card"><strong>展示可用</strong><br>无人交互时播放广告素材、通知公告或欢迎页,形成机器人对外展示窗口。</div>
+      <div class="card"><strong>交互清晰</strong><br>通过大按钮、大字体和简洁流程完成访客登记、路线引导、通知查看等操作。</div>
+      <div class="card"><strong>接口可替换</strong><br>一期先用 Mock 数据完成前端,后续接口成熟后平滑替换为真实数据。</div>
+    </div>
+    <h3>1.2 一期设计基准</h3>
+    <ul><li>屏幕尺寸按 1024×768 横屏 8 寸设计,支持 10 点触摸。</li><li>小主机系统为 Ubuntu,前端运行于 Chromium Kiosk 全屏模式。</li><li>语音识别、人脸识别、身份证读卡、TTS、导航等能力由小主机后端或机器人侧服务提供,屏幕端只负责展示与交互。</li><li>路线引导、语音指令、人脸识别、访客登记等接口可先 Mock,后续再接入真实接口。</li></ul>
+  </div>
+
+  <div class="section" id="s2"><h2>2. 运行环境与技术选型</h2>
+    <h3>2.1 推荐技术路线</h3>
+    <table><thead><tr><th>项目</th><th>建议方案</th><th>说明</th></tr></thead><tbody>
+      <tr><td>产品形态</td><td>HTML5 机器人机身屏应用</td><td>对外可理解为 H5 屏幕端应用,运行于浏览器全屏环境。</td></tr>
+      <tr><td>开发框架</td><td>Vue3 + Vite</td><td>适合组件化开发、路由管理、状态管理和后续接口联调。</td></tr>
+      <tr><td>状态管理</td><td>Pinia</td><td>管理机器人状态、播放状态、语音指令、全局弹窗、用户输入等。</td></tr>
+      <tr><td>路由</td><td>Vue Router</td><td>管理待机页、主菜单、访客登记、路线引导、通知公告等页面。</td></tr>
+      <tr><td>运行方式</td><td>Chromium Kiosk</td><td>Ubuntu 开机后自动全屏打开本地 screen 地址。</td></tr>
+      <tr><td>部署方式</td><td>Nginx 托管静态文件</td><td>Vue3 打包后生成 dist 静态文件,部署到小主机本地目录。</td></tr>
+    </tbody></table>
+    <h3>2.2 Vue3 与 HTML5 的关系</h3>
+    <div class="info">Vue3 并不是和 HTML5 二选一。Vue3 最终打包产物仍是 HTML、CSS、JavaScript 静态文件。推荐表述为:本系统采用 Vue3 开发 HTML5 机身屏应用。</div>
+    <h3>2.3 更新部署方式</h3>
+    <div class="flow">开发阶段:
+编写 Vue3 源码 → npm run build → 生成 dist 静态文件
+
+部署阶段:
+上传 dist.zip → 解压覆盖 /usr/share/nginx/html/screen → 刷新或重启 Chromium → 完成更新
+
+后续产品化:
+可将 screen-ui-vX.X.X.zip 纳入运维端 OTA 升级模块,支持备份、覆盖、刷新和失败回滚。</div>
+  </div>
+
+  <div class="section" id="s3"><h2>3. 系统边界与职责划分</h2>
+    <h3>3.1 屏幕端负责内容</h3>
+    <div><span class="tag">待机展示</span><span class="tag">素材播放</span><span class="tag">播报内容展示</span><span class="tag">语音指令响应</span><span class="tag">访客登记</span><span class="tag">预约核验</span><span class="tag">身份证读取交互</span><span class="tag">人脸识别结果展示</span><span class="tag">路线引导前端流程</span><span class="tag">通知公告展示</span><span class="tag">呼叫工作人员占位</span><span class="tag">全局状态提示</span><span class="tag">隐藏系统信息页</span></div>
+    <h3>3.2 屏幕端不负责内容</h3>
+    <ul><li>不负责人脸采集、人脸识别算法、人脸比对。</li><li>不负责身份证读卡器底层驱动和 SDK 调用。</li><li>不负责 TTS 合成能力和语音对话能力。</li><li>不负责真实导航规划、路径控制、巡逻任务控制。</li><li>不负责运维后台管理能力,如素材上传、播放方案编辑、问答库维护、白名单导入、OTA、日志中心等。</li></ul>
+    <h3>3.3 与其他系统的关系</h3>
+    <table><thead><tr><th>系统/服务</th><th>负责内容</th><th>屏幕端使用方式</th></tr></thead><tbody>
+      <tr><td>运维端 Web 管理系统</td><td>欢迎语、素材、播放方案、播报内容、播报任务、访客记录、预约记录、白名单、系统配置等管理。</td><td>屏幕端读取或消费运维端配置结果,不重复建设后台管理页面。</td></tr>
+      <tr><td>小主机后端服务</td><td>接口聚合、语音指令接收、播报任务调度、TTS 调用、身份证读卡接口、人脸识别结果转发、机器人状态接口等。</td><td>屏幕前端通过 HTTP 轮询或接口调用与其通信。</td></tr>
+      <tr><td>语音服务</td><td>语音识别、语音对话、TTS 播放或播报能力。</td><td>语音识别结果先上报小主机后端,再由屏幕端获取并执行动作。</td></tr>
+      <tr><td>机器人主控/导航服务</td><td>点位列表、导航任务、运动控制、状态采集、巡逻任务等。</td><td>一期路线引导先 Mock,后续接入真实点位和导航状态接口。</td></tr>
+    </tbody></table>
+  </div>
+
+  <div class="section" id="s3-1"><h2>3.4 典型运行状态与页面状态机</h2>
+    <p>屏幕端应围绕“无人待机、用户交互、业务办理、插播播报、异常提示”几个状态进行设计,避免页面之间随意跳转导致流程混乱。</p>
+    <table><thead><tr><th>状态</th><th>触发来源</th><th>页面表现</th><th>退出条件</th></tr></thead><tbody>
+      <tr><td>待机播放</td><td>系统启动、无操作超时、业务结束</td><td>播放素材广告;无素材时显示欢迎页。</td><td>触摸屏幕、语音唤醒、识别结果、播报任务、异常事件。</td></tr>
+      <tr><td>主菜单交互</td><td>用户触摸或语音打开菜单</td><td>展示访客登记、路线引导、通知公告、呼叫工作人员。</td><td>选择功能、返回待机、无操作超时。</td></tr>
+      <tr><td>业务办理</td><td>进入访客登记、预约核验、路线引导等流程</td><td>展示表单、确认页、状态页、结果页。</td><td>业务完成、用户取消、超时返回、异常中断。</td></tr>
+      <tr><td>播报插播</td><td>播报任务到时</td><td>暂停素材,显示播报内容文字,配合 TTS 语音播放。</td><td>播报结束后恢复原待机播放状态。</td></tr>
+      <tr><td>异常提示</td><td>低电量、故障、网络异常、服务不可用</td><td>全局弹窗或全屏提示,文案应面向访客,避免技术化。</td><td>异常解除、用户确认、系统自动恢复。</td></tr>
+    </tbody></table>
+  </div>
+
+  <div class="section" id="s4"><h2>4. 总体功能架构</h2>
+    <div class="grid-4">
+      <div class="card"><strong>待机展示</strong><ul><li>广告素材播放</li><li>默认欢迎页</li><li>通知/公告插播</li><li>无操作自动返回</li></ul></div>
+      <div class="card"><strong>主菜单</strong><ul><li>访客登记</li><li>路线引导</li><li>通知公告</li><li>呼叫工作人员</li></ul></div>
+      <div class="card"><strong>访客登记</strong><ul><li>预约到访</li><li>现场登记</li><li>身份证读取</li><li>手机号查询</li></ul></div>
+      <div class="card"><strong>识别结果</strong><ul><li>人脸识别结果</li><li>预约用户展示</li><li>白名单结果展示</li><li>识别失败引导</li></ul></div>
+      <div class="card"><strong>路线引导</strong><ul><li>目的地列表</li><li>目的地确认</li><li>模拟导航中</li><li>到达提示</li></ul></div>
+      <div class="card"><strong>语音响应</strong><ul><li>接收语音指令</li><li>页面跳转</li><li>识别文本展示</li><li>状态切换</li></ul></div>
+      <div class="card"><strong>全局状态</strong><ul><li>电量</li><li>网络</li><li>充电</li><li>异常提示</li></ul></div>
+      <div class="card"><strong>系统信息</strong><ul><li>隐藏入口</li><li>设备编号</li><li>IP/版本</li><li>刷新页面</li></ul></div>
+    </div>
+  </div>
+
+  <div class="section" id="s5"><h2>5. 主菜单与页面结构</h2>
+    <h3>5.1 一期主菜单</h3>
+    <table><thead><tr><th>菜单入口</th><th>页面职责</th><th>一期处理方式</th><th>优先级</th></tr></thead><tbody>
+      <tr><td>访客登记</td><td>进入预约到访或现场登记流程,支持身份证读取、手机号查询、手动填写等。</td><td>正式前端流程 + Mock 数据</td><td>P0</td></tr>
+      <tr><td>路线引导</td><td>选择目的地,展示引导流程和导航状态。</td><td>正式前端流程 + Mock 数据</td><td>P0</td></tr>
+      <tr><td>通知公告</td><td>展示运维端播报内容中的通知/公告类内容。</td><td>Mock 列表与详情,后续对接播报内容接口</td><td>P0</td></tr>
+      <tr><td>呼叫工作人员</td><td>为访客提供求助入口。</td><td>保留按钮和占位交互,真实方式后续确认</td><td>P1</td></tr>
+    </tbody></table>
+    <h4>5.1.1 当前主菜单页面设计收口说明</h4>
+    <ul>
+      <li>主菜单页已按“上方欢迎引导 + 下方 2×2 大触摸入口”的结构进行设计,不再采用左右 Dashboard 布局或后台卡片式布局。</li>
+      <li>上方欢迎引导区仅保留主标题和副标题,避免三层文案造成视觉负担。</li>
+      <li>四个入口统一为大按钮式服务入口,整张卡片均可点击,不再显示每张卡片右下角小箭头或“点击进入”分割栏。</li>
+      <li>图标采用内联 SVG 方式实现,并保留彩色渐变图标底座;后续如需统一图标规范,可替换为同一套线性图标库的 SVG。</li>
+      <li>左下角“返回待机”采用轻量悬浮胶囊按钮,不使用整条底部操作栏,以减少页面原型感。</li>
+    </ul>
+    <div class="note">业务咨询模块取消,不作为主菜单入口。机器人介绍不作为一级菜单,可通过待机素材、欢迎页素材或后续主题内容体现。</div>
+    <h3>5.2 页面结构建议</h3>
+    <table><thead><tr><th>页面</th><th>路由建议</th><th>说明</th></tr></thead><tbody>
+      <tr><td>待机展示页</td><td>/idle</td><td>无人交互时的默认页面,播放素材或显示欢迎页。</td></tr>
+      <tr><td>主菜单页</td><td>/menu</td><td>触摸或语音唤醒后展示四个主入口。</td></tr>
+      <tr><td>访客登记首页</td><td>/visitor</td><td>选择预约到访或现场登记。</td></tr>
+      <tr><td>预约核验页</td><td>/visitor/appointment</td><td>支持身份证读取和手机号查询。</td></tr>
+      <tr><td>预约确认页</td><td>/visitor/appointment-confirm</td><td>展示预约信息,用户确认后生成访客记录。</td></tr>
+      <tr><td>现场登记页</td><td>/visitor/walk-in</td><td>身份证读取填充或手动填写访客信息。</td></tr>
+      <tr><td>登记成功页</td><td>/visitor/success</td><td>显示登记成功、欢迎语和后续指引。</td></tr>
+      <tr><td>识别结果页</td><td>/recognition/result</td><td>根据人脸识别结果展示预约用户、白名单人员或未识别结果。</td></tr>
+      <tr><td>路线引导页</td><td>/navigation</td><td>展示目的地分类和地点列表。</td></tr>
+      <tr><td>导航状态页</td><td>/navigation/status</td><td>展示模拟导航中、到达、取消等状态。</td></tr>
+      <tr><td>通知公告页</td><td>/notice</td><td>展示通知公告列表和详情。</td></tr>
+      <tr><td>呼叫工作人员页</td><td>/call-staff</td><td>保留入口,显示占位提示。</td></tr>
+      <tr><td>隐藏系统信息页</td><td>/system-info</td><td>长按 Logo 或指定区域进入,优先级低。</td></tr>
+    </tbody></table>
+  </div>
+
+  <div class="section" id="s6"><h2>6. 核心业务流程设计</h2>
+    <h3>6.1 待机展示流程</h3>
+    <div class="flow">进入屏幕系统
+↓
+加载屏幕配置、主题配置、播放方案、机器人状态
+↓
+判断是否存在启用的播放方案
+├─ 有播放方案:播放素材广告 / 图片 / 视频
+└─ 无播放方案:显示默认欢迎页
+↓
+用户触摸屏幕或语音唤醒
+↓
+进入主菜单页</div>
+    <h3>6.2 播报内容插播流程</h3>
+    <div class="flow">小主机后端判断播报任务到时
+↓
+后端调用 TTS / 语音服务播放播报文字
+↓
+后端生成屏幕播报指令
+↓
+屏幕前端轮询获取播报指令
+↓
+暂停当前素材广告或欢迎页
+↓
+展示播报内容文字卡片与“正在播报”状态
+↓
+播报结束
+↓
+恢复原待机播放状态</div>
+    <h3>6.3 预约到访流程</h3>
+    <div class="flow">访客登记
+↓
+选择“预约到访”
+↓
+选择核验方式:身份证读取 / 手机号查询
+↓
+查询预约记录
+├─ 查询成功:展示预约信息确认页
+└─ 查询失败:提示未查询到预约,可转现场登记
+↓
+访客确认预约信息
+↓
+提交登记
+↓
+登记成功</div>
+    <h3>6.4 现场登记流程</h3>
+    <div class="flow">访客登记
+↓
+选择“现场登记”
+↓
+选择身份证读取或手动填写
+↓
+填写/回填访客姓名、手机号、身份证号、被访人、来访事由等
+↓
+提交登记前确认
+↓
+提交成功
+↓
+显示登记成功页</div>
+    <h3>6.5 人脸识别结果进入流程</h3>
+    <div class="flow">机器人侧完成人脸识别
+↓
+识别结果上报小主机后端
+↓
+屏幕前端轮询获取识别结果
+↓
+根据结果展示页面
+├─ 预约访客:进入预约确认页
+├─ 白名单人员:展示欢迎/通行提示
+└─ 未识别人员:引导进入访客登记</div>
+    <h3>6.6 语音指令响应流程</h3>
+    <div class="flow">语音服务识别用户指令
+↓ HTTP
+小主机后端接收识别结果
+↓
+屏幕前端定时轮询最新指令
+↓
+执行动作
+├─ 打开访客登记
+├─ 打开路线引导
+├─ 打开通知公告
+├─ 打开主菜单
+└─ 显示提示信息</div>
+  </div>
+
+  <div class="section" id="s7"><h2>7. 页面详细设计</h2>
+    <h3>7.1 待机展示页</h3>
+    <table><thead><tr><th>项</th><th>详细设计</th></tr></thead><tbody>
+      <tr><td>页面目标</td><td>无人操作时作为机器人对外展示窗口,播放素材广告、公告内容或显示默认欢迎页。</td></tr>
+      <tr><td>主要内容</td><td>图片素材、视频素材、欢迎标题、欢迎副标题、Logo、当前时间、机器人简要状态。</td></tr>
+      <tr><td>交互方式</td><td>触摸任意区域或语音唤醒后进入主菜单。</td></tr>
+      <tr><td>兜底逻辑</td><td>有启用播放方案且存在可播放素材时进入播放方案模式;无播放方案、播放方案禁用或素材为空时显示默认欢迎页。</td></tr>
+      <tr><td>无操作规则</td><td>其他页面长时间无操作后自动返回待机页。</td></tr>
+    </tbody></table>
+    <h4>待机页当前实现收口说明</h4>
+    <ul>
+      <li>待机页分为两种明确模式:默认欢迎模式与播放方案模式。</li>
+      <li>默认欢迎模式使用“背景图 + 前端动态文字叠加”的方式实现,机器人名称、欢迎语、副标题、按钮、时间、状态等内容均由前端或后续主题配置动态渲染。</li>
+      <li>默认欢迎背景图仅作为氛围底图,不包含文字、按钮、Logo 等 UI 元素,便于后续通过运维端主题配置替换。</li>
+      <li>播放方案模式以图片/视频素材为主视觉,状态、时间、触摸提示等仅作为轻量浮层展示。</li>
+      <li>播放方案模式中,图片按配置时长自动轮播,视频优先按 ended 事件切换;素材加载失败时跳过当前素材。</li>
+      <li>开发阶段保留欢迎模式与播放方案模式的调试切换入口,正式上线时应通过配置隐藏。</li>
+    </ul>
+    <h4>待机素材播放补充规则</h4>
+    <ul><li>图片素材建议支持展示时长、排序、启停状态、适用屏幕方向、填充方式。</li><li>视频素材建议支持静音状态、音量、循环播放、播放失败跳过、播放完成自动切下一条。</li><li>素材填充方式建议支持 cover 与 contain:cover 适合全屏沉浸展示,contain 适合完整展示但可能留边。</li><li>本地网络或素材加载异常时,应自动跳过当前素材并展示下一条;全部素材不可用时回退欢迎页。</li><li>后续可考虑素材预缓存到小主机本地,降低网络波动对待机播放的影响。</li></ul>
+    <h3>7.2 主菜单页</h3>
+    <table><thead><tr><th>项</th><th>详细设计</th></tr></thead><tbody>
+      <tr><td>页面目标</td><td>提供四个核心入口,方便访客快速选择服务。</td></tr>
+      <tr><td>菜单入口</td><td>访客登记、路线引导、通知公告、呼叫工作人员。</td></tr>
+      <tr><td>布局建议</td><td>1024×768 横屏下采用“上方欢迎引导 + 下方 2×2 大触摸入口”的布局,左下角保留轻量悬浮返回待机按钮。</td></tr>
+      <tr><td>状态展示</td><td>顶部显示 Logo、时间、电量、网络、当前工作状态。</td></tr>
+      <tr><td>语音支持</td><td>语音指令“我要登记”“我要去某地”“查看通知”等可直接跳转对应页面。</td></tr>
+    </tbody></table>
+    <h4>主菜单页当前实现收口说明</h4>
+    <ul>
+      <li>主菜单页已取消左右分栏式引导区,统一改为上下结构,使页面更贴近机器人机身屏的服务选择页。</li>
+      <li>页面顶部保留 StatusBar,主内容区居中展示标题“请选择需要的服务”和辅助说明“触摸下方入口办理业务,也可以直接说出您的需求”。</li>
+      <li>四个服务入口均为大面积触摸按钮,入口本身即代表可点击,不额外展示“点击进入”文字或右下角箭头。</li>
+      <li>按钮图标采用彩色渐变底座 + 白色线性 SVG 图标,当前语义为:访客登记、定位导航、公告喇叭、电话呼叫。</li>
+      <li>若现场屏幕肉眼显示比例与系统截图不一致,应优先排查外接屏分辨率、镜像显示、浏览器缩放和屏幕硬件拉伸,不建议通过前端强行修正比例。</li>
+    </ul>
+    <h3>7.3 访客登记页面</h3>
+    <table><thead><tr><th>模块</th><th>详细设计</th></tr></thead><tbody>
+      <tr><td>访客登记首页</td><td>展示预约到访、现场登记两个入口。</td></tr>
+      <tr><td>预约到访</td><td>支持身份证读取查询、手机号查询;人脸识别命中预约用户时可直接进入预约确认页。</td></tr>
+      <tr><td>现场登记</td><td>支持身份证读取自动填充和手动填写。</td></tr>
+      <tr><td>登记字段</td><td>访客姓名、手机号、身份证号、到访类型、登记方式、访客来源、来访事由、被访对象、预约单号、来访时间、访客照片。</td></tr>
+      <tr><td>输入方式</td><td>手机号、身份证号使用前端内置数字键盘;姓名、被访人、事由使用输入框、预置选项或系统软键盘。</td></tr>
+      <tr><td>超时规则</td><td>登记页面长时间无操作后清空敏感信息并返回待机。</td></tr>
+    </tbody></table>
+    <h3>7.4 路线引导页面</h3>
+    <table><thead><tr><th>模块</th><th>详细设计</th></tr></thead><tbody>
+      <tr><td>页面目标</td><td>让访客选择目的地,展示机器人带路或路线引导流程。</td></tr>
+      <tr><td>一期处理</td><td>正式前端版,数据使用 Mock,后续替换真实点位和导航接口。</td></tr>
+      <tr><td>页面内容</td><td>目的地分类、目的地列表、常用地点、目的地确认、导航中状态、到达提示。</td></tr>
+      <tr><td>操作按钮</td><td>开始引导、取消引导、返回首页。</td></tr>
+      <tr><td>异常提示</td><td>接口未接入或导航不可用时,提示“路线引导功能正在接入中”。</td></tr>
+    </tbody></table>
+    <h3>7.5 通知公告页面</h3>
+    <table><thead><tr><th>模块</th><th>详细设计</th></tr></thead><tbody>
+      <tr><td>数据来源</td><td>复用运维端播报内容管理中的通知、公告、提示类内容。</td></tr>
+      <tr><td>页面形式</td><td>公告列表 + 公告详情,支持大字体展示。</td></tr>
+      <tr><td>播放关系</td><td>定时播报任务触发时,可暂停素材广告并插播公告文字和语音。</td></tr>
+      <tr><td>一期处理</td><td>先使用 Mock 数据展示公告列表和详情。</td></tr>
+    </tbody></table>
+    <h3>7.6 呼叫工作人员页面</h3>
+    <table><thead><tr><th>模块</th><th>详细设计</th></tr></thead><tbody>
+      <tr><td>页面目标</td><td>为访客提供求助入口。</td></tr>
+      <tr><td>一期处理</td><td>保留入口和占位交互,真实呼叫方式后续确认。</td></tr>
+      <tr><td>提示文案</td><td>可显示“已为您通知工作人员,请稍候”或“呼叫功能正在接入中”。</td></tr>
+      <tr><td>后续扩展</td><td>可接入运维端通知、主控平台事件、语音通话或视频通话。</td></tr>
+    </tbody></table>
+    <h3>7.7 隐藏系统信息页</h3>
+    <table><thead><tr><th>模块</th><th>详细设计</th></tr></thead><tbody>
+      <tr><td>入口方式</td><td>长按 Logo 或指定区域 5 秒进入。</td></tr>
+      <tr><td>页面内容</td><td>设备编号、机器人编号、本地 IP、前端版本、后端服务状态、网络状态、当前时间。</td></tr>
+      <tr><td>操作按钮</td><td>刷新页面、返回待机。重启类操作暂不开放。</td></tr>
+      <tr><td>优先级</td><td>P2,非一期核心功能。</td></tr>
+    </tbody></table>
+  </div>
+
+  <div class="section" id="s8"><h2>8. 播放、播报与声音控制规则</h2>
+    <h3>8.1 内容优先级</h3>
+    <table><thead><tr><th>优先级</th><th>内容类型</th><th>处理规则</th></tr></thead><tbody>
+      <tr><td>最高</td><td>紧急异常 / 告警提示</td><td>立即打断当前页面,展示全局异常提示。</td></tr>
+      <tr><td>高</td><td>播报内容</td><td>暂停素材广告,展示播报文字,由后端调用 TTS/语音服务播放。</td></tr>
+      <tr><td>普通</td><td>素材广告</td><td>作为待机主内容播放图片或视频。</td></tr>
+      <tr><td>最低</td><td>欢迎页</td><td>无播放方案时的兜底页面。</td></tr>
+    </tbody></table>
+    <h3>8.2 声音来源与职责</h3>
+    <table><thead><tr><th>声音来源</th><th>归属</th><th>说明</th></tr></thead><tbody>
+      <tr><td>视频素材声音</td><td>屏幕端</td><td>视频播放时可通过喇叭输出,屏幕端需要支持静音和音量控制。</td></tr>
+      <tr><td>播报内容语音</td><td>小主机后端/语音服务</td><td>数据库只有文字,由后端调用 TTS 或语音服务播放,屏幕端展示文字。</td></tr>
+      <tr><td>登记成功/导航到达/异常提示音</td><td>屏幕端或本地服务</td><td>可根据后续实现选择前端音频文件播放或后端统一播放。</td></tr>
+      <tr><td>语音对话声音</td><td>语音服务</td><td>不归屏幕系统控制,屏幕端只展示识别结果或跳转页面。</td></tr>
+    </tbody></table>
+    <h3>8.3 音量与静音控制</h3>
+    <ul><li>屏幕端应提供静音/取消静音和音量调节能力,优先作用于视频素材和前端提示音。</li><li>当播报内容或语音对话发生时,屏幕端应暂停或静音当前视频素材。</li><li>播报结束后,屏幕端恢复素材播放状态。</li><li>语音服务和浏览器同时输出到同一喇叭时,应由小主机后端协调音频抢占策略。</li></ul>
+  </div>
+
+  <div class="section" id="s9"><h2>9. 语音指令与事件通信设计</h2>
+    <h3>9.1 一期通信链路</h3>
+    <div class="flow">语音服务识别指令
+↓ HTTP
+小主机后端接收并保存最新指令
+↓
+屏幕前端每 0.5 秒或 1 秒轮询 /screen/command/latest
+↓
+屏幕前端执行跳转或状态切换
+↓
+屏幕前端回执 /screen/command/ack</div>
+    <h3>9.2 指令类型建议</h3>
+    <table><thead><tr><th>指令类型</th><th>示例</th><th>屏幕动作</th></tr></thead><tbody>
+      <tr><td>OPEN_PAGE</td><td>打开访客登记</td><td>跳转 /visitor</td></tr>
+      <tr><td>OPEN_MENU</td><td>打开菜单</td><td>跳转 /menu</td></tr>
+      <tr><td>SHOW_RECOGNITION_RESULT</td><td>人脸识别结果</td><td>跳转识别结果页或预约确认页</td></tr>
+      <tr><td>START_BROADCAST</td><td>开始播报通知</td><td>暂停素材,展示播报内容</td></tr>
+      <tr><td>END_BROADCAST</td><td>播报结束</td><td>恢复待机播放</td></tr>
+      <tr><td>SHOW_ALERT</td><td>低电量/故障</td><td>显示全局提示</td></tr>
+    </tbody></table>
+    <div class="note">一期采用 HTTP 轮询,后续可升级为 WebSocket 或 SSE,以减少延迟并提升实时性。</div>
+  </div>
+
+  <div class="section" id="s10"><h2>10. 输入方式与触摸交互规范</h2>
+    <h3>10.1 输入策略</h3>
+    <table><thead><tr><th>输入内容</th><th>推荐方式</th><th>说明</th></tr></thead><tbody>
+      <tr><td>手机号</td><td>前端内置数字键盘</td><td>11 位手机号输入,支持清空、删除、确认。</td></tr>
+      <tr><td>身份证号</td><td>前端内置数字键盘 + X</td><td>支持 18 位身份证号和末位 X。</td></tr>
+      <tr><td>预约查询</td><td>前端内置数字键盘</td><td>优先支持手机号或身份证号查询。</td></tr>
+      <tr><td>访客姓名</td><td>系统软键盘/普通输入框</td><td>一期可依赖 Ubuntu 软键盘,后续优化中文输入。</td></tr>
+      <tr><td>被访对象</td><td>搜索选择/手动输入</td><td>后续如有被访人列表接口,优先做搜索选择。</td></tr>
+      <tr><td>来访事由</td><td>预置选项 + 其他输入</td><td>减少中文自由输入,提高触摸屏体验。</td></tr>
+    </tbody></table>
+    <h3>10.2 防误触与超时规则</h3>
+    <ul><li>长时间无操作自动返回待机页,建议默认 60 秒,可配置。</li><li>访客登记、身份证读取、手机号查询等页面超时后自动清空敏感信息。</li><li>退出登记流程时需弹窗确认,避免误触导致信息丢失。</li><li>提交登记前展示确认页,避免误提交。</li><li>重启、关机、开始巡逻、停止巡逻等高风险动作不在普通屏幕端开放。</li></ul>
+    <h3>10.3 触摸尺寸建议</h3>
+    <table><thead><tr><th>元素</th><th>建议尺寸</th></tr></thead><tbody>
+      <tr><td>主菜单卡片</td><td>不小于 220×150px</td></tr>
+      <tr><td>主按钮高度</td><td>72px - 96px</td></tr>
+      <tr><td>普通按钮高度</td><td>56px - 72px</td></tr>
+      <tr><td>图标按钮</td><td>不小于 64×64px</td></tr>
+      <tr><td>输入框高度</td><td>不小于 56px</td></tr>
+    </tbody></table>
+  </div>
+
+  <div class="section" id="s11"><h2>11. UI 视觉与屏幕适配规范</h2>
+    <h3>11.1 视觉风格</h3>
+    <div class="ok">屏幕端采用“温和迎宾风 + 轻科技感”,适配大厅、展厅、营业厅、酒店等公共接待场景。</div>
+    <table><thead><tr><th>设计项</th><th>建议</th></tr></thead><tbody>
+      <tr><td>主色</td><td>青蓝、湖蓝、柔和科技蓝。</td></tr>
+      <tr><td>背景</td><td>浅色背景、柔和渐变、低噪点纹理。</td></tr>
+      <tr><td>组件</td><td>大按钮、圆角卡片、柔和阴影、清晰图标。</td></tr>
+      <tr><td>文字</td><td>大字号、低密度、短文案、强引导。</td></tr>
+      <tr><td>避免</td><td>后台表格风、深色监控大屏风、强安防压迫感、密集小字。</td></tr>
+    </tbody></table>
+    <h3>11.2 1024×768 横屏布局建议</h3>
+    <div class="flow">顶部状态栏:Logo / 时间 / 电量 / 网络 / 状态
+中部主内容:待机素材、主菜单、登记表单或路线引导内容
+底部操作区:返回、确认、取消、帮助等大按钮</div>
+    <div class="info">实机调试补充:当系统截图比例正常但肉眼观察屏幕画面存在压缩或拉伸时,应优先检查 Mac/Ubuntu 外接屏分辨率、镜像模式、浏览器缩放比例、屏幕控制板缩放方式和设备像素比。前端 CSS 中的正方形图标容器不应为适配异常显示链路而改成非标准比例。</div>
+    <h3>11.3 字号建议</h3>
+    <table><thead><tr><th>文本类型</th><th>建议字号</th></tr></thead><tbody>
+      <tr><td>页面主标题</td><td>32px - 40px</td></tr>
+      <tr><td>模块标题</td><td>24px - 30px</td></tr>
+      <tr><td>正文/说明</td><td>18px - 22px</td></tr>
+      <tr><td>按钮文字</td><td>20px - 26px</td></tr>
+      <tr><td>状态文字</td><td>16px - 20px</td></tr>
+    </tbody></table>
+    <h3>11.4 竖屏适配说明</h3>
+    <div class="warn">一期不以未知竖屏为验收目标。代码实现时避免大量写死像素和绝对定位,优先使用 flex/grid、百分比、clamp 等方式,为后续竖屏适配保留空间。</div>
+  </div>
+
+  <div class="section" id="s12"><h2>12. Mock 数据与前端开发建议</h2>
+    <h3>12.1 一期前端开发方式</h3>
+    <ul><li>先完成 Vue3 前端工程、页面路由、组件拆分和 Mock 数据。</li><li>所有接口封装统一走 api 层,即使一期使用本地 Mock,也保留后续替换真实接口的结构。</li><li>播放状态、机器人状态、语音指令、访客登记信息等统一由 Pinia 管理。</li><li>待机播放器、数字键盘、状态栏、全局弹窗、登记表单、目的地卡片等应封装为可复用组件。</li></ul>
+    <h3>12.2 组件拆分建议</h3>
+    <table><thead><tr><th>组件</th><th>职责</th></tr></thead><tbody>
+      <tr><td>ScreenLayout.vue</td><td>统一页面布局、顶部状态栏、底部操作区。</td></tr>
+      <tr><td>StatusBar.vue</td><td>展示 Logo、时间、电量、网络、机器人状态。</td></tr>
+      <tr><td>IdlePlayer.vue</td><td>待机素材播放,支持图片、视频、欢迎页兜底。</td></tr>
+      <tr><td>BroadcastOverlay.vue</td><td>播报内容插播展示。</td></tr>
+      <tr><td>MainMenu.vue</td><td>四个主菜单入口。</td></tr>
+      <tr><td>NumericKeyboard.vue</td><td>手机号、身份证号输入键盘。</td></tr>
+      <tr><td>VisitorForm.vue</td><td>访客登记表单。</td></tr>
+      <tr><td>CameraPreview.vue</td><td>摄像头预览窗口。</td></tr>
+      <tr><td>DestinationList.vue</td><td>路线引导目的地列表。</td></tr>
+      <tr><td>GlobalAlert.vue</td><td>全局异常、低电量、网络异常等提示。</td></tr>
+    </tbody></table>
+    <h3>12.3 推荐前端目录结构</h3>
+    <div class="code">src/
+├─ api/                  接口封装与 Mock 入口
+├─ assets/               图片、图标、默认素材
+├─ components/           通用组件
+├─ layouts/              屏幕基础布局
+├─ mock/                 Mock 数据
+├─ router/               页面路由
+├─ stores/               Pinia 状态管理
+├─ utils/                工具函数、时间、格式化、设备判断
+├─ views/                页面视图
+│  ├─ idle/              待机展示
+│  ├─ menu/              主菜单
+│  ├─ visitor/           访客登记
+│  ├─ navigation/        路线引导
+│  ├─ notice/            通知公告
+│  └─ system/            系统信息
+└─ App.vue</div>
+    <h3>12.4 Mock 数据范围</h3>
+    <div><span class="tag">机器人状态</span><span class="tag">播放方案</span><span class="tag">素材列表</span><span class="tag">播报内容</span><span class="tag">语音指令</span><span class="tag">预约记录</span><span class="tag">身份证读取结果</span><span class="tag">人脸识别结果</span><span class="tag">目的地列表</span><span class="tag">通知公告</span></div>
+    <h4>本地播放素材 Mock 约定</h4>
+    <ul>
+      <li>开发阶段播放方案素材放置于 <code class="inline">src/assets/media/play-plan/images/</code> 与 <code class="inline">src/assets/media/play-plan/videos/</code> 目录。</li>
+      <li>Mock 播放方案通过 Vite 的 <code class="inline">import.meta.glob</code> 自动扫描本地图片与视频文件,不再手动逐个 import 固定素材。</li>
+      <li>本地素材仅用于模拟后端播放方案返回结果,组件仅消费 <code class="inline">playPlan.items</code> 中的 <code class="inline">url</code>、<code class="inline">type</code>、<code class="inline">duration</code>、<code class="inline">fitMode</code> 等字段。</li>
+      <li>后续对接真实接口时,应保持数据结构一致,由后端返回素材 URL 与播放参数,前端不再依赖本地素材目录。</li>
+    </ul>
+  </div>
+
+  <div class="section" id="s13"><h2>13. 后续接口设计建议</h2>
+    <div class="note">本章为后续接口对接建议,一期前端可先使用 Mock 数据实现。</div>
+    <table><thead><tr><th>接口</th><th>方法</th><th>说明</th><th>主要字段</th></tr></thead><tbody>
+      <tr><td>/screen/config</td><td>GET</td><td>获取屏幕配置</td><td>robotName、logoUrl、idleTimeout、theme、volume、mute</td></tr>
+      <tr><td>/screen/status</td><td>GET</td><td>获取机器人简要状态</td><td>batteryLevel、networkStatus、workStatus、chargeStatus、faultFlag</td></tr>
+      <tr><td>/robot-ops/screen/play-plan/current</td><td>GET</td><td>获取当前播放方案</td><td>enabled、planId、planName、loopMode、defaultFitMode、version、items</td></tr>
+      <tr><td>/screen/broadcast/current</td><td>GET</td><td>获取当前播报状态</td><td>broadcasting、title、content、startTime、endTime</td></tr>
+      <tr><td>/screen/command/latest</td><td>GET</td><td>获取最新语音/系统指令</td><td>commandId、type、action、payload、timestamp</td></tr>
+      <tr><td>/screen/command/ack</td><td>POST</td><td>指令处理回执</td><td>commandId、resultStatus、resultMsg</td></tr>
+      <tr><td>/screen/id-card/read</td><td>POST</td><td>读取身份证</td><td>name、idCardNo、gender、nation、address、photoUrl</td></tr>
+      <tr><td>/screen/appointment/query</td><td>GET</td><td>预约查询</td><td>mobile、idCardNo;返回 appointmentNo、visitorName、visitedPerson、appointmentTime</td></tr>
+      <tr><td>/screen/visitor/register</td><td>POST</td><td>提交访客登记</td><td>visitorName、mobile、idCardNo、visitType、registerType、visitorSource、visitReason、visitedPerson、appointmentNo</td></tr>
+      <tr><td>/screen/recognition/latest</td><td>GET</td><td>获取最新识别结果</td><td>personType、matchStatus、visitorName、appointmentNo、confidence</td></tr>
+      <tr><td>/screen/destination/list</td><td>GET</td><td>目的地列表</td><td>destinationId、name、category、floor、description</td></tr>
+      <tr><td>/screen/navigation/start</td><td>POST</td><td>发起导航</td><td>destinationId</td></tr>
+      <tr><td>/screen/navigation/status</td><td>GET</td><td>导航状态</td><td>taskId、status、currentPoint、targetName、progress</td></tr>
+      <tr><td>/screen/notice/list</td><td>GET</td><td>通知公告列表</td><td>title、content、publishTime、contentType</td></tr>
+      <tr><td>/screen/call-staff</td><td>POST</td><td>呼叫工作人员</td><td>callType、pagePath、remark</td></tr>
+      <tr><td>/screen/camera/preview-url</td><td>GET</td><td>获取摄像头预览地址</td><td>streamUrl、streamType、expireTime</td></tr>
+      <tr><td>/screen/audio/control</td><td>POST</td><td>音量与静音控制</td><td>volume、mute、sourceType</td></tr>
+      <tr><td>/screen/event/report</td><td>POST</td><td>屏幕事件上报</td><td>eventType、pagePath、eventData、timestamp</td></tr>
+    </tbody></table>
+
+    <h3>13.1 当前播放方案接口详细设计</h3>
+    <div class="info">本接口为机身屏待机页播放方案模式的一期核心接口。运维 Web 前端、机身屏前端和后端服务共用同一套数据库与同一个后端服务;运维端负责素材上传和播放方案配置,机身屏前端只读取当前播放方案并播放素材。</div>
+
+    <h4>13.1.1 接口基本信息</h4>
+    <table><thead><tr><th>项</th><th>设计内容</th></tr></thead><tbody>
+      <tr><td>接口地址</td><td><code class="inline">GET /robot-ops/screen/play-plan/current</code></td></tr>
+      <tr><td>接口用途</td><td>供机身屏 <code class="inline">/idle</code> 待机页读取当前启用播放方案。前端根据返回结果决定进入播放方案模式或回退默认欢迎页。</td></tr>
+      <tr><td>部署关系</td><td>运维 Web 前端、机身屏前端和后端服务部署在同一台小主机,共用同一套数据库和同一个后端服务。</td></tr>
+      <tr><td>素材访问地址</td><td>屏幕端访问素材统一使用完整 URL,例如 <code class="inline">http://192.168.0.30/profile/upload/xxx.mp4</code>。</td></tr>
+      <tr><td>是否允许多个当前方案</td><td>不允许。播放方案主表中同一时间只允许一个方案为当前播放方案。</td></tr>
+    </tbody></table>
+
+    <h4>13.1.2 数据来源与字段映射</h4>
+    <table><thead><tr><th>数据表</th><th>主要字段</th><th>接口字段</th><th>说明</th></tr></thead><tbody>
+      <tr><td>robot_ops_play_plan</td><td>id</td><td>planId</td><td>播放方案 ID。</td></tr>
+      <tr><td>robot_ops_play_plan</td><td>plan_name</td><td>planName</td><td>播放方案名称。</td></tr>
+      <tr><td>robot_ops_play_plan</td><td>loop_mode</td><td>loopMode</td><td>循环方式,建议支持 loop、once;一期默认 loop。</td></tr>
+      <tr><td>robot_ops_play_plan</td><td>status</td><td>enabled 判断依据</td><td>status=1 表示当前播放方案。</td></tr>
+      <tr><td>robot_ops_play_plan</td><td>update_time</td><td>version</td><td>可使用更新时间生成版本号,用于前端判断播放方案是否变化。</td></tr>
+      <tr><td>robot_ops_play_plan_item</td><td>id</td><td>itemId / id</td><td>播放方案明细 ID。</td></tr>
+      <tr><td>robot_ops_play_plan_item</td><td>asset_id</td><td>assetId</td><td>关联素材 ID。</td></tr>
+      <tr><td>robot_ops_play_plan_item</td><td>play_order</td><td>sort</td><td>播放顺序,按升序返回。</td></tr>
+      <tr><td>robot_ops_play_plan_item</td><td>stay_seconds</td><td>staySeconds / duration</td><td>图片停留秒数;duration = staySeconds × 1000。</td></tr>
+      <tr><td>robot_ops_media_asset</td><td>asset_name</td><td>title</td><td>素材名称。</td></tr>
+      <tr><td>robot_ops_media_asset</td><td>asset_type</td><td>type</td><td>素材类型,转换为 image 或 video。</td></tr>
+      <tr><td>robot_ops_media_asset</td><td>file_url</td><td>url</td><td>素材文件地址,后端需补全为屏幕端可直接访问的完整 URL。</td></tr>
+      <tr><td>robot_ops_media_asset</td><td>thumbnail_url</td><td>thumbnailUrl</td><td>缩略图地址,可为空。</td></tr>
+      <tr><td>robot_ops_media_asset</td><td>status</td><td>过滤依据</td><td>只有启用状态素材可被屏幕端播放。</td></tr>
+    </tbody></table>
+
+    <h4>13.1.3 后端取数规则</h4>
+    <ol>
+      <li>查询 <code class="inline">robot_ops_play_plan</code> 中 <code class="inline">status=1</code> 的当前播放方案。</li>
+      <li>如果不存在当前播放方案,返回 <code class="inline">enabled=false</code> 和空 <code class="inline">items</code>。</li>
+      <li>如果存在当前播放方案,查询该方案下的 <code class="inline">robot_ops_play_plan_item</code> 明细,并按 <code class="inline">play_order</code> 升序排序。</li>
+      <li>关联 <code class="inline">robot_ops_media_asset</code> 素材表,获取素材名称、类型、文件地址、缩略图、视频时长、启用状态等。</li>
+      <li>过滤不可播放素材:素材不存在、素材停用、<code class="inline">file_url</code> 为空、<code class="inline">asset_type</code> 不是 image/video。</li>
+      <li>如果过滤后素材列表为空,也返回 <code class="inline">enabled=false</code> 和空 <code class="inline">items</code>。</li>
+      <li>如果存在可播放素材,返回 <code class="inline">enabled=true</code> 和已排序的素材列表。</li>
+    </ol>
+
+    <h4>13.1.4 素材 URL 补全规则</h4>
+    <p>后端返回给屏幕端的 <code class="inline">url</code> 和 <code class="inline">thumbnailUrl</code> 必须是浏览器可直接访问的地址。屏幕端当前约定素材访问前缀为 <code class="inline">http://192.168.0.30</code>。</p>
+    <table><thead><tr><th>数据库 file_url 示例</th><th>接口返回 url 示例</th><th>处理规则</th></tr></thead><tbody>
+      <tr><td><code class="inline">http://192.168.0.30/profile/upload/a.mp4</code></td><td><code class="inline">http://192.168.0.30/profile/upload/a.mp4</code></td><td>已是完整 URL,原样返回。</td></tr>
+      <tr><td><code class="inline">/profile/upload/a.mp4</code></td><td><code class="inline">http://192.168.0.30/profile/upload/a.mp4</code></td><td>使用资源访问前缀拼接。</td></tr>
+      <tr><td><code class="inline">profile/upload/a.mp4</code></td><td><code class="inline">http://192.168.0.30/profile/upload/a.mp4</code></td><td>补充斜杠后再拼接。</td></tr>
+    </tbody></table>
+    <div class="note">建议后端将资源访问前缀配置化,例如 <code class="inline">screen.resource-base-url=http://192.168.0.30</code>,避免硬编码到业务代码中。</div>
+
+    <h4>13.1.5 返回示例</h4>
+    <div class="code">{
+  "code": 200,
+  "msg": "查询成功",
+  "data": {
+    "enabled": true,
+    "planId": 1,
+    "planName": "默认播放方案",
+    "loopMode": "loop",
+    "defaultFitMode": "cover",
+    "version": "20260513153000",
+    "items": [
+      {
+        "id": 1001,
+        "itemId": 1001,
+        "assetId": 501,
+        "type": "image",
+        "title": "欢迎宣传图",
+        "url": "http://192.168.0.30/profile/upload/2026/05/welcome.png",
+        "thumbnailUrl": "http://192.168.0.30/profile/upload/2026/05/welcome.png",
+        "duration": 8000,
+        "staySeconds": 8,
+        "fitMode": "cover",
+        "showTitle": false,
+        "sort": 1
+      },
+      {
+        "id": 1002,
+        "itemId": 1002,
+        "assetId": 502,
+        "type": "video",
+        "title": "机器人介绍视频",
+        "url": "http://192.168.0.30/profile/upload/2026/05/robot-intro.mp4",
+        "thumbnailUrl": "http://192.168.0.30/profile/upload/2026/05/robot-intro-cover.jpg",
+        "duration": null,
+        "staySeconds": null,
+        "fitMode": "cover",
+        "muted": false,
+        "showTitle": false,
+        "sort": 2
+      }
+    ]
+  },
+  "timestamp": "2026-05-13 15:30:00"
+}</div>
+
+    <h4>13.1.6 无播放方案或无可播放素材返回示例</h4>
+    <div class="code">{
+  "code": 200,
+  "msg": "暂无当前播放方案",
+  "data": {
+    "enabled": false,
+    "planId": null,
+    "planName": "",
+    "loopMode": "loop",
+    "defaultFitMode": "cover",
+    "version": "",
+    "items": []
+  },
+  "timestamp": "2026-05-13 15:30:00"
+}</div>
+
+    <h4>13.1.7 前端播放规则</h4>
+    <ul>
+      <li><code class="inline">enabled=false</code> 或 <code class="inline">items</code> 为空时,机身屏回退默认欢迎页。</li>
+      <li><code class="inline">enabled=true</code> 且 <code class="inline">items</code> 有数据时,机身屏进入播放方案模式。</li>
+      <li>图片素材按 <code class="inline">staySeconds</code> 控制停留时长,前端使用 <code class="inline">duration</code> 毫秒值进行定时切换。</li>
+      <li>视频素材默认完整播放,前端监听 <code class="inline">ended</code> 事件后切换下一条,不使用 <code class="inline">staySeconds</code> 强制截断。</li>
+      <li><code class="inline">fitMode</code> 默认 cover,后续可扩展 contain、stretch。</li>
+      <li><code class="inline">showTitle</code> 默认 false,避免和素材自带文字冲突。</li>
+      <li>屏幕端进入 <code class="inline">/idle</code> 待机页时,应立即请求一次 <code class="inline">/robot-ops/screen/play-plan/current</code> 当前播放方案接口。</li>
+      <li>待机播放期间,屏幕端应每 60 秒重新请求一次当前播放方案接口,用于检查运维端是否更新了播放方案或素材配置。</li>
+      <li>前端应通过接口返回的 <code class="inline">version</code> 字段判断播放方案是否变化;如果 <code class="inline">version</code> 未变化,不应重置当前播放进度。</li>
+      <li>如果 <code class="inline">version</code> 发生变化,前端应标记新方案待生效,并在当前图片展示结束或当前视频播放结束后切换到新播放方案,不建议直接打断正在播放的视频。</li>
+      <li>如果重新请求后发现无当前播放方案、播放方案禁用或素材为空,前端应在当前素材播放结束后回退默认欢迎页。</li>
+    </ul>
+
+    <h4>13.1.8 播放方案更新检查策略</h4>
+    <table><thead><tr><th>场景</th><th>处理策略</th><th>说明</th></tr></thead><tbody>
+      <tr><td>进入待机页</td><td>立即请求当前播放方案接口</td><td>确保屏幕端启动或从业务页面返回待机时读取最新播放配置。</td></tr>
+      <tr><td>待机播放中</td><td>每 60 秒请求一次 <code class="inline">/robot-ops/screen/play-plan/current</code></td><td>一期不单独增加 version 接口,直接轮询 current 接口即可。</td></tr>
+      <tr><td>version 未变化</td><td>不重置播放进度</td><td>避免图片/视频被无意义重播或闪烁。</td></tr>
+      <tr><td>version 变化</td><td>当前素材结束后切换新方案</td><td>图片在本次 duration 结束后切换;视频在 ended 后切换。</td></tr>
+      <tr><td>方案被取消或素材为空</td><td>当前素材结束后回退默认欢迎页</td><td>避免播放过程中突兀黑屏或直接中断。</td></tr>
+    </tbody></table>
+    <div class="note">后续如需降低接口数据量,可扩展轻量版本检查接口 <code class="inline">GET /robot-ops/screen/play-plan/version</code>。屏幕端每 30-60 秒请求 version,只有版本变化时再请求 current 完整播放方案。一期建议先使用 current 接口轮询,降低前后端联调复杂度。</div>
+  </div>
+
+  <div class="section" id="s14"><h2>14. 开发优先级与实施顺序</h2>
+    <table><thead><tr><th>阶段</th><th>开发内容</th><th>目标</th></tr></thead><tbody>
+      <tr><td>第一阶段</td><td>Vue3 工程、路由、布局、状态栏、全局样式、Mock 数据框架</td><td>建立屏幕端前端基础工程。</td></tr>
+      <tr><td>第二阶段</td><td>待机展示页、默认欢迎模式、播放方案模式、本地素材 Mock、图片/视频播放器、当前播放方案接口、欢迎页兜底、无操作返回待机</td><td>前端已收口;下一步由后端提供 <code class="inline">/robot-ops/screen/play-plan/current</code> 接口,跑通运维端素材与播放方案到机身屏播放链路。</td></tr>
+      <tr><td>第三阶段</td><td>主菜单页、上方欢迎引导、2×2 大触摸入口、返回待机按钮、图标与文案优化</td><td>已完成前端收口,后续重点进入访客登记流程开发。</td></tr>
+      <tr><td>第四阶段</td><td>访客登记、预约核验、身份证读取 Mock、数字键盘</td><td>完成核心登记流程。</td></tr>
+      <tr><td>第五阶段</td><td>路线引导正式前端流程、目的地 Mock、导航状态 Mock</td><td>完成路线引导演示闭环。</td></tr>
+      <tr><td>第六阶段</td><td>通知公告列表、播报内容插播、声音/静音控制</td><td>完成公告展示和插播体验。</td></tr>
+      <tr><td>第七阶段</td><td>语音指令轮询 Mock、人脸识别结果 Mock、全局异常提示</td><td>完成事件驱动页面跳转能力。</td></tr>
+      <tr><td>第八阶段</td><td>隐藏系统信息页、屏幕刷新、版本展示</td><td>补充现场维护辅助能力。</td></tr>
+    </tbody></table>
+  </div>
+
+  <div class="section" id="s15"><h2>15. 测试验收要点</h2>
+    <h3>15.1 页面与交互验收</h3>
+    <ul>
+      <li>待机页应支持默认欢迎模式与播放方案模式两种状态,并能通过播放方案是否启用和素材是否为空进行自动切换。</li>
+      <li>主菜单页应采用上方欢迎引导与下方 2×2 大触摸入口结构,四个入口整体可点击,不依赖小箭头或“点击进入”文字传达可操作性。</li>
+      <li>1024×768 分辨率下所有页面无横向滚动、无明显遮挡。</li><li>主菜单四个入口清晰可点击,按钮尺寸适合 8 寸触摸屏。</li><li>长时间无操作可自动返回待机。</li><li>访客登记流程完整,支持预约到访和现场登记。</li><li>手机号和身份证号输入使用内置数字键盘。</li><li>路线引导页面流程完整,可从目的地选择到模拟到达。</li><li>通知公告列表和详情可正常展示。</li><li>呼叫工作人员入口有明确占位反馈。</li>
+    </ul>
+    <h3>15.2 播放与声音验收</h3>
+    <ul><li>待机状态有播放方案时播放素材,无播放方案时显示欢迎页。</li><li>视频素材可播放声音,支持静音和音量调节。</li><li>播报内容插播时可暂停素材广告,播报结束后恢复。</li><li>异常提示优先级高于播报和素材广告。</li></ul>
+    <h3>15.3 Mock 与接口替换验收</h3>
+    <ul>
+      <li>运维端上传图片/视频素材并创建播放方案后,机身屏应可通过 <code class="inline">/robot-ops/screen/play-plan/current</code> 获取当前播放方案并播放素材。</li>
+      <li>当运维端未设置当前播放方案、播放方案被禁用或所有素材不可播放时,机身屏应自动回退默认欢迎页。</li>
+      <li>接口返回的素材 <code class="inline">url</code> 必须是屏幕端浏览器可直接访问的完整地址,例如 <code class="inline">http://192.168.0.30/profile/upload/xxx.mp4</code>。</li>
+      <li>屏幕端在待机播放期间应每 60 秒检查一次当前播放方案;当接口返回的 <code class="inline">version</code> 变化时,应在当前图片展示结束或当前视频播放结束后切换到新播放方案。</li>
+      <li>当播放方案更新为无可播放素材或取消当前播放方案时,屏幕端应自动回退默认欢迎页。</li>
+      <li>所有 Mock 数据均通过统一 api 层获取,后续可替换真实接口。</li>
+      <li>语音指令、识别结果、预约查询、身份证读取、导航状态均有 Mock 数据和页面响应。</li>
+      <li>关键用户操作可预留事件上报接口。</li>
+    </ul>
+  </div>
+
+  <div class="section" id="s16"><h2>16. 安全、隐私与异常兜底</h2>
+    <h3>16.1 隐私与敏感信息处理</h3>
+    <ul><li>访客手机号、身份证号、访客照片等敏感信息不应长时间停留在屏幕上。</li><li>登记成功或流程超时后,应自动清空本地表单数据和临时识别结果。</li><li>身份证号展示时可考虑脱敏,例如仅展示前 6 位和后 4 位。</li><li>摄像头预览只在识别、核验、登记等明确业务场景展示,不在待机页长期展示。</li><li>屏幕端不应在本地持久化敏感访客信息,确需缓存时应由小主机后端统一处理。</li></ul>
+    <h3>16.2 异常兜底文案</h3>
+    <table><thead><tr><th>异常类型</th><th>面向访客文案</th><th>处理建议</th></tr></thead><tbody>
+      <tr><td>网络异常</td><td>当前网络连接异常,部分功能暂不可用,请稍后再试。</td><td>保留返回待机与重试按钮。</td></tr>
+      <tr><td>后端服务异常</td><td>服务暂时不可用,请联系现场工作人员。</td><td>隐藏技术错误码,可在系统信息页展示。</td></tr>
+      <tr><td>身份证读取失败</td><td>身份证读取失败,请重新放置证件或选择手动登记。</td><td>提供重试与手动填写入口。</td></tr>
+      <tr><td>预约查询为空</td><td>未查询到预约信息,您可以选择现场登记。</td><td>提供转现场登记按钮。</td></tr>
+      <tr><td>导航不可用</td><td>路线引导功能暂不可用,请联系工作人员。</td><td>返回主菜单或呼叫工作人员。</td></tr>
+      <tr><td>素材加载失败</td><td>不向访客展示技术提示。</td><td>自动跳过素材,全部失败时显示欢迎页。</td></tr>
+    </tbody></table>
+    <h3>16.3 操作安全边界</h3>
+    <div class="warn">普通访客可见页面不开放重启、关机、开始巡逻、停止巡逻、地图编辑、参数配置等高风险操作。现场维护能力应放入隐藏系统信息页或运维端。</div>
+  </div>
+
+  <div class="section" id="s17"><h2>17. 运行部署与现场维护建议</h2>
+    <h3>17.1 Chromium Kiosk 启动建议</h3>
+    <div class="code">chromium-browser \
+  --kiosk \
+  --noerrdialogs \
+  --disable-infobars \
+  --disable-session-crashed-bubble \
+  http://127.0.0.1/screen</div>
+    <p>实际命令需根据 Ubuntu 版本、Chromium 安装路径、是否启用硬件加速等情况调整。</p>
+    <h3>17.2 开机自启建议</h3>
+    <ul><li>小主机启动后应先启动 Nginx、小主机后端、语音服务等基础服务。</li><li>Chromium Kiosk 建议在本地服务启动完成后再打开,避免首屏接口异常。</li><li>可使用 systemd 或桌面自启动脚本管理 Chromium 启动。</li><li>现场断电重启后,屏幕应自动恢复到待机展示页。</li></ul>
+    <h3>17.3 前端包更新建议</h3>
+    <div class="flow">上传 screen-ui-vX.X.X.zip
+↓
+备份旧版本目录
+↓
+解压覆盖新版本 dist
+↓
+刷新 Chromium 页面或重启 Chromium
+↓
+异常时回滚旧版本</div>
+    <h3>17.4 现场维护信息建议</h3>
+    <ul><li>隐藏系统信息页应展示前端版本、后端服务连接状态、本机 IP、机器人编号、屏幕分辨率、当前音量、网络状态。</li><li>系统信息页可提供刷新页面、重新加载配置、返回待机等低风险操作。</li><li>错误日志、接口异常、识别失败等详细排查信息建议上报后端或运维端,不直接展示给访客。</li></ul>
+  </div>
+
+  <div class="section" id="s18"><h2>18. 待确认与后续扩展事项</h2>
+    <table><thead><tr><th>事项</th><th>当前处理</th><th>后续方向</th></tr></thead><tbody>
+      <tr><td>呼叫工作人员真实方式</td><td>一期保留按钮和占位交互。</td><td>可接入运维端消息、主控平台通知、语音/视频通话。</td></tr>
+      <tr><td>真实导航接口</td><td>一期使用 Mock 数据。</td><td>对接点位列表、导航开始、导航状态、取消导航接口。</td></tr>
+      <tr><td>Ubuntu 中文输入体验</td><td>一期数字输入内置键盘,中文输入暂依赖系统软键盘或预置选项。</td><td>结合现场系统环境优化软键盘或减少自由文本输入。</td></tr>
+      <tr><td>WebSocket/SSE 推送</td><td>一期 HTTP 轮询。</td><td>后续升级为 WebSocket 或 SSE。</td></tr>
+      <tr><td>竖屏适配</td><td>一期按 1024×768 横屏开发。</td><td>屏幕选型确定后专项适配。</td></tr>
+      <tr><td>屏幕端 OTA</td><td>一期可手动部署 dist 包。</td><td>后续纳入运维端 OTA 升级模块。</td></tr>
+      <tr><td>播报 TTS 实现方式</td><td>文档建议由小主机后端调用 TTS/语音服务。</td><td>后续确认 TTS 服务地址、播放完成回调、音频抢占规则。</td></tr>
+      <tr><td>摄像头预览流格式</td><td>文档建议仅在识别相关页面展示。</td><td>后续确认 RTSP、HTTP-FLV、WebRTC 或 MJPEG 等实现方式。</td></tr>
+      <tr><td>呼叫工作人员闭环</td><td>一期占位。</td><td>后续确认是通知运维端、现场终端、语音通话还是视频通话。</td></tr>
+    </tbody></table>
+  </div>
+
+  <div class="footer">迎宾巡逻安防机器人机身屏交互系统详细设计开发文档(一期)|建议版本:V1.1</div>
+</div>
+</body>
+</html>