diff --git a/backend/README.md b/backend/README.md index da58449..ecf13c8 100644 --- a/backend/README.md +++ b/backend/README.md @@ -84,3 +84,32 @@ curl -X POST http://localhost:10085/api/display/command \ - 大屏数据由后端模拟引擎产生(`sim_engine.py`,与原前端 `parkData.js` 逻辑一致);前端离线时也有本地兜底 - 媒体文件存放于 `backend/media/`,`/file/...` 直接返回 - 安全:`backend/.env` 与根 `.env.local` 已加入 `.gitignore`,含阿里云密钥与 MQTT 凭据,勿提交 + + +## Docker 部署(推荐) + +一键构建并启动 **后端 + EMQX Broker**: + +```bash +# 1) (可选)配置部署参数 +export MQTT_PUBLIC_HOST=192.168.1.50 # 展播端可访问的服务器局域网 IP(Broker WebSocket) +export EMQX_USER=dpmserver +export EMQX_PASS=你的密码 +export DASHSCOPE_API_KEY=sk-xxx # AI/语音密钥(未配置时 AI 走本地规则引擎) +export DPM_S2S_ENABLED=1 # 实时语音对话开关 + +# 2) 构建并启动 +docker compose up -d --build + +# 3) 验证 +curl http://127.0.0.1:10085/api/health # {"ok":true,...} +docker compose logs -f backend +``` + +- 端口:`10085`(API/后台/媒体)、`8765`(s2s 语音)、`1883/8083/18083`(EMQX) +- 数据卷:`dpm_media`(媒体)、`dpm_knowledge`(知识库 park.md,可热更新)、`dpm_data`(设置/播放列表) +- 展播端(Tauri 大屏)启动时经 `GET /api/config` 自动获取 MQTT WebSocket 地址 +- 常用命令:`docker compose down` 停止;`docker compose up -d --build backend` 仅重建后端; + `docker compose ps` 查看状态 + +> 单独构建镜像:`docker build -f backend/Dockerfile -t dpm-backend .` diff --git a/backend/app/admin.py b/backend/app/admin.py index 9bc4b69..e026051 100644 --- a/backend/app/admin.py +++ b/backend/app/admin.py @@ -72,6 +72,7 @@ async def admin_page(request: Request): "play_mode": s.get("play_mode", "sequential"), "image_duration": s.get("image_duration", 5), "volume": s.get("volume", 80), + "sfx_volume": s.get("sfx_volume", 60), "fullscreen": s.get("fullscreen", True), "autostart": s.get("autostart", False), "from_screen": request.query_params.get("from") == "screen", diff --git a/backend/app/routers.py b/backend/app/routers.py index d0ef884..64926b6 100644 --- a/backend/app/routers.py +++ b/backend/app/routers.py @@ -38,6 +38,7 @@ def _media_type(path): class SettingsBody(BaseModel): volume: int | None = None + sfx_volume: int | None = None play_mode: str | None = None image_duration: int | None = None fullscreen: bool | None = None @@ -106,7 +107,7 @@ async def login(request: Request): @router.get("/api/settings") async def get_settings(): s = storage.get_settings() - return {k: s[k] for k in ("volume", "play_mode", "image_duration", "fullscreen", "autostart")} + return {k: s[k] for k in ("volume", "sfx_volume", "play_mode", "image_duration", "fullscreen", "autostart")} @router.get("/api/config") @@ -135,7 +136,9 @@ async def runtime_config(): async def update_settings(body: SettingsBody): storage.update_settings(**body.model_dump(exclude_none=True)) s = storage.get_settings() - hub.publish_command("settings_changed", {"volume": s["volume"], "play_mode": s["play_mode"]}) + hub.publish_command("settings_changed", { + "volume": s["volume"], "sfx_volume": s["sfx_volume"], "play_mode": s["play_mode"], + }) return {"ok": True} diff --git a/backend/app/storage.py b/backend/app/storage.py index cf3dc6f..28f3a8d 100644 --- a/backend/app/storage.py +++ b/backend/app/storage.py @@ -12,6 +12,7 @@ DEFAULT_SETTINGS = { "username": "admin", "password": "123456", "volume": 80, + "sfx_volume": 60, "auto_play": True, "play_mode": "sequential", "image_duration": 5, diff --git a/backend/main.py b/backend/main.py index 9f25cba..6a429be 100644 --- a/backend/main.py +++ b/backend/main.py @@ -67,7 +67,7 @@ def main(): print(f" 本地资源 : TORCH_HOME={os.environ.get('TORCH_HOME')}") print(f" NLTK_DATA={os.environ.get('NLTK_DATA')}") print("=" * 64) - uvicorn.run("app.main:app", host=settings.HOST, port=settings.PORT, reload=True) + uvicorn.run("app.main:app", host=settings.HOST, port=settings.PORT, reload=False) if __name__ == "__main__": diff --git a/backend/static/admin.js b/backend/static/admin.js index d6a4191..ca8fffe 100644 --- a/backend/static/admin.js +++ b/backend/static/admin.js @@ -222,7 +222,20 @@ volumeLabel.textContent = volumeRange.value + '%'; if (volTimer) clearTimeout(volTimer); volTimer = setTimeout(function () { - post('/api/settings', { volume: Number(volumeRange.value) }, function () { toast('音量已更新'); }); + post('/api/settings', { volume: Number(volumeRange.value) }, function () { toast('媒体音量已更新'); }); + }, 300); + }); + } + + var sfxVolumeRange = document.getElementById('sfxVolumeRange'); + var sfxVolumeLabel = document.getElementById('sfxVolumeLabel'); + var sfxVolTimer = null; + if (sfxVolumeRange && sfxVolumeLabel) { + sfxVolumeRange.addEventListener('input', function () { + sfxVolumeLabel.textContent = sfxVolumeRange.value + '%'; + if (sfxVolTimer) clearTimeout(sfxVolTimer); + sfxVolTimer = setTimeout(function () { + post('/api/settings', { sfx_volume: Number(sfxVolumeRange.value) }, function () { toast('音效音量已更新'); }); }, 300); }); } diff --git a/backend/templates/admin.html b/backend/templates/admin.html index 653ff55..7357c40 100644 --- a/backend/templates/admin.html +++ b/backend/templates/admin.html @@ -230,10 +230,15 @@
- +
{{ volume }}%
+
+ + +
{{ sfx_volume }}%
+
diff --git a/docs/win-deploy.md b/docs/win-deploy.md new file mode 100644 index 0000000..63ee5f2 --- /dev/null +++ b/docs/win-deploy.md @@ -0,0 +1,76 @@ +# 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" +} +``` + +- `api_base`:FastAPI 后端地址(API / 管理后台 / 媒体) +- `mqtt_url`:EMQX Broker 的 **WebSocket** 地址(端口 8083) +- `mqtt_username/password`:Broker 鉴权账号(默认 dpm / 123456,与后端 .env 一致) + +地址解析优先级:**config.json > 后端 /api/config > 构建默认(127.0.0.1)**。 + +## 二、后端 /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 地址 +MQTT_USERNAME=dpmserver +MQTT_PASSWORD=你的密码 +``` + +> 若后端与 Broker 同机:后端默认 `MQTT_WS_URL=ws://localhost:8083/mqtt` 即可, +> 展播端连 `127.0.0.1` 也通;跨机必须显式配置上面的 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/防火墙 diff --git a/package.json b/package.json index 6f2deee..1e004a3 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "dev": "vite", "build": "vite build", "preview": "vite preview", - "tauri": "tauri" + "tauri": "tauri", + "build:win": "scripts\\build-win.cmd" }, "dependencies": { "@appica/icons-react": "^1.0.0", diff --git a/scripts/build-win.cmd b/scripts/build-win.cmd new file mode 100644 index 0000000..831d04e --- /dev/null +++ b/scripts/build-win.cmd @@ -0,0 +1,40 @@ +@echo off +REM ============================================================ +REM Windows 一键打包脚本(Tauri) +REM 解决「error: failed to remove file ... .exe / 拒绝访问(os error 5)」: +REM 本应用关闭窗口=最小化(进程常驻),旧 exe 会被运行进程锁定无法覆盖。 +REM 打包前自动结束占用进程,再执行 tauri build。 +REM +REM 用法:双击运行,或在项目根目录执行 scripts\build-win.cmd +REM ============================================================ +chcp 65001 >nul +setlocal + +echo. +echo [1/3] 结束占用旧 exe 的进程(无则跳过)... +taskkill /f /im "昆明大学生创业园展播系统.exe" >nul 2>&1 +taskkill /f /im "云超服昆创园OPC运营中心.exe" >nul 2>&1 +taskkill /f /im "dpm.exe" >nul 2>&1 +echo 已尝试清理。 + +echo. +echo [2/3] 清理 Rust 增量产物中的旧 exe 残留(可选,失败不影响)... +if exist "src-tauri\target\release\昆明大学生创业园展播系统.exe" ( + del /f /q "src-tauri\target\release\昆明大学生创业园展播系统.exe" >nul 2>&1 +) + +echo. +echo [3/3] 开始 Tauri 打包(yarn tauri build)... +call yarn tauri build +if errorlevel 1 ( + echo. + echo [错误] 打包失败。若仍报「拒绝访问」,请: + echo 1. 确认任务管理器中没有 dpm/昆明大学生创业园 相关进程 + echo 2. 将 D:\MyCode\DPM\src-tauri\target 加入杀毒软件排除目录 + echo 3. 关闭杀毒实时防护后重试 + exit /b 1 +) + +echo. +echo [完成] 打包成功!安装包位于 src-tauri\target\release\bundle\ +endlocal diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index bc46ba1..36dffb0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,5 +1,43 @@ // 说明:早期 Rust 内嵌后端(server/storage/models/events,axum :10801)已随架构迁移到 -// Python 后端(FastAPI :10085)而移除;当前 Rust 侧仅负责窗口与生命周期。 +// Python 后端(FastAPI :10085)而移除;当前 Rust 侧仅负责窗口与生命周期, +// 外加一个「运行时配置读取」命令(供前端读取 exe 旁 config.json,部署免重打包改地址)。 + +use std::fs; +use std::path::PathBuf; +use tauri::{AppHandle, Manager}; + +/// 读取运行时配置(部署用,免重新打包): +/// 1) exe 同目录 config.json / dpm.config.json(最高优先) +/// 2) 应用配置目录 config.json +/// 内容示例: +/// { "api_base": "http://192.168.1.9:10085", +/// "mqtt_url": "ws://192.168.1.3:8083/mqtt", +/// "mqtt_username": "dpm", "mqtt_password": "123456" } +/// 返回 JSON 字符串;未配置返回 null。 +#[tauri::command] +fn read_runtime_config(app: AppHandle) -> Option { + let mut candidates: Vec = Vec::new(); + if let Ok(exe) = std::env::current_exe() { + if let Some(dir) = exe.parent() { + candidates.push(dir.join("config.json")); + candidates.push(dir.join("dpm.config.json")); + } + } + if let Ok(dir) = app.path().app_config_dir() { + candidates.push(dir.join("config.json")); + } + for p in candidates { + if p.exists() { + if let Ok(s) = fs::read_to_string(&p) { + let t = s.trim(); + if t.starts_with('{') { + return Some(t.to_string()); + } + } + } + } + None +} #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { @@ -9,6 +47,7 @@ pub fn run() { tauri_plugin_autostart::MacosLauncher::LaunchAgent, Some(vec![]), )) + .invoke_handler(tauri::generate_handler![read_runtime_config]) .on_window_event(|window, event| { if let tauri::WindowEvent::CloseRequested { api, .. } = event { api.prevent_close(); diff --git a/src/components/AiChatPanel.jsx b/src/components/AiChatPanel.jsx index 39d05a2..6255369 100644 --- a/src/components/AiChatPanel.jsx +++ b/src/components/AiChatPanel.jsx @@ -1,6 +1,7 @@ import { useState, useRef, useEffect, useCallback } from 'react'; import Icon from './Icons'; import { getApiBase as API_BASE } from '../config'; +import { Sfx } from '../utils/sounds'; import { getMqttStatus } from '../utils/mqtt'; import { renderMarkdown } from '../voice/markdown.jsx'; @@ -133,6 +134,7 @@ export default function AiChatPanel() { if (!reply) reply = pick(FALLBACK_REPLIES); setTyping(false); setMsgs((prev) => [...prev, { id: nextId(), role: 'ai', text: reply }]); + Sfx.notify(); executeLocalTools(tools); resetIdleTimer(); // 有提问 → 重置 1 分钟空闲计时 }, [typing, resetIdleTimer]); diff --git a/src/components/DpmOverlays.jsx b/src/components/DpmOverlays.jsx index c073210..e56b443 100644 --- a/src/components/DpmOverlays.jsx +++ b/src/components/DpmOverlays.jsx @@ -1,6 +1,7 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import Icon from './Icons'; import { getApiBase as API_BASE } from '../config'; +import { Sfx } from '../utils/sounds'; /* ========================================================= DpmOverlays —— 后端/AI 指令驱动的全局覆盖层 @@ -110,6 +111,7 @@ export default function DpmOverlays() { const alertTimer = useRef(null); const showAlert = useCallback((params) => { + Sfx.alert(); const item = { title: params.title || '提示', content: params.content || '', id: Date.now() + Math.random() }; setAlert(item); clearTimeout(alertTimer.current); @@ -117,6 +119,7 @@ export default function DpmOverlays() { }, []); const showCard = useCallback((params) => { + Sfx.notify(); setCard({ card: params.card || 'custom', title: params.title || '', content: params.content || '', id: Date.now() + Math.random() }); }, []); diff --git a/src/components/GlobalVision.jsx b/src/components/GlobalVision.jsx index 839c5a1..f419d84 100644 --- a/src/components/GlobalVision.jsx +++ b/src/components/GlobalVision.jsx @@ -1,6 +1,7 @@ import { useEffect } from 'react'; import { useLocation } from 'react-router-dom'; import useVisionDetection from '../voice/useVisionDetection.js'; +import { Sfx } from '../utils/sounds'; /* ========================================================= 全局手势识别(挂载于 ScreenLayout —— 所有大屏页面共享) @@ -18,6 +19,7 @@ export default function GlobalVision() { const vision = useVisionDetection({ onGesture: (g) => { // 全部手势类型(raise/fist/both_up/point/hands_close/wave)统一分发 + Sfx.success(); window.dispatchEvent(new CustomEvent('dpm:gesture', { detail: { type: g } })); }, }); diff --git a/src/components/MediaHeaderControls.jsx b/src/components/MediaHeaderControls.jsx index 276316d..b434f17 100644 --- a/src/components/MediaHeaderControls.jsx +++ b/src/components/MediaHeaderControls.jsx @@ -1,6 +1,7 @@ import { useState, useEffect } from 'react'; import Icon from './Icons'; import { onMediaUiState, mediaCommand } from '../utils/mediaControl'; +import { Sfx } from '../utils/sounds'; /* ========================================================= 页眉媒体控制区 —— 仅媒体轮播页显示 @@ -26,13 +27,13 @@ export default function MediaHeaderControls() { {/* 播放 / 暂停 */} {/* 上一段 */} - {/* 序号 */} @@ -40,7 +41,7 @@ export default function MediaHeaderControls() { {st.index + 1}/{st.total} {/* 下一段 */} - {/* 声音解锁 */} diff --git a/src/components/PageHeader.jsx b/src/components/PageHeader.jsx index 1fc4036..4cf240b 100644 --- a/src/components/PageHeader.jsx +++ b/src/components/PageHeader.jsx @@ -5,10 +5,13 @@ import MediaHeaderControls from './MediaHeaderControls'; import AboutDialog from './AboutDialog'; import { PAGE_ORDER, usePageIndex } from '../utils/pageNav'; import { Avatar, AvatarImage, AvatarFallback } from '@appica/ui-react/avatar' +import { Sfx } from '../utils/sounds'; +import { getApiBase as API_BASE } from '../config'; /* ========================================================= 共享页眉 —— 所有大屏页面统一(由 ScreenLayout 注入) 状态文案按当前页面自动识别;左右键循环 + 可点击按钮切换 + · 音量面板:媒体音量 + 音效音量(写回后端设置并 MQTT 广播同步) ========================================================= */ const STATUS_BY_PATH = { @@ -25,9 +28,66 @@ export default function PageHeader() { const [aboutOpen, setAboutOpen] = useState(false); const [gestureHit, setGestureHit] = useState(false); // 识别到手势 → 惊喜图标 3s const [vision, setVision] = useState({ status: 'off', cameraOn: false, gesture: null }); // 视觉识别状态 + const [volOpen, setVolOpen] = useState(false); + const [mediaVol, setMediaVol] = useState(80); // 媒体音量 0-100 + const [sfxVol, setSfxVol] = useState(Sfx.getVolume()); // 音效音量 0-100 const pageIdx = usePageIndex(); const gestureTimer = useRef(null); + // 启动时拉取后端设置(媒体/音效音量),并监听 MQTT settings_changed 同步 + useEffect(() => { + fetch(`${API_BASE()}/api/settings`, { cache: 'no-store' }) + .then((r) => r.json()) + .then((s) => { + if (typeof s.volume === 'number') setMediaVol(s.volume); + if (typeof s.sfx_volume === 'number') { + setSfxVol(s.sfx_volume); + Sfx.setVolume(s.sfx_volume); + } + }) + .catch(() => {}); + const onSettings = (e) => { + const p = (e.detail && (e.detail.params || e.detail)) || {}; + if (typeof p.volume === 'number') setMediaVol(p.volume); + if (typeof p.sfx_volume === 'number') { + setSfxVol(p.sfx_volume); + Sfx.setVolume(p.sfx_volume); + } + }; + // 后端/MQTT settings_changed → 同步音量(SSE 走 api/events,MQTT 走 dpm:media-control) + window.addEventListener('dpm:mqtt-local', onSettings); + window.addEventListener('dpm:media-control', onSettings); + return () => { + window.removeEventListener('dpm:mqtt-local', onSettings); + window.removeEventListener('dpm:media-control', onSettings); + }; + }, []); + + // 点击面板外部 → 关闭音量面板 + useEffect(() => { + if (!volOpen) return undefined; + const close = () => setVolOpen(false); + document.addEventListener('pointerdown', close); + return () => document.removeEventListener('pointerdown', close); + }, [volOpen]); + + // 音量修改 → 写回后端(MQTT settings_changed 广播给所有大屏) + const changeVolume = (key, value) => { + const v = Math.max(0, Math.min(100, Number(value) || 0)); + if (key === 'sfx_volume') { + setSfxVol(v); + Sfx.setVolume(v); + Sfx.notify(); + } else { + setMediaVol(v); + } + fetch(`${API_BASE()}/api/settings`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ [key]: v }), + }).catch(() => {}); + }; + useEffect(() => { const iv = setInterval(() => setNow(new Date()), 1000); return () => clearInterval(iv); @@ -93,16 +153,44 @@ export default function PageHeader() {
{/* 媒体轮播页:页眉内嵌播放控制 */} {location.pathname === '/screen' && } + {/* 音量控制(媒体 + 音效) */} +
+ + {volOpen && ( +
e.stopPropagation()}> +
媒体音量
+ changeVolume('volume', e.target.value)} + /> + {mediaVol}% +
音效音量
+ changeVolume('sfx_volume', e.target.value)} + /> + {sfxVol}% +
+ )} +
{left && ( - )} · {right && ( - diff --git a/src/main.jsx b/src/main.jsx index efa6d74..983e56a 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -2,27 +2,45 @@ import React from "react"; import ReactDOM from "react-dom/client"; import App from "./App"; import { ThemeProvider } from "@appica/ui-react/providers/theme-provider"; -import { API_BASE } from "./config"; +import { getApiBase } from "./config"; +import { invoke } from "@tauri-apps/api/core"; import "./styles/global.css"; /* ========================================================= - 启动引导(打包部署关键): - 渲染前先从后端 GET /api/config 拉取运行时配置 - (MQTT 地址/账号、API 基址),写入 window.__DPM_*__, - 之后 api.js / mqtt.js 均以惰性读取方式生效。 - 后端不可达时静默使用内置默认(Tauri 打包回退 127.0.0.1)。 + 启动引导(打包部署关键)—— 地址解析优先级: + 1. exe 旁 config.json(Tauri 命令读取,部署免重打包改 IP)★ + 2. 后端 GET /api/config(MQTT 地址/账号,后端 .env 为准) + 3. 内置默认(Tauri 打包回退 127.0.0.1 / 浏览器用当前主机) + 结果写入 window.__DPM_*__,api.js / mqtt.js 惰性读取生效。 ========================================================= */ async function bootstrapRuntimeConfig() { + // 1) exe 旁 config.json(最高优先):{api_base, mqtt_url, mqtt_username, mqtt_password} try { - const res = await fetch(`${API_BASE()}/api/config`, { cache: 'no-store' }); - if (!res.ok) return; - const cfg = await res.json(); - // 仅覆盖 MQTT 连接信息(后端 .env 为准);API 基址以前端实际可达地址为准 - if (cfg.mqtt_url) window.__DPM_MQTT__ = cfg.mqtt_url; - if (cfg.mqtt_username) window.__DPM_MQTT_USER__ = cfg.mqtt_username; - if (cfg.mqtt_password) window.__DPM_MQTT_PASS__ = cfg.mqtt_password; - // eslint-disable-next-line no-console - if (cfg.mqtt_url) console.info(`[bootstrap] MQTT -> ${cfg.mqtt_url}`); + const raw = await invoke("read_runtime_config"); + if (raw) { + const cfg = JSON.parse(raw); + if (cfg.api_base) window.__DPM_API__ = cfg.api_base; + if (cfg.mqtt_url) window.__DPM_MQTT__ = cfg.mqtt_url; + if (cfg.mqtt_username) window.__DPM_MQTT_USER__ = cfg.mqtt_username; + if (cfg.mqtt_password) window.__DPM_MQTT_PASS__ = cfg.mqtt_password; + // eslint-disable-next-line no-console + if (cfg.mqtt_url) console.info(`[bootstrap] config.json MQTT -> ${cfg.mqtt_url}`); + } + } catch { + /* 浏览器开发环境或未注册命令:跳过 */ + } + + // 2) 后端 /api/config(MQTT 地址/账号;API 基址以前端实际可达地址为准) + try { + const res = await fetch(`${getApiBase()}/api/config`, { cache: 'no-store' }); + if (res.ok) { + const cfg = await res.json(); + if (cfg.mqtt_url) window.__DPM_MQTT__ = cfg.mqtt_url; + if (cfg.mqtt_username) window.__DPM_MQTT_USER__ = cfg.mqtt_username; + if (cfg.mqtt_password) window.__DPM_MQTT_PASS__ = cfg.mqtt_password; + // eslint-disable-next-line no-console + if (cfg.mqtt_url) console.info(`[bootstrap] /api/config MQTT -> ${cfg.mqtt_url}`); + } } catch { /* 后端不可达:使用内置默认地址 */ } diff --git a/src/pages/MediaScreen.jsx b/src/pages/MediaScreen.jsx index bd0df4a..9a068f4 100644 --- a/src/pages/MediaScreen.jsx +++ b/src/pages/MediaScreen.jsx @@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; import * as api from '../utils/api'; import { getApiBase } from '../config'; +import { Sfx } from '../utils/sounds'; import { API_BASE } from '../utils/api'; import { getCurrentWindow } from '@tauri-apps/api/window'; import { enable, disable } from '@tauri-apps/plugin-autostart'; @@ -323,6 +324,9 @@ export default function MediaScreen() { }, [reloadPlaylist]); const applySettings = useCallback((msg) => { + if (msg.sfx_volume !== undefined) { + Sfx.setVolume(msg.sfx_volume); + } if (msg.volume !== undefined) { const vol = msg.volume / 100; setVolume(vol); diff --git a/src/styles/datascreen.css b/src/styles/datascreen.css index f500acf..8a3f880 100644 --- a/src/styles/datascreen.css +++ b/src/styles/datascreen.css @@ -2354,6 +2354,94 @@ backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); } + +/* ============ 页眉音量控制(媒体 + 音效) ============ */ +.bd-vol-wrap { + position: relative; + flex-shrink: 0; +} +.bd-vol-btn { + width: 34px; + height: 34px; + display: inline-flex; + align-items: center; + justify-content: center; + color: #4a5b76; + background: rgba(255,255,255,0.7); + border: 1px solid rgba(47,107,255,0.14); + border-radius: 9px; + cursor: pointer; + transition: all 0.2s; +} +.bd-vol-btn:hover { + color: var(--bd-blue); + border-color: rgba(47,107,255,0.4); + background: rgba(255,255,255,0.95); +} +.bd-vol-btn.muted { color: var(--bd-red); } +.bd-vol-panel { + position: absolute; + top: calc(100% + 8px); + right: 0; + z-index: 60; + width: 220px; + padding: 12px 14px; + background: rgba(255,255,255,0.96); + border: 1px solid rgba(47,107,255,0.16); + border-radius: 12px; + box-shadow: 0 10px 30px rgba(56,100,170,0.16), 0 1px 0 rgba(255,255,255,0.95) inset; + backdrop-filter: blur(14px) saturate(140%); + -webkit-backdrop-filter: blur(14px) saturate(140%); + animation: bdRotIn 0.2s ease both; +} +.bd-vol-title { + display: flex; + align-items: center; + gap: 6px; + font-size: 11.5px; + font-weight: 600; + color: var(--bd-ink); + margin-bottom: 4px; +} +.bd-vol-title .appica-ico { color: var(--bd-blue); } +.bd-vol-panel input[type="range"] { + width: 100%; + height: 5px; + -webkit-appearance: none; + appearance: none; + border-radius: 3px; + background: rgba(47,107,255,0.14); + outline: none; + cursor: pointer; +} +.bd-vol-panel input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 14px; + height: 14px; + border-radius: 50%; + background: linear-gradient(140deg, var(--bd-blue), var(--bd-cyan)); + border: 2px solid #fff; + box-shadow: 0 2px 6px rgba(47,107,255,0.4); +} +.bd-vol-panel input[type="range"]::-moz-range-thumb { + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--bd-blue); + border: 2px solid #fff; + box-shadow: 0 2px 6px rgba(47,107,255,0.4); +} +.bd-vol-val { + display: block; + text-align: right; + font-family: "JetBrains Mono", "Oswald", monospace; + font-size: 10.5px; + font-weight: 700; + color: var(--bd-blue); + margin: 2px 0 8px; +} +.bd-vol-val:last-child { margin-bottom: 0; } .bd-nav-key { display: inline-flex; align-items: center; diff --git a/src/utils/pageNav.js b/src/utils/pageNav.js index 139463b..88929c4 100644 --- a/src/utils/pageNav.js +++ b/src/utils/pageNav.js @@ -1,5 +1,6 @@ import { useEffect } from 'react'; import { useNavigate, useLocation } from 'react-router-dom'; +import { Sfx } from './sounds'; /* ========================================================= 页面循环导航 —— 左右方向键切换大屏页面 @@ -34,8 +35,10 @@ export function usePageNav() { if (idx === -1) return; const n = PAGE_ORDER.length; if (e.key === 'ArrowRight') { + Sfx.page(); navigate(PAGE_ORDER[(idx + 1) % n].path); } else if (e.key === 'ArrowLeft') { + Sfx.page(); navigate(PAGE_ORDER[(idx - 1 + n) % n].path); } }; diff --git a/src/utils/sounds.js b/src/utils/sounds.js new file mode 100644 index 0000000..f6e807f --- /dev/null +++ b/src/utils/sounds.js @@ -0,0 +1,114 @@ +/* ========================================================= + 全局音效引擎 —— Web Audio 合成音(无需音频资源文件) + · 独立于媒体播放音量:sfx 音量单独控制(localStorage 持久化) + · 首次用户交互后自动激活 AudioContext(浏览器自动播放策略) + · 提供:click 点击 / page 切页 / alert 通知 / success 成功 / + error 错误 / notify 提示 / tick 轻响 + ========================================================= */ + +let ctx = null; // AudioContext +let sfxGain = null; // 音效增益(接 master) +const SFX_KEY = 'dpm_sfx_volume'; +let sfxVolume = Number(localStorage.getItem(SFX_KEY)); +if (!Number.isFinite(sfxVolume)) sfxVolume = 60; // 默认 60% +sfxVolume = Math.max(0, Math.min(100, sfxVolume)); + +function ensureCtx() { + if (!ctx) { + const AC = window.AudioContext || window.webkitAudioContext; + if (!AC) return null; + ctx = new AC(); + sfxGain = ctx.createGain(); + sfxGain.gain.value = sfxVolume / 100; + sfxGain.connect(ctx.destination); + } + if (ctx.state === 'suspended') { + ctx.resume().catch(() => { /* 等用户交互后再恢复 */ }); + } + return ctx; +} + +/** + * 合成一个音 + * @param {Object} o freq 起始频率 / end 结束频率 / dur 时长s / type 波形 / + * vol 音量0-1 / delay 延迟s / attack 起音s + */ +function tone(o = {}) { + const c = ensureCtx(); + if (!c) return; + const { + freq = 880, end = freq, dur = 0.12, type = 'sine', + vol = 0.4, delay = 0, attack = 0.005, + } = o; + const t0 = c.currentTime + delay; + const osc = c.createOscillator(); + const g = c.createGain(); + osc.type = type; + osc.frequency.setValueAtTime(freq, t0); + if (end !== freq) osc.frequency.exponentialRampToValueAtTime(Math.max(20, end), t0 + dur); + g.gain.setValueAtTime(0, t0); + g.gain.linearRampToValueAtTime(vol, t0 + attack); + g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur); + osc.connect(g); + g.connect(sfxGain); + osc.start(t0); + osc.stop(t0 + dur + 0.03); +} + +/** 用户交互时调用:激活/恢复 AudioContext */ +export function unlockAudio() { + ensureCtx(); +} + +// 全局首次交互(点击/按键/触摸)即解锁 AudioContext,保证后续音效可用 +if (typeof window !== 'undefined') { + const unlockOnce = () => { unlockAudio(); }; + window.addEventListener('pointerdown', unlockOnce, { once: true }); + window.addEventListener('keydown', unlockOnce, { once: true }); + window.addEventListener('touchstart', unlockOnce, { once: true }); +} + +export const Sfx = { + /** 按钮点击 */ + click() { + tone({ freq: 1300, dur: 0.05, type: 'triangle', vol: 0.22 }); + }, + /** 页面切换(上滑音) */ + page() { + tone({ freq: 560, end: 920, dur: 0.16, type: 'sine', vol: 0.3 }); + }, + /** 通知提醒(双音 ding-ding) */ + alert() { + tone({ freq: 880, dur: 0.12, type: 'sine', vol: 0.38 }); + tone({ freq: 1174, dur: 0.2, type: 'sine', vol: 0.38, delay: 0.14 }); + }, + /** 成功(上行双音) */ + success() { + tone({ freq: 660, dur: 0.1, type: 'sine', vol: 0.3 }); + tone({ freq: 990, dur: 0.18, type: 'sine', vol: 0.3, delay: 0.09 }); + }, + /** 错误(下行低音) */ + error() { + tone({ freq: 320, end: 180, dur: 0.28, type: 'sawtooth', vol: 0.22 }); + }, + /** 轻提示(AI 回复等) */ + notify() { + tone({ freq: 1046, dur: 0.07, type: 'triangle', vol: 0.18 }); + tone({ freq: 1568, dur: 0.09, type: 'triangle', vol: 0.14, delay: 0.06 }); + }, + /** 极轻节拍(数据 tick / 播放控制) */ + tick() { + tone({ freq: 1500, dur: 0.03, type: 'square', vol: 0.06 }); + }, + /** 设置音效音量 0-100(持久化) */ + setVolume(v) { + sfxVolume = Math.max(0, Math.min(100, Number(v) || 0)); + localStorage.setItem(SFX_KEY, String(sfxVolume)); + if (sfxGain) sfxGain.gain.value = sfxVolume / 100; + }, + getVolume() { + return sfxVolume; + }, +}; + +export default Sfx; diff --git a/src/utils/useMqttControl.js b/src/utils/useMqttControl.js index 470e164..71dcbf8 100644 --- a/src/utils/useMqttControl.js +++ b/src/utils/useMqttControl.js @@ -2,6 +2,7 @@ import { useEffect } from 'react'; import { useNavigate, useLocation } from 'react-router-dom'; import { onMqttMessage, connectMqtt, publishMqtt, getClientId, useMqttStatus } from './mqtt'; import { MQTT_TOPIC_HEARTBEAT } from '../config'; +import { Sfx } from './sounds'; import { PAGE_ORDER } from './pageNav'; /* ========================================================= @@ -71,7 +72,7 @@ export function useMqttControl() { switch (cmd.action) { case 'navigate': { const path = resolvePage(cmd.params?.page); - if (path) navigate(path); + if (path) { Sfx.page(); navigate(path); } break; } case 'navigate_rel': { @@ -93,6 +94,7 @@ export function useMqttControl() { case 'pause': case 'next': case 'prev': + Sfx.tick(); case 'set_mode': case 'play_target': case 'minimize':