Compare commits

..

19 Commits

Author SHA1 Message Date
Pine 244ffe8f31 docs(readme): 对齐 park-desktop README 至 2026-10 实现现状(后端并入 server-core /park) 2026-10-21 17:57:46 +08:00
Pine a2fbad8169 chore: 同步园区大屏改动(config/main)及审查问题分布报告 2026-10-08 16:51:38 +08:00
Pine 63ad0db844 feat(media): 播放列表带设备 token 请求(园区隔离)+ 播放优先使用 CDN 直链
- api.js getPlaylist:附 Authorization 设备 token(?tenant_id 兜底),服务端按绑定园区返回播放列表
- MediaScreen getUrl:item.url(CDN/OSS 直链)优先,其次 URL 来源,最后回退 /file 静态
2026-09-16 23:18:44 +08:00
Pine 87e57ea486 fix(mqtt): MQTT 连接成功后自动周期上报心跳(绑定页/主屏通用)
- mqtt.js:connect 后立即+每10s 上报 opc/display/heartbeat(client_id/tenant_id/page),
  绑定页(BindScreen)不再只连不报——admin 端屏幕管理即可看到在线大屏;
  断线 close 时清定时器,重连后自动恢复
- useMqttControl.js:移除重复心跳(避免双发),保留注册/状态上报/订阅逻辑
2026-09-16 22:34:28 +08:00
Pine 16586ddb2e feat(env): 新增 .env.development,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 —— 上一轮的 .env.production 对 tauri dev 无效
- 本文件优先级高于 .env.local,覆盖其烘焙的局域网测试地址(192.168.1.9:10085 → legacy 丢弃 → 回退 192.168.1.9:8090)
- VITE_API_BASE=https://opc.pinesound.cn/park;MQTT 三件套置空,由 /park/api/config 下发生产地址 + DPM 账号
2026-09-16 22:21:32 +08:00
Pine b0c1c7209b fix(config): 生产构建(Tauri打包)默认回退生产 opc.pinesound.cn + 启动地址诊断日志
- resolveHost:生产构建且运行于 Tauri WebView 时默认回退 opc.pinesound.cn(不再回退局域网 192.168.1.9),
  即使 config.json / .env 缺失也能接入生产;本地 dev 仍回退局域网
- main.jsx:bootstrap 后打印最终生效 API_BASE / MQTT_URL / MQTT_USER,便于定位地址切换问题
- 已验证构建产物:烘焙 https://opc.pinesound.cn/park,无 192.168.1.9:8090 / 192.168.1.3:8083 残留
2026-09-16 22:19:57 +08:00
Pine 43bb33cc1a feat(env): 新增 .env.production 生产构建配置,大屏默认接入 https://opc.pinesound.cn
- VITE_API_BASE=https://opc.pinesound.cn/park(直连生产 server-core /park 子应用)
- VITE_MQTT_URL/USERNAME/PASSWORD 显式置空:覆盖 .env.local 烘焙的局域网测试地址,
  MQTT 地址与 DPM 专用账号统一由后端 /park/api/config 下发(避免大屏连到测试 EMQX)
