Compare commits
8 Commits
d1144e2673
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 244ffe8f31 | |||
| a2fbad8169 | |||
| 63ad0db844 | |||
| 87e57ea486 | |||
| 16586ddb2e | |||
| b0c1c7209b | |||
| 43bb33cc1a | |||
| 9287c79156 |
@@ -0,0 +1,13 @@
|
||||
# ── tauri dev 开发运行 —— 接入生产 https://opc.pinesound.cn ──
|
||||
# 注意:tauri dev 的 beforeDevCommand 为 `yarn build && yarn dev`,WebView 加载 vite dev server
|
||||
# (development 模式),只读 .env / .env.local / .env.development,不读 .env.production。
|
||||
# Vite 加载顺序:.env < .env.local < .env.development —— 本文件覆盖 .env.local 的局域网测试地址。
|
||||
#
|
||||
# API:直连生产统一入口(server-core /park 前缀子应用)
|
||||
VITE_API_BASE=https://opc.pinesound.cn/park
|
||||
#
|
||||
# MQTT:不烘焙,由后端 /park/api/config 下发生产地址 + DPM 专用账号
|
||||
# (显式置空,覆盖 .env.local 里的测试地址;避免大屏连到局域网测试 EMQX)
|
||||
VITE_MQTT_URL=
|
||||
VITE_MQTT_USERNAME=
|
||||
VITE_MQTT_PASSWORD=
|
||||
@@ -0,0 +1,12 @@
|
||||
# ── 生产构建(Tauri 打包 / vite build)—— 接入生产 https://opc.pinesound.cn ──
|
||||
# 注意:本文件优先级高于 .env.local(Vite 加载顺序 .env < .env.local < .env.production),
|
||||
# 生产构建会覆盖本地开发烘焙的局域网地址;本地开发(npm run dev)不受影响。
|
||||
#
|
||||
# API:直连生产统一入口(server-core /park 前缀子应用)
|
||||
VITE_API_BASE=https://opc.pinesound.cn/park
|
||||
#
|
||||
# MQTT:不烘焙,由后端 /park/api/config 下发生产地址 + DPM 专用账号
|
||||
# (显式置空,覆盖 .env.local 里的测试地址;避免大屏连到局域网测试 EMQX)
|
||||
VITE_MQTT_URL=
|
||||
VITE_MQTT_USERNAME=
|
||||
VITE_MQTT_PASSWORD=
|
||||
@@ -1,194 +1,108 @@
|
||||
# 大屏媒体轮播系统 (DPM)
|
||||
# 园区大屏(park-desktop / DPM)
|
||||
|
||||
**Digital Playback Media** — 基于 Tauri + React 的全屏媒体轮播控制系统,专为昆明市大学生创业园展播大屏设计。
|
||||
> 核对:2026-10-19 · 依据 park-desktop `main@a2fbad8` · 以实现为准
|
||||
|
||||
## 概述
|
||||
**DPM**(Digital Playback Media)—— 基于 **Tauri 2 + React 19** 的园区大屏客户端,为昆明市大学生创业园 OPC 运营中心提供数据大屏、数字孪生、AI 助手、媒体轮播、任务大屏与企业墙。
|
||||
|
||||
DPM 是一套完整的数字标牌(Digital Signage)解决方案,包含两大界面:
|
||||
|
||||
- **大屏展示页** (`/screen`) — 全屏自动轮播,支持视频与图片,具备沉浸式空间视觉背景
|
||||
- **管理后台** (`/admin`) — 登录保护的管理面板,用于媒体上传、播放列表编排、播放控制与系统设置
|
||||
|
||||
系统通过 **SSE(Server-Sent Events)** 实现管理端与展示端的实时状态同步,支持局域网内远程管理。
|
||||
> 园区后端已并入 **server-core `/park`**(不再有独立 `:10085` 后端);管理后台为后端 **FastAPI + Jinja** 页面(`GET /admin`),**不由 React 路由接管**。实时控制走 **MQTT(EMQX `:8083`)**,不再使用内置 Axum HTTP 服务 / SSE / nps。
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 层级 | 技术 |
|
||||
|------|------|
|
||||
| 前端框架 | React 19 + React Router 7 |
|
||||
| 构建工具 | Vite 7 |
|
||||
| 桌面框架 | Tauri 2 (Rust) |
|
||||
| HTTP 服务 | Axum 0.8 (嵌入式 HTTP 服务器) |
|
||||
| 数据持久化 | JSON 文件存储 |
|
||||
| 实时通信 | Server-Sent Events (SSE) |
|
||||
| 内网穿透 | nps 客户端嵌入式集成 |
|
||||
| 构建工具 | Vite 7 + Tailwind 4.3(`@tailwindcss/vite`) |
|
||||
| 桌面框架 | Tauri 2(Rust,`src-tauri/`,本地 `wry` 补丁) |
|
||||
| 三维 / 可视化 | three 0.185(`@react-three/fiber` / `drei`)、`@appica/ui-react` |
|
||||
| 实时通信 | MQTT 5.15(`mqtt.js`,EMQX) |
|
||||
| 二维码 | `qrcode.react` |
|
||||
|
||||
## 功能特性
|
||||
## 页面(`src/App.jsx`)
|
||||
|
||||
### 媒体管理
|
||||
- **本地上传** — 支持 .mp4、.mkv、.avi、.jpg、.jpeg、.png 格式
|
||||
- **远程 URL** — 添加网络媒体资源链接
|
||||
- **媒体预览** — 图片与视频的模态框预览
|
||||
- **文件删除** — 从媒体库移除文件
|
||||
| 路由 | 页面 | 说明 |
|
||||
|------|------|------|
|
||||
| `/` | `DataScreen` | 园区数据大屏(概览/数据快照) |
|
||||
| `/twin` | `DigitalTwin` | 数字孪生(3D 园区) |
|
||||
| `/ai` | `AiAssistant` | 园区 AI 助手(含语音 s2s) |
|
||||
| `/screen` | `MediaScreen` | 全屏媒体轮播(视频/图片,MQTT 受控) |
|
||||
| `/tasks` | `TaskDisplay` | 任务大屏(轮播卡 + 二维码扫码接单) |
|
||||
| `/wall` | `CompanyWall` | 入驻企业展示墙 |
|
||||
|
||||
### 播放控制
|
||||
- **播放/暂停** — 实时控制展示端的播放状态
|
||||
- **上一曲/下一曲** — 切换播放内容
|
||||
- **顺序/随机播放** — 两种播放模式
|
||||
- **图片时长可调** — 1~300 秒
|
||||
- **音量控制** — 实时同步调节
|
||||
- 统一页眉布局 `components/ScreenLayout`;`/voice` 有 `VoiceAssistant` 组件但**当前未挂路由**。
|
||||
- 首启可走 **屏幕绑定**(`components/BindScreen` + `utils/tenant`,绑定后按租户拉数据);支持双屏(`set_dual_screen`)、全屏、开机自启(`tauri-plugin-autostart`)、应用内自动更新(`components/UpdateNotify` + `utils/updateCheck`)。
|
||||
|
||||
### 展示端体验
|
||||
- **沉浸式空间背景** — 深邃星空背景 + 透视网格 + 动态环境光晕
|
||||
- **自动播放** — 启动后自动加载播放列表
|
||||
- **视频/图片自适应** — 全屏覆盖显示
|
||||
- **声音管理** — 浏览器自动播放策略处理
|
||||
- **点击暂停/恢复** — 现场快速控制
|
||||
## 功能概览
|
||||
|
||||
### 系统功能
|
||||
- **全屏/窗口模式切换** — 自由切换展示形态
|
||||
- **最小化窗口** — 后台运行
|
||||
- **开机自启动** — 系统级自启(Tauri autostart 插件)
|
||||
- **关闭 → 最小化** — 防止误关闭
|
||||
- **内网穿透** — 内置 nps 客户端,支持外网远程访问
|
||||
|
||||
### 管理后台
|
||||
- **登录保护** — 账号密码验证
|
||||
- **实时状态显示** — 当前播放内容、状态
|
||||
- **上传进度条** — 文件上传实时反馈
|
||||
- **响应式设计** — 桌面/移动端自适应
|
||||
- **Toast 通知** — 操作即时反馈
|
||||
- **媒体轮播**(`pages/MediaScreen.jsx`):视频/图片全屏轮播、沉浸式空间背景、自动播放、点击暂停/恢复、音量与顺序/随机播放、播放状态上报(`opc/display/*` MQTT 主题)。
|
||||
- **任务大屏**(`pages/TaskDisplay.jsx`):任务轮播卡 + 二维码扫码接单。
|
||||
- **园区数据 / 数字孪生 / 企业墙**:数据快照(`opc/dashboard/tick`)、3D 园区、入驻企业展示。
|
||||
- **AI 助手**:对话面板 + 语音 s2s(`src/voice/`)。
|
||||
- **设备与运维**:屏幕绑定/租户隔离、心跳上报(`opc/display/heartbeat`,后端统计在线)、双屏、全屏、开机自启、自动更新。
|
||||
- **管理后台**:媒体上传、播放列表编排与播放控制等由 **server-core `/park`** 的后端 `/admin` 页面提供(前端不再内置管理页)。
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 环境要求
|
||||
|
||||
- **Rust** (edition 2021)
|
||||
- **Node.js** >= 18
|
||||
- **Yarn** 或 npm
|
||||
- **Tauri CLI** (`cargo install tauri-cli`)
|
||||
- **Rust**(edition 2021)、**Node.js** >= 18、**Yarn**(或 npm)、Tauri 2 CLI(`yarn tauri` 走本地依赖)
|
||||
|
||||
### 安装
|
||||
### 安装与运行
|
||||
|
||||
```bash
|
||||
# 安装前端依赖
|
||||
yarn install
|
||||
|
||||
# 开发模式运行(同时启动 Vite 开发服务器和 Tauri 应用)
|
||||
yarn tauri dev
|
||||
|
||||
# 仅启动 Web 开发服务器(浏览器中预览)
|
||||
yarn dev
|
||||
yarn dev # 仅启动 Vite(http://localhost:1420,浏览器预览大屏)
|
||||
yarn tauri dev # Tauri 桌面端(前端 + Rust 壳)
|
||||
yarn build # 前端产物 dist/
|
||||
yarn tauri build # 桌面安装包,产物在 src-tauri/target/release/
|
||||
```
|
||||
|
||||
### 构建生产版本
|
||||
> ⚠️ 按团队铁律:**AI 不启动/重启任何服务(含 vite / Tauri)**,需要时通知用户亲自操作。
|
||||
|
||||
```bash
|
||||
yarn tauri build
|
||||
```
|
||||
### 配置(`src/config.js`)
|
||||
|
||||
构建产物位于 `src-tauri/target/release/` 目录。
|
||||
地址解析优先级:**exe 旁 `config.json`(Tauri `read_runtime_config`)> 后端 `/park/api/config` 下发 > 构建期 `VITE_*` > 内置默认**。
|
||||
|
||||
| 项 | 默认 | 覆盖方式 |
|
||||
|----|------|---------|
|
||||
| 园区 API | `http://<当前主机>:8090/park`(打包生产回退 `opc.pinesound.cn`) | `window.__DPM_API__`、`VITE_API_BASE` |
|
||||
| MQTT | `ws(s)://<当前主机>:8083/mqtt`(生产 `wss`) | `window.__DPM_MQTT__`、`VITE_MQTT_URL` |
|
||||
| 视频识别 | 空 = 禁用 | `window.__DPM_VISION__`、`VITE_VISION_API_BASE` |
|
||||
| 大屏引导令牌 | 空 | `window.__DPM_BOOTSTRAP__`、`VITE_PARK_BOOTSTRAP_TOKEN` |
|
||||
|
||||
旧 `:10085` 地址一律判为失效并回退 server-core 统一入口。
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
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 事件广播管理器
|
||||
index.html
|
||||
package.json # 包名 dpm
|
||||
vite.config.js # 端口 1420;无 dev 代理(API 由 src/config.js 直连 server-core)
|
||||
public/ # logo、语音 worklets 等静态资源
|
||||
src/
|
||||
main.jsx # React 入口 + 运行时配置引导 + 致命错误兜底
|
||||
App.jsx # 6 个大屏路由
|
||||
config.js # API / MQTT / Vision / bootstrap 令牌解析
|
||||
pages/ # DataScreen / DigitalTwin / AiAssistant / MediaScreen / TaskDisplay / CompanyWall
|
||||
components/ # ScreenLayout / BindScreen / PageHeader / 各类面板、弹窗
|
||||
utils/ # api.js、mqtt.js、tenant.js、taskData.js、updateCheck.js、pageNav.js、sounds.js
|
||||
voice/ # s2s 语音客户端、orb 可视化、worklets
|
||||
styles/ # tokens/global/datascreen/mediascreen/task-display 等
|
||||
src-tauri/
|
||||
Cargo.toml # tauri 2 + opener/autostart + 本地 wry 补丁
|
||||
tauri.conf.json # productName「云超服昆创园OPC运营中心」,1200x800 全屏
|
||||
capabilities/default.json # 窗口/自启/opener 权限
|
||||
src/{main.rs,lib.rs} # 入口;命令:set_dual_screen / install_update / read_runtime_config
|
||||
```
|
||||
|
||||
## 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 二进制中,发布时只需分发单个可执行文件。
|
||||
| 1420 | Vite 前端开发服务器(`strictPort`) |
|
||||
| 8090 | server-core(园区 API 前缀 `/park`) |
|
||||
| 8083 | MQTT Broker(EMQX,`/mqtt`) |
|
||||
| 10085 | **已废弃**的旧园区后端(源码已回退到统一入口,勿再使用) |
|
||||
|
||||
## 许可
|
||||
|
||||
|
||||
+11
-5
@@ -11,10 +11,11 @@ const env = import.meta.env || {};
|
||||
|
||||
/* 运行期解析(惰性读取 window.__DPM_*__,供 main.jsx bootstrap 覆盖后再生效) */
|
||||
function resolveHost() {
|
||||
const h = window.location.hostname || '192.168.1.9';
|
||||
// Tauri 打包 WebView(tauri.localhost / localhost)→ 服务同机部署用 192.168.1.9
|
||||
if (h === 'tauri.localhost' || h === 'localhost' || h === '192.168.1.9' || h.includes('tauri')) {
|
||||
return '192.168.1.9';
|
||||
const h = window.location.hostname || 'opc.pinesound.cn';
|
||||
// Tauri 打包 WebView(tauri.localhost / localhost)→ 生产构建默认接入生产 opc.pinesound.cn;
|
||||
// 本地开发(npm run dev)仍回退局域网测试机 192.168.1.9
|
||||
if (h === 'tauri.localhost' || h === 'localhost' || h.includes('tauri')) {
|
||||
return import.meta.env.PROD ? 'opc.pinesound.cn' : '192.168.1.9';
|
||||
}
|
||||
return h; // 浏览器开发/局域网:当前主机 IP
|
||||
}
|
||||
@@ -33,9 +34,14 @@ const _isLegacyVision = (u) => !!(u && (String(u).includes(':10085') || String(u
|
||||
const _visionFromWindow = window.__DPM_VISION__ && !_isLegacyVision(window.__DPM_VISION__) ? window.__DPM_VISION__ : null;
|
||||
const _visionFromEnv = env.VITE_VISION_API_BASE && !_isLegacyVision(env.VITE_VISION_API_BASE) ? env.VITE_VISION_API_BASE : null;
|
||||
export const VISION_API_BASE = (_visionFromWindow || _visionFromEnv || '').replace(/\/$/, '');
|
||||
export const MQTT_URL = window.__DPM_MQTT__ || env.VITE_MQTT_URL || `ws://${resolveHost()}:8083/mqtt`;
|
||||
// MQTT 地址:生产默认走 wss://(TLS);本地开发走 ws://。
|
||||
// 若 broker 侧未启用 TLS,请用构建期 VITE_MQTT_URL 或 /api/config 下发覆盖为 ws://。
|
||||
const _mqttScheme = import.meta.env.PROD ? 'wss' : 'ws';
|
||||
export const MQTT_URL = window.__DPM_MQTT__ || env.VITE_MQTT_URL || `${_mqttScheme}://${resolveHost()}:8083/mqtt`;
|
||||
export const MQTT_USERNAME = window.__DPM_MQTT_USER__ || env.VITE_MQTT_USERNAME || '';
|
||||
export const MQTT_PASSWORD = window.__DPM_MQTT_PASS__ || env.VITE_MQTT_PASSWORD || '';
|
||||
// 大屏引导令牌(/park/api/config 鉴权):window.__DPM_BOOTSTRAP__ > 构建期 VITE_PARK_BOOTSTRAP_TOKEN
|
||||
export const PARK_BOOTSTRAP_TOKEN = window.__DPM_BOOTSTRAP__ || env.VITE_PARK_BOOTSTRAP_TOKEN || '';
|
||||
|
||||
/** 惰性读取 API 基址(仅接受 server-core 地址,旧 :10085 一律回退到 :8090/park) */
|
||||
export function getApiBase() {
|
||||
|
||||
+9
-2
@@ -5,7 +5,7 @@ import BindScreen from "./components/BindScreen.jsx";
|
||||
import { isDeviceBound } from "./utils/tenant";
|
||||
import ErrorBoundary from "./components/ErrorBoundary";
|
||||
import { ThemeProvider } from "@appica/ui-react/providers/theme-provider";
|
||||
import { getApiBase } from "./config";
|
||||
import { getApiBase, getMqttUrl, getMqttCredentials, PARK_BOOTSTRAP_TOKEN } from "./config";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import "./styles/global.css";
|
||||
|
||||
@@ -62,7 +62,10 @@ async function bootstrapRuntimeConfig() {
|
||||
// 则后端 /api/config 不再覆盖(避免后端默认 localhost 冲掉正确地址)。
|
||||
const bakedMqtt = import.meta.env.VITE_MQTT_URL;
|
||||
try {
|
||||
const res = await fetch(`${getApiBase()}/api/config`, { cache: 'no-store' });
|
||||
const res = await fetch(`${getApiBase()}/api/config`, {
|
||||
cache: 'no-store',
|
||||
headers: PARK_BOOTSTRAP_TOKEN ? { 'X-Park-Bootstrap': PARK_BOOTSTRAP_TOKEN } : undefined,
|
||||
});
|
||||
if (res.ok) {
|
||||
const cfg = await res.json();
|
||||
if (!bakedMqtt && cfg.mqtt_url) window.__DPM_MQTT__ = cfg.mqtt_url;
|
||||
@@ -80,6 +83,10 @@ async function bootstrapRuntimeConfig() {
|
||||
}
|
||||
|
||||
bootstrapRuntimeConfig().then(() => {
|
||||
// 启动诊断:打印最终生效的 API / MQTT 地址(config.json > 构建烘焙 > /api/config 下发 > 内置默认)
|
||||
try {
|
||||
console.info(`[bootstrap] API_BASE=${getApiBase()} MQTT_URL=${getMqttUrl()} MQTT_USER=${getMqttCredentials().username || "(空)"}`);
|
||||
} catch (e) { /* 忽略 */ }
|
||||
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||
<React.StrictMode>
|
||||
<ErrorBoundary>
|
||||
|
||||
@@ -61,7 +61,9 @@ export default function MediaScreen() {
|
||||
useEffect(() => { soundBlockedRef.current = soundBlocked; }, [soundBlocked]);
|
||||
|
||||
const getUrl = useCallback((item) => {
|
||||
// 惰性读取 API 基址:打包部署引导覆盖后媒体文件地址同步生效
|
||||
// CDN/OSS 直链优先(admin 端上传到 OSS 后返回的直链,播放不再经过服务端代理);
|
||||
// 无直链时回退旧逻辑:URL 源用原地址,本地文件走 /file 静态服务
|
||||
if (item.url) return item.url;
|
||||
return item.source === 'url' ? item.relative_path : `${getApiBase()}/file/${item.relative_path}`;
|
||||
}, []);
|
||||
|
||||
@@ -202,7 +204,7 @@ export default function MediaScreen() {
|
||||
const load = async () => {
|
||||
try {
|
||||
const data = await api.getPlaylist();
|
||||
const files = data.files || [];
|
||||
const files = data.playlist || data.files || [];
|
||||
const vol = (data.volume || 80) / 100;
|
||||
setList(files);
|
||||
setVolume(vol);
|
||||
@@ -284,7 +286,7 @@ export default function MediaScreen() {
|
||||
const reloadPlaylist = useCallback(async () => {
|
||||
try {
|
||||
const data = await api.getPlaylist();
|
||||
const newList = data.files || [];
|
||||
const newList = data.playlist || data.files || [];
|
||||
const currentItem = listRef.current[indexRef.current] || null;
|
||||
|
||||
if (newList.length === 0) {
|
||||
|
||||
@@ -100,6 +100,8 @@ export default function TaskDisplay() {
|
||||
<div className="tasks-loading">正在加载任务…</div>
|
||||
) : error && tasks.length === 0 ? (
|
||||
<div className="tasks-loading">{error}</div>
|
||||
) : tasks.length === 0 ? (
|
||||
<div className="tasks-loading">暂无上墙任务(运营端发布后自动展示)</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="tasks-grid" style={{
|
||||
|
||||
+7
-1
@@ -1,5 +1,6 @@
|
||||
// API 基地址 —— 独立 FastAPI 后端(配置见 src/config.js,可用 window.__DPM_API__ 覆盖)
|
||||
import { getApiBase } from '../config';
|
||||
import { getTenantId, getDeviceToken } from './tenant';
|
||||
|
||||
/* 惰性读取 API 基址:允许 main.jsx 引导阶段从后端 /api/config 覆盖(打包部署时生效) */
|
||||
export const API_BASE = getApiBase();
|
||||
@@ -110,8 +111,13 @@ export async function deleteMedia(path) {
|
||||
}
|
||||
|
||||
// ============ Playlist ============
|
||||
/** 播放列表:绑定后按本园区取(设备 token 鉴权,未绑定回退全局旧数据)。 */
|
||||
export async function getPlaylist() {
|
||||
const res = await fetch(`${base()}/api/playlist`);
|
||||
const token = getDeviceToken();
|
||||
const tid = getTenantId();
|
||||
const res = await fetch(`${base()}/api/playlist${tid ? `?tenant_id=${encodeURIComponent(tid)}` : ''}`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
|
||||
+26
-1
@@ -1,23 +1,42 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import mqtt from 'mqtt';
|
||||
import { getMqttUrl, getMqttCredentials, getDeviceId, MQTT_TOPIC_COMMAND, MQTT_TOPIC_TICK, MQTT_TOPIC_BIND } from '../config';
|
||||
import { getMqttUrl, getMqttCredentials, getDeviceId, MQTT_TOPIC_COMMAND, MQTT_TOPIC_TICK, MQTT_TOPIC_BIND, MQTT_TOPIC_HEARTBEAT } from '../config';
|
||||
import { getTenantId } from './tenant';
|
||||
|
||||
/* =========================================================
|
||||
MQTT 客户端(mqtt.js)—— 后端控制通道
|
||||
订阅 opc/display/command(页面/媒体/卡片控制)与 opc/dashboard/tick(数据快照)
|
||||
连接成功后自动周期上报心跳 opc/display/heartbeat(绑定页/主屏通用,
|
||||
后端据此统计在线大屏,admin 端屏幕管理显示在线状态)
|
||||
断线自动重连;未连接时静默降级(页面仍可本地操作)
|
||||
========================================================= */
|
||||
|
||||
const HEARTBEAT_MS = 10000;
|
||||
|
||||
let client = null;
|
||||
let status = 'connecting'; // connecting | connected | offline
|
||||
let clientId = null;
|
||||
let heartbeatTimer = null;
|
||||
const listeners = new Set();
|
||||
|
||||
function notifyStatus() {
|
||||
window.dispatchEvent(new CustomEvent('dpm:mqtt-status', { detail: { status } }));
|
||||
}
|
||||
|
||||
/** 心跳上报(连接成功后自动周期调用;绑定页/主屏均生效) */
|
||||
function sendHeartbeat() {
|
||||
if (status !== 'connected' || !client) return;
|
||||
try {
|
||||
const page = (typeof window !== 'undefined' && window.location) ? window.location.pathname : '';
|
||||
client.publish(MQTT_TOPIC_HEARTBEAT, JSON.stringify({
|
||||
client_id: getClientId() || getDeviceId(),
|
||||
tenant_id: getTenantId(),
|
||||
page,
|
||||
ts: Date.now(),
|
||||
}), { qos: 1 });
|
||||
} catch { /* 忽略单次失败 */ }
|
||||
}
|
||||
|
||||
/* 每个连接生成全局唯一 client_id(独立客户端 ID 铁律):
|
||||
优先 crypto.randomUUID(WebView2/现代浏览器),回退时间戳+双段高熵随机 */
|
||||
function makeClientId() {
|
||||
@@ -55,6 +74,11 @@ export function connectMqtt() {
|
||||
tid ? `${MQTT_TOPIC_TICK}/${tid}` : MQTT_TOPIC_TICK,
|
||||
tid ? `${MQTT_TOPIC_COMMAND}/${tid}/${clientId}` : `${MQTT_TOPIC_COMMAND}/${clientId}`,
|
||||
], { qos: 1 });
|
||||
// 连接成功即启动心跳(绑定页/主屏通用;幂等防重复定时器)
|
||||
if (!heartbeatTimer) {
|
||||
sendHeartbeat();
|
||||
heartbeatTimer = setInterval(sendHeartbeat, HEARTBEAT_MS);
|
||||
}
|
||||
});
|
||||
client.on('reconnect', () => {
|
||||
status = 'connecting';
|
||||
@@ -63,6 +87,7 @@ export function connectMqtt() {
|
||||
client.on('close', () => {
|
||||
status = 'offline';
|
||||
notifyStatus();
|
||||
if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
|
||||
});
|
||||
client.on('error', () => {
|
||||
status = 'offline';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { onMqttMessage, connectMqtt, publishMqtt, getClientId, useMqttStatus } from './mqtt';
|
||||
import { MQTT_TOPIC_HEARTBEAT, MQTT_TOPIC_COMMAND, MQTT_TOPIC_BIND, getApiBase as API_BASE } from '../config';
|
||||
import { MQTT_TOPIC_COMMAND, MQTT_TOPIC_BIND, getApiBase as API_BASE } from '../config';
|
||||
import { getTenantId, clearBinding } from './tenant';
|
||||
import { Sfx } from './sounds';
|
||||
import { PAGE_ORDER } from './pageNav';
|
||||
@@ -75,23 +75,7 @@ export function useMqttControl() {
|
||||
return off;
|
||||
}, []);
|
||||
|
||||
// 大屏在线心跳(后端据此统计在线大屏数)
|
||||
useEffect(() => {
|
||||
connectMqtt();
|
||||
const sendHeartbeat = () => {
|
||||
if (mqttStatus === 'connected') {
|
||||
publishMqtt(MQTT_TOPIC_HEARTBEAT, {
|
||||
client_id: getClientId(),
|
||||
tenant_id: getTenantId(),
|
||||
page: location.pathname,
|
||||
ts: Date.now(),
|
||||
});
|
||||
}
|
||||
};
|
||||
sendHeartbeat();
|
||||
const hb = setInterval(sendHeartbeat, 10000);
|
||||
return () => clearInterval(hb);
|
||||
}, [mqttStatus, location.pathname]);
|
||||
// 大屏在线心跳已由 mqtt.js 连接成功后自动周期上报(绑定页/主屏通用),此处不再重复发送
|
||||
|
||||
// 页面状态即时上报:路由变化即通知服务端当前所在页面
|
||||
useEffect(() => {
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
<html style="margin:0;padding:0;">
|
||||
<div style="background-color:transparent;box-sizing:border-box;font-family:'PingFang SC','Roboto',sans-serif;padding:8px;">
|
||||
<div style="font-size:15px;font-weight:600;color:#1A1B1C;margin:4px 0 10px;">park-desktop 审查问题分布(28 项)</div>
|
||||
<div style="display:flex;gap:16px;flex-wrap:wrap;box-sizing:border-box;">
|
||||
<div style="flex:1 1 260px;min-width:0;">
|
||||
<div style="font-size:12px;color:#6B7280;margin-bottom:6px;">按严重度</div>
|
||||
<div style="display:flex;flex-direction:column;gap:6px;">
|
||||
<div style="display:flex;align-items:center;gap:8px;"><span style="width:8px;height:8px;border-radius:50%;background:#EA6668;flex:0 0 auto;"></span><span style="flex:1;font-size:12.5px;color:#1A1B1C;">严重 · 展示口径错误</span><b style="font-size:13px;color:#1A1B1C;">6</b></div>
|
||||
<div style="display:flex;align-items:center;gap:8px;"><span style="width:8px;height:8px;border-radius:50%;background:#FAAD14;flex:0 0 auto;"></span><span style="flex:1;font-size:12.5px;color:#1A1B1C;">重要 · 架构/文档脱节</span><b style="font-size:13px;color:#1A1B1C;">7</b></div>
|
||||
<div style="display:flex;align-items:center;gap:8px;"><span style="width:8px;height:8px;border-radius:50%;background:#8BC8EA;flex:0 0 auto;"></span><span style="flex:1;font-size:12.5px;color:#1A1B1C;">一般 · 死代码/健壮性</span><b style="font-size:13px;color:#1A1B1C;">10</b></div>
|
||||
<div style="display:flex;align-items:center;gap:8px;"><span style="width:8px;height:8px;border-radius:50%;background:#E4E3DD;flex:0 0 auto;"></span><span style="flex:1;font-size:12.5px;color:#1A1B1C;">轻微 · 清理项</span><b style="font-size:13px;color:#1A1B1C;">5</b></div>
|
||||
</div>
|
||||
<div style="font-size:12px;color:#6B7280;margin:14px 0 6px;">按模块</div>
|
||||
<div style="display:flex;flex-direction:column;gap:4px;">
|
||||
<div style="display:flex;align-items:center;gap:6px;"><span style="width:110px;font-size:12px;color:#1A1B1C;">DataScreen 数据页</span><div style="flex:1;height:10px;background:#F4F3EE;border-radius:5px;"><div style="width:32%;height:10px;background:#EA6668;border-radius:5px;"></div></div><b style="font-size:11px;color:#1A1B1C;">9</b></div>
|
||||
<div style="display:flex;align-items:center;gap:6px;"><span style="width:110px;font-size:12px;color:#1A1B1C;">企业数据(4 源)</span><div style="flex:1;height:10px;background:#F4F3EE;border-radius:5px;"><div style="width:25%;height:10px;background:#FAAD14;border-radius:5px;"></div></div><b style="font-size:11px;color:#1A1B1C;">7</b></div>
|
||||
<div style="display:flex;align-items:center;gap:6px;"><span style="width:110px;font-size:12px;color:#1A1B1C;">部署/文档</span><div style="flex:1;height:10px;background:#F4F3EE;border-radius:5px;"><div style="width:21%;height:10px;background:#FAAD14;border-radius:5px;"></div></div><b style="font-size:11px;color:#1A1B1C;">6</b></div>
|
||||
<div style="display:flex;align-items:center;gap:6px;"><span style="width:110px;font-size:12px;color:#1A1B1C;">语音/死代码</span><div style="flex:1;height:10px;background:#F4F3EE;border-radius:5px;"><div style="width:14%;height:10px;background:#8BC8EA;border-radius:5px;"></div></div><b style="font-size:11px;color:#1A1B1C;">4</b></div>
|
||||
<div style="display:flex;align-items:center;gap:6px;"><span style="width:110px;font-size:12px;color:#1A1B1C;">Tauri/Rust</span><div style="flex:1;height:10px;background:#F4F3EE;border-radius:5px;"><div style="width:7%;height:10px;background:#8BC8EA;border-radius:5px;"></div></div><b style="font-size:11px;color:#1A1B1C;">2</b></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="flex:1 1 260px;min-width:0;border-left:1px solid #E4E3DD;padding-left:16px;box-sizing:border-box;">
|
||||
<div style="font-size:12px;color:#6B7280;margin-bottom:6px;">核心结论</div>
|
||||
<div style="font-size:12.5px;line-height:1.6;color:#1A1B1C;">
|
||||
<b style="color:#EA6668;">营收 252.15 万元被显示为"252.2 元"</b>(同页 4 处单位打架),ShowCard 再除 10000 显示"0.0 亿"——<b>大屏核心数字口径错误,参观即露馅</b>。<br/><br/>
|
||||
企业数据 4 套独立维护、分区命名 3 套体系,同一企业"云南上古绝学"在两页分区互相矛盾;README/compose/.env 停留在旧架构(:10085),生产 MQTT 指向 192.168.1.3 需核实。<br/><br/>
|
||||
648 行语音页 + 1342 行 s2s 客户端未注册路由(不可达);构建通过但主 chunk 867KB + Building3D 958KB。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="font-size:11px;color:#6B7280;margin-top:10px;">数据来源:park-desktop 源码逐文件审查(2026-09-16)· 构建 npm run build 通过</div>
|
||||
</div>
|
||||
</html>
|
||||
Reference in New Issue
Block a user