Compare commits

...

10 Commits

36 changed files with 1198 additions and 1336 deletions
-6
View File
@@ -1,6 +0,0 @@
# 前端构建配置(已 gitignore,勿提交)
# 前端 MQTT 账号:dpm / 123456
VITE_API_BASE=http://192.168.1.9:10085
VITE_MQTT_URL=ws://192.168.1.3:8083/mqtt
VITE_MQTT_USERNAME=dpm
VITE_MQTT_PASSWORD=123456
+9
View File
@@ -13,6 +13,15 @@ dist-ssr
# 后端密钥 / 前端本地配置(含阿里云密钥、MQTT 凭据,不入库)
backend/.env
.env.local
# Windows 安装包产物(可随时由 source + tauri build 重新生成,不入库)
*.exe
# 误放入的 @appica 前端组件文档(非项目源码)
backend/installation.md
backend/autocomplete.md
backend/icons.md
# 后端运行时数据(媒体上传、持久化配置)
backend/media/
+2
View File
@@ -1,5 +1,7 @@
# 工作规则(永远遵守)
> 📍 工作区位置:`code/dpm/`(园区大屏系统,独立仓库)。整体目录规范见根 `CLAUDE.md`;进程管理铁律适用于本仓库全部前后端服务。
## 进程管理铁律
- **绝对禁止 AI 启动、重启、停止任何前后端服务进程**(后端 10085 / s2s 8765 / vite 1420 / Tauri 等)。
- 需要启动或重启时,**必须先通知用户**,由用户亲自操作。
+65 -171
View File
@@ -1,199 +1,93 @@
# 大屏媒体轮播系统 (DPM)
**Digital Playback Media** — 基于 Tauri + React 的全屏媒体轮播控制系统,专为昆明市大学生创业园展播大屏设计。
> 📍 工作区位置:`code/dpm/`(独立仓库)。系统/运维文档见 `design/dpm/`;权威后端说明见 `backend/README.md`;整体目录规范见根 `CLAUDE.md`。
## 概述
**Digital Playback Media** — 园区大屏展播控制系统,服务昆明市大学生创业园:数据大屏、数字孪生 3D、AI 助手、语音助手、媒体轮播、企业展示墙,浏览器后台可远程控制。
DPM 是一套完整的数字标牌(Digital Signage)解决方案,包含两大界面:
## 架构总览
- **大屏展示页** (`/screen`) — 全屏自动轮播,支持视频与图片,具备沉浸式空间视觉背景
- **管理后台** (`/admin`) — 登录保护的管理面板,用于媒体上传、播放列表编排、播放控制与系统设置
```
┌──────────────┐ REST(全部数据/媒体/设置) ┌────────────────┐
│ Tauri 大屏 │ ◄────────────────────────── │ FastAPI :10085 │
│ (React 前端) │ └───────┬────────┘
│ │ MQTT 订阅 opc/display/command │ MQTT 发布
│ 账号: dpm │ ◄────────────────────────────────────│ 账号: dpmserver
└──────┬───────┘ MQTT Broker (EMQX) │
│ 192.168.1.3:1883 │
└───────────────────────────────────────────────┘
```
系统通过 **SSE(Server-Sent Events)** 实现管理端与展示端的实时状态同步,支持局域网内远程管理。
- **数据 / 媒体 / 设置** → REST API(FastAPI,JSON 文件持久化)
- **页面与媒体控制** → MQTT(EMQX broker),按 `clientId` 定向下发;SSE `/api/events` 作回退
- **桌面壳** → Tauri 2(Rust),仅负责窗口生命周期与 3 个命令(读运行配置、双屏、安装更新)
- **管理后台** → FastAPI + Jinja 渲染的 `/admin` 页面(非 React 路由)
## 技术栈
> **权威说明以 `backend/README.md` 为准**,含全部接口、MQTT 主题、环境变量与 Docker 部署。
| 层级 | 技术 |
## 目录
| 路径 | 说明 |
|------|------|
| 前端框架 | React 19 + React Router 7 |
| 构建工具 | Vite 7 |
| 桌面框架 | Tauri 2 (Rust) |
| HTTP 服务 | Axum 0.8 (嵌入式 HTTP 服务器) |
| 数据持久化 | JSON 文件存储 |
| 实时通信 | Server-Sent Events (SSE) |
| 内网穿透 | nps 客户端嵌入式集成 |
## 功能特性
### 媒体管理
- **本地上传** — 支持 .mp4、.mkv、.avi、.jpg、.jpeg、.png 格式
- **远程 URL** — 添加网络媒体资源链接
- **媒体预览** — 图片与视频的模态框预览
- **文件删除** — 从媒体库移除文件
### 播放控制
- **播放/暂停** — 实时控制展示端的播放状态
- **上一曲/下一曲** — 切换播放内容
- **顺序/随机播放** — 两种播放模式
- **图片时长可调** — 1~300 秒
- **音量控制** — 实时同步调节
### 展示端体验
- **沉浸式空间背景** — 深邃星空背景 + 透视网格 + 动态环境光晕
- **自动播放** — 启动后自动加载播放列表
- **视频/图片自适应** — 全屏覆盖显示
- **声音管理** — 浏览器自动播放策略处理
- **点击暂停/恢复** — 现场快速控制
### 系统功能
- **全屏/窗口模式切换** — 自由切换展示形态
- **最小化窗口** — 后台运行
- **开机自启动** — 系统级自启(Tauri autostart 插件)
- **关闭 → 最小化** — 防止误关闭
- **内网穿透** — 内置 nps 客户端,支持外网远程访问
### 管理后台
- **登录保护** — 账号密码验证
- **实时状态显示** — 当前播放内容、状态
- **上传进度条** — 文件上传实时反馈
- **响应式设计** — 桌面/移动端自适应
- **Toast 通知** — 操作即时反馈
| `backend/` | **后端**:FastAPI(uv 管理),`:10085`;`app/` 内含全部 REST、MQTT、模拟引擎、AI/语音/RAG、手势识别。 |
| `src/` | **前端**:React 19 + Vite + Tailwind + three.js + MQTT + Tauri API。路由 `/`(数据)、`/twin`(孪生)、`/ai`、`/screen`(媒体)、`/voice`、`/wall`。 |
| `src-tauri/` | Tauri 2 桌面壳(Rust)。 |
| `docs/` | 部署 / 更新 / 语音 / MQTT 手册 / ESP32。 |
| `public/` | 静态资源。 |
| `数据资料/` | 园区本地数据资料(不入库)。 |
## 快速开始
### 环境要求
- **Rust** (edition 2021)
- **Node.js** >= 18
- **Yarn** 或 npm
- **Tauri CLI** (`cargo install tauri-cli`)
### 安装
### 前端(vite)
```bash
# 安装前端依赖
yarn install
# 开发模式运行(同时启动 Vite 开发服务器和 Tauri 应用)
yarn tauri dev
# 仅启动 Web 开发服务器(浏览器中预览)
yarn dev
yarn dev # Vite 开发服务器 :1420
yarn build # 构建 dist/
```
### 构建生产版本
### 后端(uv)
```bash
yarn tauri build
cd backend
uv sync
cp .env.example .env # 按需填 DashScope Key、MQTT 账号
uv run python main.py # 监听 0.0.0.0:10085
```
构建产物位于 `src-tauri/target/release/` 目录。
### Docker 部署(后端 + EMQX,推荐)
## 项目结构
```bash
export MQTT_PUBLIC_HOST=192.168.1.50 # 展播端可访问的服务器局域网 IP
export DASHSCOPE_API_KEY=sk-xxx # 未配置时 AI 走本地规则引擎
docker compose up -d --build
curl http://<服务器IP>:10085/api/health
```
端口:`10085`(API/后台/媒体)、`8765`(s2s 语音)、`1883/8083/18083`(EMQX)。数据卷 `dpm_media` / `dpm_knowledge` / `dpm_data`。
## 关键约定
- **进程管理**:见 `AGENTS.md` —— AI 禁止启动/重启/停止任何服务(后端 :10085 / s2s :8765 / vite :1420 / Tauri)。
- **凭据**:`backend/.env` 与根 `.env.local`(`VITE_*` 前缀)含阿里云密钥与 MQTT 凭据,不入库。
- **前端连接配置**:`.env.local` 中 `VITE_API_BASE` / `VITE_MQTT_URL` / `VITE_MQTT_USERNAME` / `VITE_MQTT_PASSWORD`;运行时优先级:exe 旁 `config.json` > 后端 `/api/config` > 内置默认 IP。
## 项目结构(源码)
```
dpm/
├── index.html # HTML 入口
├── package.json # 前端依赖配置
├── vite.config.js # Vite 构建配置(API 代理到 :10801)
├── public/
│ └── npc.zip # 内网穿透客户端(嵌入二进制)
├── src/ # 前端源代码
│ ├── main.jsx # React 入口
│ ├── App.jsx # 路由配置(/admin, /screen)
│ ├── pages/
│ │ ├── Admin.jsx # 管理后台页面
│ │ └── Screen.jsx # 大屏展示页面
│ ├── utils/
│ │ └── api.js # API 封装(REST + SSE)
│ └── styles/
│ ├── tokens.css # CSS 基础重置
│ ├── admin.css # 管理后台样式
│ └── screen.css # 大屏展示样式
└── src-tauri/ # Rust 后端源代码
├── Cargo.toml # Rust 依赖配置
├── tauri.conf.json # Tauri 应用配置
├── capabilities/
│ └── default.json # Tauri 权限配置
└── src/
├── main.rs # 程序入口
├── lib.rs # 模块导出 + 启动逻辑
├── server.rs # Axum HTTP 服务器(路由 + 处理器)
├── models.rs # 数据模型(Settings, PlaybackState, etc.)
├── storage.rs # JSON 文件持久化存储
└── events.rs # SSE 事件广播管理器
├── package.json / vite.config.js
├── src/ # 前端(React 19)
│ ├── main.jsx # 入口:读 Tauri 运行配置 + /api/config
│ ├── App.jsx # 路由(/ /twin /ai /screen /voice /wall)
│ ├── pages/ # 六大屏页面
│ ├── components/ # Building3D、AiChatPanel、GlobalVision 等
│ ├── voice/ # 实时语音栈(s2s-ws-client、worklets)
│ └── utils/ # mqtt、useMqttControl、updateCheck、statusBus
├── backend/
│ ├── main.py # 启动入口(prepare_env → uvicorn)
│ └── app/ # config/routers/mqtt/sim_engine/storage/llm/...
└── src-tauri/ # Tauri 2(Rust)
```
## API 参考
系统内置 HTTP 服务器监听 `0.0.0.0:10801`,提供以下 REST API:
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/api/login` | 管理员登录 |
| GET | `/api/settings` | 获取系统设置 |
| POST | `/api/settings` | 更新系统设置 |
| GET | `/api/playlist` | 获取播放列表(含音量/模式/时长) |
| POST | `/api/playlist/add` | 添加到播放列表 |
| POST | `/api/playlist/remove` | 从播放列表移除 |
| GET | `/media` | 获取所有媒体文件列表 |
| POST | `/upload` | 上传媒体文件(multipart) |
| POST | `/api/media/add-url` | 添加 URL 媒体 |
| POST | `/api/delete` | 删除媒体文件 |
| POST | `/api/control` | 播放控制(play/pause/next/prev) |
| GET | `/api/state` | 获取当前播放状态 |
| POST | `/api/state` | 更新播放状态 |
| GET | `/api/events` | SSE 实时事件流 |
| POST | `/api/display-command` | 显示命令(minimize 等) |
| GET | `/file/*` | 静态媒体文件服务 |
### 默认登录凭据
- **账号**: `admin`
- **密码**: `**********`
> 可通过编辑系统数据目录下的 `dpm/data.json` 修改。
### 数据存储位置
系统数据存储在操作系统标准数据目录下的 `dpm/` 文件夹中:
- **macOS**: `~/Library/Application Support/dpm/data.json`
- **Linux**: `~/.local/share/dpm/data.json`
- **Windows**: `C:\Users\<用户>\AppData\Roaming\dpm/data.json`
媒体文件存储在用户 `~/Downloads/Media/` 目录。
## 内网穿透
系统内置 [nps](https://github.com/ehang-io/nps) 客户端,自动从嵌入的 `public/npc.zip` 解压并启动,实现外网远程访问管理后台。
- 启动参数在 `server.rs` 中配置
- *仅支持 Windows*
- 自动设置 Unix 可执行权限
- 解压到系统数据目录 `dpm/npc/`
## 开发说明
### 端口
| 端口 | 用途 |
|------|------|
| 10801 | 后端 HTTP API 服务器 |
| 1420 | Vite 前端开发服务器 |
### Vite 开发代理
开发模式下,Vite 自动将 `/api`、`/file`、`/media`、`/upload` 路径代理到 `localhost:10801`,前端无需关心跨域问题。
### 构建
前端构建产物(`dist/`)通过 `RustEmbed` 编译到 Rust 二进制中,发布时只需分发单个可执行文件。
## 许可
本项目为昆明市大学生创业园公益定制开发。
---
*云南派音人工智能科技提供技术支持*
昆明市大学生创业园公益定制开发,*云南派音人工智能科技提供技术支持*。
+2
View File
@@ -1,5 +1,7 @@
# OPC 智能园区后端(FastAPI + MQTT)
> 📍 工作区位置:`code/dpm/backend/`(DPM 园区大屏后端,独立仓库)。权威说明以本文档为准;系统/运维文档见 `design/dpm/`,前端见 `code/dpm/src/`。
独立后端服务:**所有数据通过 REST API 提供**,**页面/媒体控制通过 MQTT 下发**,媒体资源由后端统一存储与返回。
## 架构
+12 -12
View File
@@ -14,34 +14,34 @@ log = logging.getLogger("dpm.ai_tools")
# ---------------- 工具执行 ----------------
def execute_tool(tool):
"""执行一个工具调用:发布 MQTT 控制指令,返回是否发布成功"""
def execute_tool(tool, screen_id=""):
"""执行一个工具调用:发布 MQTT 控制指令(带发起屏幕 screen_id,只发到该屏),返回是否发布成功"""
t = tool.get("type")
params = tool.get("params") or {}
if t == "navigate":
return hub.publish_command("navigate", {"page": params.get("page", "home")}).get("published", False)
return hub.publish_command("navigate", {"page": params.get("page", "home")}, screen_id=screen_id).get("published", False)
if t == "control":
action = params.get("action")
if action in ("play", "pause", "next", "prev"):
return hub.publish_command(action).get("published", False)
return hub.publish_command(action, screen_id=screen_id).get("published", False)
if action == "set_mode":
return hub.publish_command("set_mode", {"mode": params.get("mode", "auto")}).get("published", False)
return hub.publish_command("set_mode", {"mode": params.get("mode", "auto")}, screen_id=screen_id).get("published", False)
return False
if t == "alert":
return hub.publish_command("alert", {"title": params.get("title", "提示"), "content": params.get("content", "")}).get("published", False)
return hub.publish_command("alert", {"title": params.get("title", "提示"), "content": params.get("content", "")}, screen_id=screen_id).get("published", False)
if t == "show_card":
return hub.publish_command("show_card", params).get("published", False)
return hub.publish_command("show_card", params, screen_id=screen_id).get("published", False)
return False
def run_tools(tools):
"""执行一组工具,返回 [{type, ok}] 并汇总是否全部发布成功"""
def run_tools(tools, screen_id=""):
"""执行一组工具(带发起屏幕 screen_id),返回 [{type, ok}] 并汇总是否全部发布成功"""
results = []
all_ok = True
for tool in tools or []:
ok = bool(execute_tool(tool))
log.info("ai_tools: 执行工具 %s params=%s -> %s", tool.get("type"),
json.dumps(tool.get("params") or {}, ensure_ascii=False), ok)
ok = bool(execute_tool(tool, screen_id))
log.info("ai_tools: 执行工具 %s params=%s screen=%s -> %s", tool.get("type"),
json.dumps(tool.get("params") or {}, ensure_ascii=False), screen_id or "all", ok)
results.append({"type": tool.get("type"), "ok": ok})
all_ok = all_ok and ok
return results, all_ok
+4
View File
@@ -39,6 +39,10 @@ class Settings:
DATA_FILE = Path(_env("DPM_DATA_FILE", str(BASE_DIR / "data.json")))
DIST_DIR = Path(_env("DPM_DIST_DIR", str(BASE_DIR.parent / "dist"))) # 前端构建产物(可选托管)
# ---- 视频帧保存(摄像头识别帧,仅检测到人脸/手势时落盘,便于排查) ----
SAVE_VISION_FRAMES = _env("DPM_SAVE_VISION_FRAMES", "1") == "1"
VISION_FRAME_SAVE_DIR = Path(_env("DPM_VISION_SAVE_DIR", str(MEDIA_DIR / "video")))
# ---- MQTT(优先使用 .env 中的局域网 Broker 配置) ----
MQTT_ENABLED = _env("DPM_MQTT_ENABLED", "1") == "1"
MQTT_HOST = _env("DPM_MQTT_HOST", _env("MQTT_BROKER_HOST", "192.168.1.9"))
+10 -10
View File
@@ -135,32 +135,32 @@ def _chat_once(messages, with_tools=True):
return json.loads(resp.read().decode("utf-8"))
def _fallback(messages):
def _fallback(messages, screen_id=""):
"""DashScope 不可用时回退本地规则引擎"""
from .ai_tools import run_chat as rule_run
result = rule_run(messages)
# 规则引擎产出的工具同样执行(MQTT 广播),并汇总发布结果
_, all_ok = _exec_all(result.get("tools", []))
# 规则引擎产出的工具同样执行(MQTT 广播,带发起屏幕),并汇总发布结果
_, all_ok = _exec_all(result.get("tools", []), screen_id)
result["model"] = "rule-engine"
result["mqtt_published"] = all_ok
return result
def _exec_all(tools):
"""执行工具列表,返回 (results, all_ok)"""
def _exec_all(tools, screen_id=""):
"""执行工具列表(带发起屏幕 screen_id,工具结果只发到该屏),返回 (results, all_ok)"""
from .ai_tools import run_tools
results, all_ok = run_tools(tools)
results, all_ok = run_tools(tools, screen_id)
return results, all_ok
def run_chat(messages):
def run_chat(messages, screen_id=""):
"""入口:{reply, tools, model}
tools 已在后端执行(MQTT 广播),返回值供请求端本地同步执行"""
tools 已在后端执行(MQTT 广播,带发起屏幕 screen_id),返回值供请求端本地同步执行"""
if not settings.DASHSCOPE_API_KEY:
log.warning("未配置 DASHSCOPE_API_KEY,使用本地规则引擎")
return _fallback(messages)
return _fallback(messages, screen_id)
# 注入园区知识库(标准资料口径):按用户 query 检索命中段落动态注入,
# 替代原 1200 字截断全量注入(避免尾部知识丢失)。无命中时回退 brief()。
@@ -226,7 +226,7 @@ def run_chat(messages):
"content": json.dumps({"ok": True}, ensure_ascii=False),
})
# 工具执行(MQTT 广播),并汇总发布结果
results, all_ok = _exec_all(executed)
results, all_ok = _exec_all(executed, screen_id)
log.info("llm: 工具执行结果 %s", results)
for t in executed:
log.info("llm: 执行 %s %s", t["type"], json.dumps(t["params"], ensure_ascii=False))
+8 -18
View File
@@ -14,7 +14,7 @@ from pathlib import Path
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
@@ -108,8 +108,8 @@ app.add_api_route("/admin/logout", admin_logout, methods=["GET"], include_in_sch
_DOWNLOAD_ROOT = Path(__file__).resolve().parent.parent # backend/
@app.get("/download", include_in_schema=False)
@app.get("/11", include_in_schema=False)
@app.get("/download")
@app.get("/11")
async def download_installer():
"""下载 Windows 客户端安装包(backend/ 下 *.setup.exe,如 云超服..._x64-setup.exe)。"""
for f in sorted(_DOWNLOAD_ROOT.glob("*setup.exe")):
@@ -146,7 +146,7 @@ class UpdateCheckBody(BaseModel):
current: str = "" # 前端上报的当前版本,如 0.1.0
@app.post("/api/update/check", include_in_schema=False)
@app.post("/api/update/check")
async def update_check(body: UpdateCheckBody):
"""检测更新:返回最新版本号、安装包下载地址、是否需要更新(仅 Windows)。"""
inst = _latest_installer()
@@ -171,17 +171,7 @@ async def update_check(body: UpdateCheckBody):
# ---------- 前端静态托管 + SPA 回退(浏览器直接访问 :8000 即可) ----------
@app.get("/{path:path}", include_in_schema=False)
async def spa_fallback(path: str):
if not DIST.exists():
return JSONResponse({"detail": "前端未构建(dist 目录不存在)"}, status_code=404)
dist = DIST.resolve()
if not path:
target = dist / "index.html"
else:
target = (dist / path).resolve()
if target.is_file() and target.is_relative_to(dist):
return FileResponse(target)
index = dist / "index.html"
if index.exists():
return FileResponse(index)
return JSONResponse({"detail": "Not Found"}, status_code=404)
async def spa_fallback(request: Request):
"""默认导向管理后台:屏蔽通过后端地址直接访问大屏前端(含首页 / 及 /twin /ai /screen 等)。
/api、/admin、/static、/file 等已在更早路由/挂载处理,不会走到这里。"""
return RedirectResponse(url="/admin", status_code=307)
+106 -14
View File
@@ -33,7 +33,7 @@ class MqttHub:
self.client = None
self.connected = False
self._lock = threading.RLock()
self._screens = {} # client_id -> last_seen_ts
self.devices = {} # device_id(client_id) -> {role, parent_id, last_seen}
self.last_command = None # {action, params, ts, published}
# ---------- 生命周期 ----------
@@ -81,30 +81,45 @@ class MqttHub:
log.warning("MQTT 断开(rc=%s),自动重连中...", rc)
def _on_message(self, client, userdata, msg):
"""接收大屏心跳:记录 client_id 与时间"""
"""接收大屏心跳:更新设备表在线时间(设备由注册表登记 role/parent)"""
if msg.topic == settings.TOPIC_HEARTBEAT:
try:
payload = json.loads(msg.payload.decode("utf-8"))
cid = payload.get("client_id") or msg.topic
with self._lock:
self._screens[cid] = time.time()
# 清理过期
cutoff = time.time() - settings.SCREEN_TTL
self._screens = {k: v for k, v in self._screens.items() if v > cutoff}
cur = self.devices.get(cid, {"role": "", "parent_id": ""})
cur["last_seen"] = time.time()
self.devices[cid] = cur
self._prune()
except Exception: # noqa: BLE001
pass
def _prune(self):
"""剔除超时未上报的设备"""
cutoff = time.time() - settings.SCREEN_TTL
self.devices = {k: v for k, v in self.devices.items() if v.get("last_seen", 0) > cutoff}
# ---------- 状态查询 ----------
def screens_online(self):
with self._lock:
cutoff = time.time() - settings.SCREEN_TTL
return sum(1 for v in self._screens.values() if v > cutoff)
return sum(1 for v in self.devices.values() if v.get("last_seen", 0) > time.time() - settings.SCREEN_TTL)
def status(self):
with self._lock:
self._prune()
devices = [
{
"device_id": cid,
"role": v.get("role", ""),
"parent_id": v.get("parent_id", ""),
}
for cid, v in self.devices.items()
]
return {
"mqtt_connected": self.connected,
"mqtt_host": f"{settings.MQTT_HOST}:{settings.MQTT_PORT}",
"screens_online": self.screens_online(),
"screens_online": len(devices),
"devices": devices,
"last_command": self.last_command,
}
@@ -120,26 +135,103 @@ class MqttHub:
log.warning("MQTT 发布失败: %s", e)
return False
def publish_command(self, action, params=None):
"""统一指令信封:{cmd_id, ts, action, params, published}
MQTT 广播 + SSE 兼容推送;published 标记是否真正发布成功"""
def register_device(self, device_id, role="", parent_id=""):
"""前端上报设备:唯一 device_id + 角色(main/secondary) + 从属主屏 parent_id。"""
device_id = (device_id or "").strip()
if not device_id:
return
with self._lock:
cur = self.devices.get(device_id, {})
if role:
cur["role"] = role
if parent_id:
cur["parent_id"] = parent_id
cur["last_seen"] = time.time()
self.devices[device_id] = cur
self._prune()
log.info("mqtt: 设备注册 id=%s role=%s parent=%s", device_id, role or cur.get("role"), parent_id or cur.get("parent_id"))
def publish_command(self, action, params=None, screen_id=None, screen_role=None):
"""按客户端(设备)路由下发指令:
· screen_id 精确到某台设备 → 只发到该设备独有 topic
· screen_role(main/secondary) → 解析到该角色的设备,发到其独有 topic
· 两者皆空(全部)→ 遍历在线设备列表,依次下发到各自独有 topic
客户端只订阅自己的独有频道,因此定向指令绝不会被其它屏幕收到。"""
payload = {
"cmd_id": new_cmd_id(),
"ts": int(time.time() * 1000),
"action": action,
"params": params or {},
}
ok = self.publish(settings.TOPIC_COMMAND, payload)
target = None
requested = bool(screen_id or screen_role in ("main", "secondary"))
if screen_id:
target = (screen_id or "").strip()
elif screen_role in ("main", "secondary"):
with self._lock:
for cid, v in self.devices.items():
if v.get("role") == screen_role:
target = cid
break
if target:
# 定向:只发到目标设备独有频道
return self._publish_to(payload, target, screen_role)
if requested:
# 指定了目标但未找到对应设备:不广播,避免误发到全部
log.warning("mqtt: 目标设备未找到(id=%s role=%s),未下发", screen_id, screen_role)
payload["published"] = False
with self._lock:
self.last_command = {"action": action, "params": params or {}, "ts": payload["ts"], "published": False, "topic": "none"}
return payload
# 全部:遍历在线设备列表,依次下发到各自独有频道
with self._lock:
cutoff = time.time() - settings.SCREEN_TTL
online = [cid for cid, v in self.devices.items() if v.get("last_seen", 0) > cutoff]
ok = True
published_topics = []
for cid in online:
p = dict(payload)
p["screen_id"] = cid
with self._lock:
p["screen_role"] = self.devices.get(cid, {}).get("role", "")
if self.publish(f"{settings.TOPIC_COMMAND}/{cid}", p):
bus.emit(p)
published_topics.append(cid)
else:
ok = False
payload["published"] = ok
payload["screen_id"] = ""
payload["screen_role"] = "both"
with self._lock:
self.last_command = {
"action": action,
"params": params or {},
"ts": payload["ts"],
"published": ok,
"topic": "all",
}
log.info("mqtt: 全部下发 %s 到 %d 台设备", action, len(published_topics))
return payload
def _publish_to(self, payload, target, screen_role=""):
"""发到指定设备独有频道,并记录 last_command。"""
topic = f"{settings.TOPIC_COMMAND}/{target}"
payload["screen_id"] = target
with self._lock:
payload["screen_role"] = self.devices.get(target, {}).get("role", screen_role or "")
ok = self.publish(topic, payload)
payload["published"] = ok
with self._lock:
self.last_command = {
"action": payload["action"],
"params": payload.get("params") or {},
"ts": payload["ts"],
"published": ok,
"topic": topic,
}
if ok:
bus.emit(payload) # SSE 兼容通道(仅发布成功时推送)
bus.emit(payload)
return payload
def publish_tick(self, snapshot):
+26 -5
View File
@@ -73,6 +73,8 @@ class StateBody(BaseModel):
class DisplayCommandBody(BaseModel):
action: str
params: dict = Field(default_factory=dict)
screen_id: str = "" # 目标屏幕唯一 client_id(精确到某台)
screen_role: str = "" # main | secondary(按角色定位)
class ChatMessage(BaseModel):
@@ -82,6 +84,7 @@ class ChatMessage(BaseModel):
class ChatBody(BaseModel):
messages: list[ChatMessage]
screen_id: str = "" # 发起对话的屏幕设备 id(工具结果只发到该屏)
# ==================== 认证 / 设置 ====================
@@ -449,7 +452,7 @@ async def ai_chat(body: ChatBody):
"""大模型对话:工具调用(切页/控制/卡片/通知)经 MQTT 广播到所有大屏
返回 {reply, tools, model};tools 供请求端本地同步执行"""
messages = [m.model_dump() for m in body.messages]
result = llm_run_chat(messages)
result = llm_run_chat(messages, body.screen_id or "")
return result
@@ -480,18 +483,35 @@ _VALID_DISPLAY_ACTIONS = {
"voice_start", "voice_stop", "voice_refresh",
# 媒体
"play", "pause", "next", "prev", "set_mode", "play_target",
# 双屏
"dual_screen",
# 全局
"alert", "show_card", "minimize", "settings_changed", "playlist_changed",
}
class RegisterBody(BaseModel):
device_id: str = "" # 唯一设备标识(MQTT client_id)
role: str = "" # main | secondary
parent_id: str = "" # 副屏从属的主屏 device_id;主屏为空
@router.post("/api/display/register")
async def display_register(body: RegisterBody):
"""设备注册:前端上报唯一 device_id + 角色 + 从属主屏,后端维护设备表供精准控制。"""
hub.register_device(body.device_id, body.role, body.parent_id)
return {"ok": True, "device_id": body.device_id, "role": body.role, "parent_id": body.parent_id}
@router.post("/api/display/command")
async def display_command_publish(body: DisplayCommandBody):
"""管理端/任意客户端通过 REST 发控制指令 → 后端转 MQTT 广播给所有大屏"""
if body.action not in _VALID_DISPLAY_ACTIONS:
return JSONResponse({"ok": False, "error": f"不支持的指令: {body.action}"}, status_code=400)
cmd = hub.publish_command(body.action, body.params)
# 按屏幕终端路由:screen_id 精确到某客户端;screen_role 按主/副定位;皆空 → 全局广播
cmd = hub.publish_command(body.action, body.params, body.screen_id or None, body.screen_role or None)
return {"ok": cmd.get("published", False), "cmd_id": cmd["cmd_id"], "action": body.action,
"screen_id": body.screen_id or "", "screen_role": body.screen_role or "both",
"mqtt_connected": hub.connected}
@@ -585,6 +605,7 @@ def vision_llm(body: VisionLlmBody):
class ToolsExecBody(BaseModel):
name: str = ""
args: dict = {}
screen_id: str = "" # 发起语音对话的屏幕设备 id(工具结果只发到该屏)
@router.post("/api/tools/exec")
def tools_exec(body: ToolsExecBody):
@@ -593,10 +614,10 @@ def tools_exec(body: ToolsExecBody):
if not name:
return {"ok": False, "error": "missing tool name"}
from .tools import exec_tool
result = exec_tool(name, body.args or {})
log.info("api: /api/tools/exec name=%s args=%s result=%s", name,
result = exec_tool(name, body.args or {}, body.screen_id or "")
log.info("api: /api/tools/exec name=%s args=%s screen=%s result=%s", name,
json.dumps(body.args or {}, ensure_ascii=False)[:300],
str(result)[:400])
body.screen_id or "all", str(result)[:400])
return {"ok": True, "result": result}
@router.get("/api/kb/brief")
+11 -7
View File
@@ -60,15 +60,15 @@ def _query_companies(args):
}, ensure_ascii=False)
def _control_display(args):
"""大屏显示控制:切页 / 弹通知 / 媒体播放暂停(经 MQTT 广播到大屏)"""
def _control_display(args, screen_id=""):
"""大屏显示控制:切页 / 弹通知 / 媒体播放暂停(经 MQTT 按发起屏幕下发)"""
from .mqtt import hub
action = (args.get("action") or "").strip()
target = (args.get("target") or "").strip()
if action not in ("switch_page", "alert", "media_play", "media_pause"):
return json.dumps({"error": f"不支持的 action: {action}"}, ensure_ascii=False)
params = {"target": target} if target else {}
ok = hub.publish_command(action, params).get("published", False)
ok = hub.publish_command(action, params, screen_id=screen_id).get("published", False)
return json.dumps({"ok": ok, "action": action, "target": target}, ensure_ascii=False)
@@ -125,15 +125,19 @@ def tool_schemas():
]
def exec_tool(name: str, args: dict):
"""执行工具 → 返回给 LLM 的字符串结果(JSON)"""
def exec_tool(name: str, args: dict, screen_id: str = ""):
"""执行工具 → 返回给 LLM 的字符串结果(JSON)。
控制类工具(control_display)按发起屏幕 screen_id 只下发到该屏。"""
t = TOOLS.get(name)
if not t:
log.warning("tools: 未知工具 %s", name)
return json.dumps({"error": f"未知工具: {name}"}, ensure_ascii=False)
try:
result = t["fn"](args or {})
log.info("tools: 执行工具 %s args=%s", name, json.dumps(args, ensure_ascii=False))
if name == "control_display":
result = _control_display(args or {}, screen_id)
else:
result = t["fn"](args or {})
log.info("tools: 执行工具 %s args=%s screen=%s", name, json.dumps(args, ensure_ascii=False), screen_id or "all")
log.info("tools: 结果 -> %s", _trunc(result, 1000))
return result
except Exception as e: # noqa: BLE001
+39
View File
@@ -19,8 +19,12 @@ from pathlib import Path
from PIL import Image
from .config import settings
log = logging.getLogger("dpm.vision")
_frame_seq = 0
MODEL_DIR = Path(__file__).resolve().parent.parent / "models"
FACE_MODEL_PATH = MODEL_DIR / "yolov8n-face.pt"
POSE_MODEL_PATH = MODEL_DIR / "yolov8n-pose.pt"
@@ -188,6 +192,40 @@ def predict_jpeg(jpeg_bytes: bytes):
}
def _should_save(data: dict) -> bool:
"""仅当画面检测到人脸/手势时才值得落盘保存。"""
return (
int(data.get("faces") or 0) > 0
or bool(data.get("raised"))
or bool(data.get("fists"))
or bool(data.get("both_up"))
or bool(data.get("pointing"))
or bool(data.get("hands_close"))
)
def maybe_save_frame(jpeg: bytes, data: dict):
"""仅将命中(有人/有动作)的 JPEG 帧落盘到 MEDIA_DIR/video/<小时目录>/,便于排查识别链路。
目录按小时分割(YYYYMMDD_HH),不自动清理。
可通过 DPM_SAVE_VISION_FRAMES=0 关闭,或 DPM_VISION_SAVE_DIR 改目录。"""
global _frame_seq
if not settings.SAVE_VISION_FRAMES:
return
if not _should_save(data):
return
_frame_seq += 1
ts = time.strftime("%Y%m%d_%H%M%S")
hour = time.strftime("%Y%m%d_%H")
try:
save_dir = settings.VISION_FRAME_SAVE_DIR / hour
save_dir.mkdir(parents=True, exist_ok=True)
path = save_dir / f"{ts}_{_frame_seq:05d}.jpg"
with open(path, "wb") as f:
f.write(jpeg)
except Exception as e: # noqa: BLE001
log.warning("vision: 保存帧失败 %s", e)
def predict_base64(b64: str):
"""入口:base64 JPEG → 检测结果 dict(含 ok 标记,失败时带 error)"""
try:
@@ -198,6 +236,7 @@ def predict_base64(b64: str):
try:
data = predict_jpeg(jpeg)
data["ok"] = True
maybe_save_frame(jpeg, data)
return data
except Exception as e: # noqa: BLE001
log.error("vision: 推理失败 %s", e, exc_info=True)
+125
View File
@@ -1265,3 +1265,128 @@ button { font-family: "Poppins", "PingFang SC", sans-serif; cursor: pointer; tra
font-size: 13px; color: #1d2c44; outline: none;
}
.mqtt-input:focus { border-color: #2f6bff; }
/* ============ 弱化连接状态(沉底、紧凑、低调) ============ */
.conn-card { opacity: 0.88; }
.conn-card .card-header { padding-bottom: 6px; }
.conn-card .conn-status { display: flex; flex-wrap: wrap; gap: 6px 22px; }
.conn-card .conn-row { min-width: 150px; }
.conn-card .conn-label { font-size: 11px; }
/* ============ 大屏控制:mqtt-group 按操作优先级排序 ============ */
.mqtt-card { display: flex; flex-direction: column; }
.mqtt-card .mqtt-group { order: 50; } /* 未显式设置 order 的组默认靠后 */
/* ============ 手机适配(响应式) ============ */
@media (max-width: 900px) {
.main-grid { grid-template-columns: 1fr; padding: 16px; gap: 16px; }
}
@media (max-width: 600px) {
.main-grid { padding: 10px; gap: 12px; }
.topbar { flex-wrap: wrap; row-gap: 8px; }
.topbar-center { order: 3; width: 100%; justify-content: flex-start; }
.page-switch-grid { grid-template-columns: repeat(2, 1fr); gap: 8px; }
.mqtt-row { flex-wrap: wrap; gap: 8px; }
.page-switch-btn { flex: 1 1 45%; }
.form-inline { flex-direction: column; }
.form-row { flex-wrap: wrap; }
.conn-card .conn-status { flex-direction: column; gap: 6px; }
.card { padding: 16px; }
}
/* ============ 遥控器式快捷控制条 ============ */
.remote-bar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px 18px;
max-width: 1400px;
margin: 16px auto 0;
padding: 12px 16px;
background: var(--surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
box-shadow: var(--shadow-card);
}
.remote-group { display: flex; gap: 6px; }
.remote-btn {
width: 42px; height: 42px;
display: inline-flex; align-items: center; justify-content: center;
font-size: 24px; line-height: 1;
border: 1px solid var(--border-default);
border-radius: var(--radius-sm);
background: #fff; color: var(--text);
cursor: pointer;
transition: var(--transition);
}
.remote-btn:hover { background: var(--primary-subtle); border-color: var(--primary); color: var(--primary); }
.remote-target {
display: flex; align-items: center; gap: 8px;
padding: 5px 12px;
background: var(--primary-subtle);
border: 1px solid rgba(0,189,125,.2);
border-radius: var(--radius-md);
}
.remote-label { font-size: 12px; font-weight: 600; color: var(--text-secondary); white-space: nowrap; }
.remote-count { font-size: 11px; color: var(--text-dim); white-space: nowrap; }
.remote-target select {
min-width: 160px;
padding: 6px 10px;
border: 1px solid var(--border-default);
border-radius: var(--radius-sm);
background: #fff; color: var(--text); font-size: 13px;
}
.remote-pages { display: flex; gap: 6px; flex-wrap: wrap; }
@media (max-width: 600px) {
.remote-bar { margin: 10px; padding: 10px; }
.remote-target { flex: 1 1 100%; }
.remote-target select { flex: 1; min-width: 0; }
}
/* 遥控条:页面切换合并模块 + 分隔线 */
.remote-nav { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; }
.remote-sep { width: 1px; height: 26px; background: var(--border-default); margin: 0 4px; }
@media (max-width: 600px) {
.remote-nav { width: 100%; }
.remote-sep { display: none; }
}
/* ============ 顶部控制模块(选择屏幕 / 页面切换,独立上下排列) ============ */
.control-stack {
max-width: 1400px;
margin: 16px auto 0;
padding: 0 32px;
display: flex;
flex-direction: column;
gap: 24px;
}
.control-card { margin: 0; }
@media (max-width: 600px) {
.control-stack { padding: 0 10px; }
}
/* 页面切换:左右箭头独立一行,与下方页面按钮分隔开 */
.page-nav-prev {
display: flex;
gap: 8px;
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 1px solid var(--border-subtle);
}
.page-nav-links { padding-top: 2px; }
@media (max-width: 600px) {
.page-nav-prev { justify-content: center; }
.page-nav-links { justify-content: flex-start; }
}
/* 卡片图标:继承主题主色(appica color -> currentColor) */
.card-header .card-icon { color: var(--primary); }
/* 页面按钮:当前激活页高亮 */
.page-switch-btn.active {
background: var(--primary);
border-color: var(--primary);
color: #fff;
box-shadow: 0 2px 8px rgba(0,189,125,0.35);
}
+80 -21
View File
@@ -10,8 +10,60 @@
var fullscreen = !!init.fullscreen;
var autostart = !!init.autostart;
/* 目标屏(双屏独立控制):both | main | secondary */
var screenTarget = 'both';
/* 目标屏幕(按 MQTT 客户端精准控制):client_id + role */
var screenId = '';
var screenRole = '';
/* 状态识别:点击某页面后,把该页操作模块前置到页面控制下方,并高亮当前页面按钮 */
function activatePage(page) {
if (!page) return;
var cards = document.querySelectorAll('.main-grid .card[data-page]');
for (var i = 0; i < cards.length; i++) cards[i].style.order = '';
var active = null;
for (var j = 0; j < cards.length; j++) {
if (cards[j].getAttribute('data-page') === page) { active = cards[j]; break; }
}
if (active) active.style.order = -10; // 前置到 main-grid 顶部(页面控制下方)
var navs = document.querySelectorAll('[data-nav]');
for (var k = 0; k < navs.length; k++) navs[k].classList.toggle('active', navs[k].getAttribute('data-nav') === page);
}
function applyScreenTarget(sel) {
sel = sel || document.getElementById('screenTarget');
if (!sel) return;
var v = sel.value || '';
screenId = v;
var opt = null;
if (v) { for (var i = 0; i < sel.options.length; i++) { if (sel.options[i].value === v) { opt = sel.options[i]; break; } } }
screenRole = opt ? (opt.getAttribute('data-role') || '') : '';
}
function loadScreens() {
fetch('/api/display/state', { cache: 'no-store' })
.then(function (r) { return r.json(); })
.then(function (d) {
var sel = document.getElementById('screenTarget');
var cnt = document.getElementById('screenCount');
if (!sel) return;
var devices = (d && d.devices) || [];
if (cnt) cnt.textContent = '在线设备:' + (devices.length || 0) + ' 台';
var cur = sel.value;
sel.innerHTML = '<option value="">全部屏幕</option>';
devices.forEach(function (dev) {
var role = dev.role || '';
var name = role === 'main' ? '主屏' : role === 'secondary' ? '副屏' : (role || '屏幕');
var parent = (dev.role === 'secondary' && dev.parent_id) ? '·属于:' + String(dev.parent_id).slice(0, 8) : '';
var opt = document.createElement('option');
opt.value = dev.device_id;
opt.setAttribute('data-role', role);
opt.textContent = name + parent + ' · ' + String(dev.device_id).slice(0, 16);
sel.appendChild(opt);
});
if (cur) sel.value = cur;
applyScreenTarget(sel);
})
.catch(function () {});
}
/* ---------- Toast ---------- */
var toastContainer = document.getElementById('toastContainer');
@@ -82,6 +134,7 @@
setConn('api', true, '在线');
setConn('mqtt', !!d.mqtt_connected, d.mqtt_connected ? '已连接(' + (d.mqtt_host || '') + ')' : '未连接');
setConn('screens', (d.screens_online || 0) > 0, (d.screens_online || 0) + ' 台');
loadScreens(); // 刷新目标屏幕下拉(按 MQTT 客户端)
var lc = d.last_command;
if (lc) {
var names = { navigate: '页面切换', play: '播放', pause: '暂停', next: '下一项', prev: '上一项',
@@ -98,6 +151,12 @@
});
}
if (connEls.api) { pollStatus(); setInterval(pollStatus, 3000); }
// 目标屏幕下拉:变更时记录目标 client_id + role;初始加载一次
var screenSel = document.getElementById('screenTarget');
if (screenSel) {
screenSel.addEventListener('change', function () { applyScreenTarget(screenSel); });
loadScreens();
}
/* =========================================================
事件委托:页面切换 / 播放控制 / 媒体操作
@@ -110,19 +169,10 @@
});
document.addEventListener('click', function (e) {
var btn = e.target.closest ? e.target.closest('[data-nav], [data-mqtt], [data-control], [data-action], .copy-btn, [data-preview], .screen-target, #mqttAiSend, #mqttAlertSend') : null;
var btn = e.target.closest ? e.target.closest('[data-nav], [data-mqtt], [data-control], [data-action], .copy-btn, [data-preview], #mqttAiSend, #mqttAlertSend') : null;
if (!btn) return;
e.stopPropagation();
// 目标屏选择(双屏独立控制)
if (btn.classList.contains('screen-target')) {
screenTarget = btn.getAttribute('data-screen') || 'both';
var btns = document.querySelectorAll('.screen-target');
for (var i = 0; i < btns.length; i++) btns[i].classList.toggle('active', btns[i] === btn);
toast('目标屏:' + ({ both: '全部(镜像)', main: '仅主屏', secondary: '仅副屏' }[screenTarget] || screenTarget));
return;
}
// 预览
if (btn.hasAttribute('data-preview')) { openPreview(btn.closest('.media-card')); return; }
@@ -141,10 +191,15 @@
if (btn.hasAttribute('data-mqtt')) {
var cmd = null;
try { cmd = JSON.parse(btn.getAttribute('data-mqtt')); } catch (err) { toast('指令 JSON 错误', 'error'); return; }
// 双屏独立控制:除「双屏开关」外,注入目标屏(main/secondary/both)
if (cmd.action !== 'dual_screen') cmd.screen = screenTarget;
// 屏幕终端精准控制:除「双屏开关」外,注入目标 client_id + role;全空=全局广播
if (cmd.action !== 'dual_screen') { cmd.screen_id = screenId; cmd.screen_role = screenRole; }
// 状态识别:切页命令 → 前置对应页面操作模块
if (cmd.action === 'navigate' && cmd.params && cmd.params.page) activatePage(cmd.params.page);
var tgtTxt = screenId
? (cmd.screen_role === 'main' ? '主屏' : cmd.screen_role === 'secondary' ? '副屏' : '屏幕') + '·' + String(screenId).slice(0, 10)
: '全部';
post('/api/display/command', cmd, function (d) {
toast(d && d.ok !== false ? '指令已发送:' + (cmd.action || '') : '发送失败(MQTT 未连接?)',
toast(d && d.ok !== false ? ('指令已发送:' + (cmd.action || '') + ' → ' + tgtTxt) : '发送失败(MQTT 未连接?)',
d && d.ok !== false ? 'ok' : 'error');
});
return;
@@ -154,7 +209,7 @@
if (btn.id === 'mqttAiSend') {
var text = (document.getElementById('mqttAiInput').value || '').trim();
if (!text) { toast('请输入问题', 'error'); return; }
post('/api/display/command', { action: 'ai_input', params: { text: text } }, function () {
post('/api/display/command', { action: 'ai_input', params: { text: text }, screen_id: screenId, screen_role: screenRole }, function () {
toast('已发送问题到大屏 AI 页');
});
return;
@@ -164,17 +219,21 @@
if (btn.id === 'mqttAlertSend') {
var alertText = (document.getElementById('mqttAlertText').value || '').trim();
if (!alertText) { toast('请输入通知内容', 'error'); return; }
post('/api/display/command', { action: 'alert', params: { text: alertText } }, function () {
toast('通知已广播');
post('/api/display/command', { action: 'alert', params: { text: alertText }, screen_id: screenId, screen_role: screenRole }, function () {
toast('通知已发送');
});
return;
}
// 页面切换
// 页面切换(绑定目标屏幕:screen_id 精确某台;空=全部)
if (btn.hasAttribute('data-nav')) {
var page = btn.getAttribute('data-nav');
post('/api/display/command', { action: 'navigate', params: { page: page } }, function () {
toast('已切换大屏至「' + btn.textContent.trim() + '」');
activatePage(page); // 状态识别:前置该页操作模块
var tgtTxt = screenId
? (screenRole === 'main' ? '主屏' : screenRole === 'secondary' ? '副屏' : '屏幕') + '·' + String(screenId).slice(0, 10)
: '全部';
post('/api/display/command', { action: 'navigate', params: { page: page }, screen_id: screenId, screen_role: screenRole }, function () {
toast('已切换大屏至「' + btn.textContent.trim() + '」 → ' + tgtTxt);
});
return;
}
+437 -329
View File
@@ -1,157 +1,246 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>管理后台 · 昆明市大学生创业园</title>
<link rel="stylesheet" href="/static/admin.css">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>管理后台 · 昆明市大学生创业园</title>
<link rel="stylesheet" href="/static/admin.css">
</head>
<body>
{% if not logged_in %}
<!-- ==================== 登录页 ==================== -->
<div class="admin-page">
<div class="login-wrapper">
<div class="login-card">
<div class="login-brand">
<div class="icon">
<div class="iso-top"></div>
<div class="iso-left"></div>
<div class="iso-right"></div>
<div class="iso-dot"></div>
{% if not logged_in %}
<!-- ==================== 登录页 ==================== -->
<div class="admin-page">
<div class="login-wrapper">
<div class="login-card">
<div class="login-brand">
<div class="icon">
<div class="iso-top"></div>
<div class="iso-left"></div>
<div class="iso-right"></div>
<div class="iso-dot"></div>
</div>
<h2>昆明市大学生创业园</h2>
<p>大屏幕轮播控制系统</p>
</div>
<h2>昆明市大学生创业园</h2>
<p>大屏幕轮播控制系统</p>
{% if error %}
<div class="toast error" id="loginError" style="position:static;margin-bottom:12px;">账号或密码错误,请重试</div>
{% endif %}
<form method="post" action="/admin/login">
<div class="field">
<label>账号</label>
<input name="username" placeholder="请输入账号" autocomplete="username" required>
</div>
<div class="field">
<label>密码</label>
<input type="password" name="password" placeholder="请输入密码" autocomplete="current-password" required>
</div>
<button type="submit" class="btn-login">登 录</button>
</form>
</div>
{% if error %}
<div class="toast error" id="loginError" style="position:static;margin-bottom:12px;">账号或密码错误,请重试</div>
{% endif %}
<form method="post" action="/admin/login">
<div class="field">
<label>账号</label>
<input name="username" placeholder="请输入账号" autocomplete="username" required>
</div>
<div class="field">
<label>密码</label>
<input type="password" name="password" placeholder="请输入密码" autocomplete="current-password" required>
</div>
<button type="submit" class="btn-login">登 录</button>
</form>
</div>
</div>
</div>
{% else %}
<!-- ==================== 管理台 ==================== -->
<div class="admin-page">
<div class="dashboard active">
{% else %}
<!-- ==================== 管理台 ==================== -->
<div class="admin-page">
<div class="dashboard active">
<!-- 顶栏 -->
<div class="topbar">
<div class="topbar-left">
<div class="brand-dot"></div>
<h1>昆明市大学生创业园</h1>
</div>
<div class="topbar-center">
<span class="status-bar-text" id="statusText">等待大屏连接...</span>
</div>
<div class="topbar-right">
<button class="btn-control" data-control="prev" title="上一个">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M11 3L6 8L11 13" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<button class="btn-control play-btn" data-control="play" title="播放">
<svg width="18" height="18" viewBox="0 0 18 18" fill="currentColor"><polygon points="5,3 15,9 5,15"/></svg>
</button>
<button class="btn-control play-btn" data-control="pause" title="暂停">
<svg width="18" height="18" viewBox="0 0 18 18" fill="currentColor"><rect x="4" y="3" width="3" height="12" rx="1"/><rect x="11" y="3" width="3" height="12" rx="1"/></svg>
</button>
<button class="btn-control" data-control="next" title="下一个">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M5 3L10 8L5 13" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<span class="topbar-divider"></span>
{% if from_screen %}<a class="btn-return" href="/screen">返回展播</a>{% endif %}
<a class="btn-return" href="/">数据大屏</a>
<a class="btn-logout" href="/admin/logout">退出</a>
</div>
</div>
<div class="main-grid">
<!-- 连接状态 -->
<div class="card">
<div class="card-header">
<div class="card-icon"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="#1D5DCE" stroke-width="1.6"><path d="M5 12.5a10 10 0 0 1 14 0"/><path d="M8 15.5a6 6 0 0 1 8 0"/><path d="M12 19h.01"/></svg></div>
<h3>连接状态</h3>
<!-- 顶栏 -->
<div class="topbar">
<div class="topbar-left">
<div class="brand-dot"></div>
<h1>昆明市大学生创业园</h1>
</div>
<div class="conn-status">
<div class="conn-row" id="connApi">
<span class="conn-dot"></span>
<span class="conn-label">后端服务</span>
<span class="conn-value">检测中...</span>
<div class="topbar-center">
<span class="status-bar-text" id="statusText">等待大屏连接...</span>
</div>
<div class="topbar-right">
<button class="btn-control" data-control="prev" title="上一个">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M11 3L6 8L11 13" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round"
stroke-linejoin="round" />
</svg>
</button>
<button class="btn-control play-btn" data-control="play" title="播放">
<svg width="18" height="18" viewBox="0 0 18 18" fill="currentColor">
<polygon points="5,3 15,9 5,15" />
</svg>
</button>
<button class="btn-control play-btn" data-control="pause" title="暂停">
<svg width="18" height="18" viewBox="0 0 18 18" fill="currentColor">
<rect x="4" y="3" width="3" height="12" rx="1" />
<rect x="11" y="3" width="3" height="12" rx="1" />
</svg>
</button>
<button class="btn-control" data-control="next" title="下一个">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M5 3L10 8L5 13" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round"
stroke-linejoin="round" />
</svg>
</button>
<span class="topbar-divider"></span>
{% if from_screen %}<a class="btn-return" href="/screen">返回展播</a>{% endif %}
<a class="btn-return" href="/">数据大屏</a>
<a class="btn-logout" href="/admin/logout">退出</a>
</div>
</div>
<!-- 选择屏幕(独立模块) -->
<div class="control-stack">
<div class="remote-target">
<span id="screenCount" class="remote-count">在线 0 台</span>
<select id="screenTarget">
<option value="">全部控制</option>
</select>
</div>
<!-- 页面切换(独立模块) -->
<div class="card control-card">
<div class="page-nav-prev">
<button class="remote-btn" data-mqtt='{"action":"navigate_rel","params":{"delta":-1}}' title="向左切页"
aria-label="向左">‹</button>
<button class="remote-btn" data-mqtt='{"action":"navigate_rel","params":{"delta":1}}' title="向右切页"
aria-label="向右">›</button>
</div>
<div class="conn-row" id="connMqtt">
<span class="conn-dot"></span>
<span class="conn-label">MQTT Broker</span>
<span class="conn-value">检测中...</span>
</div>
<div class="conn-row" id="connScreens">
<span class="conn-dot"></span>
<span class="conn-label">在线大屏</span>
<span class="conn-value">检测中...</span>
</div>
<div class="conn-row" id="connLastCmd">
<span class="conn-dot"></span>
<span class="conn-label">最近指令</span>
<span class="conn-value">—</span>
</div>
</div>
</div>
<!-- 页面切换 -->
<div class="card">
<div class="card-header">
<div class="card-icon"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="#1D5DCE" stroke-width="1.6"><rect x="3" y="3" width="8" height="8" rx="1.5"/><rect x="13" y="3" width="8" height="8" rx="1.5"/><rect x="3" y="13" width="8" height="8" rx="1.5"/><rect x="13" y="13" width="8" height="8" rx="1.5"/></svg></div>
<h3>目标屏(双屏独立控制)</h3>
</div>
<div class="page-switch-grid">
<button class="page-switch-btn screen-target" data-screen="both">全部(镜像)</button>
<button class="page-switch-btn screen-target" data-screen="main">仅主屏</button>
<button class="page-switch-btn screen-target" data-screen="secondary">仅副屏</button>
</div>
<div class="mqtt-hint">选择后,下方命令只发送到对应屏幕;「全部」两边同步。</div>
</div>
<div class="card">
<div class="card-header">
<div class="card-icon"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="#1D5DCE" stroke-width="1.6"><rect x="3" y="3" width="8" height="8" rx="1.5"/><rect x="13" y="3" width="8" height="8" rx="1.5"/><rect x="3" y="13" width="8" height="8" rx="1.5"/><rect x="13" y="13" width="8" height="8" rx="1.5"/></svg></div>
<h3>页面切换(控制大屏展示)</h3>
</div>
<div class="page-switch-grid">
<button class="page-switch-btn" data-nav="/">数据大屏</button>
<button class="page-switch-btn" data-nav="/twin">数字孪生</button>
<button class="page-switch-btn" data-nav="/ai">AI 助手</button>
<button class="page-switch-btn" data-nav="/screen">媒体轮播</button>
</div>
</div>
<!-- 大屏 MQTT 控制(全部前端命令) -->
<div class="card">
<div class="card-header">
<div class="card-icon"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="#1D5DCE" stroke-width="1.6"><path d="M2 6a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2z"/><path d="M8 10h8M8 14h4"/></svg></div>
<h3>大屏控制(MQTT 指令)</h3>
</div>
<!-- 页面导航 -->
<div class="mqtt-group">
<div class="mqtt-group-title">页面导航</div>
<div class="mqtt-row">
<div class="mqtt-row page-nav-links">
<button class="page-switch-btn" data-nav="/">数据大屏</button>
<button class="page-switch-btn" data-nav="/twin">数字孪生</button>
<button class="page-switch-btn" data-nav="/ai">AI 助手</button>
<button class="page-switch-btn" data-nav="/screen">媒体轮播</button>
<button class="page-switch-btn" data-mqtt='{"action":"navigate","params":{"page":"/voice"}}'>语音对话</button>
<button class="page-switch-btn" data-mqtt='{"action":"navigate","params":{"page":"/wall"}}'>企业展示墙</button>
<button class="page-switch-btn" data-mqtt='{"action":"navigate_rel","params":{"delta":-1}}'>← 向左</button>
<button class="page-switch-btn" data-mqtt='{"action":"navigate_rel","params":{"delta":1}}'>向右 →</button>
</div>
</div>
</div>
<div class="main-grid">
<!-- 连接状态(弱化:沉到底部、紧凑展示) -->
<div class="card conn-card" style="order:99">
<div class="card-header">
<div class="card-icon"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor"
stroke-width="1.5">
<path d="M3 12h4l3 8 4-16 3 8h4" />
</svg></div>
<h3>连接状态</h3>
</div>
<div class="conn-status">
<div class="conn-row" id="connApi">
<span class="conn-dot"></span>
<span class="conn-label">后端服务</span>
<span class="conn-value">检测中...</span>
</div>
<div class="conn-row" id="connMqtt">
<span class="conn-dot"></span>
<span class="conn-label">MQTT Broker</span>
<span class="conn-value">检测中...</span>
</div>
<div class="conn-row" id="connScreens">
<span class="conn-dot"></span>
<span class="conn-label">在线大屏</span>
<span class="conn-value">检测中...</span>
</div>
<div class="conn-row" id="connLastCmd">
<span class="conn-dot"></span>
<span class="conn-label">最近指令</span>
<span class="conn-value">—</span>
</div>
</div>
</div>
<!-- 企业展示墙 -->
<div class="mqtt-group">
<div class="mqtt-group-title">企业展示墙(/wall)</div>
<!-- 媒体轮播(独立模块) -->
<div class="card" data-page="/screen">
<div class="card-header">
<div class="card-icon"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor"
stroke-width="1.5">
<path d="M6 4v16l13-8z" />
</svg></div>
<h3>媒体轮播</h3>
</div>
<div class="mqtt-row">
<button class="page-switch-btn" data-mqtt='{"action":"prev","params":{}}'>上一段</button>
<button class="page-switch-btn" data-mqtt='{"action":"play","params":{}}'>播放</button>
<button class="page-switch-btn" data-mqtt='{"action":"pause","params":{}}'>暂停</button>
<button class="page-switch-btn" data-mqtt='{"action":"next","params":{}}'>下一段</button>
</div>
</div>
<!-- 语音对话(独立模块) -->
<div class="card" data-page="/voice">
<div class="card-header">
<div class="card-icon"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor"
stroke-width="1.5">
<path d="M9 2a3 3 0 0 1 3 3v5a3 3 0 0 1-6 0V5a3 3 0 0 1 3-3z" />
<path d="M5 10a7 7 0 0 0 14 0" />
<path d="M12 19v3" />
</svg></div>
<h3>语音对话</h3>
</div>
<div class="mqtt-row">
<button class="page-switch-btn" data-mqtt='{"action":"voice_start","params":{}}'>启动对话</button>
<button class="page-switch-btn" data-mqtt='{"action":"voice_stop","params":{}}'>关闭对话</button>
<button class="page-switch-btn" data-mqtt='{"action":"voice_refresh","params":{}}'>刷新页面</button>
</div>
</div>
<!-- AI 助手(独立模块) -->
<div class="card" data-page="/ai">
<div class="card-header">
<div class="card-icon"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor"
stroke-width="1.5">
<path d="M12 5V3" />
<path d="M18 4a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z" />
<path d="M9 7h6" />
<path d="M10 11h.01M14 11h.01" />
<path d="M12 13v3" />
<path d="M7 21h10" />
</svg></div>
<h3>AI 助手</h3>
</div>
<div class="mqtt-row">
<input class="mqtt-input" id="mqttAiInput" type="text" placeholder="输入问题,回车发送">
<button class="page-switch-btn" id="mqttAiSend">发送问题</button>
</div>
<div class="mqtt-row">
<button class="page-switch-btn" data-mqtt='{"action":"ai_preset","params":{"index":0}}'>预设①入驻</button>
<button class="page-switch-btn" data-mqtt='{"action":"ai_preset","params":{"index":1}}'>预设②政策</button>
<button class="page-switch-btn" data-mqtt='{"action":"ai_preset","params":{"index":2}}'>预设③企业</button>
</div>
</div>
<!-- 数字孪生(独立模块) -->
<div class="card" data-page="/twin">
<div class="card-header">
<div class="card-icon"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor"
stroke-width="1.5">
<path d="M4 8l8-5 8 5-8 5z" />
<path d="M4 12l8 5 8-5" />
<path d="M4 16l8 5 8-5" />
</svg></div>
<h3>数字孪生</h3>
</div>
<div class="mqtt-row">
<button class="page-switch-btn" data-mqtt='{"action":"ai_company","params":{"action":"next"}}'>下一个企业</button>
<button class="page-switch-btn" data-mqtt='{"action":"ai_company","params":{"action":"prev"}}'>上一个企业</button>
</div>
<div class="mqtt-row">
<button class="page-switch-btn" data-mqtt='{"action":"ai_zone","params":{"zone":"孵化加速区"}}'>孵化加速区</button>
<button class="page-switch-btn" data-mqtt='{"action":"ai_zone","params":{"zone":"创业苗圃区"}}'>创业苗圃区</button>
<button class="page-switch-btn" data-mqtt='{"action":"ai_zone","params":{"zone":"国际创客区"}}'>国际创客区</button>
</div>
</div>
<!-- 企业展示墙(独立模块) -->
<div class="card" data-page="/wall">
<div class="card-header">
<div class="card-icon"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor"
stroke-width="1.5">
<rect x="3" y="21" width="18" height="3" />
<path d="M5 21V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16" />
<path d="M9 21v-4h6v4" />
</svg></div>
<h3>企业展示墙</h3>
</div>
<div class="mqtt-row">
<button class="page-switch-btn" data-mqtt='{"action":"wall_pause","params":{}}'>暂停滚动</button>
<button class="page-switch-btn" data-mqtt='{"action":"wall_resume","params":{}}'>继续滚动</button>
@@ -163,186 +252,191 @@
</div>
</div>
<!-- 视觉识别 -->
<div class="mqtt-group">
<div class="mqtt-group-title">人物识别(全局)</div>
<!-- 人物识别(独立模块) -->
<!-- <div class="card">
<div class="card-header">
<div class="card-icon"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor"
stroke-width="1.5">
<circle cx="9" cy="7" r="4" />
<path d="M2 21v-2a4 4 0 0 1 4-4h6a4 4 0 0 1 4 4v2" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg></div>
<h3>人物识别</h3>
</div>
<div class="mqtt-row">
<button class="page-switch-btn" data-mqtt='{"action":"vision_set","params":{"enabled":true}}'>开启识别</button>
<button class="page-switch-btn" data-mqtt='{"action":"vision_set","params":{"enabled":false}}'>关闭识别</button>
</div>
</div>
</div> -->
<!-- AI 助手页 -->
<div class="mqtt-group">
<div class="mqtt-group-title">AI 助手页</div>
<div class="mqtt-row">
<input class="mqtt-input" id="mqttAiInput" type="text" placeholder="输入问题,回车发送到 AI 页">
<button class="page-switch-btn" id="mqttAiSend">发送问题</button>
<!-- 双屏(独立模块) -->
<!-- <div class="card">
<div class="card-header">
<div class="card-icon"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor"
stroke-width="1.5">
<rect x="2" y="3" width="20" height="14" rx="2" />
<path d="M8 21h8M12 17v4" />
</svg></div>
<h3>双屏控制</h3>
</div>
<div class="mqtt-row">
<button class="page-switch-btn" data-mqtt='{"action":"ai_preset","params":{"index":0}}'>预设①入驻</button>
<button class="page-switch-btn" data-mqtt='{"action":"ai_preset","params":{"index":1}}'>预设②政策</button>
<button class="page-switch-btn" data-mqtt='{"action":"ai_preset","params":{"index":2}}'>预设③企业</button>
<button class="page-switch-btn" data-mqtt='{"action":"ai_company","params":{"action":"next"}}'>下一个企业</button>
<button class="page-switch-btn" data-mqtt='{"action":"ai_company","params":{"action":"prev"}}'>上一个企业</button>
</div>
<div class="mqtt-row">
<button class="page-switch-btn" data-mqtt='{"action":"ai_zone","params":{"zone":"加速区"}}'>加速区</button>
<button class="page-switch-btn" data-mqtt='{"action":"ai_zone","params":{"zone":"国际区"}}'>国际区</button>
<button class="page-switch-btn" data-mqtt='{"action":"ai_zone","params":{"zone":"成长区"}}'>成长区</button>
</div>
</div>
<!-- 语音对话页 -->
<div class="mqtt-group">
<div class="mqtt-group-title">语音对话页</div>
<div class="mqtt-row">
<button class="page-switch-btn" data-mqtt='{"action":"voice_start","params":{}}'>启动对话</button>
<button class="page-switch-btn" data-mqtt='{"action":"voice_stop","params":{}}'>关闭对话</button>
<button class="page-switch-btn" data-mqtt='{"action":"voice_refresh","params":{}}'>刷新页面</button>
</div>
</div>
<!-- 双屏 -->
<div class="mqtt-group">
<div class="mqtt-group-title">双屏控制</div>
<div class="mqtt-row">
<button class="page-switch-btn" data-mqtt='{"action":"dual_screen","params":{"on":true}}'>启动双屏</button>
<button class="page-switch-btn" data-mqtt='{"action":"dual_screen","params":{"on":false}}'>关闭双屏</button>
</div>
<div class="mqtt-hint">在电脑另一块屏幕全屏展示(需展播机连接显示器)</div>
</div>
</div> -->
<!-- 媒体控制 -->
<div class="mqtt-group">
<div class="mqtt-group-title">媒体轮播</div>
<div class="mqtt-row">
<button class="page-switch-btn" data-mqtt='{"action":"prev","params":{}}'>上一段</button>
<button class="page-switch-btn" data-mqtt='{"action":"play","params":{}}'>播放</button>
<button class="page-switch-btn" data-mqtt='{"action":"pause","params":{}}'>暂停</button>
<button class="page-switch-btn" data-mqtt='{"action":"next","params":{}}'>下一段</button>
<!-- 全局通知(独立模块) -->
<!-- <div class="card">
<div class="card-header">
<div class="card-icon"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor"
stroke-width="1.5">
<path d="M10 5a2 2 0 1 1 4 0a7 7 0 0 1 4 6v3a4 4 0 0 0 2 3H4a4 4 0 0 0 2-3v-3a7 7 0 0 1 4-6z" />
<path d="M9 17v1a3 3 0 0 0 6 0v-1" />
</svg></div>
<h3>全局通知</h3>
</div>
</div>
<!-- 通知 -->
<div class="mqtt-group">
<div class="mqtt-group-title">全局通知</div>
<div class="mqtt-row">
<input class="mqtt-input" id="mqttAlertText" type="text" placeholder="通知内容,回车广播">
<button class="page-switch-btn" id="mqttAlertSend">广播通知</button>
</div>
</div>
</div>
</div> -->
<!-- 播放设置 -->
<div class="card">
<div class="card-header">
<div class="card-icon"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="#1D5DCE" stroke-width="1.6"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg></div>
<h3>播放设置</h3>
</div>
<div class="form-inline">
<div class="form-group">
<label>播放模式</label>
<select id="playMode">
<option value="sequential" {% if play_mode == 'sequential' %}selected{% endif %}>顺序播放</option>
<option value="random" {% if play_mode == 'random' %}selected{% endif %}>随机播放</option>
</select>
<!-- 播放设置 -->
<div class="card">
<div class="card-header">
<div class="card-icon"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor"
stroke-width="1.5">
<path d="M10.3 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v10" />
<circle cx="18" cy="18" r="3" />
<path d="M19 19l2 2" />
</svg></div>
<h3>播放设置</h3>
</div>
<div class="form-group">
<label>图片时长(秒)</label>
<input type="number" id="imageDuration" value="{{ image_duration }}" min="1" max="300">
</div>
<div class="form-group">
<label>音量(媒体)</label>
<input type="range" id="volumeRange" min="0" max="100" value="{{ volume }}">
<div class="volume-label" id="volumeLabel">{{ volume }}%</div>
</div>
<div class="form-group">
<label>音量(音效)</label>
<input type="range" id="sfxVolumeRange" min="0" max="100" value="{{ sfx_volume }}">
<div class="volume-label" id="sfxVolumeLabel">{{ sfx_volume }}%</div>
<div class="form-inline">
<div class="form-group">
<label>播放模式</label>
<select id="playMode">
<option value="sequential" {% if play_mode=='sequential' %}selected{% endif %}>顺序播放</option>
<option value="random" {% if play_mode=='random' %}selected{% endif %}>随机播放</option>
</select>
</div>
<div class="form-group">
<label>图片时长(秒)</label>
<input type="number" id="imageDuration" value="{{ image_duration }}" min="1" max="300">
</div>
<div class="form-group">
<label>音量(媒体)</label>
<input type="range" id="volumeRange" min="0" max="100" value="{{ volume }}">
<div class="volume-label" id="volumeLabel">{{ volume }}%</div>
</div>
<div class="form-group">
<label>音量(音效)</label>
<input type="range" id="sfxVolumeRange" min="0" max="100" value="{{ sfx_volume }}">
<div class="volume-label" id="sfxVolumeLabel">{{ sfx_volume }}%</div>
</div>
</div>
</div>
</div>
<!-- 语音 AI 测试(admin → 前端主动问候) -->
<div class="card">
<div class="card-header">
<div class="card-icon"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="#1D5DCE" stroke-width="1.6"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2M12 19v3"/></svg></div>
<h3>语音 AI 测试</h3>
</div>
<div class="form-inline">
<div class="form-group" style="flex:2;">
<label>问候语(前端将主动播报,测试语音 AI)</label>
<input type="text" id="voiceGreetText" value="您好呀,我是园区智能语音助手,有什么可以帮您?" style="width:100%;">
<!-- 语音 AI 测试(admin → 前端主动问候) -->
<div class="card">
<div class="card-header">
<div class="card-icon"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor"
stroke-width="1.5">
<path d="M9 2a3 3 0 0 1 3 3v5a3 3 0 0 1-6 0V5a3 3 0 0 1 3-3z" />
<path d="M5 10a7 7 0 0 0 14 0" />
<path d="M12 19v3" />
</svg></div>
<h3>语音 AI 测试</h3>
</div>
<div class="form-group">
<label>&nbsp;</label>
<button class="btn-primary" id="btnVoiceGreet" style="width:100%;">播报问候</button>
<div class="form-inline">
<div class="form-group" style="flex:2;">
<label>问候语(前端将主动播报,测试语音 AI)</label>
<input type="text" id="voiceGreetText" value="您好呀,我是园区智能语音助手,有什么可以帮您?" style="width:100%;">
</div>
<div class="form-group">
<label>&nbsp;</label>
<button class="btn-primary" id="btnVoiceGreet" style="width:100%;">播报问候</button>
</div>
</div>
<div class="hint" id="voiceGreetHint">点击后展播端将自动进入语音对话页并主动播报上述问候(LLM 生成 + TTS),用于验证语音链路。</div>
</div>
<div class="hint" id="voiceGreetHint">点击后展播端将自动进入语音对话页并主动播报上述问候(LLM 生成 + TTS),用于验证语音链路。</div>
</div>
<!-- 显示控制 -->
<div class="card">
<div class="card-header">
<div class="card-icon"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="#1D5DCE" stroke-width="1.6"><rect x="3" y="4" width="18" height="13" rx="2"/><path d="M8 21h8M12 17v4"/></svg></div>
<h3>显示控制</h3>
</div>
<div class="form-inline">
<div class="form-group">
<label>显示模式</label>
<button class="btn-outline" id="btnFullscreen" style="width:100%;">切换大屏模式</button>
<!-- 显示控制 -->
<div class="card">
<div class="card-header">
<div class="card-icon"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor"
stroke-width="1.5">
<rect x="2" y="3" width="20" height="14" rx="2" />
<path d="M8 21h8M12 17v4" />
</svg></div>
<h3>显示控制</h3>
</div>
<div class="form-group">
<label>窗口操作</label>
<button class="btn-outline" data-control="minimize" style="width:100%;">最小化窗口</button>
</div>
<div class="form-group">
<label>开机自启动</label>
<button class="btn-outline" id="btnAutostart" style="width:100%;">已关闭</button>
<div class="form-inline">
<div class="form-group">
<label>显示模式</label>
<button class="btn-outline" id="btnFullscreen" style="width:100%;">切换大屏模式</button>
</div>
<div class="form-group">
<label>窗口操作</label>
<button class="btn-outline" data-control="minimize" style="width:100%;">最小化窗口</button>
</div>
<div class="form-group">
<label>开机自启动</label>
<button class="btn-outline" id="btnAutostart" style="width:100%;">已关闭</button>
</div>
</div>
</div>
</div>
<!-- 添加媒体 -->
<div class="card">
<div class="card-header">
<div class="card-icon"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="#1D5DCE" stroke-width="1.6"><path d="M12 16V4m0 0L7 9m5-5l5 5"/><path d="M4 16v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3"/></svg></div>
<h3>添加媒体</h3>
</div>
<div class="form-group">
<label>本地上传</label>
<div class="form-row">
<input type="file" id="fileInput" accept=".mp4,.mkv,.avi,.jpg,.jpeg,.png" multiple>
<button class="btn-accent" id="btnUpload">上传</button>
<!-- 添加媒体 -->
<div class="card">
<div class="card-header">
<div class="card-icon"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor"
stroke-width="1.5">
<path d="M12 16V4m0 0L7 9m5-5l5 5" />
<path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2" />
</svg></div>
<h3>添加媒体</h3>
</div>
<div class="upload-progress" id="uploadProgress" style="display:none;">
<div class="progress-bar"><div class="progress-fill" id="progressFill" style="width:0%"></div></div>
<span class="progress-text" id="progressText"></span>
<div class="form-group">
<label>本地上传</label>
<div class="form-row">
<input type="file" id="fileInput" accept=".mp4,.mkv,.avi,.jpg,.jpeg,.png" multiple>
<button class="btn-accent" id="btnUpload">上传</button>
</div>
<div class="upload-progress" id="uploadProgress" style="display:none;">
<div class="progress-bar">
<div class="progress-fill" id="progressFill" style="width:0%"></div>
</div>
<span class="progress-text" id="progressText"></span>
</div>
</div>
<div class="form-group">
<label>远程 URL / 播放链接</label>
<div class="form-row">
<input type="text" id="urlInput" placeholder="https://example.com/media.mp4">
<button class="btn-accent" id="btnAddUrl">添加到媒体库</button>
<button class="btn-accent" id="btnAddUrlPlay">加入播放列表</button>
</div>
</div>
</div>
<div class="form-group">
<label>远程 URL / 播放链接</label>
<div class="form-row">
<input type="text" id="urlInput" placeholder="https://example.com/media.mp4">
<button class="btn-accent" id="btnAddUrl">添加到媒体库</button>
<button class="btn-accent" id="btnAddUrlPlay">加入播放列表</button>
</div>
</div>
</div>
<!-- 媒体库 -->
<div class="card full">
<div class="card-header">
<div class="card-icon"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="#1D5DCE" stroke-width="1.6"><path d="M4 20V10l6-5 6 5v10"/><path d="M10 20v-5h4v5"/></svg></div>
<h3>媒体库({{ files|length }})</h3>
</div>
<div class="media-grid">
{% if files %}
<!-- 媒体库 -->
<div class="card full">
<div class="card-header">
<div class="card-icon"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor"
stroke-width="1.5">
<rect x="3" y="3" width="18" height="18" rx="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<path d="M21 15l-5-5L5 21" />
</svg></div>
<h3>媒体库({{ files|length }})</h3>
</div>
<div class="media-grid">
{% if files %}
{% for item in files %}
<div class="media-card" data-path="{{ item.relative_path }}" data-type="{{ item.type }}" data-source="{{ item.source }}" data-name="{{ item.name }}">
<div class="media-card" data-path="{{ item.relative_path }}" data-type="{{ item.type }}"
data-source="{{ item.source }}" data-name="{{ item.name }}">
{% if item.type == 'image' %}
<img src="{{ item.url }}" loading="lazy" class="thumb" alt="{{ item.name }}" data-preview>
{% else %}
@@ -354,8 +448,10 @@
<div class="body">
<span class="name" title="{{ item.name }}">{{ item.name }}</span>
<div class="meta-row">
<span class="badge {{ 'badge-url' if item.source == 'url' else 'badge-local' }}">{{ '远程' if item.source == 'url' else '本地' }}</span>
<span class="badge {{ 'badge-video' if item.type == 'video' else 'badge-image' }}">{{ '视频' if item.type == 'video' else '图片' }}</span>
<span class="badge {{ 'badge-url' if item.source == 'url' else 'badge-local' }}">{{ '远程' if
item.source == 'url' else '本地' }}</span>
<span class="badge {{ 'badge-video' if item.type == 'video' else 'badge-image' }}">{{ '视频' if
item.type == 'video' else '图片' }}</span>
</div>
<div class="media-url" data-url="{{ item.url }}" title="FastAPI 输出的资源 URL">
<span>{{ item.url }}</span>
@@ -369,25 +465,34 @@
</div>
</div>
{% endfor %}
{% else %}
{% else %}
<div class="empty-state">
<div class="empty-icon"><svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="#9ca3af" stroke-width="1.4"><rect x="3" y="5" width="18" height="14" rx="2"/><circle cx="9" cy="10" r="2"/><path d="M3 17l5-4 4 3 4-4 5 5"/></svg></div>
<div class="empty-icon"><svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="#9ca3af"
stroke-width="1.4">
<rect x="3" y="5" width="18" height="14" rx="2" />
<circle cx="9" cy="10" r="2" />
<path d="M3 17l5-4 4 3 4-4 5 5" />
</svg></div>
暂无媒体文件<br>请上传或通过 URL 添加
</div>
{% endif %}
{% endif %}
</div>
</div>
</div>
<!-- 播放列表 -->
<div class="card full">
<div class="card-header">
<div class="card-icon"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="#1D5DCE" stroke-width="1.6"><path d="M4 6h16M4 12h16M4 18h10"/></svg></div>
<h3>播放列表({{ playlist|length }})</h3>
</div>
<div class="media-grid">
{% if playlist %}
<!-- 播放列表 -->
<div class="card full">
<div class="card-header">
<div class="card-icon"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor"
stroke-width="1.5">
<path d="M4 6h16M4 12h16M4 18h10" />
</svg></div>
<h3>播放列表({{ playlist|length }})</h3>
</div>
<div class="media-grid">
{% if playlist %}
{% for item in playlist %}
<div class="media-card" data-path="{{ item.relative_path }}" data-type="{{ item.type }}" data-source="{{ item.source }}" data-name="{{ item.name }}">
<div class="media-card" data-path="{{ item.relative_path }}" data-type="{{ item.type }}"
data-source="{{ item.source }}" data-name="{{ item.name }}">
{% if item.type == 'image' %}
<img src="{{ item.url }}" loading="lazy" class="thumb" alt="{{ item.name }}" data-preview>
{% else %}
@@ -399,8 +504,10 @@
<div class="body">
<span class="name" title="{{ item.name }}">{{ item.name }}</span>
<div class="meta-row">
<span class="badge {{ 'badge-url' if item.source == 'url' else 'badge-local' }}">{{ '远程' if item.source == 'url' else '本地' }}</span>
<span class="badge {{ 'badge-video' if item.type == 'video' else 'badge-image' }}">{{ '视频' if item.type == 'video' else '图片' }}</span>
<span class="badge {{ 'badge-url' if item.source == 'url' else 'badge-local' }}">{{ '远程' if
item.source == 'url' else '本地' }}</span>
<span class="badge {{ 'badge-video' if item.type == 'video' else 'badge-image' }}">{{ '视频' if
item.type == 'video' else '图片' }}</span>
</div>
<div class="media-url" data-url="{{ item.url }}" title="FastAPI 输出的资源 URL">
<span>{{ item.url }}</span>
@@ -413,47 +520,48 @@
</div>
</div>
{% endfor %}
{% else %}
{% else %}
<div class="empty-state">
<div class="empty-icon">▶</div>
播放列表为空<br>从上方媒体库添加内容
</div>
{% endif %}
{% endif %}
</div>
</div>
</div>
</div>
</div>
<!-- 预览弹窗 -->
<div class="modal-overlay" id="previewModal" style="display:none;">
<div class="modal-panel">
<div class="modal-header">
<span class="modal-title" id="previewTitle"></span>
<button class="modal-close" id="previewClose">&times;</button>
</div>
<div class="modal-body" id="previewBody"></div>
<div class="modal-footer">
<span class="modal-info" id="previewMeta"></span>
<div class="modal-actions">
<button class="btn-accent btn-sm" id="previewPlay">立即播放</button>
<button class="btn-accent btn-sm" id="previewAdd">加入播放</button>
<button class="btn-danger btn-sm" id="previewDelete">删除</button>
<!-- 预览弹窗 -->
<div class="modal-overlay" id="previewModal" style="display:none;">
<div class="modal-panel">
<div class="modal-header">
<span class="modal-title" id="previewTitle"></span>
<button class="modal-close" id="previewClose">&times;</button>
</div>
<div class="modal-body" id="previewBody"></div>
<div class="modal-footer">
<span class="modal-info" id="previewMeta"></span>
<div class="modal-actions">
<button class="btn-accent btn-sm" id="previewPlay">立即播放</button>
<button class="btn-accent btn-sm" id="previewAdd">加入播放</button>
<button class="btn-danger btn-sm" id="previewDelete">删除</button>
</div>
</div>
</div>
</div>
<!-- Toast -->
<div class="toast-container" id="toastContainer"></div>
</div>
<!-- Toast -->
<div class="toast-container" id="toastContainer"></div>
</div>
<script>
window.__DPM_INIT = {
fullscreen: {{ 'true' if fullscreen else 'false' }},
autostart: {{ 'true' if autostart else 'false' }}
};
</script>
<script src="/static/admin.js"></script>
{% endif %}
<script>
window.__DPM_INIT = {
fullscreen: {{ 'true' if fullscreen else 'false' }},
autostart: {{ 'true' if autostart else 'false' }},
};
</script>
<script src="/static/admin.js"></script>
{% endif %}
</body>
</html>
</html>
-234
View File
@@ -1,234 +0,0 @@
# ESP32 红外遥控控制大屏 —— 开发文档
> 通过红外遥控器(NEC 协议)直接控制园区大屏页面切换与操作。
> 链路:红外遥控 → ESP32(红外接收 + 解码)→ MQTT → 前端调度器 → 页面动作
## 1. 系统架构
```
┌──────────┐ 38kHz 红外 ┌─────────┐ WiFi/MQTT ┌──────────────┐ opc/display/command ┌────────┐
│ 红外遥控器 │ ───────────► │ ESP32 │ ───────────► │ EMQX Broker │ ─────────────────────► │ 大屏前端 │
│ (NEC协议) │ │ 接收+解码 │ │ 192.168.1.3 │ │ 调度器 │
└──────────┘ └─────────┘ └──────────────┘ └────────┘
```
- **红外遥控器**:任意 NEC 协议遥控器(电视/机顶盒/学习型均可),按键映射可自定义
- **ESP32**:接收红外信号 → 解码键码 → 映射成指令 → MQTT 发布
- **MQTT**:复用现有 `opc/display/command` 频道(见 `docs/mqtt-commands.md`)
- **大屏前端**:`useMqttControl` 调度器接收指令执行(切页/媒体/语音/视觉)
## 2. 硬件清单与接线
| 硬件 | 型号/规格 | 数量 |
|---|---|---|
| 开发板 | ESP32(DevKitC / NodeMCU-32S) | 1 |
| 红外接收头 | VS1838B / TL1838(38kHz,3 脚) | 1 |
| 电阻 | 100Ω(限流,可选) | 1 |
| 红外遥控器 | NEC 协议(电视/机顶盒遥控器) | 1 |
**VS1838B 接线**(面对接收头平面,引脚从左到右):
| 引脚 | 接 ESP32 |
|---|---|
| 1 (OUT/信号) | GPIO 15(可改,见代码 `IR_RX_PIN`) |
| 2 (GND) | GND |
| 3 (VCC) | 3.3V(或 5V,接收头宽压 2.7~5.5V) |
## 3. 红外协议(NEC)
- NEC 协议:38kHz 载波,地址码 + 命令码 + 反码
- 本方案**只取命令码(command byte)**即可区分按键,地址码忽略
- 任意 NEC 遥控器的按键都有固定键码;不同遥控器键码不同 → **首次需"学习模式"采集键码**(见 §6)
## 4. Arduino 固件
依赖库(Arduino IDE 库管理器安装):
- `IRremote`(by Armin Joachimsmeyer,v4.x)
- `PubSubClient`(by Nick O'Leary)
```cpp
// esp32_ir_mqtt.ino —— 红外遥控 → MQTT 控制大屏
#include <WiFi.h>
#include <PubSubClient.h>
#include <IRremote.h>
// ── WiFi / MQTT 配置 ─────────────────────────────────────
const char* WIFI_SSID = "你的WiFi";
const char* WIFI_PASS = "你的WiFi密码";
const char* MQTT_HOST = "192.168.1.3"; // EMQX Broker
const int MQTT_PORT = 1883;
const char* MQTT_USER = "mqtt用户名"; // backend/.env MQTT_USERNAME
const char* MQTT_PASS = "mqtt密码"; // backend/.env MQTT_PASSWORD
const char* MQTT_TOPIC = "opc/display/command";
// ── 红外引脚 ─────────────────────────────────────────────
const uint8_t IR_RX_PIN = 15;
// ── 按键映射表:遥控器键码 → 大屏指令 ─────────────────────
// 键码需先用「学习模式」采集(串口打印 decodedIRData.command),再填入
struct CmdMap { uint32_t key; const char* payload; };
const CmdMap MAP[] = {
// 数字键 1~5:直接切页
{ 0x16, R"({"action":"navigate","params":{"page":"/"}})" }, // 1 → 数据大屏
{ 0x19, R"({"action":"navigate","params":{"page":"/twin"}})" }, // 2 → 数字孪生
{ 0x0D, R"({"action":"navigate","params":{"page":"/ai"}})" }, // 3 → AI 助手
{ 0x0C, R"({"action":"navigate","params":{"page":"/voice"}})" }, // 4 → 语音对话
{ 0x18, R"({"action":"navigate","params":{"page":"/screen"}})" }, // 5 → 媒体轮播
// CH+/CH-(或左右方向):向左/右切页
{ 0x00, R"({"action":"navigate_rel","params":{"delta":-1}})" }, // CH- → 左切
{ 0x01, R"({"action":"navigate_rel","params":{"delta":1}})" }, // CH+ → 右切
// 音量+/−:媒体播放/暂停(或 next/prev)
{ 0x02, R"({"action":"play","params":{}})" }, // VOL+ → 播放
{ 0x03, R"({"action":"pause","params":{}})" }, // VOL- → 暂停
// 电源键:语音对话开关
{ 0x0B, R"({"action":"voice_start","params":{}})" }, // 电源 → 语音启动
// OK/静音键:视觉识别开关(toggle 示例:直接开/关,可换 toggle 逻辑)
{ 0x0A, R"({"action":"vision_set","params":{"enabled":true}})" }, // 静音 → 开识别
};
const int MAP_LEN = sizeof(MAP) / sizeof(MAP[0]);
// ── 全局 ─────────────────────────────────────────────────
WiFiClient espClient;
PubSubClient mqtt(espClient);
unsigned long lastReconnect = 0;
void setup() {
Serial.begin(115200);
Serial.println("ESP32 IR → MQTT 启动");
IrReceiver.begin(IR_RX_PIN, ENABLE_LED_FEEDBACK);
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASS);
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.println("\nWiFi 已连接: " + String(WiFi.localIP()));
mqtt.setServer(MQTT_HOST, MQTT_PORT);
mqtt.setKeepAlive(30);
}
void loop() {
if (!mqtt.connected()) reconnect();
mqtt.loop();
// ── 红外解码 ──
if (IrReceiver.decode()) {
uint32_t cmd = IrReceiver.decodedIRData.command;
Serial.printf("[IR] 地址=0x%02X 键码=0x%02X\n",
IrReceiver.decodedIRData.address, cmd);
IrReceiver.resume(); // 立即恢复接收
sendCommand(cmd);
}
}
// ── 键码 → MQTT ──
void sendCommand(uint32_t key) {
for (int i = 0; i < MAP_LEN; i++) {
if (MAP[i].key == key) {
Serial.println("[MQTT] 发送: " + String(MAP[i].payload));
mqtt.publish(MQTT_TOPIC, MAP[i].payload);
return;
}
}
Serial.println("[IR] 未映射键码(可在学习模式后加入映射表)");
}
// ── MQTT 重连 ──
void reconnect() {
if (millis() - lastReconnect < 3000) return;
lastReconnect = millis();
String id = "esp32-ir-" + String((uint32_t)ESP.getEfuseMac(), HEX);
if (mqtt.connect(id.c_str(), MQTT_USER, MQTT_PASS)) {
Serial.println("MQTT 已连接");
} else {
Serial.printf("MQTT 连接失败 rc=%d\n", mqtt.state());
}
}
```
## 5. 键位映射建议
| 遥控器按键 | 键码(示例) | 指令 | 效果 |
|---|---|---|---|
| 1 ~ 5 | 0x16/0x19/0x0D/0x0C/0x18 | `navigate` | 直达各页面 |
| CH− / CH+ | 0x00 / 0x01 | `navigate_rel` | 向左/右循环切页 |
| VOL+ / VOL− | 0x02 / 0x03 | `play` / `pause` | 媒体播放/暂停 |
| 电源 | 0x0B | `voice_start` | 启动语音对话 |
| 静音 | 0x0A | `vision_set` | 开/关人物识别 |
> 键码因遥控器而异——上面的 0x16/0x19… 是常见 NEC 遥控器的值,**首次使用务必先跑学习模式实测**。
其他可用指令(自定义映射):`ai_input`(AI 页提问)、`ai_company`(切企业)、`ai_zone`(切分区)、`voice_stop`、`next`/`prev`(媒体)、`alert`(通知)——参数见 `docs/mqtt-commands.md`。
## 5.1 企业展示墙控制(/wall,39 家入驻企业滚动墙)
**MQTT 控制消息**(ESP32 解码红外键码后发布到 `opc/display/command`,JSON 载荷):
| 遥控器按键 | 建议指令 action | params | 效果 | 示例消息 |
|---|---|---|---|---|
| 1 / 2 / 3 | `wall_speed` | `speed: "slow" \| "normal" \| "fast"` | 慢速 150s / 标准 80s / 快速 40s 一圈 | `{"action":"wall_speed","params":{"speed":"slow"}}` |
| CH− | `wall_pause` | `{}` | 暂停滚动 | `{"action":"wall_pause","params":{}}` |
| CH+ | `wall_resume` | `{}` | 继续滚动 | `{"action":"wall_resume","params":{}}` |
| 电源 | `navigate` | `page: "/wall"` | 切换到企业展示墙 | `{"action":"navigate","params":{"page":"/wall"}}` |
**ESP32 固件映射示例**(在 §4 固件的 `MAP[]` 键码映射表中追加):
```cpp
// 键码 → MQTT 指令(payload 为 JSON 字符串,经 publishCommand 发布)
{ 0x02, R"({"action":"wall_pause","params":{}})" }, // VOL− 暂停墙滚动
{ 0x03, R"({"action":"wall_resume","params":{}})" }, // VOL+ 继续墙滚动
{ 0x00, R"({"action":"wall_speed","params":{"speed":"fast"}})" }, // CH− 快速
{ 0x01, R"({"action":"wall_speed","params":{"speed":"slow"}})" }, // CH+ 慢速
{ 0x0B, R"({"action":"navigate","params":{"page":"/wall"}})" }, // 电源 切到展示墙
```
> 前端 `useMqttControl` 收到 `wall_*` 指令后派发 `dpm:wall-control` 事件,展示墙页据此暂停/继续/变速;未在展示墙页时 `wall_*` 指令不产生副作用(安全幂等)。
## 6. 学习模式(首次必须)
键码采集固件(只需串口打印,不用连 MQTT):
```cpp
#include <IRremote.h>
const uint8_t IR_RX_PIN = 15;
void setup() {
Serial.begin(115200);
IrReceiver.begin(IR_RX_PIN, ENABLE_LED_FEEDBACK);
Serial.println("按遥控器按键,串口会打印键码(学习模式)");
}
void loop() {
if (IrReceiver.decode()) {
Serial.printf("address=0x%02X command=0x%02X\n",
IrReceiver.decodedIRData.address,
IrReceiver.decodedIRData.command);
IrReceiver.resume();
}
}
```
步骤:
1. 烧录学习固件,打开串口监视器(115200)
2. 逐个按遥控器按键,记录每个键的 `command` 十六进制值
3. 把键码填入正式固件的 `MAP[]` 映射表
4. 烧录正式固件
## 7. 调试与故障排查
| 现象 | 排查 |
|---|---|
| 串口无任何 IR 输出 | 接线检查(OUT→GPIO15、GND、VCC);接收头正面对遥控器;换遥控器电池 |
| 有 IR 输出但 MQTT 不发送 | 打印显示"未映射键码"→ 键码表没配对该遥控器;WiFi/MQTT 未连接 |
| MQTT 连接失败 rc=5 | 用户名/密码错误(对照 backend/.env) |
| 前端无反应但 MQTT 收到 | `mosquitto_sub -t opc/display/command` 验证消息到达 Broker;前端需在运行且 MQTT 已连(页面右上角在线状态) |
| 连按无效 | NEC 重复码(0xFFFFFFFF)被忽略属正常;长按只发一次指令 |
## 8. 进阶扩展(可选)
- **学习型配置**:ESP32 加按钮 + OLED,现场学习键码并存入 NVS,免改代码
- **多键组合**:短按/长按区分(记录按下时长)→ 同一键多指令
- **OTA 升级**:ArduinoOTA 远程更新固件
- **多屏控制**:指令包带 `client_id` 过滤,指定某台大屏执行
-175
View File
@@ -1,175 +0,0 @@
# MQTT 标准操作手册(OPC 智能园区大屏系统)
> 通过 MQTT 对前端大屏进行集中控制:切页、页面操作、媒体控制、视觉开关、状态上报。
## 1. Broker 连接
| 项 | 值 | 说明 |
|---|---|---|
| 地址 | `192.168.1.3` | 局域网 EMQX Broker |
| TCP 端口 | `1883` | MQTT 标准端口(后端/命令行用) |
| WebSocket 端口 | `8083` | 前端浏览器用(`ws://192.168.1.3:8083/mqtt`) |
| 用户名/密码 | `backend/.env` 的 `MQTT_USERNAME` / `MQTT_PASSWORD` | 连接鉴权 |
## 2. 频道(Topic)
| Topic | 方向 | 载荷 | 用途 |
|---|---|---|---|
| `opc/display/command` | 服务端 → 前端 | 指令信封 | **控制指令**(切页/页面操作/媒体/视觉) |
| `opc/dashboard/tick` | 服务端 → 前端 | 园区数据快照 | 数据大屏实时刷新(2.2s) |
| `opc/display/heartbeat` | 前端 → 服务端 | `{client_id, page, ts}` | 大屏在线心跳(10s) |
| `opc/frontend/state` | 前端 → 服务端 | `{client_id, page, ts}` | **页面状态即时上报**(路由变化即发) |
| `opc/display/ack` | 前端 → 服务端 | 指令回执 | 指令确认(可选) |
## 3. 统一指令信封
所有控制指令发到 `opc/display/command`,JSON 格式:
```json
{
"cmd_id": "uuid", // 指令 ID(可选)
"ts": 1755490000000, // 时间戳 ms(可选)
"action": "navigate", // 命令名(必填)
"params": { "page": "/ai" } // 参数(按命令)
}
```
后端 `hub.publish_command(action, params)` 自动生成信封;命令行手动发送时只需 `action` + `params`。
## 4. 命令全集
### 4.1 页面切换
| action | params | 效果 |
|---|---|---|
| `navigate` | `page: "/" \| "/twin" \| "/ai" \| "/voice" \| "/wall" \| "/screen"`(或别名 home/twin/ai/voice/wall/screen/数据大屏/企业展示墙…) | 切换到指定页面 |
| `navigate_rel` | `delta: 1 \| -1` | 向右 / 向左循环切换页面 |
### 4.2 全局视觉识别
| action | params | 效果 |
|---|---|---|
| `vision_set` | `enabled: true \| false` | 开启 / 关闭全局人物识别(YOLO 人脸/姿态/手势) |
### 4.3 AI 助手页(/ai)
| action | params | 效果 |
|---|---|---|
| `ai_input` | `text: "介绍一下入驻政策"` | 输入问题并自动发送 |
| `ai_preset` | `index: 0 \| 1 \| 2` | 选择预设问题并发送 |
| `ai_company` | `action: "next" \| "prev"` | 切换展示企业 |
| `ai_zone` | `zone: "加速区" \| "国际区" \| "成长区"` | 切换企业分区 |
### 4.4 语音对话页(/voice)
| action | params | 效果 |
|---|---|---|
| `voice_start` | — | 启动语音对话 |
| `voice_stop` | — | 关闭语音对话 |
| `voice_refresh` | — | 刷新页面 |
### 4.5 媒体轮播页(/screen)
| action | params | 效果 |
|---|---|---|
| `play` / `pause` | — | 播放 / 暂停 |
| `next` / `prev` | — | 下一项 / 上一项 |
| `set_mode` | `mode: sequential \| shuffle \| loop` | 播放模式 |
| `play_target` | `path: "媒体路径"` | 播放指定媒体 |
| `settings_changed` | 设置对象 | 应用设置变更 |
| `playlist_changed` | — | 重新加载播放列表 |
### 4.6 企业展示墙页(/wall)
> 39 家入驻企业信息缓慢滚动展示墙(数据来源:《入驻企业信息表》)
| action | params | 效果 |
|---|---|---|
| `wall_pause` | — | 暂停滚动 |
| `wall_resume` | — | 继续滚动 |
| `wall_speed` | `speed: slow \| normal \| fast` | 滚动速度:慢速 150s / 标准 80s / 快速 40s 一圈 |
### 4.7 全局
| action | params | 效果 |
|---|---|---|
| `alert` | `text: "通知内容"` | 全局通知弹窗 |
| `show_card` | 卡片数据 | 展示信息卡片 |
| `minimize` | — | 最小化窗口(Tauri) |
## 5. 命令行发送
### mosquitto_pub(推荐,需安装 `mosquitto-clients`)
```bash
# 切页到 AI 助手
mosquitto_pub -h 192.168.1.3 -p 1883 -u <用户名> -P <密码> \
-t opc/display/command \
-m '{"action":"navigate","params":{"page":"/ai"}}'
# 向左切页
mosquitto_pub -h 192.168.1.3 -p 1883 -u <用户名> -P <密码> \
-t opc/display/command -m '{"action":"navigate_rel","params":{"delta":-1}}'
# 关闭人物识别
mosquitto_pub -h 192.168.1.3 -p 1883 -u <用户名> -P <密码> \
-t opc/display/command -m '{"action":"vision_set","params":{"enabled":false}}'
# AI 页提问
mosquitto_pub -h 192.168.1.3 -p 1883 -u <用户名> -P <密码> \
-t opc/display/command -m '{"action":"ai_input","params":{"text":"介绍一下入驻政策"}}'
# 切换企业分区
mosquitto_pub -h 192.168.1.3 -p 1883 -u <用户名> -P <密码> \
-t opc/display/command -m '{"action":"ai_zone","params":{"zone":"国际区"}}'
# 语音启动 / 媒体下一项
mosquitto_pub -h 192.168.1.3 -p 1883 -u <用户名> -P <密码> \
-t opc/display/command -m '{"action":"voice_start","params":{}}'
mosquitto_pub -h 192.168.1.3 -p 1883 -u <用户名> -P <密码> \
-t opc/display/command -m '{"action":"next","params":{}}'
# 切到企业展示墙 / 暂停滚动
mosquitto_pub -h 192.168.1.3 -p 1883 -u <用户名> -P <密码> \
-t opc/display/command -m '{"action":"navigate","params":{"page":"/wall"}}'
mosquitto_pub -h 192.168.1.3 -p 1883 -u <用户名> -P <密码> \
-t opc/display/command -m '{"action":"wall_pause","params":{}}'
```
### Python(paho-mqtt,后端 venv 自带)
```bash
cd /Volumes/Pine/mycode/DPM/dpm/backend
.venv/bin/python - <<'EOF'
import paho.mqtt.client as mqtt
def send(action, params=None):
c = mqtt.Client()
c.username_pw_set("用户名", "密码")
c.connect("192.168.1.3", 1883, 30)
c.publish("opc/display/command", str({"action": action, "params": params or {}}))
c.disconnect()
send("navigate", {"page": "/voice"})
send("ai_company", {"action": "next"})
send("voice_stop")
EOF
```
### MQTTX(图形界面)
1. 新建连接:`mqtt://192.168.1.3:1883`,填用户名/密码
2. 发布到 `opc/display/command`,消息体如上 JSON
## 6. 订阅调试
```bash
# 监听前端页面上报(验证"当前页面")
mosquitto_sub -h 192.168.1.3 -p 1883 -u <用户名> -P <密码> -t 'opc/frontend/state'
# 监听心跳
mosquitto_sub -h 192.168.1.3 -p 1883 -u <用户名> -P <密码> -t 'opc/display/heartbeat'
# 监听全部指令
mosquitto_sub -h 192.168.1.3 -p 1883 -u <用户名> -P <密码> -t 'opc/display/command' -v
```
-168
View File
@@ -1,168 +0,0 @@
# DPM 实时语音对话整合方案(复用 live-avatar 云化栈)
> 目标:砍掉数字人/口型/wav2lip 等全部视觉部分,只保留「语音识别 + 对话 + 实时交互」链路;
> 前端在 DPM React 应用中新增对话页(复用 live-avatar demo 前端的样式与逻辑);
> 后端并入 DPM `backend/pyproject.toml`。
> 基线:live-avatar 云化栈已在 `/Volumes/Pine/mycode/opc/live` 跑通(s2s v0.2.11 + cloud-migration 补丁 + demo 前端)。
---
## 0. 现状对比
| | live-avatar(已跑通) | DPM(现状) |
|---|---|---|
| 前端 | 原生 JS demo(index.html / main.js / s2s-ws-client.js / 两个 worklet) | React 19 + Vite + Tailwind;`/ai` 页已有 `AiChatPanel`(文字 + **录音上传式**语音) |
| 后端 | s2s 独立进程 :8765(VAD 本地 + 云 ASR/LLM/TTS) | FastAPI :8000;已有 `app/asr.py`(dashscope paraformer)、`app/llm.py`(qwen + 工具)、`SSL_CERT_FILE=certifi` 同款处理 |
| 语音体验 | **实时全双工**:本地 VAD → 流式云 ASR → 流式 LLM → 流式 TTS → 音频回放 | 录音 → 上传 → 转写 → 文字回复(无流式、无 TTS 回音) |
**DPM 缺的正是 live-avatar 栈的实时交互能力**;两边的云凭据/CA 处理已一致(dashscope + certifi),合并成本低。
---
## 1. 两个关键决策点
### 1.1 后端:内嵌进 DPM FastAPI(推荐) vs 独立 s2s 进程
- **推荐:内嵌**。s2s v0.2.11 的 `api/openai_realtime/websocket_router.create_app(pool, stop_event)` 本身就返回一个 FastAPI 应用,
可在 DPM `app.main` 里构建管线池后 `app.mount("/v1", s2s_app)`,与现有 `/api/*` 共存、单进程单端口。
Tauri 桌面打包只需一个后端进程,无子进程管理。
- 备选:s2s 独立跑 :8765,DPM 负责拉起/守护。改动最小,但两进程两端口,打包与部署更重。
### 1.2 前端:iframe 内嵌(推荐 v1) vs React 组件移植(v2)
- **推荐 v1:iframe 内嵌**。把 live-avatar `demo/` 目录拷入 DPM `public/voice-demo/`(去掉数字人 iframe/视觉元素),
React 新增 `VoiceAssistant.jsx`(路由 `/voice`)里 `<iframe src="/voice-demo/">` 全屏。
**100% 复用现有样式与逻辑、零 React 改写、风险最低**;WS 地址由 `voice-demo/config.js` 按 `VITE_API_BASE` 派生。
- v2:React 组件移植。把 `s2s-ws-client.js` + 两个 worklet 拷入 `src/voice/`,用 hooks 驱动、JSX 重绘圆球/气泡,
与 DPM 布局/Tailwind 完全融合;工作量较大,建议先 v1 跑通再决定。
---
## 2. 后端合并清单
### 2.1 `backend/pyproject.toml`
```toml
dependencies = [
# ...现有依赖(fastapi/uvicorn/dashscope/certifi 已覆盖)...
# s2s 云化栈(path 依赖,指向补丁后的 s2s-cloud;或 vendored 到 backend/vendor/)
"speech-to-speech @ file:///Volumes/Pine/mycode/opc/live/s2s-cloud",
# s2s 运行所需(base 依赖里 Darwin 专用的 mlx/transformers 等不要全装)
"torch>=2.4", # VAD(silero) 本地必需;CPU 亦可
"numpy>=1.26",
"scipy>=1.10",
"httpx>=0.28",
"websockets>=12",
"nltk==3.9.4",
"openai==2.28.0",
"soundfile>=0.13",
]
```
> 安装用 `pip install -e "backend[voice]" --no-deps` + 手动补依赖,避免 s2s base 依赖把 mlx/transformers 等大件拖进来。
> `torch` 是唯一的大依赖(VAD 本地必需);若部署机无 GPU,CPU torch 跑 silero 足够。
### 2.2 配置(`app/config.py` + `backend/.env`)
```env
DASHSCOPE_API_KEY=sk-... # 已有(与 app/asr.py、app/llm.py 共用)
S2S_ENABLED=1
S2S_NUM_PIPELINES=4 # 并发会话数
S2S_WS_PATH=/v1/realtime
S2S_STT_MODEL=qwen3-asr-flash-realtime
S2S_LLM_MODEL=qwen-plus
S2S_TTS_MODEL=qwen3-tts-flash-realtime
S2S_TTS_VOICE=Cherry
```
### 2.3 代码(新 `app/s2s_bridge.py` + 挂载)
1. `s2s_bridge.py`:读取 Settings → 用与 `start_backend.sh` 等价的参数构建管线池
(`--stt dashscope-asr --llm_backend chat-completions --tts qwen3-cloud --enable_live_transcription false`),
返回 `websocket_router.create_app(pool, stop_event)`。
2. `app/main.py` lifespan:`ENABLED` 时启动池 → `app.mount(settings.S2S_WS_PATH, s2s_app)`;关闭时 stop。
3. 现有 `/api/ai/chat`、`/api/ai/asr` 保留(旧 AiChatPanel 流程不破坏)。
---
## 3. 前端合并清单
1. `src/pages/VoiceAssistant.jsx`(新):
- 全屏 `<iframe src="/voice-demo/" />`(v1),或未来 React 移植版。
- 加入 `App.jsx` 路由:`<Route path="/voice" element={<VoiceAssistant />} />`(建议放在 ScreenLayout 内或独立全屏)。
2. `public/voice-demo/`:拷贝 live-avatar `demo/`(index.html / main.js / style.css / ws/ / worklets/ / textchat.html),
并删除数字人 iframe(`index.html` 里的 `#avatar-frame` → 背景改纯色/园区图)。
3. `public/voice-demo/config.js`:暴露 `window.VOICE_WS_URL`(从 `VITE_API_BASE` 派生 `ws://<host>:<port>/v1/realtime`)。
4. 麦克风权限:
- 浏览器开发模式:`localhost` 直接可用。
- **Tauri v2 打包**:`src-tauri/tauri.conf.json` 需 `app.macOSPrivateApi: true`(macOS 麦克风)+ 系统隐私授权说明;
Windows 需在 manifest/权限描述中声明 microphone。
---
## 4. 实施步骤
| 步骤 | 内容 | 工作量 |
|---|---|---|
| P1 | 后端内嵌:pyproject 加依赖 → s2s_bridge.py → mount → 冒烟(WS 连得上、说话→回复→出声) | 0.5–1 天 |
| P2 | 前端:public/voice-demo 拷贝去视觉化 → VoiceAssistant.jsx + 路由 → WS 指向 DPM 后端 | 0.5 天 |
| P3 | 联调:DPM 页面语音对话全链路 + 并发验证 | 0.5 天 |
| P4(可选) | React 原生移植(hooks 驱动 client + worklets,Tailwind 重绘 UI) | 1–2 天 |
---
## 5. 风险与注意
- **torch 体积**:VAD 本地必需(s2s 管线结构固定 VAD 本地),CPU 版可接受;若部署机极简可评估 VAD 上云(不推荐,延迟差)。
- **单管线槽**:`S2S_NUM_PIPELINES` 默认 1 → 并发对话会拒连;按需调到 4。
- **WS 鉴权**:内网/桌面场景可无鉴权;如需,在 session.update 里带 token 校验。
- **iframe 限制**:v1 iframe 内样式隔离、无法用 DPM 主题;v2 移植可解决。
- **Tauri 麦克风**:打包前务必配置 macOSPrivateApi + 权限描述,否则桌面版无声/无权限。
---
## 附录 A · 视觉触发子系统(USB 摄像头 + 桌面端检测)
> 目标:大屏前 USB 摄像头拍观众;检测有人**正对且停留 ≥10 秒** → 主动问候 + 全屏联动;问候后**静默 60 秒**。
### A.1 架构与数据通路
```
大屏机(Tauri 应用,fullscreen)
└─ USB 摄像头(大屏前,拍观众)
└─ webview getUserMedia(一路视频流)
├─ 持续检测:MediaPipe FaceMesh 1~2fps(本机、不出设备)
│ ├─ 头姿 yaw/pitch 各 ±15° = 正对
│ └─ 连续 10s 正对 → 触发(窗口内 ≥80% 帧正对,防抖)
└─ 按需抓帧:LLM 决定时 canvas 取当前帧 → input_image → 云 qwen3-vl(人数/人物)
触发动作:
① 主动问候:client.sendUserText("检测到有人正对摄像头…请主动问候") + requestResponse()
→ s2s 走 LLM 生成问候语 → TTS 播报(零后端改动,复用现有 WS 协议)
② 全屏联动:调后端 → event_bus / mqtt.publish → 各屏响应
静默窗口:问候触发后 60s 冷却,期内检测照跑但不触发
```
### A.2 Tauri 摄像头权限(必须配置)
- **macOS**:`tauri.conf.json` → `app.macOSPrivateApi: true`;Info.plist 加
`NSCameraUsageDescription` / `NSMicrophoneUsageDescription`(麦克风语音对话也需要)。
- **Windows(NSIS 打包目标)**:WebView2 的 getUserMedia 跟随系统隐私设置
(设置 → 隐私和安全性 → 相机/麦克风 → 允许该应用);安装包建议在应用清单声明设备能力。
- 大屏机摄像头常开:确认系统隐私里该应用允许相机,否则检测与抓帧同时失效。
### A.3 隐私与合规
- 持续检测帧**仅在本机内存处理、即用即弃**,不上云;只有 LLM 决定抓的那一帧发往百炼 VLM。
- 大屏前采集观众人脸属个人信息处理 → 大屏旁需公示"影像识别用于互动服务"。
### A.4 落地清单(并入 P2/P3)
| 项 | 说明 |
|---|---|
| 前端新增 `useCameraDetect` hook | getUserMedia + MediaPipe FaceMesh + 状态机(IDLE→FACING→TRIGGER→SILENT) |
| 依赖 | `@mediapipe/tasks-vision`(face_detector + face_landmarker) |
| 参数化 | 停留 10s / 静默 60s / 角度 ±15° / 频率 1~2fps → 配置文件或设置页 |
| 触发动作 | 主动问候(sendUserText+create)+ MQTT 联动(后端 API) |
| 首对话抓帧 | 与检测共用同一路视频流,canvas 取帧 → input_image → qwen3-vl |
> 注:视觉子系统是**可选增强**,不影响主语音对话方案;摄像头/检测不可用时语音链路照常工作。
-78
View File
@@ -1,78 +0,0 @@
# Windows 打包部署指南(免重打包改地址)
## 一、打包后在 exe 同目录放置 config.json(推荐)
打包安装后,在 exe 所在目录(如 `C:\Program Files\云超服昆创园OPC运营中心\`)新建 **`config.json`**,
改地址**不需要重新打包**,重启应用即生效:
```json
{
"api_base": "http://192.168.1.9:10085",
"mqtt_url": "ws://192.168.1.3:8083/mqtt",
"mqtt_username": "dpm",
"mqtt_password": "123456",
"voice_url": "ws://192.168.1.3:8765/v1/realtime"
}
```
- `api_base`:FastAPI 后端地址(API / 管理后台 / 媒体)
- `mqtt_url`:EMQX Broker 的 **WebSocket** 地址(端口 8083)
- `mqtt_username/password`:Broker 鉴权账号(默认 dpm / 123456,与后端 .env 一致)
地址解析优先级:**config.json > 后端 /api/config > 构建默认(192.168.1.9)**。
## 二、后端 /api/config 方式(同机部署自动生效)
后端 `.env`(backend/.env)配置 Broker 后,展播端启动时自动从
`GET /api/config` 获取 MQTT 地址,**无需 config.json**:
```
DPM_MQTT_HOST=192.168.1.3
DPM_MQTT_PORT=1883
DPM_MQTT_WS=ws://192.168.1.3:8083/mqtt # 展播端可访问的 WebSocket 地址
DPM_VOICE_WS=ws://192.168.1.3:8765/v1/realtime # s2s 语音 WS(可选,默认按主机推导)
MQTT_USERNAME=dpmserver
MQTT_PASSWORD=你的密码
```
> 若后端与 Broker 同机:后端默认 `MQTT_WS_URL=ws://localhost:8083/mqtt` 即可,
> 展播端连 `192.168.1.9` 也通;跨机必须显式配置上面的 IP。
## 三、构建期固定地址(不推荐,需重新打包)
在项目根目录(**构建机器上**)配置 `.env.local`(已被 gitignore):
```
VITE_API_BASE=http://192.168.1.9:10085
VITE_MQTT_URL=ws://192.168.1.3:8083/mqtt
VITE_MQTT_USERNAME=dpm
VITE_MQTT_PASSWORD=123456
```
然后 `yarn build:win`(脚本自动清理旧 exe 进程再打包)。
## 四、打包前必须确认
| 检查项 | 说明 |
|---|---|
| exe 未被运行 | `taskkill /f /im "昆明大学生创业园展播系统.exe"`,或直接用 `yarn build:win` |
| 后端已启动 | 浏览器打开 `http://<后端IP>:10085/api/health` 应返回 `{"ok":true,...}` |
| Broker WebSocket 已开 | EMQX 需开启 8083 端口 WS 监听,且账号密码与 config.json 一致 |
| Windows 防火墙 | 首次运行允许,或放行出站 10085/8083(管理端上传用 10085) |
## 五、常见排查
```
# 测试后端连通(展播机 PowerShell)
Invoke-WebRequest http://192.168.1.9:10085/api/health
# 测试 Broker WS 端口
Test-NetConnection 192.168.1.3 -Port 8083
# 看前端启动日志(bootstrap 会打印 MQTT 地址来源)
打开应用后按 F12(WebView2 开发者工具)→ Console
```
- 应用日志打印 `[bootstrap] config.json MQTT -> ...` = config.json 生效
- 打印 `[bootstrap] /api/config MQTT -> ...` = 后端下发
- 两者都没有 = 两个来源都不可达,请检查 IP/防火墙
+5
View File
@@ -12,6 +12,11 @@
<body>
<div id="root"></div>
<div id="fatal-error" style="display:none;position:fixed;inset:0;z-index:99999;background:#f7fafd;color:#1d2c44;padding:40px;font-family:system-ui,sans-serif;overflow:auto;">
<h3 style="margin:0 0 12px;">页面加载出错</h3>
<pre id="fatal-error-text" style="white-space:pre-wrap;font-size:12px;background:#fff;border:1px solid #e3ecf7;padding:14px;border-radius:8px;"></pre>
<button onclick="location.reload()" style="margin-top:12px;padding:8px 18px;cursor:pointer;">重载</button>
</div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "dpm",
"private": true,
"version": "0.1.0",
"version": "0.1.2",
"type": "module",
"scripts": {
"dev": "vite",
+1 -1
View File
@@ -5135,7 +5135,7 @@ dependencies = [
[[package]]
name = "昆明大学生创业园展播系统"
version = "0.1.0"
version = "0.1.2"
dependencies = [
"serde",
"tauri",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "昆明大学生创业园展播系统"
version = "0.1.0"
version = "0.1.2"
description = "大屏媒体轮播系统 - Tauri App"
authors = ["Pine"]
edition = "2021"
+1
View File
@@ -5,6 +5,7 @@
"windows": ["main", "secondary"],
"permissions": [
"core:default",
"core:app:default",
"core:window:default",
"core:window:allow-minimize",
"core:window:allow-set-fullscreen",
+68 -21
View File
@@ -9,7 +9,7 @@ use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder};
/// 双屏控制:on=true 在第二块屏幕上新建一个全屏窗口(加载前端首页),on=false 关闭它。
/// 用于 admin 端「启动双屏」,让展播内容同时投到电脑的另一块显示器。
#[tauri::command]
fn set_dual_screen(app: AppHandle, on: bool) -> Result<String, String> {
fn set_dual_screen(app: AppHandle, on: bool, parent_id: Option<String>) -> Result<String, String> {
if !on {
if let Some(w) = app.get_webview_window("secondary") {
let _ = w.close();
@@ -24,30 +24,73 @@ fn set_dual_screen(app: AppHandle, on: bool) -> Result<String, String> {
let _ = w.set_focus();
return Ok("on".into());
}
let mut builder = WebviewWindowBuilder::new(&app, "secondary", WebviewUrl::App("/".into()))
.title("OPC 运营中心 · 双屏");
// 定位到第二块屏(非主屏;无第二屏则用第一块)
if let Ok(monitors) = app.available_monitors() {
let primary_pos = app
.primary_monitor()
.ok()
.flatten()
.map(|pm| { let p = pm.position(); (p.x, p.y) });
let target = monitors
.iter()
.find(|m| { let p = m.position(); Some((p.x, p.y)) != primary_pos })
.or_else(|| monitors.first());
if let Some(mon) = target {
let p = mon.position();
let s = mon.size();
builder = builder.position(p.x as f64, p.y as f64).inner_size(s.width as f64, s.height as f64);
// 主屏位置(用于排除主屏;必须把副窗放到主屏之外)
let primary_pos = app
.primary_monitor()
.ok()
.flatten()
.map(|pm| { let p = pm.position(); (p.x, p.y) });
// 获取全部显示器,寻找一块与主屏位置不同的副屏
let monitors = app.available_monitors().map_err(|e| format!("读取显示器失败: {e}"))?;
eprintln!("[dual] monitors={} primary_pos={:?}", monitors.len(), primary_pos);
let mut target: Option<(i32, i32, u32, u32)> = None; // (x, y, w, h)
for m in &monitors {
let p = m.position();
if Some((p.x, p.y)) != primary_pos {
let s = m.size();
target = Some((p.x, p.y, s.width, s.height));
break;
}
}
let win = builder.build().map_err(|e| e.to_string())?;
let _ = win.set_fullscreen(true);
// 绝对不允许在主屏桌面上开启;没有副屏则拒绝
let (x, y, w, h) = match target {
Some(t) => t,
None => return Err("未检测到副屏(需连接第二块显示器),无法开启双屏".into()),
};
eprintln!("[dual] secondary {}x{} @ {},{}", w, h, x, y);
let win = WebviewWindowBuilder::new(&app, "secondary", WebviewUrl::default())
.title("OPC 运营中心 · 双屏")
.position(x as f64, y as f64)
.inner_size(w as f64, h as f64)
// 由 Rust 明确告知副窗:它的身份是 secondary,且从属于主屏 device_id(parent_id)
.initialization_script(format!(
"window.__DPM_WINDOW_LABEL__='secondary';window.__DPM_PARENT_ID__='{}';",
parent_id.unwrap_or_default().replace('\'', "")
))
.build()
.map_err(|e| format!("创建副窗失败: {e}"))?;
// 等窗口内容就绪后再全屏(避免过早全屏导致白屏/布局异常)
let win2 = win.clone();
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(400));
let _ = win2.set_fullscreen(true);
let _ = win2.set_focus();
});
Ok("on".into())
}
/// 安装更新:把前端已下载的安装包字节写入磁盘 → 启动安装程序 → 退出应用。
/// bytes 由前端 fetch /download 得到;启动安装程序后进程独立,应用退出不影响它。
#[tauri::command]
fn install_update(app: AppHandle, bytes: Vec<u8>, filename: String) -> Result<String, String> {
if bytes.len() < 1000 {
return Err("下载内容无效或为空".into());
}
let dest = std::env::temp_dir().join(filename);
std::fs::write(&dest, &bytes).map_err(|e| format!("写入安装包失败: {e}"))?;
// 启动安装程序(Windows 下 Command::spawn 为独立进程,应用退出后仍在运行)
std::process::Command::new(&dest)
.spawn()
.map_err(|e| format!("启动安装程序失败: {e}"))?;
// 稍后退出应用,让安装程序接管
let app2 = app.clone();
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(800));
app2.exit(0);
});
Ok(dest.to_string_lossy().into_owned())
}
/// 读取运行时配置(部署用,免重新打包):
/// 1) exe 同目录 config.json / dpm.config.json(最高优先)
/// 2) 应用配置目录 config.json
@@ -89,7 +132,11 @@ pub fn run() {
tauri_plugin_autostart::MacosLauncher::LaunchAgent,
Some(vec![]),
))
.invoke_handler(tauri::generate_handler![read_runtime_config, set_dual_screen])
.invoke_handler(tauri::generate_handler![
read_runtime_config,
set_dual_screen,
install_update
])
.on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
// 仅主窗口关闭=最小化(进程常驻);双屏副窗口可正常关闭
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "云超服昆创园OPC运营中心",
"version": "0.1.0",
"version": "0.1.2",
"identifier": "com.pine.dpm",
"build": {
"beforeDevCommand": "yarn build && yarn dev",
+2 -2
View File
@@ -3,7 +3,7 @@ import Icon from './Icons';
import ShowCard from './ShowCard';
import { getApiBase as API_BASE } from '../config';
import { Sfx } from '../utils/sounds';
import { getMqttStatus } from '../utils/mqtt';
import { getMqttStatus, getClientId } from '../utils/mqtt';
import { aiStatus } from '../utils/statusBus';
import { renderMarkdown } from '../voice/markdown.jsx';
@@ -161,7 +161,7 @@ export default function AiChatPanel() {
const res = await fetch(`${API_BASE()}/api/ai/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: history }),
body: JSON.stringify({ messages: history, screen_id: getClientId() }),
});
if (res.ok) {
data = await res.json();
+35
View File
@@ -0,0 +1,35 @@
import { Component } from 'react';
/* 全局错误边界:渲染崩溃时不白屏,显示具体错误 + 重载按钮,便于双屏/异常定位 */
export default class ErrorBoundary extends Component {
constructor(props) {
super(props);
this.state = { err: null };
}
static getDerivedStateFromError(err) {
return { err };
}
componentDidCatch(err, info) {
// eslint-disable-next-line no-console
console.error('[AppError]', err, info);
}
render() {
if (this.state.err) {
return (
<div style={{ padding: 40, fontFamily: 'sans-serif', color: '#333' }}>
<h3>页面加载出错</h3>
<pre style={{ whiteSpace: 'pre-wrap', fontSize: 12, background: '#f5f5f5', padding: 12, borderRadius: 8 }}>
{String(this.state.err && (this.state.err.message || this.state.err))}
</pre>
<button
style={{ marginTop: 12, padding: '8px 16px', cursor: 'pointer' }}
onClick={() => { this.setState({ err: null }); window.location.reload(); }}
>
重载
</button>
</div>
);
}
return this.props.children;
}
}
+57 -34
View File
@@ -1,41 +1,51 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import Icon from './Icons';
import { checkForUpdate, openUpdate } from '../utils/updateCheck';
import { checkForUpdate, downloadAndInstall } from '../utils/updateCheck';
/* =========================================================
UpdateNotify —— 检测新版本(Windows)
· 启动静默检查一次 + 每 30 分钟一次(仅在有更新时提示)
· 页眉 logo 点击 → dpm:check-update → 主动检测:
有更新 → 弹更新提示;无更新 → 临时提示「已是最新版本」
· 页眉 logo 点击 → dpm:check-update → 主动检测
· 「下载更新」→ 下载安装包 → 启动安装程序 → 应用退出
========================================================= */
export default function UpdateNotify() {
const [upd, setUpd] = useState(null); // 更新提示
const [msg, setMsg] = useState(null); // 临时提示(已是最新 / 失败)
const [upd, setUpd] = useState(null); // 更新提示
const [msg, setMsg] = useState(null); // 临时提示(已是最新 / 失败)
const [dismissed, setDismissed] = useState(false);
const [busy, setBusy] = useState(null); // 'downloading' | 'installing'
const [errText, setErrText] = useState('');
const timerRef = useRef(null);
const run = useCallback(async (manual) => {
const u = await checkForUpdate();
if (u) {
if (u.available) {
setUpd(u);
setDismissed(false);
} else if (manual) {
setMsg(`已是最新版本 v${u.latest}`);
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => setMsg(null), 3000);
}
} else if (manual) {
setMsg('更新检测失败');
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => setMsg(null), 3000);
}
if (u.available) { setUpd(u); setDismissed(false); }
else if (manual) { setMsg(`已是最新版本 v${u.latest}`); flash(); }
} else if (manual) { setMsg('更新检测失败'); flash(); }
}, []);
const flash = () => {
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => setMsg(null), 3000);
};
const handleDownload = useCallback(async () => {
if (!upd || busy) return;
setBusy('downloading');
setErrText('');
try {
await downloadAndInstall(upd.url, upd.name);
setBusy('installing'); // 应用即将退出
} catch (e) {
setErrText(String((e && e.message) || e));
setBusy(null);
}
}, [upd, busy]);
useEffect(() => {
const onCheck = () => run(true);
window.addEventListener('dpm:check-update', onCheck);
run(false); // 启动静默检测
run(false);
const iv = setInterval(() => run(false), 30 * 60 * 1000);
return () => {
window.removeEventListener('dpm:check-update', onCheck);
@@ -44,25 +54,38 @@ export default function UpdateNotify() {
};
}, [run]);
return (
<>
{upd && !dismissed && (
<div className="upd-toast" role="status">
<div className="upd-ico"><Icon name="rocket" size={15} /></div>
<div className="upd-body">
<b>发现新版本 v{upd.latest}</b>
<span>当前 v{upd.current} · 点击下载更新安装包(Windows)</span>
</div>
<button className="upd-btn" onClick={() => openUpdate(upd.url)}>下载更新</button>
<button className="upd-close" onClick={() => setDismissed(true)} aria-label="关闭">×</button>
</div>
)}
{msg && (
if (!upd || dismissed) {
if (msg) {
return (
<div className="upd-toast upd-msg" role="status">
<div className="upd-ico"><Icon name="check" size={15} /></div>
<div className="upd-body"><b>{msg}</b></div>
</div>
);
}
return null;
}
return (
<div className="upd-toast" role="status">
<div className="upd-ico"><Icon name="rocket" size={15} /></div>
<div className="upd-body">
{busy === 'downloading' && <b>正在下载更新 v{upd.latest}…</b>}
{busy === 'installing' && <b>正在启动安装程序,应用即将退出…</b>}
{!busy && <b>发现新版本 v{upd.latest}</b>}
{!busy && <span>当前 v{upd.current} · 点击下载更新并安装(Windows)</span>}
{errText && <span style={{ color: '#f04438' }}>{errText}</span>}
</div>
<button
className="upd-btn"
disabled={!!busy}
onClick={handleDownload}
>
{busy ? '处理中…' : '下载更新'}
</button>
{!busy && (
<button className="upd-close" onClick={() => setDismissed(true)} aria-label="关闭">×</button>
)}
</>
</div>
);
}
+23 -3
View File
@@ -1,11 +1,29 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import ErrorBoundary from "./components/ErrorBoundary";
import { ThemeProvider } from "@appica/ui-react/providers/theme-provider";
import { getApiBase } from "./config";
import { invoke } from "@tauri-apps/api/core";
import "./styles/global.css";
/* 全局致命错误捕获:任何模块加载/运行/异步错误 → 显示到页面(替代白屏),便于副屏等定位 */
function installFatalErrorDiagnostics() {
const show = (label, err) => {
const el = document.getElementById("fatal-error");
if (!el || el.style.display === "block") return;
const text = document.getElementById("fatal-error-text");
if (text) {
const msg = (err && (err.stack || err.message)) || String(err);
text.textContent = `${label}\n${msg}`;
}
el.style.display = "block";
};
window.addEventListener("error", (e) => show("加载错误:", e.error || e.message));
window.addEventListener("unhandledrejection", (e) => show("未处理异常:", e.reason));
}
installFatalErrorDiagnostics();
/* =========================================================
启动引导(打包部署关键)—— 地址解析优先级:
1. exe 旁 config.json(Tauri 命令读取,部署免重打包改 IP)★
@@ -60,9 +78,11 @@ async function bootstrapRuntimeConfig() {
bootstrapRuntimeConfig().then(() => {
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<ThemeProvider>
<App />
</ThemeProvider>
<ErrorBoundary>
<ThemeProvider>
<App />
</ThemeProvider>
</ErrorBoundary>
</React.StrictMode>,
);
});
+2 -1
View File
@@ -9,6 +9,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { S2sWsRealtimeClient } from '../voice/s2s-ws-client.js';
import useVisionDetection, { VISION_DWELL_MS } from '../voice/useVisionDetection.js';
import { getApiBase as API_BASE, getVoiceWsUrl } from '../config';
import { getClientId } from '../utils/mqtt';
import { aiStatus } from '../utils/statusBus';
import '../styles/datascreen.css';
import './voice-original.css';
@@ -242,7 +243,7 @@ export default function VoiceAssistant() {
const res = await fetch(`${API_BASE()}/api/tools/exec`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, args }),
body: JSON.stringify({ name, args, screen_id: getClientId() }),
});
const data = await res.json();
output = data?.result ?? JSON.stringify(data);
+3 -1
View File
@@ -46,7 +46,9 @@ export function connectMqtt() {
client.on('connect', () => {
status = 'connected';
notifyStatus();
client.subscribe([MQTT_TOPIC_COMMAND, MQTT_TOPIC_TICK], { qos: 1 });
// 客户端只监听自己唯一的频道 opc/display/command/<clientId>(+ 数据 tick)。
// 不再订阅全局命令频道 → 后端按客户端路由时,他屏的指令绝收不到。
client.subscribe([MQTT_TOPIC_TICK, `${MQTT_TOPIC_COMMAND}/${clientId}`], { qos: 1 });
});
client.on('reconnect', () => {
status = 'connecting';
+27 -15
View File
@@ -1,40 +1,52 @@
/* =========================================================
客户端更新检测(仅 Windows)
客户端更新检测与安装(仅 Windows)
后端 POST /api/update/check 返回最新版本 + 安装包下载地址;
前端比对后提示,并提供下载(opener 打开安装包下载链接)。
前端 fetch 下载安装包字节 → invoke install_update(写盘 → 启动安装程序 → 退出应用)。
========================================================= */
import { getApiBase } from '../config';
import { openUrl } from '@tauri-apps/plugin-opener';
import { invoke } from '@tauri-apps/api/core';
// 当前版本:发布新版本时同步 package.json 的 version
export const APP_VERSION = '0.1.0';
/** 读取真实安装的应用版本(Tauri getVersion = package.json/tauri.conf.json version);失败时兜底。 */
async function getCurrentVersion() {
try {
const { getVersion } = await import('@tauri-apps/api/app');
const v = await getVersion();
return v || '0.1.0';
} catch {
return '0.1.0';
}
}
export async function checkForUpdate() {
try {
const current = await getCurrentVersion();
const res = await fetch(`${getApiBase()}/api/update/check`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ current: APP_VERSION }),
body: JSON.stringify({ current }),
});
const data = await res.json();
if (data && data.ok) {
return {
current: APP_VERSION,
current,
latest: data.latest_version,
available: !!data.update_available,
url: `${getApiBase()}${data.download_url || '/download'}`,
name: data.installer_name || '',
name: data.installer_name || 'dpm_update_setup.exe',
};
}
} catch { /* 后端不可达时忽略 */ }
return null;
}
/** 打开安装包下载地址(优先系统浏览器下载)。 */
export function openUpdate(url) {
try {
openUrl(url);
} catch {
window.open(url, '_blank');
}
/** 下载安装包并交给后端安装(写盘 → 启动安装程序 → 退出应用)。 */
export async function downloadAndInstall(url, filename) {
const res = await fetch(url);
if (!res.ok) throw new Error(`下载失败(HTTP ${res.status})`);
const buf = new Uint8Array(await res.arrayBuffer());
if (buf.length < 1000) throw new Error('安装包内容无效');
return invoke('install_update', {
bytes: Array.from(buf),
filename: filename || 'dpm_update_setup.exe',
});
}
+35 -7
View File
@@ -1,16 +1,20 @@
import { useEffect } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { onMqttMessage, connectMqtt, publishMqtt, getClientId, useMqttStatus } from './mqtt';
import { MQTT_TOPIC_HEARTBEAT } from '../config';
import { MQTT_TOPIC_HEARTBEAT, MQTT_TOPIC_COMMAND, getApiBase as API_BASE } from '../config';
import { Sfx } from './sounds';
import { PAGE_ORDER } from './pageNav';
import { aiStatus } from './statusBus';
import { invoke } from '@tauri-apps/api/core';
import { getCurrentWindow } from '@tauri-apps/api/window';
// 当前窗口标识:主屏 'main' / 双屏副窗 'secondary'(浏览器开发环境回退 'main')
// 当前窗口标识:主屏 'main' / 双屏副窗 'secondary'
// 优先用 Rust 注入的 __DPM_WINDOW_LABEL__(副窗初始化脚本),再回退 getCurrentWindow().label
const WINDOW_LABEL = (() => {
try { return getCurrentWindow().label || 'main'; } catch { return 'main'; }
try {
if (window.__DPM_WINDOW_LABEL__) return window.__DPM_WINDOW_LABEL__;
return getCurrentWindow().label || 'main';
} catch { return 'main'; }
})();
/* =========================================================
@@ -42,6 +46,20 @@ export function useMqttControl() {
const location = useLocation();
const mqttStatus = useMqttStatus();
// 设备注册:MQTT 连接后向后端上报「唯一 device_id + 角色 + 从属主屏」,供后端精准控制
useEffect(() => {
if (mqttStatus !== 'connected' || !getClientId()) return;
fetch(`${API_BASE()}/api/display/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
device_id: getClientId(),
role: WINDOW_LABEL,
parent_id: window.__DPM_PARENT_ID__ || '', // 副屏标识从属的主屏 device_id
}),
}).catch(() => {});
}, [mqttStatus]);
// 大屏在线心跳(后端据此统计在线大屏数)
useEffect(() => {
connectMqtt();
@@ -75,9 +93,14 @@ export function useMqttControl() {
useEffect(() => {
connectMqtt();
const off = onMqttMessage((topic, cmd) => {
if (topic !== 'opc/display/command' || !cmd || !cmd.action) return;
// 双屏独立控制:cmd.screen = main|secondary|both(缺省视为 both)
// 接收全局控制 topic 或本客户端独有 topic(后端按屏幕终端分别下发)
const isCmd = topic === MQTT_TOPIC_COMMAND || topic.startsWith(`${MQTT_TOPIC_COMMAND}/`);
if (!isCmd || !cmd || !cmd.action) return;
// 屏幕终端精准控制:后端按 client_id 路由到独有 topic。
// 兜底:命令若带 screen_id,只有本客户端 client_id 匹配才处理(防误收全局)。
const sc = cmd.screen;
console.warn(`[dual] 命令 action=${cmd.action} screen_id=${cmd.screen_id || '(无)'} screen_role=${cmd.screen_role || '(无)'} 本窗口=${WINDOW_LABEL} 本client=${getClientId()}`);
if (cmd.screen_id && cmd.screen_id !== getClientId()) return;
if (sc && sc !== 'both' && sc !== WINDOW_LABEL) return;
switch (cmd.action) {
@@ -97,10 +120,15 @@ export function useMqttControl() {
break;
}
case 'dual_screen':
// 双屏:admin 控制第二块屏全屏展示(Tauri 命令)——仅主窗口执行
// 双屏:仅主窗口执行(只有主屏能开启/关闭副屏),把主屏 device_id 传给副窗作 parent_id
if (WINDOW_LABEL !== 'main') break;
aiStatus(cmd.params?.on === false ? '正在关闭双屏' : '正在启动双屏', { icon: 'page' });
invoke('set_dual_screen', { on: cmd.params?.on !== false }).catch(() => {});
invoke('set_dual_screen', { on: cmd.params?.on !== false, parentId: getClientId() })
.catch((err) => {
const msg = String((err && err.message) || err || '操作失败');
aiStatus(`双屏失败:${msg}`, { state: 'error', icon: 'error' });
console.error('[dual]', err);
});
break;
case 'alert':
// 通知本身即展示,无需再弹「正在发送通知」状态提示