- 更新前端支持触控操作

This commit is contained in:
Pine
2026-05-14 15:07:41 +08:00
parent f4dcc30c2b
commit af326571da
16 changed files with 795 additions and 38 deletions
+114
View File
@@ -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() {
<IconNext />
</button>
<span className="topbar-divider"></span>
{fromScreen && (
<button className="btn-return" onClick={handleReturnToScreen}>返回展播</button>
)}
<button className="btn-logout" onClick={handleLogout}>退出</button>
</div>
</div>
{/* Main Content */}
<div className="main-grid">
{/* Mobile Playback Controls (hidden on desktop) */}
<div className="card playback-controls-card">
<div className="card-header">
<h3>播放控制</h3>
</div>
<div className="playback-controls-row">
<button className="btn-control" onClick={() => handleSendControl('prev')} title="上一个">
<IconPrev />
</button>
<button className={`btn-control play-btn${isPlaying ? ' active' : ''}`} onClick={() => handleSendControl('play')} title="播放">
<IconPlay />
</button>
<button className={`btn-control play-btn${isPaused ? ' active' : ''}`} onClick={() => handleSendControl('pause')} title="暂停">
<IconPause />
</button>
<button className="btn-control" onClick={() => handleSendControl('next')} title="下一个">
<IconNext />
</button>
</div>
</div>
{/* Settings Card */}
<div className="card">
<div className="card-header">
@@ -401,6 +489,32 @@ export default function Admin() {
</div>
</div>
{/* Display Control Card */}
<div className="card">
<div className="card-header">
<div className="card-icon"><svg t="1778605826535" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2889" width="200" height="200"><path d="M564 771.47H171c-16.57 0-30-13.43-30-30V566.78c0-78.12 63.55-141.67 141.67-141.67H564c16.57 0 30 13.43 30 30v286.36c0 16.57-13.43 30-30 30z m-363-60h333V485.11H282.67c-45.03 0-81.67 36.64-81.67 81.67v144.69z" fill="#9BC5ED" p-id="2890"></path><path d="M830.72 212.82l-77.32-44.64c-30.39-17.55-68.38 4.39-68.38 39.48v230.42c-35.84-37.33-86.24-60.58-142.08-60.58-108.76 0-196.93 88.17-196.93 196.93s88.17 196.93 196.93 196.93 196.93-88.17 196.93-196.93c0-1.32-0.02-2.63-0.05-3.95 0.03-0.51 0.05-1.02 0.05-1.53V341.6a46.01 46.01 0 0 0 13.53-5.19l77.32-44.64c30.39-17.55 30.39-61.42 0-78.96z" fill="#1D5DCE" p-id="2891"></path><path d="M630.69 501.22m-30.86 0a30.86 30.86 0 1 0 61.72 0 30.86 30.86 0 1 0-61.72 0Z" fill="#FFFFFF" p-id="2892"></path><path d="M865.63 671.66l-146.65-84.67c-28.18-16.27-63.41 4.07-63.41 36.61v169.34c0 32.54 35.23 52.88 63.41 36.61l146.65-84.67c28.18-16.27 28.18-56.95 0-73.22z" fill="#9BC5ED" p-id="2893"></path></svg></div>
<h3>显示控制</h3>
</div>
<div className="form-inline">
<div className="form-group">
<label>显示模式</label>
<button className="btn-outline" onClick={handleToggleFullscreen} style={{ width: '100%' }}>
{fullscreenMode ? '切换窗口模式' : '切换大屏模式'}
</button>
</div>
<div className="form-group">
<label>窗口操作</label>
<button className="btn-outline" onClick={handleMinimize} style={{ width: '100%' }}>最小化窗口</button>
</div>
<div className="form-group">
<label>开机自启动</label>
<button className={`btn-outline${autostartEnabled ? ' active' : ''}`} onClick={handleToggleAutostart} style={{ width: '100%' }}>
{autostartEnabled ? '已开启' : '已关闭'}
</button>
</div>
</div>
</div>
{/* Upload Card */}
<div className="card">
<div className="card-header">
+157 -12
View File
@@ -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 */}
<div className={`corner-info${loaded && currentItem ? ' visible' : ''}`}>
<div
className={`corner-info${loaded && currentItem ? ' visible' : ''}`}
onClick={handleTitleClick}
style={{ cursor: 'pointer' }}
>
<div className="ci-dot"></div>
<span className="ci-text">{'昆明市大学生创业园'}</span>
</div>
{/* Connection Indicator */}
<div className={`conn-indicator${loaded ? ' visible' : ''}`}>
{/* Connection Indicator — 点击切换播放/暂停 */}
<div className={`conn-indicator${loaded ? ' visible' : ''}`} onClick={togglePlayPause}>
<div className="conn-rings">
<div className="cr-inner"></div>
<div className={`cr-inner${paused ? ' paused' : ''}`}></div>
<div className="cr-outer"></div>
</div>
<span className="conn-label">Live</span>
<span className="conn-label">{paused ? 'Paused' : 'Live'}</span>
</div>
{/* Bottom Status */}
{/* Bottom Status — 左侧点击上一段,右侧点击下一段 */}
<div className={`status-bar${loaded ? ' visible' : ''}`}>
<div className={`sb-dot${paused ? ' idle' : ''}`}></div>
<span className="sb-text">{currentItem ? `${index + 1} / ${list.length}` : '0 / 0'}</span>
<div className="sb-prev" onClick={() => skip(-1)}>
<div className={`sb-dot${paused ? ' idle' : ''}`}></div>
<span className="sb-text">{currentItem ? `${index + 1}` : '0'}</span>
</div>
<span className="sb-sep">/</span>
<div className="sb-next" onClick={() => skip(1)}>
<span className="sb-text">{list.length}</span>
</div>
</div>
{/* Login Modal */}
<div className={`login-overlay${showLoginModal ? ' visible' : ''}`} onClick={(e) => { if (e.target === e.currentTarget) handleCloseLogin(); }}>
<div className="login-modal" onClick={e => e.stopPropagation()}>
<button className="login-modal-close" onClick={handleCloseLogin}>&times;</button>
<div className="login-modal-brand">
<h2>后台管理</h2>
<p>请输入账号密码登录</p>
</div>
<div className="login-modal-field">
<label>账号</label>
<input
ref={loginInputRef}
value={loginUsername}
onChange={e => { setLoginUsername(e.target.value); setLoginError(''); }}
onKeyDown={handleLoginKeyDown}
placeholder="请输入账号"
autoComplete="username"
/>
</div>
<div className="login-modal-field">
<label>密码</label>
<input
type="password"
value={loginPassword}
onChange={e => { setLoginPassword(e.target.value); setLoginError(''); }}
onKeyDown={handleLoginKeyDown}
placeholder="请输入密码"
autoComplete="current-password"
/>
</div>
{loginError && <div className="login-modal-error">{loginError}</div>}
<button className="login-modal-btn" onClick={handleLoginSubmit}> </button>
</div>
</div>
{/* Watermark */}