- 更新前端支持触控操作
This commit is contained in:
@@ -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
@@ -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}>×</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 */}
|
||||
|
||||
+117
-13
@@ -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); }
|
||||
}
|
||||
|
||||
+225
-3
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user