From af326571da3c70596d507c8adaf598ffef77ff1f Mon Sep 17 00:00:00 2001 From: Pine Date: Thu, 14 May 2026 15:07:41 +0800 Subject: [PATCH] =?UTF-8?q?-=20=E6=9B=B4=E6=96=B0=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E8=A7=A6=E6=8E=A7=E6=93=8D=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + src-tauri/Cargo.lock | 82 +++++++++- src-tauri/Cargo.toml | 1 + src-tauri/capabilities/default.json | 6 +- src-tauri/src/lib.rs | 4 + src-tauri/src/models.rs | 10 ++ src-tauri/src/server.rs | 40 +++++ src-tauri/src/storage.rs | 6 + src-tauri/目录结构.md | 24 +++ src/pages/Admin.jsx | 114 ++++++++++++++ src/pages/Screen.jsx | 169 +++++++++++++++++++-- src/styles/admin.css | 130 ++++++++++++++-- src/styles/screen.css | 228 +++++++++++++++++++++++++++- src/utils/api.js | 8 + vite.config.js | 1 + yarn.lock | 9 +- 16 files changed, 795 insertions(+), 38 deletions(-) create mode 100644 src-tauri/目录结构.md diff --git a/package.json b/package.json index 341f6d2..66752ec 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@tauri-apps/api": "^2", + "@tauri-apps/plugin-autostart": "^2.5.1", "@tauri-apps/plugin-opener": "^2", "react": "^19.1.0", "react-dom": "^19.1.0", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 14478bd..7bda5e8 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -227,6 +227,17 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "auto-launch" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f012b8cc0c850f34117ec8252a44418f2e34a2cf501de89e29b241ae5f79471" +dependencies = [ + "dirs 4.0.0", + "thiserror 1.0.69", + "winreg 0.10.1", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -859,13 +870,33 @@ dependencies = [ "subtle", ] +[[package]] +name = "dirs" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" +dependencies = [ + "dirs-sys 0.3.7", +] + [[package]] name = "dirs" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ - "dirs-sys", + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs-sys" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" +dependencies = [ + "libc", + "redox_users 0.4.6", + "winapi", ] [[package]] @@ -876,7 +907,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.5.2", "windows-sys 0.61.2", ] @@ -956,7 +987,7 @@ version = "0.1.0" dependencies = [ "axum", "chrono", - "dirs", + "dirs 6.0.0", "futures", "once_cell", "rust-embed", @@ -964,6 +995,7 @@ dependencies = [ "serde_json", "tauri", "tauri-build", + "tauri-plugin-autostart", "tauri-plugin-opener", "tokio", "tokio-stream", @@ -1026,7 +1058,7 @@ dependencies = [ "rustc_version", "toml 1.1.2+spec-1.1.0", "vswhom", - "winreg", + "winreg 0.55.0", ] [[package]] @@ -2970,6 +3002,17 @@ dependencies = [ "bitflags 2.11.1", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -3713,7 +3756,7 @@ dependencies = [ "anyhow", "bytes", "cookie", - "dirs", + "dirs 6.0.0", "dunce", "embed_plist", "getrandom 0.3.4", @@ -3763,7 +3806,7 @@ checksum = "3a318b234cc2dea65f575467bafcfb76286bce228ebc3778e337d61d03213007" dependencies = [ "anyhow", "cargo_toml", - "dirs", + "dirs 6.0.0", "glob", "heck 0.5.0", "json-patch", @@ -3833,6 +3876,20 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-autostart" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "459383cebc193cdd03d1ba4acc40f2c408a7abce419d64bdcd2d745bc2886f70" +dependencies = [ + "auto-launch", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + [[package]] name = "tauri-plugin-opener" version = "2.5.4" @@ -4341,7 +4398,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "15edbb0d80583e85ee8df283410038e17314df5cba30da2087a54a85216c0773" dependencies = [ "crossbeam-channel", - "dirs", + "dirs 6.0.0", "libappindicator", "muda", "objc2", @@ -5153,6 +5210,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + [[package]] name = "winreg" version = "0.55.0" @@ -5273,7 +5339,7 @@ dependencies = [ "block2", "cookie", "crossbeam-channel", - "dirs", + "dirs 6.0.0", "dom_query", "dpi", "dunce", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 956d5d0..a3fb0c8 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -15,6 +15,7 @@ tauri-build = { version = "2", features = [] } [dependencies] tauri = { version = "2", features = [] } tauri-plugin-opener = "2" +tauri-plugin-autostart = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["full"] } diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 4cdbf49..0657820 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -5,6 +5,10 @@ "windows": ["main"], "permissions": [ "core:default", - "opener:default" + "core:window:default", + "core:window:allow-minimize", + "core:window:allow-set-fullscreen", + "opener:default", + "autostart:default" ] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0339c60..476547d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -42,6 +42,10 @@ pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_opener::init()) + .plugin(tauri_plugin_autostart::init( + tauri_plugin_autostart::MacosLauncher::LaunchAgent, + Some(vec![]), + )) .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index 08d4757..912f672 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -9,6 +9,8 @@ pub struct Settings { pub auto_play: bool, pub play_mode: String, pub image_duration: u32, + pub fullscreen: bool, + pub autostart: bool, } impl Default for Settings { @@ -20,6 +22,8 @@ impl Default for Settings { auto_play: true, play_mode: "sequential".into(), image_duration: 5, + fullscreen: true, + autostart: false, } } } @@ -88,6 +92,10 @@ pub struct ServerEvent { pub play_mode: Option, #[serde(skip_serializing_if = "Option::is_none")] pub image_duration: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub fullscreen: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub autostart: Option, } // ============ Playlist Response ============ @@ -116,6 +124,8 @@ pub struct SettingsUpdate { pub volume: Option, pub play_mode: Option, pub image_duration: Option, + pub fullscreen: Option, + pub autostart: Option, } #[derive(Debug, Deserialize)] diff --git a/src-tauri/src/server.rs b/src-tauri/src/server.rs index c4d8614..91731d1 100644 --- a/src-tauri/src/server.rs +++ b/src-tauri/src/server.rs @@ -169,6 +169,7 @@ pub fn create_router(state: AppState) -> Router { Router::new() .route("/api/login", post(login_handler)) .route("/api/settings", get(get_settings).post(update_settings)) + .route("/api/display-command", post(display_command_handler)) .route("/media", get(list_media)) .route("/upload", post(upload_handler)) .route("/api/delete", post(delete_handler)) @@ -221,6 +222,8 @@ async fn get_settings() -> Json { "volume": s.volume, "play_mode": s.play_mode, "image_duration": s.image_duration, + "fullscreen": s.fullscreen, + "autostart": s.autostart, })) } @@ -231,6 +234,8 @@ async fn update_settings( volume: body.volume, play_mode: body.play_mode.clone(), image_duration: body.image_duration, + fullscreen: body.fullscreen, + autostart: body.autostart, }); EVENTS.broadcast(&ServerEvent { @@ -239,6 +244,8 @@ async fn update_settings( volume: body.volume, play_mode: body.play_mode, image_duration: body.image_duration, + fullscreen: body.fullscreen, + autostart: body.autostart, }); Json(serde_json::json!({"ok": true})) @@ -356,6 +363,8 @@ async fn delete_handler( volume: None, play_mode: None, image_duration: None, + fullscreen: None, + autostart: None, }); Json(OkResponse { ok: true }) @@ -439,6 +448,8 @@ async fn add_playlist( volume: None, play_mode: None, image_duration: None, + fullscreen: None, + autostart: None, }); Json(OkResponse { ok: true }) } @@ -453,6 +464,8 @@ async fn remove_playlist( volume: None, play_mode: None, image_duration: None, + fullscreen: None, + autostart: None, }); Json(OkResponse { ok: true }) } @@ -472,6 +485,8 @@ async fn control_handler( volume: None, play_mode: None, image_duration: None, + fullscreen: None, + autostart: None, }); let mut playback = state.playback.lock().await; @@ -489,11 +504,34 @@ async fn control_handler( volume: None, play_mode: None, image_duration: None, + fullscreen: None, + autostart: None, }); Ok(Json(serde_json::json!({"ok": true, "action": body.action}))) } +// ===================== Handler: 显示命令(最小化、全屏等) ===================== +async fn display_command_handler( + Json(body): Json, +) -> Result, (StatusCode, String)> { + match body.action.as_str() { + "minimize" => { + EVENTS.broadcast(&ServerEvent { + action: "minimize_window".into(), + state: None, + volume: None, + play_mode: None, + image_duration: None, + fullscreen: None, + autostart: None, + }); + Ok(Json(serde_json::json!({"ok": true, "action": "minimize"}))) + } + _ => Err((StatusCode::BAD_REQUEST, "不支持的显示命令".into())), + } +} + // ===================== Handler: 状态 ===================== async fn get_state( State(state): State, @@ -527,6 +565,8 @@ async fn update_state( volume: None, play_mode: None, image_duration: None, + fullscreen: None, + autostart: None, }); Json(OkResponse { ok: true }) diff --git a/src-tauri/src/storage.rs b/src-tauri/src/storage.rs index 25a5f5b..1e634ea 100644 --- a/src-tauri/src/storage.rs +++ b/src-tauri/src/storage.rs @@ -86,6 +86,12 @@ impl Storage { if let Some(d) = update.image_duration { s.image_duration = d; } + if let Some(f) = update.fullscreen { + s.fullscreen = f; + } + if let Some(a) = update.autostart { + s.autostart = a; + } } } self.save(); diff --git a/src-tauri/目录结构.md b/src-tauri/目录结构.md new file mode 100644 index 0000000..699f41c --- /dev/null +++ b/src-tauri/目录结构.md @@ -0,0 +1,24 @@ +``` +src-tauri/ +├── src/ +│ ├── commands/ # 所有 Tauri 命令(前端调用的 Rust 函数) +│ │ ├── mod.rs # 命令模块导出 +│ │ ├── file.rs # 文件操作命令 +│ │ ├── system.rs # 系统信息/权限命令 +│ │ └── app.rs # 应用控制(窗口、托盘、重启) +│ ├── core/ # 核心业务逻辑(纯 Rust,不依赖 Tauri) +│ │ ├── mod.rs +│ │ ├── config.rs # 配置管理 +│ │ ├── storage.rs # 本地数据存储 +│ │ └── service.rs # 业务服务 +│ ├── utils/ # 工具函数 +│ │ ├── mod.rs +│ │ ├── error.rs # 统一错误处理 +│ │ └── helpers.rs # 通用工具 +│ ├── window.rs # 窗口管理(创建、样式、行为) +│ ├── tray.rs # 系统托盘逻辑 +│ ├── lib.rs # 库入口(导出所有模块) +│ └── main.rs # 程序入口(轻量,只做初始化) +├── Cargo.toml +└── tauri.conf.json +``` \ No newline at end of file diff --git a/src/pages/Admin.jsx b/src/pages/Admin.jsx index 32c54b3..f1c2ebc 100644 --- a/src/pages/Admin.jsx +++ b/src/pages/Admin.jsx @@ -1,6 +1,9 @@ import { useState, useEffect, useCallback, useRef } from 'react'; +import { useNavigate } from 'react-router-dom'; import * as api from '../utils/api'; import { API_BASE } from '../utils/api'; +import { getCurrentWindow } from '@tauri-apps/api/window'; +import { enable as enableAutostart, disable as disableAutostart } from '@tauri-apps/plugin-autostart'; import '../styles/admin.css'; /* ============ SVG Icons ============ */ @@ -157,6 +160,8 @@ export default function Admin() { const [previewItem, setPreviewItem] = useState(null); const [toasts, setToasts] = useState([]); const [uploadProgress, setUploadProgress] = useState(null); + const [fullscreenMode, setFullscreenMode] = useState(true); + const [autostartEnabled, setAutostartEnabled] = useState(false); const toastIdRef = useRef(0); const volTimerRef = useRef(null); @@ -194,6 +199,8 @@ export default function Admin() { setVolume(d.volume ?? 80); setPlayMode(d.play_mode || 'sequential'); setImageDuration(d.image_duration || 5); + setFullscreenMode(d.fullscreen ?? true); + setAutostartEnabled(d.autostart ?? false); } catch { /* ignore */ } }, []); @@ -242,6 +249,9 @@ export default function Admin() { setStatusText(`已暂停 — ${s.name}`); } } + if (msg.action === 'minimize_window') { + getCurrentWindow().minimize().catch(() => {}); + } if (msg.action === 'playlist_changed') { loadPlaylist(); } @@ -249,6 +259,14 @@ export default function Admin() { if (msg.volume !== undefined) setVolume(msg.volume); if (msg.play_mode !== undefined) setPlayMode(msg.play_mode); if (msg.image_duration !== undefined) setImageDuration(msg.image_duration); + if (msg.fullscreen !== undefined) { + setFullscreenMode(msg.fullscreen); + getCurrentWindow().setFullscreen(msg.fullscreen).catch(() => {}); + } + if (msg.autostart !== undefined) { + setAutostartEnabled(msg.autostart); + if (msg.autostart) { enableAutostart().catch(() => {}); } else { disableAutostart().catch(() => {}); } + } } } catch { /* ignore */ } }; @@ -306,6 +324,52 @@ export default function Admin() { await api.sendControl(action); }; + // ============ Return to Screen ============ + const navigate = useNavigate(); + const fromScreen = new URLSearchParams(window.location.search).get('from') === 'screen'; + + const handleReturnToScreen = () => { + localStorage.removeItem('token'); + setIsLoggedIn(false); + navigate('/screen'); + }; + + // ============ Display Controls ============ + const handleToggleFullscreen = async () => { + const newMode = !fullscreenMode; + setFullscreenMode(newMode); + try { + await getCurrentWindow().setFullscreen(newMode); + } catch (e) { + console.error('Fullscreen toggle failed:', e); + } + await api.updateSettings({ fullscreen: newMode }); + }; + + const handleMinimize = async () => { + try { + await getCurrentWindow().minimize(); + } catch (e) { + console.error('Minimize failed:', e); + } + await api.sendDisplayCommand('minimize'); + }; + + const handleToggleAutostart = async () => { + const newValue = !autostartEnabled; + setAutostartEnabled(newValue); + try { + if (newValue) { + await enableAutostart(); + } else { + await disableAutostart(); + } + } catch (e) { + console.error('Autostart toggle failed:', e); + } + await api.updateSettings({ autostart: newValue }); + }; + // ============ Render ============ if (!isLoggedIn) { return ( @@ -369,12 +433,36 @@ export default function Admin() { + {fromScreen && ( + + )} {/* Main Content */}
+ {/* Mobile Playback Controls (hidden on desktop) */} +
+
+

播放控制

+
+
+ + + + +
+
+ {/* Settings Card */}
@@ -401,6 +489,32 @@ export default function Admin() {
+ {/* Display Control Card */} +
+
+
+

显示控制

+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ {/* Upload Card */}
diff --git a/src/pages/Screen.jsx b/src/pages/Screen.jsx index 1ffbbbf..95302ad 100644 --- a/src/pages/Screen.jsx +++ b/src/pages/Screen.jsx @@ -1,7 +1,9 @@ import { useState, useEffect, useRef, useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; import * as api from '../utils/api'; import { API_BASE } from '../utils/api'; import { getCurrentWindow } from '@tauri-apps/api/window'; +import { enable, disable } from '@tauri-apps/plugin-autostart'; import '../styles/screen.css'; /* ============ Loader ============ */ @@ -29,6 +31,7 @@ function Loader({ hidden, text }) { Screen Page ========================================================= */ export default function Screen() { + const navigate = useNavigate(); const [list, setList] = useState([]); const [index, setIndex] = useState(0); const [volume, setVolume] = useState(0.8); @@ -40,10 +43,16 @@ export default function Screen() { const [loaderText, setLoaderText] = useState('加载播放列表中...'); const [mediaKey, setMediaKey] = useState(0); // 强制重新渲染,用于单项目循环 const [showVideo, setShowVideo] = useState(true); + const [showLoginModal, setShowLoginModal] = useState(false); + const [loginUsername, setLoginUsername] = useState(''); + const [loginPassword, setLoginPassword] = useState(''); + const [loginError, setLoginError] = useState(''); const videoRef = useRef(null); const imgRef = useRef(null); const timerRef = useRef(null); + const loginTimerRef = useRef(null); + const loginInputRef = useRef(null); // ref 存储可变值,避免回调中的闭包过期 const listRef = useRef(list); @@ -53,6 +62,10 @@ export default function Screen() { const imageDurationRef = useRef(imageDuration); const soundBlockedRef = useRef(soundBlocked); + // 从后端同步的显示设置(Screen 上无需 UI 控制,但需要响应 SSE) + const [fullscreenMode, setFullscreenMode] = useState(true); + const [autostartEnabled, setAutostartEnabled] = useState(false); + useEffect(() => { listRef.current = list; }, [list]); useEffect(() => { indexRef.current = index; }, [index]); useEffect(() => { pausedRef.current = paused; }, [paused]); @@ -232,6 +245,9 @@ export default function Screen() { setPaused(false); skip(-1); break; + case 'minimize_window': + getCurrentWindow().minimize().catch(() => {}); + break; case 'playlist_changed': reloadPlaylist(); break; @@ -298,6 +314,18 @@ export default function Screen() { } if (msg.play_mode !== undefined) setPlayMode(msg.play_mode); if (msg.image_duration !== undefined) setImageDuration(msg.image_duration); + if (msg.fullscreen !== undefined) { + setFullscreenMode(msg.fullscreen); + getCurrentWindow().setFullscreen(msg.fullscreen).catch(() => {}); + } + if (msg.autostart !== undefined) { + setAutostartEnabled(msg.autostart); + if (msg.autostart) { + enable().catch(() => {}); + } else { + disable().catch(() => {}); + } + } }, []); const enableSound = useCallback(() => { @@ -314,6 +342,61 @@ export default function Screen() { }); }, []); + // ============ 点击标题弹出登录弹窗 ============ + const handleTitleClick = useCallback(() => { + setLoginUsername(''); + setLoginPassword(''); + setLoginError(''); + setShowLoginModal(true); + // 30 秒无操作自动关闭 + if (loginTimerRef.current) clearTimeout(loginTimerRef.current); + loginTimerRef.current = setTimeout(() => { + setShowLoginModal(false); + }, 30000); + // 下一帧聚焦输入框 + setTimeout(() => { + if (loginInputRef.current) loginInputRef.current.focus(); + }, 100); + }, []); + + const handleCloseLogin = useCallback(() => { + if (loginTimerRef.current) clearTimeout(loginTimerRef.current); + setShowLoginModal(false); + }, []); + + const handleLoginSubmit = useCallback(async () => { + try { + const data = await api.login(loginUsername, loginPassword); + if (data.success) { + if (loginTimerRef.current) clearTimeout(loginTimerRef.current); + localStorage.setItem('token', 'admin_logged'); + navigate('/admin?from=screen'); + } else { + setLoginError('账号或密码错误'); + } + } catch { + setLoginError('登录失败,请重试'); + } + }, [loginUsername, loginPassword, navigate]); + + const handleLoginKeyDown = useCallback((e) => { + if (e.key === 'Enter') handleLoginSubmit(); + }, [handleLoginSubmit]); + + // ============ LIVE 点击切换播放/暂停 ============ + const togglePlayPause = useCallback(() => { + if (pausedRef.current) { + // 恢复播放 + setPaused(false); + } else { + // 暂停 + setPaused(true); + const video = videoRef.current; + if (video) video.pause(); + if (timerRef.current) clearTimeout(timerRef.current); + } + }, []); + // Global click for sound unblock useEffect(() => { const handler = () => { @@ -323,21 +406,38 @@ export default function Screen() { return () => document.removeEventListener('click', handler); }, [enableSound]); - // Escape 退出 Tauri 窗口全屏 + // 加载显示设置(大屏/小窗口、开机自启动) + useEffect(() => { + const load = async () => { + try { + const data = await api.getSettings(); + setFullscreenMode(data.fullscreen ?? true); + setAutostartEnabled(data.autostart ?? false); + } catch {/* ignore */} + }; + load(); + }, []); + + // Escape 退出 Tauri 窗口全屏 / 关闭登录弹窗 useEffect(() => { const handler = (e) => { if (e.key === 'Escape') { - getCurrentWindow().setFullscreen(false); + if (showLoginModal) { + handleCloseLogin(); + } else { + getCurrentWindow().setFullscreen(false).catch(() => {}); + } } }; document.addEventListener('keydown', handler); return () => document.removeEventListener('keydown', handler); - }, []); + }, [showLoginModal, handleCloseLogin]); - // Cleanup timer on unmount + // Cleanup timers on unmount useEffect(() => { return () => { if (timerRef.current) clearTimeout(timerRef.current); + if (loginTimerRef.current) clearTimeout(loginTimerRef.current); }; }, []); @@ -373,24 +473,69 @@ export default function Screen() { /> {/* Corner Info — Now Playing */} -
+
{'昆明市大学生创业园'}
- {/* Connection Indicator */} -
+ {/* Connection Indicator — 点击切换播放/暂停 */} +
-
+
- Live + {paused ? 'Paused' : 'Live'}
- {/* Bottom Status */} + {/* Bottom Status — 左侧点击上一段,右侧点击下一段 */}
-
- {currentItem ? `${index + 1} / ${list.length}` : '0 / 0'} +
skip(-1)}> +
+ {currentItem ? `${index + 1}` : '0'} +
+ / +
skip(1)}> + {list.length} +
+
+ + {/* Login Modal */} +
{ if (e.target === e.currentTarget) handleCloseLogin(); }}> +
e.stopPropagation()}> + +
+

后台管理

+

请输入账号密码登录

+
+
+ + { setLoginUsername(e.target.value); setLoginError(''); }} + onKeyDown={handleLoginKeyDown} + placeholder="请输入账号" + autoComplete="username" + /> +
+
+ + { setLoginPassword(e.target.value); setLoginError(''); }} + onKeyDown={handleLoginKeyDown} + placeholder="请输入密码" + autoComplete="current-password" + /> +
+ {loginError &&
{loginError}
} + +
{/* Watermark */} diff --git a/src/styles/admin.css b/src/styles/admin.css index 0fa3245..4aa5e95 100644 --- a/src/styles/admin.css +++ b/src/styles/admin.css @@ -431,6 +431,25 @@ color: var(--danger); transform: translateY(-1px); } +.btn-return { + padding: var(--space-8) var(--space-16); + font-family: "Poppins", "PingFang SC", sans-serif; + font-size: 13px; + font-weight: 500; + background: transparent; + border: 1px solid var(--border-default); + color: var(--text-secondary); + border-radius: var(--radius-md); + cursor: pointer; + transition: all var(--transition); + white-space: nowrap; +} +.btn-return:hover { + background: rgba(0, 189, 125, 0.08); + border-color: rgba(0, 189, 125, 0.3); + color: var(--primary); + transform: translateY(-1px); +} /* ============ Main Grid ============ */ .main-grid { @@ -468,6 +487,7 @@ transform: perspective(1000px) translateZ(4px); } .card.full { grid-column: 1 / -1; } +.playback-controls-card { display: none; } .card-header { display: flex; align-items: center; @@ -601,8 +621,9 @@ input[type="range"]::-webkit-slider-thumb:hover { align-items: end; position: relative; z-index: 1; + flex-wrap: wrap; } -.form-inline .form-group { flex: 1; margin-bottom: 0; } +.form-inline .form-group { flex: 1; margin-bottom: 0; min-width: 0; } /* ============ Buttons ============ */ button { font-family: "Poppins", "PingFang SC", sans-serif; cursor: pointer; transition: all var(--transition); } @@ -642,6 +663,29 @@ button { font-family: "Poppins", "PingFang SC", sans-serif; cursor: pointer; tra transform: translateY(-1px); } .btn-accent:active { transform: translateY(1px); } +.btn-outline { + padding: var(--space-10) var(--space-20); + border-radius: var(--radius-sm); + border: 1px solid var(--border-default); + font-size: 13px; + font-weight: 500; + background: transparent; + color: var(--text-secondary); + white-space: nowrap; + cursor: pointer; + transition: all 0.2s; + letter-spacing: 0.02em; +} +.btn-outline:hover { + border-color: var(--primary); + color: var(--text); + background: rgba(0, 189, 125, 0.06); +} +.btn-outline.active { + border-color: var(--primary); + color: var(--primary); + background: rgba(0, 189, 125, 0.1); +} .btn-danger { padding: var(--space-12) var(--space-24); border-radius: var(--radius-sm); @@ -984,17 +1028,25 @@ button { font-family: "Poppins", "PingFang SC", sans-serif; cursor: pointer; tra @media (max-width: 768px) { .login-card { padding: var(--space-24) var(--space-16) var(--space-32); margin: var(--space-12); max-width: none; } .login-brand h2 { font-size: 20px; } - .topbar { padding: 0 var(--space-16); height: 56px; gap: var(--space-8); } - .topbar-left h1 { font-size: 15px; } + .topbar { padding: 0 var(--space-16); height: 56px; gap: var(--space-4); } + .topbar-left h1 { font-size: 14px; } .topbar-center { display: none; } - .btn-control { width: 36px; height: 36px; font-size: 14px; } - .btn-control.play-btn { width: 42px; height: 42px; font-size: 16px; } - .btn-logout { padding: var(--space-4) var(--space-12); font-size: 12px; } - .topbar-divider { display: none; } - .main-grid { grid-template-columns: 1fr; gap: var(--space-16); padding: var(--space-16); } - .card { padding: var(--space-16); } + .topbar-right { gap: var(--space-2); } + /* Hide playback controls in topbar on mobile — they're in their own card */ + .topbar-right .btn-control, + .topbar-right .play-btn, + .topbar-right .topbar-divider { display: none; } + .btn-logout, .btn-return { padding: var(--space-4) var(--space-10); font-size: 11px; } + .main-grid { grid-template-columns: 1fr; gap: var(--space-12); padding: var(--space-12); } + .card { padding: var(--space-12) var(--space-16); } .card.full { grid-column: 1; } - .form-inline { flex-direction: column; gap: var(--space-16); } + .card-header { margin-bottom: var(--space-16); } + .card-header h3 { font-size: 14px; } + .card-header .card-icon { width: 30px; height: 30px; } + .card-header .card-icon svg { width: 16px; height: 16px; } + .form-inline { flex-direction: column; gap: var(--space-12); align-items: stretch; } + .form-group { margin-bottom: var(--space-12); } + .form-group label { font-size: 11px; } .media-grid { grid-template-columns: repeat(2, 1fr); gap: var(--space-8); } .media-card .thumb { height: 88px; } .thumb-video-wrap { height: 88px; } @@ -1004,18 +1056,70 @@ button { font-family: "Poppins", "PingFang SC", sans-serif; cursor: pointer; tra .toast-container { top: var(--space-12); right: var(--space-12); left: var(--space-12); } .toast { max-width: none; font-size: 12px; } .btn-primary, .btn-accent, .btn-danger { padding: var(--space-12) var(--space-16); font-size: 12px; } + .btn-outline { padding: var(--space-8) var(--space-14); font-size: 12px; } .modal-overlay { padding: var(--space-16); } .modal-panel { max-width: 100%; max-height: 90vh; } .modal-body { padding: var(--space-12); max-height: 55vh; } .modal-body img, .modal-body video { max-height: 50vh; } .modal-header { padding: var(--space-12) var(--space-16); } .modal-footer { padding: var(--space-8) var(--space-16); } + + /* Mobile playback control card */ + .playback-controls-card { display: block; } + .playback-controls-row { + display: flex; + align-items: center; + justify-content: center; + gap: var(--space-8); + padding: var(--space-4) 0; + } + .playback-controls-row .btn-control { + width: 44px; + height: 44px; + border-radius: var(--radius-md); + border: 1px solid var(--border-subtle); + background: #f9fafb; + color: var(--text-secondary); + cursor: pointer; + font-size: 16px; + display: flex; + align-items: center; + justify-content: center; + transition: all var(--transition); + flex-shrink: 0; + box-shadow: var(--shadow-xs); + } + .playback-controls-row .btn-control:hover { + background: var(--surface); + border-color: var(--border-strong); + color: var(--text); + box-shadow: var(--shadow-sm); + transform: translateY(-1px); + } + .playback-controls-row .btn-control.active { + background: var(--primary); + border-color: var(--primary); + color: var(--text-inverse); + box-shadow: 0 0 16px rgba(0, 189, 125, 0.3); + } + .playback-controls-row .play-btn { + width: 52px; + height: 52px; + font-size: 20px; + } + .playback-controls-row .btn-control:active { transform: translateY(1px); } } @media (max-width: 400px) { - .topbar-left h1 { font-size: 13px; } + .topbar-left h1 { font-size: 12px; } .topbar-right { gap: 2px; } - .btn-control { width: 32px; height: 32px; font-size: 12px; } - .btn-control.play-btn { width: 38px; height: 38px; font-size: 14px; } + .topbar-right .btn-logout, .topbar-right .btn-return { font-size: 10px; padding: var(--space-2) var(--space-8); } + .main-grid { gap: var(--space-8); padding: var(--space-8); } + .card { padding: var(--space-12); } .media-grid { grid-template-columns: repeat(2, 1fr); gap: var(--space-4); } + .playback-controls-row { gap: var(--space-4); } + .playback-controls-row .btn-control { width: 38px; height: 38px; font-size: 14px; } + .playback-controls-row .play-btn { width: 44px; height: 44px; font-size: 17px; } + .form-inline { gap: var(--space-8); align-items: stretch; } + .form-group { margin-bottom: var(--space-8); } } diff --git a/src/styles/screen.css b/src/styles/screen.css index 9d91a68..f6b6a6c 100644 --- a/src/styles/screen.css +++ b/src/styles/screen.css @@ -335,7 +335,6 @@ box-shadow: 0 2px 12px rgba(0,0,0,0.2), 0 0 0 1px rgba(255,255,255,0.02) inset; - pointer-events: none; } .corner-info.visible { opacity: 1; } .corner-info .ci-dot { @@ -367,7 +366,7 @@ gap: 8px; opacity: 0; transition: opacity 0.6s ease; - pointer-events: none; + cursor: pointer; } .conn-indicator.visible { opacity: 1; } .conn-rings { position: relative; width: 8px; height: 8px; } @@ -379,6 +378,10 @@ box-shadow: 0 0 6px rgba(52, 211, 153, 0.3); transition: all 0.6s ease; } +.conn-rings .cr-inner.paused { + background: rgba(255,255,255,0.2); + box-shadow: none; +} .conn-rings .cr-outer { position: absolute; inset: -3px; @@ -409,9 +412,28 @@ opacity: 0; transition: opacity 0.6s ease; box-shadow: 0 2px 10px rgba(0,0,0,0.15); - pointer-events: none; } .status-bar.visible { opacity: 1; } +.status-bar .sb-prev, +.status-bar .sb-next { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + padding: 2px 4px; + border-radius: 6px; + transition: background 0.2s; +} +.status-bar .sb-prev:hover, +.status-bar .sb-next:hover { + background: rgba(255,255,255,0.06); +} +.status-bar .sb-sep { + font-size: 10px; + color: var(--text-dim); + opacity: 0.4; + pointer-events: none; +} .status-bar .sb-dot { width: 5px; height: 5px; @@ -444,3 +466,203 @@ user-select: none; font-family: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif; } + +.admin-btn-primary { + width: 100%; + padding: 12px; + background: rgba(6, 182, 212, 0.15); + border: 1px solid rgba(6, 182, 212, 0.25); + border-radius: 10px; + color: rgba(255,255,255,0.9); + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: background 0.2s, border-color 0.2s; + letter-spacing: 0.04em; +} +.admin-btn-primary:hover { + background: rgba(6, 182, 212, 0.25); + border-color: rgba(6, 182, 212, 0.4); +} + +.admin-control-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 0; + border-bottom: 1px solid rgba(255,255,255,0.04); +} +.admin-control-item:last-child { + border-bottom: none; +} + +.admin-control-label { + font-size: 14px; + font-weight: 450; + color: rgba(255,255,255,0.7); +} + +.admin-btn-toggle, +.admin-btn-action { + padding: 8px 16px; + background: rgba(255,255,255,0.06); + border: 1px solid rgba(255,255,255,0.1); + border-radius: 8px; + color: rgba(255,255,255,0.8); + font-size: 13px; + cursor: pointer; + transition: all 0.2s; + white-space: nowrap; +} +.admin-btn-toggle:hover, +.admin-btn-action:hover { + background: rgba(255,255,255,0.1); + border-color: rgba(255,255,255,0.18); +} +.admin-btn-toggle.active { + background: rgba(52, 211, 153, 0.12); + border-color: rgba(52, 211, 153, 0.3); + color: rgba(52, 211, 153, 0.9); +} + +/* ============ Screen Login Modal ============ */ +.login-overlay { + position: fixed; + inset: 0; + z-index: 200; + display: flex; + align-items: center; + justify-content: center; + background: rgba(4, 8, 16, 0.7); + backdrop-filter: blur(12px) saturate(120%); + -webkit-backdrop-filter: blur(12px) saturate(120%); + opacity: 0; + visibility: hidden; + transition: opacity 0.35s ease, visibility 0.35s ease; +} +.login-overlay.visible { + opacity: 1; + visibility: visible; +} +.login-modal { + position: relative; + background: rgba(16, 24, 40, 0.92); + backdrop-filter: blur(32px) saturate(180%); + -webkit-backdrop-filter: blur(32px) saturate(180%); + border: 1px solid rgba(255,255,255,0.08); + border-bottom: 1px solid rgba(255,255,255,0.03); + border-radius: 20px; + padding: 40px 36px 36px; + width: 100%; + max-width: 380px; + text-align: center; + box-shadow: + 0 24px 80px rgba(0,0,0,0.5), + 0 0 0 1px rgba(255,255,255,0.03) inset, + 0 1px 0 rgba(255,255,255,0.04) inset; + animation: loginModalIn 0.4s cubic-bezier(0.16, 1, 0.3, 1); + transform: perspective(800px) rotateX(0.5deg); +} +@keyframes loginModalIn { + from { opacity: 0; transform: perspective(800px) rotateX(3deg) translateY(24px) scale(0.92); } + to { opacity: 1; transform: perspective(800px) rotateX(0.5deg) translateY(0) scale(1); } +} +.login-modal-close { + position: absolute; + top: 14px; + right: 14px; + width: 34px; + height: 34px; + border-radius: 50%; + border: 1px solid rgba(255,255,255,0.08); + background: rgba(255,255,255,0.04); + color: rgba(255,255,255,0.45); + font-size: 20px; + line-height: 1; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; +} +.login-modal-close:hover { + background: rgba(255,255,255,0.08); + border-color: rgba(255,255,255,0.15); + color: rgba(255,255,255,0.8); +} +.login-modal-brand { margin-bottom: 28px; } +.login-modal-brand h2 { + font-size: 22px; + font-weight: 600; + color: rgba(255,255,255,0.92); + letter-spacing: 0.03em; + margin-bottom: 6px; +} +.login-modal-brand p { + font-size: 13px; + color: rgba(255,255,255,0.38); + letter-spacing: 0.05em; +} +.login-modal-field { + margin-bottom: 16px; + text-align: left; +} +.login-modal-field label { + display: block; + font-size: 11px; + font-weight: 500; + color: rgba(255,255,255,0.4); + margin-bottom: 6px; + letter-spacing: 0.05em; + text-transform: uppercase; +} +.login-modal-field input { + width: 100%; + padding: 12px 16px; + background: rgba(255,255,255,0.06); + border: 1px solid rgba(255,255,255,0.1); + border-radius: 10px; + font-size: 15px; + font-family: inherit; + font-weight: 400; + color: rgba(255,255,255,0.88); + outline: none; + transition: all 0.2s; + box-sizing: border-box; +} +.login-modal-field input::placeholder { + color: rgba(255,255,255,0.22); +} +.login-modal-field input:focus { + border-color: rgba(6, 182, 212, 0.5); + box-shadow: 0 0 0 3px rgba(6, 182, 212, 0.1); + background: rgba(255,255,255,0.08); +} +.login-modal-error { + font-size: 13px; + color: #f87171; + margin-bottom: 12px; + text-align: center; +} +.login-modal-btn { + width: 100%; + padding: 13px; + background: rgba(6, 182, 212, 0.15); + border: 1px solid rgba(6, 182, 212, 0.25); + border-radius: 10px; + color: rgba(255,255,255,0.9); + font-size: 15px; + font-weight: 500; + font-family: inherit; + letter-spacing: 0.06em; + cursor: pointer; + transition: all 0.2s; +} +.login-modal-btn:hover { + background: rgba(6, 182, 212, 0.25); + border-color: rgba(6, 182, 212, 0.4); + transform: translateY(-1px); +} +.login-modal-btn:active { + transform: translateY(0); +} diff --git a/src/utils/api.js b/src/utils/api.js index 15cceac..8765d70 100644 --- a/src/utils/api.js +++ b/src/utils/api.js @@ -155,3 +155,11 @@ export async function updateState(state) { body: JSON.stringify({ state }), }); } + +// ============ Display Commands ============ +export async function sendDisplayCommand(action) { + return request('/api/display-command', { + method: 'POST', + body: JSON.stringify({ action }), + }); +} diff --git a/vite.config.js b/vite.config.js index aa1e84c..7f8811c 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,6 +1,7 @@ import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; + // https://vite.dev/config/ export default defineConfig(async () => ({ plugins: [react()], diff --git a/yarn.lock b/yarn.lock index 1426dc3..2bad890 100644 --- a/yarn.lock +++ b/yarn.lock @@ -454,7 +454,7 @@ resolved "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.3.tgz#cc6f094a3ffe5556bb4a831ee6fb572b8cd81a75" integrity sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA== -"@tauri-apps/api@^2", "@tauri-apps/api@^2.11.0": +"@tauri-apps/api@^2", "@tauri-apps/api@^2.11.0", "@tauri-apps/api@^2.8.0": version "2.11.0" resolved "https://registry.npmmirror.com/@tauri-apps/api/-/api-2.11.0.tgz#00fb69996010178a5153798d4a84f6fe3a1e1182" integrity sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA== @@ -531,6 +531,13 @@ "@tauri-apps/cli-win32-ia32-msvc" "2.11.1" "@tauri-apps/cli-win32-x64-msvc" "2.11.1" +"@tauri-apps/plugin-autostart@^2.5.1": + version "2.5.1" + resolved "https://registry.npmmirror.com/@tauri-apps/plugin-autostart/-/plugin-autostart-2.5.1.tgz#e9e0bdfd721838eff620430889325c641a853a54" + integrity sha512-zS/xx7yzveCcotkA+8TqkI2lysmG2wvQXv2HGAVExITmnFfHAdj1arGsbbfs3o6EktRHf6l34pJxc3YGG2mg7w== + dependencies: + "@tauri-apps/api" "^2.8.0" + "@tauri-apps/plugin-opener@^2": version "2.5.4" resolved "https://registry.npmmirror.com/@tauri-apps/plugin-opener/-/plugin-opener-2.5.4.tgz#b37883e4d36125b8c5a0c74f683395958a65bd7d"