- Vite 加载顺序 .env < .env.local < .env.production:生产构建覆盖本地,本地 dev 不受影响
- 已验证:构建产物烘焙 opc.pinesound.cn,无 192.168.1.3:8083 / 192.168.1.9:10085 残留
2026-09-16 22:15:34 +08:00
Pine 9287c79156 fix(task-display): 空数据时显示「暂无上墙任务」提示,避免任务中心空白页 2026-09-16 22:09:24 +08:00
Pine d1144e2673 brand: favicon 改用 logo.png 2026-08-27 19:53:45 +08:00
Pine 5c3ac97304 uimark: 更换品牌 logo.png 为新版品牌标识 2026-08-27 19:44:12 +08:00
Pine 8ee08854cd feat(screen): 园区大屏园区资讯 widget
- ParkFeed 拉取 /park/api/content/feed,滚动展示本园区内容标题;图标用 @appica/icons-react(禁用emoji)
- DataScreen 挂载 ParkFeed

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-27 15:53:38 +08:00
Pine 64b998eb2a fix(tasks): 右上角仅保留翻页倒计时,去掉总数/页码
- TaskDisplay.jsx tasks-head-info 精简为只剩「翻页 Ns」。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-25 20:33:33 +08:00
Pine 327e09c2a0 feat(tasks): 切换页加「获取最新任务中」刷新状态 + 旋转动画,1.2s 后切换
- TaskDisplay.jsx:displayedPage 与 page 分离,页切换先置 switching 展示加载遮罩,SWITCH_MS(1200ms) 后再切到新页;task_refresh 亦播放一次。
- task-display.css:tasks-screen position:relative;+ tasks-switch-overlay / tasks-spinner(旋转) / tasks-switch-text。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-25 20:21:18 +08:00
Pine b00ecbdca1 fix(tasks): 右上角信息改一行平铺 —— 总数·翻页倒计时·页码·接单说明(去掉芯片上下布局)
- TaskDisplay.jsx:tasks-head-info 改 inline 一行(总数/翻页s/页码/说明),加分隔点。
- task-display.css:tasks-info* 改为无芯片的一行 baseline 布局,去列叠。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-25 20:15:58 +08:00
Pine b121eb748b feat(tasks): 任务页右上角信息面板 —— 说明/总数/轮播倒计时/页码
- TaskDisplay.jsx:右上角加 总数、翻页倒计时(s, 每秒递减翻页时复位)、页码 x/y、接单方式说明。
- task-display.css 加 tasks-head-info / tasks-info-* 样式。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-25 19:55:36 +08:00
Pine 953441806d fix(tasks): 任务展示页一屏固定 2 行 —— 2×2=4 或 2×3=6
- 布局改为固定 2 行;宽≥1600→3 列(2×3=6),否则 2 列(2×2=4)。
- grid repeat(cols,1fr) + grid-template-rows repeat(2,minmax(0,1fr)),两行均分高度。
- columnsFor() 改返回 3/2 列;pageSize=cols*2;不足一页按行补位。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-25 19:51:52 +08:00
Pine e57bbe67a4 feat(tasks): 任务展示页自适应 —— 一屏 4 或 6 张(按屏幕宽度计算)
- TaskDisplay.jsx:视口宽度→列数(宽≥1600→6,否则 4),grid repeat(cols,1fr),每页 cols 张翻页;导出 columnsFor();移除固定 PAGE_SIZE=3。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-25 19:42:08 +08:00
Pine 75f50cebba feat(tasks): 大屏任务卡展示增强 —— 接单方式/截止/交付时限/人数/独占/发包方
- taskData.js normalizeTask 加 mode/modeLabel/deadline/deliveryDays/headcount/exclusive/publisherName。
- TaskDisplay.jsx 卡片增 接单方式+预算、截止/交付时限/独占|人数 徽标、发包方。
- task-display.css 加 task-meta 行样式。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-25 19:30:34 +08:00
Pine d61b59acf5 feat(bigscreen): 任务展示页 + 暂时关闭语音对话页 + 导航/文档同步
- App.jsx 移除 /voice、新增 /tasks→TaskDisplay。
- pageNav PAGE_ORDER 移除 voice、加 tasks(方向键/页眉/MQTT navigate_rel 自动跟随)。
- useMqttControl:PAGE_ALIAS 加 tasks;移除 voice_*、改 task_refresh。
- PageHeader STATUS_BY_PATH 加 /tasks。
- 新增 TaskDisplay.jsx + task-display.css:轮播卡(分类/状态/标题/摘要/标签/预算/任务ID+QRCodeSVG 扫码接单)、useTasks 轮询 /park/api/tasks、接受 MQTT task_refresh。
- 新增 utils/taskData.js(useTasks, tenantFetch 带租户身份)。
- docs/mqtt-commands.md:navigate 列表/语音章节标注语音页关闭、tasks 页指令。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-25 18:42:32 +08:00
25 changed files with 792 additions and 222 deletions
+13
View File
@@ -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=
+12
View File
@@ -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=
+68 -154
View File
@@ -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)解决方案,包含两大界面: > 园区后端已并入 **server-core `/park`**(不再有独立 `:10085` 后端);管理后台为后端 **FastAPI + Jinja** 页面(`GET /admin`),**不由 React 路由接管**。实时控制走 **MQTT(EMQX `:8083`)**,不再使用内置 Axum HTTP 服务 / SSE / nps。
- **大屏展示页** (`/screen`) — 全屏自动轮播,支持视频与图片,具备沉浸式空间视觉背景
- **管理后台** (`/admin`) — 登录保护的管理面板,用于媒体上传、播放列表编排、播放控制与系统设置
系统通过 **SSE(Server-Sent Events)** 实现管理端与展示端的实时状态同步,支持局域网内远程管理。
## 技术栈 ## 技术栈
| 层级 | 技术 | | 层级 | 技术 |
|------|------| |------|------|
| 前端框架 | React 19 + React Router 7 | | 前端框架 | React 19 + React Router 7 |
| 构建工具 | Vite 7 | | 构建工具 | Vite 7 + Tailwind 4.3(`@tailwindcss/vite`) |
| 桌面框架 | Tauri 2 (Rust) | | 桌面框架 | Tauri 2(Rust,`src-tauri/`,本地 `wry` 补丁) |
| HTTP 服务 | Axum 0.8 (嵌入式 HTTP 服务器) | | 三维 / 可视化 | three 0.185(`@react-three/fiber` / `drei`)、`@appica/ui-react` |
| 数据持久化 | JSON 文件存储 | | 实时通信 | MQTT 5.15(`mqtt.js`,EMQX) |
| 实时通信 | Server-Sent Events (SSE) | | 二维码 | `qrcode.react` |
| 内网穿透 | nps 客户端嵌入式集成 |
## 功能特性 ## 页面(`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` | 入驻企业展示墙 |
### 播放控制 - 统一页眉布局 `components/ScreenLayout`;`/voice` 有 `VoiceAssistant` 组件但**当前未挂路由**。
- **播放/暂停** — 实时控制展示端的播放状态 - 首启可走 **屏幕绑定**(`components/BindScreen` + `utils/tenant`,绑定后按租户拉数据);支持双屏(`set_dual_screen`)、全屏、开机自启(`tauri-plugin-autostart`)、应用内自动更新(`components/UpdateNotify` + `utils/updateCheck`)。
- **上一曲/下一曲** — 切换播放内容
- **顺序/随机播放** — 两种播放模式
- **图片时长可调** — 1~300 秒
- **音量控制** — 实时同步调节
### 展示端体验 ## 功能概览
- **沉浸式空间背景** — 深邃星空背景 + 透视网格 + 动态环境光晕
- **自动播放** — 启动后自动加载播放列表
- **视频/图片自适应** — 全屏覆盖显示
- **声音管理** — 浏览器自动播放策略处理
- **点击暂停/恢复** — 现场快速控制
### 系统功能 - **媒体轮播**(`pages/MediaScreen.jsx`):视频/图片全屏轮播、沉浸式空间背景、自动播放、点击暂停/恢复、音量与顺序/随机播放、播放状态上报(`opc/display/*` MQTT 主题)。
- **全屏/窗口模式切换** — 自由切换展示形态 - **任务大屏**(`pages/TaskDisplay.jsx`):任务轮播卡 + 二维码扫码接单。
- **最小化窗口** — 后台运行 - **园区数据 / 数字孪生 / 企业墙**:数据快照(`opc/dashboard/tick`)、3D 园区、入驻企业展示。
- **开机自启动** — 系统级自启(Tauri autostart 插件) - **AI 助手**:对话面板 + 语音 s2s(`src/voice/`)。
- **关闭 → 最小化** — 防止误关闭 - **设备与运维**:屏幕绑定/租户隔离、心跳上报(`opc/display/heartbeat`,后端统计在线)、双屏、全屏、开机自启、自动更新。
- **内网穿透** — 内置 nps 客户端,支持外网远程访问 - **管理后台**:媒体上传、播放列表编排与播放控制等由 **server-core `/park`** 的后端 `/admin` 页面提供(前端不再内置管理页)。
### 管理后台
- **登录保护** — 账号密码验证
- **实时状态显示** — 当前播放内容、状态
- **上传进度条** — 文件上传实时反馈
- **响应式设计** — 桌面/移动端自适应
- **Toast 通知** — 操作即时反馈
## 快速开始 ## 快速开始
### 环境要求 ### 环境要求
- **Rust** (edition 2021) - **Rust**(edition 2021)、**Node.js** >= 18、**Yarn**(或 npm)、Tauri 2 CLI(`yarn tauri` 走本地依赖)
- **Node.js** >= 18
- **Yarn** 或 npm
- **Tauri CLI** (`cargo install tauri-cli`)
### 安装 ### 安装与运行
```bash ```bash
# 安装前端依赖
yarn install yarn install
# 开发模式运行(同时启动 Vite 开发服务器和 Tauri 应用) yarn dev # 仅启动 Vite(http://localhost:1420,浏览器预览大屏)
yarn tauri dev yarn tauri dev # Tauri 桌面端(前端 + Rust 壳)
yarn build # 前端产物 dist/
# 仅启动 Web 开发服务器(浏览器中预览) yarn tauri build # 桌面安装包,产物在 src-tauri/target/release/
yarn dev
``` ```
### 构建生产版本 > ⚠️ 按团队铁律:**AI 不启动/重启任何服务(含 vite / Tauri)**,需要时通知用户亲自操作。
```bash ### 配置(`src/config.js`)
yarn tauri build
```
构建产物位于 `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
├── index.html # HTML 入口 package.json # 包名 dpm
├── package.json # 前端依赖配置 vite.config.js # 端口 1420;无 dev 代理(API 由 src/config.js 直连 server-core)
├── vite.config.js # Vite 构建配置(API 代理到 :10801) public/ # logo、语音 worklets 等静态资源
├── public/ src/
│ └── npc.zip # 内网穿透客户端(嵌入二进制) main.jsx # React 入口 + 运行时配置引导 + 致命错误兜底
├── src/ # 前端源代码 App.jsx # 6 个大屏路由
│ ├── main.jsx # React 入口 config.js # API / MQTT / Vision / bootstrap 令牌解析
│ ├── App.jsx # 路由配置(/admin, /screen) pages/ # DataScreen / DigitalTwin / AiAssistant / MediaScreen / TaskDisplay / CompanyWall
│ ├── pages/ components/ # ScreenLayout / BindScreen / PageHeader / 各类面板、弹窗
│ │ ├── Admin.jsx # 管理后台页面 utils/ # api.js、mqtt.js、tenant.js、taskData.js、updateCheck.js、pageNav.js、sounds.js
│ │ └── Screen.jsx # 大屏展示页面 voice/ # s2s 语音客户端、orb 可视化、worklets
│ ├── utils/ styles/ # tokens/global/datascreen/mediascreen/task-display 等
│ │ └── api.js # API 封装(REST + SSE) src-tauri/
│ └── styles/ Cargo.toml # tauri 2 + opener/autostart + 本地 wry 补丁
│ ├── tokens.css # CSS 基础重置 tauri.conf.json # productName「云超服昆创园OPC运营中心」,1200x800 全屏
│ ├── admin.css # 管理后台样式 capabilities/default.json # 窗口/自启/opener 权限
│ └── screen.css # 大屏展示样式 src/{main.rs,lib.rs} # 入口;命令:set_dual_screen / install_update / read_runtime_config
└── 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 事件广播管理器
``` ```
## 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 前端开发服务器(`strictPort`) |
| 1420 | Vite 前端开发服务器 | | 8090 | server-core(园区 API 前缀 `/park`) |
| 8083 | MQTT Broker(EMQX,`/mqtt`) |
### Vite 开发代理 | 10085 | **已废弃**的旧园区后端(源码已回退到统一入口,勿再使用) |
开发模式下,Vite 自动将 `/api`、`/file`、`/media`、`/upload` 路径代理到 `localhost:10801`,前端无需关心跨域问题。
### 构建
前端构建产物(`dist/`)通过 `RustEmbed` 编译到 Rust 二进制中,发布时只需分发单个可执行文件。
## 许可 ## 许可
+5 -5
View File
@@ -42,7 +42,7 @@
| action | params | 效果 | | action | params | 效果 |
|---|---|---| |---|---|---|
| `navigate` | `page: "/" \| "/twin" \| "/ai" \| "/voice" \| "/wall" \| "/screen"`(或别名 home/twin/ai/voice/wall/screen/数据大屏/企业展示墙…) | 切换到指定页面 | | `navigate` | `page: "/" \| "/twin" \| "/ai" \| "/tasks" \| "/wall" \| "/screen"`(或别名 home/twin/ai/tasks/wall/screen/数据大屏/任务展示/企业展示墙…) | 切换到指定页面 |
| `navigate_rel` | `delta: 1 \| -1` | 向右 / 向左循环切换页面 | | `navigate_rel` | `delta: 1 \| -1` | 向右 / 向左循环切换页面 |
### 4.2 全局视觉识别 ### 4.2 全局视觉识别
@@ -60,13 +60,13 @@
| `ai_company` | `action: "next" \| "prev"` | 切换展示企业 | | `ai_company` | `action: "next" \| "prev"` | 切换展示企业 |
| `ai_zone` | `zone: "加速区" \| "国际区" \| "成长区"` | 切换企业分区 | | `ai_zone` | `zone: "加速区" \| "国际区" \| "成长区"` | 切换企业分区 |
### 4.4 语音对话页(/voice) ### 4.4 任务展示页(/tasks)
| action | params | 效果 | | action | params | 效果 |
|---|---|---| |---|---|---|
| `voice_start` | — | 启动语音对话 | | `task_refresh` | — | 触发任务页刷新重拉 |
| `voice_stop` | — | 关闭语音对话 |
| `voice_refresh` | — | 刷新页面 | > 语音对话页(/voice)已**暂时关闭**,`voice_start/stop/refresh` 不再处理。
### 4.5 媒体轮播页(/screen) ### 4.5 媒体轮播页(/screen)
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/png" href="/logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>大屏媒体轮播系统</title> <title>大屏媒体轮播系统</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 2.4 MiB

+3 -3
View File
@@ -3,8 +3,8 @@ import MediaScreen from './pages/MediaScreen';
import DataScreen from './pages/DataScreen'; import DataScreen from './pages/DataScreen';
import DigitalTwin from './pages/DigitalTwin'; import DigitalTwin from './pages/DigitalTwin';
import AiAssistant from './pages/AiAssistant'; import AiAssistant from './pages/AiAssistant';
import VoiceAssistant from './pages/VoiceAssistant';
import CompanyWall from './pages/CompanyWall'; import CompanyWall from './pages/CompanyWall';
import TaskDisplay from './pages/TaskDisplay';
import ScreenLayout from './components/ScreenLayout'; import ScreenLayout from './components/ScreenLayout';
import './styles/tokens.css'; import './styles/tokens.css';
@@ -18,8 +18,8 @@ export default function App() {
<Route path="/twin" element={<DigitalTwin />} /> <Route path="/twin" element={<DigitalTwin />} />
<Route path="/ai" element={<AiAssistant />} /> <Route path="/ai" element={<AiAssistant />} />
<Route path="/screen" element={<MediaScreen />} /> <Route path="/screen" element={<MediaScreen />} />
{/* 实时语音对话页(React 原生,复用页眉) */} {/* 任务展示页(系统任务中心:轮播卡 + 二维码扫码接单) */}
<Route path="/voice" element={<VoiceAssistant />} /> <Route path="/tasks" element={<TaskDisplay />} />
{/* 入驻企业展示墙 */} {/* 入驻企业展示墙 */}
<Route path="/wall" element={<CompanyWall />} /> <Route path="/wall" element={<CompanyWall />} />
</Route> </Route>
+30 -3
View File
@@ -1,4 +1,6 @@
import { useEffect } from 'react'; import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { unbindDevice } from '../utils/tenant';
/* ========================================================= /* =========================================================
AboutDialog —— 应用关于弹窗(PineSound 品牌) AboutDialog —— 应用关于弹窗(PineSound 品牌)
@@ -20,6 +22,8 @@ const APP_INFO = {
}; };
export default function AboutDialog({ open, onClose }) { export default function AboutDialog({ open, onClose }) {
const [busy, setBusy] = useState(false);
const [confirming, setConfirming] = useState(false);
// Esc 关闭 // Esc 关闭
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
@@ -27,9 +31,25 @@ export default function AboutDialog({ open, onClose }) {
window.addEventListener('keydown', onKey); window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey);
}, [open, onClose]); }, [open, onClose]);
// 关闭弹窗时重置确认/忙碌态,下次打开保持初始
useEffect(() => {
if (!open) { setConfirming(false); setBusy(false); }
}, [open]);
if (!open) return null; if (!open) return null;
return (
// 两步确认(不用 window.confirm —— Tauri WebView 的原生 confirm 不可靠/可能返回假值):
// 第一次点击进入确认态,再次点击才真正执行解绑;加载中禁止重复触发。
const handleUnbind = async () => {
if (busy) return;
if (!confirming) { setConfirming(true); return; }
setBusy(true);
await unbindDevice(); // 通知服务端解除 → 清本地 → 刷新回绑定页(内部有超时,不卡顿)
};
// Portal 到 body:页眉含 backdrop-filter,会成为 position:fixed 的包含块,
// 使弹窗叠在页眉盒子上;portal 到 body 后方能真正以视口为基准居中。
return createPortal(
<div className="about-overlay" onClick={onClose} role="dialog" aria-modal="true"> <div className="about-overlay" onClick={onClose} role="dialog" aria-modal="true">
<div className="about-modal" onClick={(e) => e.stopPropagation()}> <div className="about-modal" onClick={(e) => e.stopPropagation()}>
<button className="about-close" type="button" aria-label="关闭" onClick={onClose}>×</button> <button className="about-close" type="button" aria-label="关闭" onClick={onClose}>×</button>
@@ -50,10 +70,17 @@ export default function AboutDialog({ open, onClose }) {
</div> </div>
<div className="about-meta"> <div className="about-meta">
<div className="about-meta-info">
<span>版本 v{APP_INFO.version}</span> <span>版本 v{APP_INFO.version}</span>
<span>{APP_INFO.copyright}</span> <span>{APP_INFO.copyright}</span>
{confirming && !busy && <span className="about-unbind-hint">再次点击确认解除,点击空白处取消</span>}
</div>
<button className="about-unbind" type="button" disabled={busy} onClick={handleUnbind}>
{busy ? '解除中…' : confirming ? '确认解除绑定' : '解除绑定'}
</button>
</div> </div>
</div> </div>
</div> </div>,
document.body
); );
} }
+1
View File
@@ -19,6 +19,7 @@ const STATUS_BY_PATH = {
'/': '数据实时', '/': '数据实时',
'/twin': '3D 实时', '/twin': '3D 实时',
'/ai': 'AI 助手', '/ai': 'AI 助手',
'/tasks': '任务中心',
'/screen': '媒体播放', '/screen': '媒体播放',
}; };
+33
View File
@@ -0,0 +1,33 @@
import React, { useEffect, useState } from 'react';
import { Broadcast } from '@appica/icons-react';
import { getApiBase } from '../config';
import '../styles/datascreen.css';
/** 园区资讯 widget:拉取本园区内容(/park/api/content/feed),滚动展示标题(大屏)。 */
export default function ParkFeed() {
const [items, setItems] = useState([]);
useEffect(() => {
let alive = true;
fetch(`${getApiBase()}/api/content/feed`, { cache: 'no-store' })
.then((r) => r.json())
.then((d) => { if (alive) setItems(Array.isArray(d?.items) ? d.items : []); })
.catch(() => {});
return () => { alive = false; };
}, []);
if (!items.length) return null;
return (
<div className="bd-feed" style={{ marginTop: 12 }}>
<div className="bd-ticker-title"><Broadcast size={12} style={{ verticalAlign: '-1px', marginRight: 6 }} />园区资讯 <span>({items.length})</span></div>
<div className="bd-ticker">
<div className="bd-ticker-inner">
{[...items, ...items].map((c, i) => (
<div className="bd-ticker-item" key={i} title={c.title}>
<span className="bd-ticker-dot" />
{c.title}
</div>
))}
</div>
</div>
</div>
);
}
+11 -5
View File
@@ -11,10 +11,11 @@ const env = import.meta.env || {};
/* 运行期解析(惰性读取 window.__DPM_*__,供 main.jsx bootstrap 覆盖后再生效) */ /* 运行期解析(惰性读取 window.__DPM_*__,供 main.jsx bootstrap 覆盖后再生效) */
function resolveHost() { function resolveHost() {
const h = window.location.hostname || '192.168.1.9'; const h = window.location.hostname || 'opc.pinesound.cn';
// Tauri 打包 WebView(tauri.localhost / localhost)→ 服务同机部署用 192.168.1.9 // Tauri 打包 WebView(tauri.localhost / localhost)→ 生产构建默认接入生产 opc.pinesound.cn;
if (h === 'tauri.localhost' || h === 'localhost' || h === '192.168.1.9' || h.includes('tauri')) { // 本地开发(npm run dev)仍回退局域网测试机 192.168.1.9
return '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 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 _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; 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 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_USERNAME = window.__DPM_MQTT_USER__ || env.VITE_MQTT_USERNAME || '';
export const MQTT_PASSWORD = window.__DPM_MQTT_PASS__ || env.VITE_MQTT_PASSWORD || ''; 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) */ /** 惰性读取 API 基址(仅接受 server-core 地址,旧 :10085 一律回退到 :8090/park) */
export function getApiBase() { export function getApiBase() {
+9 -2
View File
@@ -5,7 +5,7 @@ import BindScreen from "./components/BindScreen.jsx";
import { isDeviceBound } from "./utils/tenant"; import { isDeviceBound } from "./utils/tenant";
import ErrorBoundary from "./components/ErrorBoundary"; import ErrorBoundary from "./components/ErrorBoundary";
import { ThemeProvider } from "@appica/ui-react/providers/theme-provider"; 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 { invoke } from "@tauri-apps/api/core";
import "./styles/global.css"; import "./styles/global.css";
@@ -62,7 +62,10 @@ async function bootstrapRuntimeConfig() {
// 则后端 /api/config 不再覆盖(避免后端默认 localhost 冲掉正确地址)。 // 则后端 /api/config 不再覆盖(避免后端默认 localhost 冲掉正确地址)。
const bakedMqtt = import.meta.env.VITE_MQTT_URL; const bakedMqtt = import.meta.env.VITE_MQTT_URL;
try { 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) { if (res.ok) {
const cfg = await res.json(); const cfg = await res.json();
if (!bakedMqtt && cfg.mqtt_url) window.__DPM_MQTT__ = cfg.mqtt_url; if (!bakedMqtt && cfg.mqtt_url) window.__DPM_MQTT__ = cfg.mqtt_url;
@@ -80,6 +83,10 @@ async function bootstrapRuntimeConfig() {
} }
bootstrapRuntimeConfig().then(() => { 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( ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode> <React.StrictMode>
<ErrorBoundary> <ErrorBoundary>
+18 -8
View File
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react';
import '../styles/datascreen.css'; import '../styles/datascreen.css';
import Icon from '../components/Icons'; import Icon from '../components/Icons';
import ParkOverviewStrip from '../components/ParkOverviewStrip'; import ParkOverviewStrip from '../components/ParkOverviewStrip';
import ParkFeed from '../components/ParkFeed';
import { useParkSim } from '../utils/parkData'; import { useParkSim } from '../utils/parkData';
/* ========================================================= /* =========================================================
@@ -43,8 +44,15 @@ const TOOL_RANK = [
]; ];
/* ---------- 工具函数 ---------- */ /* ---------- 工具函数 ---------- */
const fmtInt = (n) => Math.round(n).toLocaleString('zh-CN'); const fmtInt = (n) => (Number.isFinite(Number(n)) ? Math.round(Number(n)).toLocaleString('zh-CN') : '--');
const fmtWan = (n) => (n >= 10000 ? (n / 10000).toFixed(2) + '亿' : n >= 100 ? n.toFixed(1) + '万' : n.toFixed(1)); /** 万/亿格式化:非有限数值(缺失/不一致口径)安全显示 '--',避免崩溃 */
const fmtWan = (n) => {
const v = Number(n);
if (!Number.isFinite(v)) return '--';
return (v >= 10000 ? (v / 10000).toFixed(2) + '亿' : v >= 100 ? v.toFixed(1) + '万' : v.toFixed(1));
};
/** 安全取数:缺失/非数值回退 0,供直接 .toFixed 调用点使用 */
const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0);
/* 数据源:后端 /api/dashboard/snapshot(离线时本地模拟兜底),见 utils/parkData.js */ /* 数据源:后端 /api/dashboard/snapshot(离线时本地模拟兜底),见 utils/parkData.js */
@@ -265,7 +273,7 @@ function TodayOverview({ d }) {
</div> </div>
<div className="bd-cell bd-cell-tools"> <div className="bd-cell bd-cell-tools">
<KpiCard tone="tool" icon="wrench" label="工具调用" value={d.tools.today} unit="次" <KpiCard tone="tool" icon="wrench" label="工具调用" value={d.tools.today} unit="次"
sub={`累计 ${(d.tools.total / 10000).toFixed(1)}万次 · 成功率 ${d.tools.success}%`} /> sub={`累计 ${(num(d.tools.total) / 10000).toFixed(1)}万次 · 成功率 ${d.tools.success}%`} />
</div> </div>
<div className="bd-cell bd-cell-jobs"> <div className="bd-cell bd-cell-jobs">
<KpiCard tone="job" icon="users" label="带动就业" value={d.jobs.total} unit="人" <KpiCard tone="job" icon="users" label="带动就业" value={d.jobs.total} unit="人"
@@ -294,10 +302,10 @@ function TokenBig({ d }) {
</div> </div>
} /> } />
<div className="bd-real-metrics"> <div className="bd-real-metrics">
<span>首 token 平均 <b>{d.live.firstToken.toFixed(1)}s</b></span> <span>首 token 平均 <b>{num(d.live.firstToken).toFixed(1)}s</b></span>
<span>吞吐 <b>{d.live.throughput}</b> tok/s</span> <span>吞吐 <b>{d.live.throughput}</b> tok/s</span>
<span>缓存命中 <b>{d.live.cacheHit.toFixed(1)}%</b></span> <span>缓存命中 <b>{num(d.live.cacheHit).toFixed(1)}%</b></span>
<span>会话输入 <b>{d.live.inputM.toFixed(1)}M</b> / 输出 <b>{d.live.outputM.toFixed(1)}M</b> tok</span> <span>会话输入 <b>{num(d.live.inputM).toFixed(1)}M</b> / 输出 <b>{num(d.live.outputM).toFixed(1)}M</b> tok</span>
<span><b>{d.live.rounds}</b> 轮 · <b>{d.live.steps}</b> 步</span> <span><b>{d.live.rounds}</b> 轮 · <b>{d.live.steps}</b> 步</span>
</div> </div>
<div className="bd-big-body"> <div className="bd-big-body">
@@ -338,7 +346,7 @@ function ToolsBig({ d }) {
<CardHead title="工具调用" icon="wrench" right={ <CardHead title="工具调用" icon="wrench" right={
<div className="bd-head-stats"> <div className="bd-head-stats">
<span className="bd-ost-chip"><i>今日调用</i><b>{fmtInt(d.tools.today)} 次</b></span> <span className="bd-ost-chip"><i>今日调用</i><b>{fmtInt(d.tools.today)} 次</b></span>
<span className="bd-ost-chip"><i>累计调用</i><b>{(d.tools.total / 10000).toFixed(1)}万 次</b></span> <span className="bd-ost-chip"><i>累计调用</i><b>{(num(d.tools.total) / 10000).toFixed(1)}万 次</b></span>
<span className="bd-ost-chip"><i>成功率</i><b>{d.tools.success}%</b></span> <span className="bd-ost-chip"><i>成功率</i><b>{d.tools.success}%</b></span>
<span className="bd-ost-chip"><i>服务节点</i><b>{d.tools.nodes} / {d.tools.nodes} 在线</b></span> <span className="bd-ost-chip"><i>服务节点</i><b>{d.tools.nodes} / {d.tools.nodes} 在线</b></span>
</div> </div>
@@ -346,7 +354,7 @@ function ToolsBig({ d }) {
<div className="bd-big-body"> <div className="bd-big-body">
<div className="bd-big-right bd-big-stats"> <div className="bd-big-right bd-big-stats">
<div className="bd-big-stat"><span>今日调用</span><b>{fmtInt(d.tools.today)}<em>次</em></b></div> <div className="bd-big-stat"><span>今日调用</span><b>{fmtInt(d.tools.today)}<em>次</em></b></div>
<div className="bd-big-stat"><span>累计调用</span><b>{(d.tools.total / 10000).toFixed(1)}<em>万次</em></b></div> <div className="bd-big-stat"><span>累计调用</span><b>{(num(d.tools.total) / 10000).toFixed(1)}<em>万次</em></b></div>
<div className="bd-big-stat"><span>成功率</span><b>{d.tools.success}<em>%</em></b></div> <div className="bd-big-stat"><span>成功率</span><b>{d.tools.success}<em>%</em></b></div>
<div className="bd-big-stat"><span>AI 服务节点</span><b>12<em>/12 在线</em></b></div> <div className="bd-big-stat"><span>AI 服务节点</span><b>12<em>/12 在线</em></b></div>
</div> </div>
@@ -569,6 +577,8 @@ export default function DataScreen() {
</div> </div>
</section> </section>
<ParkFeed />
{/* 园区概览(保持在最底部) */} {/* 园区概览(保持在最底部) */}
<ParkOverviewStrip d={d} /> <ParkOverviewStrip d={d} />
</div> </div>
+5 -3
View File
@@ -61,7 +61,9 @@ export default function MediaScreen() {
useEffect(() => { soundBlockedRef.current = soundBlocked; }, [soundBlocked]); useEffect(() => { soundBlockedRef.current = soundBlocked; }, [soundBlocked]);
const getUrl = useCallback((item) => { 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}`; return item.source === 'url' ? item.relative_path : `${getApiBase()}/file/${item.relative_path}`;
}, []); }, []);
@@ -202,7 +204,7 @@ export default function MediaScreen() {
const load = async () => { const load = async () => {
try { try {
const data = await api.getPlaylist(); const data = await api.getPlaylist();
const files = data.files || []; const files = data.playlist || data.files || [];
const vol = (data.volume || 80) / 100; const vol = (data.volume || 80) / 100;
setList(files); setList(files);
setVolume(vol); setVolume(vol);
@@ -284,7 +286,7 @@ export default function MediaScreen() {
const reloadPlaylist = useCallback(async () => { const reloadPlaylist = useCallback(async () => {
try { try {
const data = await api.getPlaylist(); const data = await api.getPlaylist();
const newList = data.files || []; const newList = data.playlist || data.files || [];
const currentItem = listRef.current[indexRef.current] || null; const currentItem = listRef.current[indexRef.current] || null;
if (newList.length === 0) { if (newList.length === 0) {
+179
View File
@@ -0,0 +1,179 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { QRCodeSVG } from 'qrcode.react';
import { useTasks } from '../utils/taskData';
import '../styles/task-display.css';
/* 任务展示页:轮播卡片 + 二维码扫码接单。
· 数据:useTasks() 轮询 /park/api/tasks(系统任务中心上墙任务)
· 布局:一屏固定 2 行;宽度 ≥1600 → 3 列(2×3=6 张),否则 → 2 列(2×2=4 张)
· 卡片:分类/状态徽标、标题、摘要(2行)、标签、预算、任务ID + 二维码(扫码即领)
· 轮播:每页 列数×行数 张,自动翻页;MQTT task_refresh 可触发刷新 */
const ROTATE_MS = 14_000;
const ROWS = 2; // 一屏固定 2 行
/* 按宽度计算列数:宽≥1600→3 列,否则 2 列。导出便于测试。 */
export function columnsFor(width) {
const w = width || (typeof window !== 'undefined' ? window.innerWidth : 1280);
return w >= 1600 ? 3 : 2;
}
export default function TaskDisplay() {
const { tasks, loading, error } = useTasks();
const [page, setPage] = useState(0);
const [refreshTick, setRefreshTick] = useState(0);
const [cols, setCols] = useState(() => columnsFor());
const tickRef = useRef(0);
// 视口宽度 → 列数(2 或 3 列),随屏幕大小自适应(行数固定 2)
useEffect(() => {
const update = () => setCols(columnsFor());
update();
window.addEventListener('resize', update);
return () => window.removeEventListener('resize', update);
}, []);
// 接受 MQTT task_refresh → 触发重新轮询
useEffect(() => {
const onRefresh = () => { tickRef.current += 1; setRefreshTick(tickRef.current); };
window.addEventListener('dpm:task-control', onRefresh);
return () => window.removeEventListener('dpm:task-control', onRefresh);
}, []);
const pageSize = cols * ROWS; // 2×2=4 或 2×3=6
const pageCount = Math.max(1, Math.ceil(tasks.length / pageSize));
const [countdown, setCountdown] = useState(Math.round(ROTATE_MS / 1000));
// 切换:page 为目标页,displayedPage 为当前展示页;切换时先显示「获取最新任务中」动画,1-2 秒后再切
const [displayedPage, setDisplayedPage] = useState(0);
const [switching, setSwitching] = useState(false);
useEffect(() => {
if (pageCount <= 1) { setPage(0); return; }
const t = setInterval(() => setPage((p) => (p + 1) % pageCount), ROTATE_MS);
return () => clearInterval(t);
}, [pageCount]);
// 轮播倒计时:每秒递减,翻页时重置(页数或页变更即复位)
useEffect(() => { setCountdown(Math.round(ROTATE_MS / 1000)); }, [page, pageCount]);
useEffect(() => {
const t = setInterval(() => setCountdown((c) => (c > 0 ? c - 1 : 0)), 1000);
return () => clearInterval(t);
}, []);
// 页切换 → 显示加载动画,SWITCH_MS 后再切到新页
const SWITCH_MS = 1200;
useEffect(() => {
if (page === displayedPage) return;
setSwitching(true);
const t = setTimeout(() => { setDisplayedPage(page); setSwitching(false); }, SWITCH_MS);
return () => clearTimeout(t);
}, [page, displayedPage]);
// 手动/任务刷新 → 同样播放一次「获取最新任务中」
useEffect(() => {
if (refreshTick === 0) return;
setSwitching(true);
const t = setTimeout(() => setSwitching(false), SWITCH_MS);
return () => clearTimeout(t);
}, [refreshTick]);
const visible = useMemo(() => {
if (tasks.length === 0) return [];
const start = displayedPage * pageSize;
// 不足一页时按行补位(循环取),保证 2 行填满
const items = tasks.slice(start, start + pageSize);
return items.length ? items : Array.from({ length: pageSize }, (_, i) => tasks[i % tasks.length]);
}, [tasks, displayedPage, pageSize]);
return (
<div className="tasks-screen">
<div className="tasks-head">
<span className="tasks-head-dot" />
<span className="tasks-head-title">系统任务中心</span>
<span className="tasks-head-sub">扫码即可接单 · 园区企业发标 · 合伙人接单</span>
{/* 右上角:仅翻页倒计时 */}
<div className="tasks-head-info">
<span className="tasks-info">翻页 <b>{pageCount > 1 ? `${countdown}s` : '—'}</b></span>
</div>
</div>
{loading && tasks.length === 0 ? (
<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={{
gridTemplateColumns: `repeat(${cols}, 1fr)`,
gridTemplateRows: `repeat(${ROWS}, minmax(0, 1fr))`,
}}>
{visible.map((t) => (
<TaskCard key={t.key} task={t} />
))}
</div>
{pageCount > 1 && (
<div className="tasks-dots">
{Array.from({ length: pageCount }).map((_, i) => (
<span key={i} className={i === displayedPage ? 'tasks-dot on' : 'tasks-dot'} />
))}
</div>
)}
{/* 切换/刷新加载遮罩 */}
{switching && (
<div className="tasks-switch-overlay">
<span className="tasks-spinner" />
<span className="tasks-switch-text">获取最新任务中…</span>
</div>
)}
</>
)}
</div>
);
}
function TaskCard({ task }) {
const qrValue = task.scan_payload || task.task_code;
return (
<div className={task.claimed ? 'task-card claimed' : 'task-card'}>
<div className="task-top">
<span className="task-cat">{task.category}</span>
{task.claimed ? (
<span className="task-status taken">已接单</span>
) : (
<span className="task-status open">可接单</span>
)}
</div>
<div className="task-title">{task.title}</div>
{task.summary ? <p className="task-summary">{task.summary}</p> : null}
<div className="task-tags">
{task.tags.slice(0, 4).map((tg) => (
<span key={tg} className="task-tag">{tg}</span>
))}
</div>
<div className="task-budget">预算 {task.budgetLabel} · {task.modeLabel}</div>
<div className="task-meta-row">
{task.deadline && <span className="task-meta-item">接单截止 {task.deadline}</span>}
{task.deliveryDays > 0 && <span className="task-meta-item">交付时限 {task.deliveryDays} 天</span>}
{task.exclusive
? <span className="task-meta-item">独占接单</span>
: task.headcount > 0 ? <span className="task-meta-item">限 {task.headcount} 人</span> : null}
</div>
<div className="task-foot">
<div className="task-meta">
<span className="task-id-label">任务ID · {task.publisherName ? `${task.publisherName} 发包` : '园区发包'}</span>
<span className="task-id">{task.task_code || task.id}</span>
</div>
<div className="task-qr">
<QRCodeSVG value={qrValue || 'TASK'} size={72} fgColor="#1d2c44" level="M" />
<span className="task-qr-hint">扫码接单</span>
</div>
</div>
</div>
);
}
+35
View File
@@ -2996,6 +2996,8 @@
.about-modal { .about-modal {
position: relative; position: relative;
width: min(420px, calc(100vw - 48px)); width: min(420px, calc(100vw - 48px));
max-height: calc(100vh - 48px);
overflow-y: auto;
background: #fff; background: #fff;
border-radius: 16px; border-radius: 16px;
padding: 28px 30px 22px; padding: 28px 30px 22px;
@@ -3087,10 +3089,43 @@
padding-top: 14px; padding-top: 14px;
border-top: 1px dashed #e3ecf7; border-top: 1px dashed #e3ecf7;
display: flex; display: flex;
align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 12px;
font-size: 11px; font-size: 11px;
color: #9fb0c6; color: #9fb0c6;
} }
.about-meta-info {
display: flex;
flex-direction: column;
gap: 2px;
line-height: 1.4;
}
.about-unbind-hint {
margin-top: 3px;
color: #e5484d;
font-size: 11px;
}
.about-unbind {
flex: none;
padding: 6px 14px;
border-radius: 8px;
border: 1px solid rgba(240, 68, 56, 0.35);
background: #fff;
color: #e5484d;
font-size: 12px;
line-height: 1;
cursor: pointer;
transition: all 0.2s;
}
.about-unbind:hover {
background: #fef2f2;
border-color: rgba(240, 68, 56, 0.6);
}
.about-unbind:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* 页眉开发署名(© 图标 + 公司名 + 开发;空间不足自动折叠为两行) */ /* 页眉开发署名(© 图标 + 公司名 + 开发;空间不足自动折叠为两行) */
.bd-credit { .bd-credit {
+150
View File
@@ -0,0 +1,150 @@
/* ============ 任务展示页(系统任务中心 · 轮播卡 + 二维码扫码接单)============ */
.tasks-screen {
display: flex;
flex-direction: column;
gap: 18px;
padding: 24px 34px;
width: 100%;
height: 100%;
overflow: hidden;
position: relative; /* 供切换加载遮罩定位于此 */
background: radial-gradient(ellipse at 20% 0%, rgba(79, 135, 255, 0.08), transparent 55%),
radial-gradient(ellipse at 80% 100%, rgba(23, 178, 106, 0.06), transparent 55%);
}
.tasks-head {
display: flex;
align-items: center;
gap: 12px;
border-bottom: 1px solid var(--bd-line, #e3ecf7);
padding-bottom: 14px;
}
.tasks-head-dot {
width: 9px; height: 9px; border-radius: 50%;
background: var(--bd-green, #17b26a);
box-shadow: 0 0 10px var(--bd-green, #17b26a);
}
.tasks-head-title { font-size: 22px; font-weight: 700; color: var(--bd-ink, #1d2c44); letter-spacing: 0.02em; }
.tasks-head-sub { font-size: 13px; color: var(--bd-sub, #7d8fa9); }
.tasks-head-count { margin-left: auto; font-size: 13px; color: var(--bd-blue, #2f6bff); font-weight: 600; }
/* 右上角信息:一行平铺(总数 / 翻页倒计时 / 页码 / 接单说明)—— 非芯片上下布局 */
.tasks-head-info {
margin-left: auto;
display: flex;
align-items: baseline;
gap: 8px;
white-space: nowrap;
}
.tasks-info { font-size: 13px; color: var(--bd-ink, #1d2c44); }
.tasks-info b { font-weight: 700; color: var(--bd-blue, #2f6bff); }
.tasks-info.hint { font-size: 12px; color: var(--bd-sub, #7d8fa9); }
.tasks-info-sep { color: var(--bd-sub, #7d8fa9); }
.tasks-loading {
color: var(--bd-sub, #7d8fa9); font-size: 15px;
display: flex; align-items: center; justify-content: center; height: 60%;
}
.tasks-grid {
flex: 1;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 18px;
align-content: start;
overflow: hidden;
}
.task-card {
display: flex;
flex-direction: column;
gap: 10px;
padding: 20px 20px 16px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.9);
border: 1px solid var(--bd-line, #e3ecf7);
backdrop-filter: blur(10px);
box-shadow: 0 8px 24px rgba(29, 44, 68, 0.06);
transition: all 0.25s ease;
}
.task-card:hover { transform: translateY(-3px); border-color: var(--bd-blue, #2f6bff); }
.task-card.claimed { background: rgba(255, 249, 235, 0.92); border-color: rgba(247, 144, 9, 0.4); }
.task-top { display: flex; align-items: center; gap: 8px; }
.task-cat {
font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 999px;
background: rgba(47, 107, 255, 0.12); color: var(--bd-blue, #2f6bff);
}
.task-status { margin-left: auto; font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 999px; }
.task-status.open { background: rgba(23, 178, 106, 0.14); color: var(--bd-green, #17b26a); }
.task-status.taken { background: rgba(247, 144, 9, 0.16); color: var(--bd-orange, #f79009); }
.task-title { font-size: 18px; font-weight: 700; color: var(--bd-ink, #1d2c44); line-height: 1.3; }
.task-summary {
margin: 0; font-size: 13px; line-height: 1.55; color: var(--bd-sub, #7d8fa9);
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.task-tags { display: flex; flex-wrap: wrap; gap: 6px; }
.task-tag {
font-size: 11px; padding: 2px 8px; border-radius: 999px;
background: rgba(29, 44, 68, 0.06); color: var(--bd-ink, #1d2c44);
}
.task-budget { font-size: 15px; font-weight: 700; color: var(--bd-blue, #2f6bff); }
.task-meta-row { display: flex; flex-wrap: wrap; gap: 8px; }
.task-meta-item {
font-size: 11px; padding: 2px 8px; border-radius: 6px;
background: rgba(29, 44, 68, 0.05); color: var(--bd-sub, #7d8fa9);
}
.task-foot {
margin-top: auto;
display: flex; align-items: flex-end; justify-content: space-between; gap: 12px;
border-top: 1px solid var(--bd-line, #e3ecf7); padding-top: 12px;
}
.task-meta { display: flex; flex-direction: column; gap: 4px; }
.task-id-label { font-size: 11px; color: var(--bd-sub, #7d8fa9); }
.task-id { font-size: 14px; font-weight: 600; color: var(--bd-ink, #1d2c44); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.task-qr { display: flex; flex-direction: column; align-items: center; gap: 4px; }
.task-qr svg { border-radius: 8px; background: #fff; padding: 4px; }
.task-qr-hint { font-size: 11px; color: var(--bd-blue, #2f6bff); font-weight: 600; }
.task-dots { display: flex; justify-content: center; gap: 8px; padding-bottom: 6px; }
.task-dot { width: 8px; height: 8px; border-radius: 50%; background: rgba(29, 44, 68, 0.15); transition: all 0.25s; }
.task-dot.on { background: var(--bd-blue, #2f6bff); width: 20px; border-radius: 999px; }
/* ---- 切换/刷新加载遮罩:文字(获取最新任务中) + 旋转动画 ---- */
.tasks-switch-overlay {
position: absolute;
inset: 0;
z-index: 5;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 14px;
background: rgba(255, 255, 255, 0.72);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
.tasks-spinner {
width: 42px;
height: 42px;
border-radius: 50%;
border: 4px solid rgba(47, 107, 255, 0.18);
border-top-color: var(--bd-blue, #2f6bff);
animation: tasks-spin 0.8s linear infinite;
}
.tasks-switch-text {
font-size: 15px;
font-weight: 600;
color: var(--bd-blue, #2f6bff);
letter-spacing: 0.05em;
}
@keyframes tasks-spin {
to { transform: rotate(360deg); }
}
+7 -1
View File
@@ -1,5 +1,6 @@
// API 基地址 —— 独立 FastAPI 后端(配置见 src/config.js,可用 window.__DPM_API__ 覆盖) // API 基地址 —— 独立 FastAPI 后端(配置见 src/config.js,可用 window.__DPM_API__ 覆盖)
import { getApiBase } from '../config'; import { getApiBase } from '../config';
import { getTenantId, getDeviceToken } from './tenant';
/* 惰性读取 API 基址:允许 main.jsx 引导阶段从后端 /api/config 覆盖(打包部署时生效) */ /* 惰性读取 API 基址:允许 main.jsx 引导阶段从后端 /api/config 覆盖(打包部署时生效) */
export const API_BASE = getApiBase(); export const API_BASE = getApiBase();
@@ -110,8 +111,13 @@ export async function deleteMedia(path) {
} }
// ============ Playlist ============ // ============ Playlist ============
/** 播放列表:绑定后按本园区取(设备 token 鉴权,未绑定回退全局旧数据)。 */
export async function getPlaylist() { 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(); return res.json();
} }
+26 -1
View File
@@ -1,23 +1,42 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import mqtt from 'mqtt'; 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'; import { getTenantId } from './tenant';
/* ========================================================= /* =========================================================
MQTT 客户端(mqtt.js)—— 后端控制通道 MQTT 客户端(mqtt.js)—— 后端控制通道
订阅 opc/display/command(页面/媒体/卡片控制)与 opc/dashboard/tick(数据快照) 订阅 opc/display/command(页面/媒体/卡片控制)与 opc/dashboard/tick(数据快照)
连接成功后自动周期上报心跳 opc/display/heartbeat(绑定页/主屏通用,
后端据此统计在线大屏,admin 端屏幕管理显示在线状态)
断线自动重连;未连接时静默降级(页面仍可本地操作) 断线自动重连;未连接时静默降级(页面仍可本地操作)
========================================================= */ ========================================================= */
const HEARTBEAT_MS = 10000;
let client = null; let client = null;
let status = 'connecting'; // connecting | connected | offline let status = 'connecting'; // connecting | connected | offline
let clientId = null; let clientId = null;
let heartbeatTimer = null;
const listeners = new Set(); const listeners = new Set();
function notifyStatus() { function notifyStatus() {
window.dispatchEvent(new CustomEvent('dpm:mqtt-status', { detail: { status } })); 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 铁律): /* 每个连接生成全局唯一 client_id(独立客户端 ID 铁律):
优先 crypto.randomUUID(WebView2/现代浏览器),回退时间戳+双段高熵随机 */ 优先 crypto.randomUUID(WebView2/现代浏览器),回退时间戳+双段高熵随机 */
function makeClientId() { function makeClientId() {
@@ -55,6 +74,11 @@ export function connectMqtt() {
tid ? `${MQTT_TOPIC_TICK}/${tid}` : MQTT_TOPIC_TICK, tid ? `${MQTT_TOPIC_TICK}/${tid}` : MQTT_TOPIC_TICK,
tid ? `${MQTT_TOPIC_COMMAND}/${tid}/${clientId}` : `${MQTT_TOPIC_COMMAND}/${clientId}`, tid ? `${MQTT_TOPIC_COMMAND}/${tid}/${clientId}` : `${MQTT_TOPIC_COMMAND}/${clientId}`,
], { qos: 1 }); ], { qos: 1 });
// 连接成功即启动心跳(绑定页/主屏通用;幂等防重复定时器)
if (!heartbeatTimer) {
sendHeartbeat();
heartbeatTimer = setInterval(sendHeartbeat, HEARTBEAT_MS);
}
}); });
client.on('reconnect', () => { client.on('reconnect', () => {
status = 'connecting'; status = 'connecting';
@@ -63,6 +87,7 @@ export function connectMqtt() {
client.on('close', () => { client.on('close', () => {
status = 'offline'; status = 'offline';
notifyStatus(); notifyStatus();
if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
}); });
client.on('error', () => { client.on('error', () => {
status = 'offline'; status = 'offline';
+2 -2
View File
@@ -4,7 +4,7 @@ import { Sfx } from './sounds';
/* ========================================================= /* =========================================================
页面循环导航 —— 左右方向键切换大屏页面 页面循环导航 —— 左右方向键切换大屏页面
顺序:数据大屏 → 数字孪生 → AI 助手 → 媒体轮播(循环) 顺序:数据大屏 → 数字孪生 → AI 助手 → 任务展示 → 企业展示墙 → 媒体轮播(循环)
管理后台不参与循环切换,仅在登录后进入 管理后台不参与循环切换,仅在登录后进入
← 向左切换 · → 向右切换 ← 向左切换 · → 向右切换
========================================================= */ ========================================================= */
@@ -13,7 +13,7 @@ export const PAGE_ORDER = [
{ path: '/', label: '数据大屏' }, { path: '/', label: '数据大屏' },
{ path: '/twin', label: '数字孪生' }, { path: '/twin', label: '数字孪生' },
{ path: '/ai', label: 'AI 助手' }, { path: '/ai', label: 'AI 助手' },
{ path: '/voice', label: '语音对话' }, { path: '/tasks', label: '任务展示' },
{ path: '/wall', label: '企业展示墙' }, { path: '/wall', label: '企业展示墙' },
{ path: '/screen', label: '媒体轮播' }, { path: '/screen', label: '媒体轮播' },
]; ];
+36 -3
View File
@@ -13,8 +13,13 @@ import { tenantFetch } from './tenant';
工具调用:按每次调用约 2 万 tokens 折算(今日 ≈ 3.3 万次) 工具调用:按每次调用约 2 万 tokens 折算(今日 ≈ 3.3 万次)
========================================================= */ ========================================================= */
export const fmtInt = (n) => Math.round(n).toLocaleString('zh-CN'); export const fmtInt = (n) => (Number.isFinite(Number(n)) ? Math.round(Number(n)).toLocaleString('zh-CN') : '--');
export const fmtWan = (n) => (n >= 10000 ? (n / 10000).toFixed(2) + '亿' : n >= 100 ? n.toFixed(1) + '万' : n.toFixed(1)); /** 万/亿格式化:非有限数值(缺失/不一致口径)安全显示 '--',避免崩溃 */
export const fmtWan = (n) => {
const v = Number(n);
if (!Number.isFinite(v)) return '--';
return (v >= 10000 ? (v / 10000).toFixed(2) + '亿' : v >= 100 ? v.toFixed(1) + '万' : v.toFixed(1));
};
/* ---------- 真实锚点(官方统计《2026年7月运行情况统计表》+ 附件1省级材料 + 宣传册) ---------- */ /* ---------- 真实锚点(官方统计《2026年7月运行情况统计表》+ 附件1省级材料 + 宣传册) ---------- */
const REAL = { const REAL = {
@@ -207,6 +212,34 @@ function nextSnapshot(s) {
}; };
} }
/* ---------- 后端快照归一化:字段缺失/为 null 时用本地真实锚点补齐,避免大屏崩溃 ---------- */
function mergeObj(base, src) {
if (!src || typeof src !== 'object') return base;
const out = { ...base };
for (const k of Object.keys(src)) if (src[k] !== undefined && src[k] !== null) out[k] = src[k];
return out;
}
function normalizeSnapshot(d) {
const base = initSnapshot(); // 完整本地默认(真实锚点),作为缺省兜底
if (!d || typeof d !== 'object') return base;
return {
...base,
...d,
live: mergeObj(base.live, d.live),
token: mergeObj(base.token, d.token),
tools: mergeObj(base.tools, d.tools),
projects: mergeObj(base.projects, d.projects),
jobs: mergeObj(base.jobs, d.jobs),
revenue: mergeObj(base.revenue, d.revenue),
cumulative: mergeObj(base.cumulative, d.cumulative),
park: mergeObj(base.park, d.park),
zones: Array.isArray(d.zones) ? d.zones : base.zones,
companies: Array.isArray(d.companies) ? d.companies : base.companies,
industryMix: Array.isArray(d.industryMix) ? d.industryMix : base.industryMix,
feed: Array.isArray(d.feed) ? d.feed : base.feed,
};
}
export function useParkSim() { export function useParkSim() {
const [snap, setSnap] = useState(initSnapshot); const [snap, setSnap] = useState(initSnapshot);
useEffect(() => { useEffect(() => {
@@ -216,7 +249,7 @@ export function useParkSim() {
const res = await tenantFetch('/api/dashboard/snapshot', { cache: 'no-store' }); const res = await tenantFetch('/api/dashboard/snapshot', { cache: 'no-store' });
if (!res.ok) throw new Error(`HTTP ${res.status}`); if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json(); const data = await res.json();
if (!cancelled && data && data.token) setSnap(data); if (!cancelled && data && data.token) setSnap(normalizeSnapshot(data));
} catch { } catch {
// 后端不可用:本地真实快照继续(仅 TOKEN/工具轻微波动) // 后端不可用:本地真实快照继续(仅 TOKEN/工具轻微波动)
if (!cancelled) setSnap((p) => nextSnapshot(p)); if (!cancelled) setSnap((p) => nextSnapshot(p));
+79
View File
@@ -0,0 +1,79 @@
/* 任务展示页数据:轮询 /park/api/tasks(系统任务中心上墙任务),带租户身份。
参考 parkData.js 的 useParkSim 轮询 + 归一化防崩模式。 */
import { useEffect, useRef, useState } from 'react';
import { tenantFetch } from './tenant';
const POLL_MS = 8_000;
function normalizeTask(raw, idx) {
const t = raw || {};
return {
id: t.id || '',
task_code: t.task_code || '',
scan_payload: t.scan_payload || t.task_code || '',
title: t.title || '未命名任务',
category: t.category || '综合',
summary: t.summary || '',
tags: Array.isArray(t.tags) ? t.tags : [],
budgetMin: Number(t.budget_min) || 0,
budgetMax: Number(t.budget_max) || 0,
budgetLabel: fmtBudget(t.budget_min, t.budget_max),
mode: t.mode || 'grab',
modeLabel: MODE_LABEL[t.mode] || '抢单',
deadline: t.deadline || '',
deliveryDays: Number(t.delivery_days) || 0,
headcount: Number(t.headcount) || 0,
exclusive: !!t.exclusive,
publisherName: t.publisher_name || '',
status: t.status || 'published',
claimed: t.status === 'claimed' || t.status === 'doing' || !!t.claimed_by,
claimedBy: t.claimed_by || '',
claimedAt: t.claimed_at || '',
key: t.id || `task-${idx}`,
};
}
const MODE_LABEL = {
grab: '抢单', bid: '竞标', assign: '指派', recommend: '推荐',
};
function fmtBudget(min, max) {
const n = Number(min) || 0, x = Number(max) || 0;
if (!n && !x) return '面议';
if (!x) return `¥${n}`;
if (n === x) return `¥${n}`;
return `¥${n}~¥${x}`;
}
export function useTasks() {
const [tasks, setTasks] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const aliveRef = useRef(true);
useEffect(() => {
aliveRef.current = true;
setLoading(true);
const load = async () => {
try {
const res = await tenantFetch('/api/tasks');
if (!res.ok) throw new Error(`任务加载失败(${res.status})`);
const body = await res.json();
const items = Array.isArray(body?.items) ? body.items : [];
if (aliveRef.current) {
setTasks(items.map(normalizeTask));
setError('');
}
} catch (e) {
if (aliveRef.current) setError(e.message || '任务加载失败');
} finally {
if (aliveRef.current) setLoading(false);
}
};
load();
const timer = setInterval(load, POLL_MS);
return () => { aliveRef.current = false; clearInterval(timer); };
}, []);
return { tasks, loading, error };
}
+16
View File
@@ -42,6 +42,22 @@ export function clearBinding() {
window.location.href = '/'; window.location.href = '/';
} }
/** 解除大屏与园区的绑定:先通知服务端清除该设备绑定,再清本地并回到待绑定页。
* 服务端请求带 4s 硬超时:无论服务端成功 / 不可达 / 鉴权失败,都会继续本地解绑,
* 保证不会因请求挂起而卡住界面。 */
export async function unbindDevice() {
try {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 4000);
await tenantFetch(`/api/devices/${getDeviceId()}/unbind`, { method: 'POST', signal: ctrl.signal });
clearTimeout(timer);
} catch (e) {
/* 超时 / 网络错误 / 鉴权异常:忽略,继续本地解绑 */
}
// 桌面端清理本地绑定状态,并刷新回绑定页(main.jsx 见 isDeviceBound()=false 渲染 BindScreen)
clearBinding();
}
/** 带设备 token 的 fetch:附 Authorization(设备 token)。 */ /** 带设备 token 的 fetch:附 Authorization(设备 token)。 */
export function tenantFetch(path, options = {}) { export function tenantFetch(path, options = {}) {
const tok = getDeviceToken(); const tok = getDeviceToken();
+18 -29
View File
@@ -1,8 +1,8 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useNavigate, useLocation } from 'react-router-dom'; import { useNavigate, useLocation } from 'react-router-dom';
import { onMqttMessage, connectMqtt, publishMqtt, getClientId, useMqttStatus } from './mqtt'; import { onMqttMessage, connectMqtt, publishMqtt, getClientId, useMqttStatus } from './mqtt';
import { MQTT_TOPIC_HEARTBEAT, MQTT_TOPIC_COMMAND, getApiBase as API_BASE } from '../config'; import { MQTT_TOPIC_COMMAND, MQTT_TOPIC_BIND, getApiBase as API_BASE } from '../config';
import { getTenantId } from './tenant'; import { getTenantId, clearBinding } from './tenant';
import { Sfx } from './sounds'; import { Sfx } from './sounds';
import { PAGE_ORDER } from './pageNav'; import { PAGE_ORDER } from './pageNav';
import { aiStatus } from './statusBus'; import { aiStatus } from './statusBus';
@@ -31,6 +31,7 @@ const PAGE_ALIAS = {
home: '/', '数据大屏': '/', '首页': '/', home: '/', '数据大屏': '/', '首页': '/',
twin: '/twin', '数字孪生': '/twin', '3d': '/twin', twin: '/twin', '数字孪生': '/twin', '3d': '/twin',
ai: '/ai', 'ai助手': '/ai', ai: '/ai', 'ai助手': '/ai',
tasks: '/tasks', '任务展示': '/tasks', task: '/tasks',
screen: '/screen', '媒体轮播': '/screen', media: '/screen', screen: '/screen', '媒体轮播': '/screen', media: '/screen',
wall: '/wall', '企业展示墙': '/wall', '企业墙': '/wall', '展示墙': '/wall', wall: '/wall', '企业展示墙': '/wall', '企业墙': '/wall', '展示墙': '/wall',
}; };
@@ -61,23 +62,20 @@ export function useMqttControl() {
}).catch(() => {}); }).catch(() => {});
}, [mqttStatus]); }, [mqttStatus]);
// 大屏在线心跳(后端据此统计在线大屏数) // 后端主动解绑:监听绑定频道,收到 unbound 事件 → 清理本地绑定并退出到待绑定页
// (大屏已绑定后仍订阅 bind/<clientId>,见 mqtt.js subscribe;unbind 后 main.jsx 见
// isDeviceBound()=false 渲染 BindScreen)
useEffect(() => { useEffect(() => {
connectMqtt(); connectMqtt();
const sendHeartbeat = () => { const off = onMqttMessage((topic, data) => {
if (mqttStatus === 'connected') { if (topic === `${MQTT_TOPIC_BIND}/${getClientId()}` && data && data.event === 'unbound') {
publishMqtt(MQTT_TOPIC_HEARTBEAT, { clearBinding();
client_id: getClientId(),
tenant_id: getTenantId(),
page: location.pathname,
ts: Date.now(),
});
} }
}; });
sendHeartbeat(); return off;
const hb = setInterval(sendHeartbeat, 10000); }, []);
return () => clearInterval(hb);
}, [mqttStatus, location.pathname]); // 大屏在线心跳已由 mqtt.js 连接成功后自动周期上报(绑定页/主屏通用),此处不再重复发送
// 页面状态即时上报:路由变化即通知服务端当前所在页面 // 页面状态即时上报:路由变化即通知服务端当前所在页面
useEffect(() => { useEffect(() => {
@@ -170,19 +168,10 @@ export function useMqttControl() {
})); }));
break; break;
// ── 语音对话页操作 ── // ── 任务展示页操作(语音页已暂时关闭,voice_* 指令不再处理) ──
case 'voice_start': case 'task_refresh':
// admin 主动问候测试:携带 text;若不在 /voice 页先跳转,文本暂存 sessionStorage window.dispatchEvent(new CustomEvent('dpm:task-control', {
if (cmd.params?.text) sessionStorage.setItem('dpm_voice_greet', cmd.params.text); detail: { action: 'refresh', ...(cmd.params || {}) },
if (location.pathname !== '/voice') navigate('/voice');
window.dispatchEvent(new CustomEvent('dpm:voice-control', {
detail: { action: 'start', text: cmd.params?.text || '' },
}));
break;
case 'voice_stop':
case 'voice_refresh':
window.dispatchEvent(new CustomEvent('dpm:voice-control', {
detail: { action: cmd.action.replace('voice_', '') },
})); }));
break; break;
+33
View File
@@ -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>