refactor(park-desktop): admin 后台新 UI + vision_yolo 手势接入,清理设计文档与安装包,补充工作区规范
This commit is contained in:
@@ -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
|
||||
@@ -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/
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# 工作规则(永远遵守)
|
||||
|
||||
> 📍 工作区位置:`code/dpm/`(园区大屏系统,独立仓库)。整体目录规范见根 `CLAUDE.md`;进程管理铁律适用于本仓库全部前后端服务。
|
||||
|
||||
## 进程管理铁律
|
||||
- **绝对禁止 AI 启动、重启、停止任何前后端服务进程**(后端 10085 / s2s 8765 / vite 1420 / Tauri 等)。
|
||||
- 需要启动或重启时,**必须先通知用户**,由用户亲自操作。
|
||||
|
||||
@@ -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 二进制中,发布时只需分发单个可执行文件。
|
||||
|
||||
## 许可
|
||||
|
||||
本项目为昆明市大学生创业园公益定制开发。
|
||||
|
||||
---
|
||||
|
||||
*云南派音人工智能科技提供技术支持*
|
||||
昆明市大学生创业园公益定制开发,*云南派音人工智能科技提供技术支持*。
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# OPC 智能园区后端(FastAPI + MQTT)
|
||||
|
||||
> 📍 工作区位置:`code/dpm/backend/`(DPM 园区大屏后端,独立仓库)。权威说明以本文档为准;系统/运维文档见 `design/dpm/`,前端见 `code/dpm/src/`。
|
||||
|
||||
独立后端服务:**所有数据通过 REST API 提供**,**页面/媒体控制通过 MQTT 下发**,媒体资源由后端统一存储与返回。
|
||||
|
||||
## 架构
|
||||
|
||||
@@ -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"))
|
||||
|
||||
+5
-15
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,20 @@
|
||||
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;
|
||||
@@ -179,6 +193,8 @@
|
||||
try { cmd = JSON.parse(btn.getAttribute('data-mqtt')); } catch (err) { toast('指令 JSON 错误', 'error'); return; }
|
||||
// 屏幕终端精准控制:除「双屏开关」外,注入目标 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)
|
||||
: '全部';
|
||||
@@ -212,6 +228,7 @@
|
||||
// 页面切换(绑定目标屏幕:screen_id 精确某台;空=全部)
|
||||
if (btn.hasAttribute('data-nav')) {
|
||||
var page = btn.getAttribute('data-nav');
|
||||
activatePage(page); // 状态识别:前置该页操作模块
|
||||
var tgtTxt = screenId
|
||||
? (screenRole === 'main' ? '主屏' : screenRole === 'secondary' ? '副屏' : '屏幕') + '·' + String(screenId).slice(0, 10)
|
||||
: '全部';
|
||||
|
||||
+432
-325
@@ -1,158 +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>
|
||||
<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 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="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>目标屏幕(按 MQTT 客户端精准控制)</h3>
|
||||
</div>
|
||||
<div class="page-switch-grid" style="align-items:center;gap:8px;">
|
||||
<span id="screenCount" class="mqtt-hint" style="white-space:nowrap;">在线屏幕:加载中…</span>
|
||||
<select id="screenTarget" style="flex:1;min-width:0;padding:8px 10px;border:1px solid rgba(0,189,125,.2);border-radius:8px;background:#0e1a2b;color:#e8f0fa;font-size:12px;">
|
||||
<option value="">全部屏幕</option>
|
||||
<!-- 选择屏幕(独立模块) -->
|
||||
<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="mqtt-hint">选择某台屏幕后,下方命令只发给它(按 client_id);「全部」则所有屏幕同步。</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="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="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>
|
||||
@@ -164,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> </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> </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 %}
|
||||
@@ -355,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>
|
||||
@@ -370,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 %}
|
||||
@@ -400,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>
|
||||
@@ -414,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">×</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">×</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>
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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` 过滤,指定某台大屏执行
|
||||
@@ -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
|
||||
```
|
||||
@@ -1,67 +0,0 @@
|
||||
# 客户端更新检测与安装(Windows)—— 接口文档
|
||||
|
||||
> 展播端(Tauri Windows 应用)检测后端是否有新版本安装包,下载并启动安装。仅 Windows。
|
||||
|
||||
## 一、检测更新接口
|
||||
|
||||
**`POST /api/update/check`**
|
||||
|
||||
请求体:
|
||||
```json
|
||||
{ "current": "0.1.0" } // 前端上报的当前安装版本(Tauri getVersion)
|
||||
```
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"latest_version": "0.1.1",
|
||||
"installer_name": "云超服昆创园OPC运营中心_0.1.1_x64-setup.exe",
|
||||
"download_url": "/download",
|
||||
"update_available": true
|
||||
}
|
||||
```
|
||||
|
||||
字段:
|
||||
| 字段 | 说明 |
|
||||
|---|---|
|
||||
| `ok` | 是否成功 |
|
||||
| `latest_version` | 最新版本号(从安装包文件名解析) |
|
||||
| `installer_name` | 安装包文件名 |
|
||||
| `download_url` | 安装包下载相对路径(前缀 `api_base`) |
|
||||
| `update_available` | 是否需要更新(`latest > current`) |
|
||||
|
||||
**最新版本如何确定**:扫描 `backend/` 目录下 `*.setup.exe`,从文件名正则 `_(\d+\.\d+(?:\.\d+)?)` 提取版本号(如 `..._0.1.1_x64-setup.exe` → `0.1.1`)。文件名倒序取第一个。
|
||||
|
||||
## 二、下载安装包接口
|
||||
|
||||
**`GET /download`**(别名 `/11`)—— 返回 `backend/` 下最新安装包,`application/octet-stream` 附件下载。
|
||||
|
||||
## 三、前端流程
|
||||
|
||||
| 文件 | 逻辑 |
|
||||
|---|---|
|
||||
| `src/utils/updateCheck.js` | `getCurrentVersion()` 用 Tauri `getVersion()` 读真实版本 → `checkForUpdate()` 调 `/api/update/check` → `downloadAndInstall()` fetch 下载字节 |
|
||||
| `src/components/UpdateNotify.jsx` | 检测到更新弹底部提示;点「下载更新」→ 下载 → 启动安装 → 应用退出 |
|
||||
| Rust `install_update` | 写盘到 `%TEMP%` → 启动安装程序 → 0.8s 后 `app.exit(0)` |
|
||||
|
||||
触发时机:启动静默查一次 + 每 30 分钟自动查 + 点页眉 logo 主动查。
|
||||
|
||||
## 四、发布新版本步骤
|
||||
|
||||
1. 把新安装包放入 `backend/`,文件名带版本号:`云超服昆创园OPC运营中心_0.2.0_x64-setup.exe`。
|
||||
2. 同步改 `package.json` 的 `version`(Tauri `getVersion` 读取它)。
|
||||
3. 重启后端 + 重新打包前端 `yarn build:win`。
|
||||
4. 安装端检测到 `latest > current` → 弹更新提示 → 下载安装。
|
||||
|
||||
## 五、验证
|
||||
|
||||
```bash
|
||||
curl -X POST http://192.168.1.9:10085/api/update/check \
|
||||
-H "Content-Type: application/json" -d '{"current":"0.1.0"}'
|
||||
```
|
||||
`backend/` 有更高版本安装包时返回 `update_available: true`。
|
||||
|
||||
## 六、注意
|
||||
- 当前按「文件名倒序取第一个」判定最新;若目录同时存在多个版本(如 `0.9` 与 `0.10`),字符串倒序可能取错。多版本共存时应改为「解析全部版本号 → 取数值最大」。
|
||||
- `downloadAndInstall` 经 `invoke` 传递安装包字节(约 2.5MB,JSON 数组稍慢);如需更快可改 Rust 端直接 HTTP 下载。
|
||||
@@ -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 |
|
||||
|
||||
> 注:视觉子系统是**可选增强**,不影响主语音对话方案;摄像头/检测不可用时语音链路照常工作。
|
||||
@@ -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/防火墙
|
||||
Reference in New Issue
Block a user