easyremote/crates/server/static/index.html

2826 lines
105 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>EasyRemote 管理后台</title>
<style>
/* 导入字体 */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
:root {
/* 主色调 - 渐变紫蓝(与客户端一致) */
--primary: #6366f1;
--primary-hover: #4f46e5;
--primary-light: #818cf8;
--primary-bg: rgba(99, 102, 241, 0.12);
--primary-glow: rgba(99, 102, 241, 0.4);
/* 强调色 */
--accent: #8b5cf6;
--accent-light: #a78bfa;
/* 背景色 - 深邃空间感 */
--bg-primary: #0c0d12;
--bg-secondary: #12141c;
--bg-tertiary: #1a1d28;
--bg-card: rgba(26, 29, 40, 0.8);
--bg-card-hover: rgba(32, 36, 50, 0.9);
--bg-glass: rgba(255, 255, 255, 0.03);
/* 文字色 */
--text-primary: #f8fafc;
--text-secondary: #94a3b8;
--text-muted: #64748b;
/* 边框 */
--border-color: rgba(255, 255, 255, 0.08);
--border-light: rgba(255, 255, 255, 0.12);
/* 状态色 */
--success: #22c55e;
--success-bg: rgba(34, 197, 94, 0.15);
--warning: #f59e0b;
--warning-bg: rgba(245, 158, 11, 0.15);
--error: #ef4444;
--error-bg: rgba(239, 68, 68, 0.15);
/* 阴影 */
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.4);
--shadow-md: 0 8px 24px rgba(0, 0, 0, 0.5);
--shadow-lg: 0 16px 48px rgba(0, 0, 0, 0.6);
--shadow-glow: 0 0 40px var(--primary-glow);
/* 圆角 */
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
-webkit-font-smoothing: antialiased;
}
::selection {
background: var(--primary);
color: white;
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--border-light);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
.layout {
display: flex;
min-height: 100vh;
background:
radial-gradient(ellipse 80% 50% at 50% -20%, rgba(99, 102, 241, 0.15) 0%, transparent 50%),
radial-gradient(ellipse 60% 40% at 100% 100%, rgba(139, 92, 246, 0.1) 0%, transparent 40%),
linear-gradient(180deg, var(--bg-primary) 0%, #08090d 100%);
}
/* 侧边栏 */
.sidebar {
width: 260px;
background: rgba(18, 20, 28, 0.9);
backdrop-filter: blur(20px);
border-right: 1px solid var(--border-color);
padding: 24px 16px;
position: relative;
}
.sidebar::before {
content: '';
position: absolute;
top: 0;
right: 0;
width: 1px;
height: 100%;
background: linear-gradient(180deg, transparent, rgba(255,255,255,0.05), transparent);
}
.logo {
font-size: 20px;
font-weight: 700;
color: var(--text-primary);
padding: 0 12px 24px;
border-bottom: 1px solid var(--border-color);
margin-bottom: 24px;
display: flex;
align-items: center;
gap: 12px;
letter-spacing: -0.02em;
}
.logo::before {
content: '';
width: 6px;
height: 28px;
background: linear-gradient(180deg, var(--primary) 0%, var(--accent) 100%);
border-radius: 3px;
box-shadow: 0 0 16px var(--primary-glow);
}
.nav-item {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
border-radius: var(--radius-md);
color: var(--text-secondary);
cursor: pointer;
transition: all 0.25s ease;
margin-bottom: 4px;
position: relative;
overflow: hidden;
}
.nav-item::before {
content: '';
position: absolute;
left: 0;
top: 0;
width: 3px;
height: 100%;
background: linear-gradient(180deg, var(--primary), var(--accent));
opacity: 0;
transform: scaleY(0);
transition: all 0.25s ease;
}
.nav-item:hover {
background: var(--bg-glass);
color: var(--text-primary);
}
.nav-item.active {
background: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%);
color: white;
box-shadow: 0 4px 16px var(--primary-glow);
}
.nav-item.active::before {
opacity: 0;
}
.nav-item svg {
opacity: 0.7;
transition: opacity 0.2s;
}
.nav-item:hover svg,
.nav-item.active svg {
opacity: 1;
}
/* 主内容区 */
.main {
flex: 1;
padding: 28px;
overflow-y: auto;
position: relative;
}
.main::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='0.01'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
pointer-events: none;
z-index: 0;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 32px;
position: relative;
z-index: 1;
}
.page-title {
font-size: 28px;
font-weight: 700;
letter-spacing: -0.02em;
background: linear-gradient(135deg, var(--text-primary) 0%, var(--primary-light) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* 统计卡片 */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 32px;
position: relative;
z-index: 1;
}
.stat-card {
background: var(--bg-card);
backdrop-filter: blur(16px);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 24px;
transition: all 0.25s ease;
position: relative;
overflow: hidden;
}
.stat-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.1), transparent);
}
.stat-card:hover {
transform: translateY(-4px);
border-color: var(--border-light);
box-shadow: var(--shadow-md);
}
.stat-label {
font-size: 13px;
color: var(--text-secondary);
margin-bottom: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.stat-value {
font-size: 36px;
font-weight: 700;
background: linear-gradient(135deg, var(--text-primary) 0%, var(--primary-light) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.stat-change {
font-size: 12px;
color: var(--success);
margin-top: 8px;
}
/* 卡片 */
.card {
background: var(--bg-card);
backdrop-filter: blur(16px);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
margin-bottom: 24px;
position: relative;
z-index: 1;
overflow: hidden;
transition: all 0.25s ease;
}
.card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.1), transparent);
}
.card:hover {
border-color: var(--border-light);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
border-bottom: 1px solid var(--border-color);
}
.card-title {
font-size: 16px;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
}
.card-title::before {
content: '';
width: 4px;
height: 16px;
background: linear-gradient(180deg, var(--primary) 0%, var(--accent) 100%);
border-radius: 2px;
}
/* 表格 */
.table {
width: 100%;
border-collapse: collapse;
}
.table th,
.table td {
padding: 16px 24px;
text-align: left;
border-bottom: 1px solid var(--border-color);
}
.table th {
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.table td {
font-size: 14px;
}
.table tr:last-child td {
border-bottom: none;
}
.table tr {
transition: background 0.2s;
}
.table tr:hover {
background: var(--bg-glass);
}
/* 状态标签 */
.status {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border-radius: 20px;
font-size: 12px;
font-weight: 500;
}
.status.online {
background: var(--success-bg);
color: var(--success);
}
.status.offline {
background: var(--bg-tertiary);
color: var(--text-secondary);
}
.status.active {
background: var(--primary-bg);
color: var(--primary-light);
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(1.2); }
}
/* 按钮 */
.btn {
padding: 10px 18px;
border: none;
border-radius: var(--radius-sm);
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.25s ease;
display: inline-flex;
align-items: center;
gap: 8px;
position: relative;
overflow: hidden;
}
.btn::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.1), transparent);
transition: left 0.5s;
}
.btn:hover::before {
left: 100%;
}
.btn-primary {
background: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%);
color: white;
box-shadow: 0 4px 16px var(--primary-glow);
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px var(--primary-glow);
}
.btn-danger {
background: transparent;
border: 1px solid var(--error);
color: var(--error);
}
.btn-danger:hover {
background: var(--error-bg);
}
.btn-secondary {
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border-color);
}
.btn-secondary:hover {
background: var(--bg-card-hover);
border-color: var(--border-light);
}
/* 搜索框 */
.search-box {
display: flex;
align-items: center;
gap: 12px;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 10px 16px;
transition: all 0.25s;
}
.search-box:focus-within {
border-color: var(--primary);
box-shadow: 0 0 0 3px var(--primary-bg);
}
.search-box input {
background: transparent;
border: none;
color: var(--text-primary);
font-size: 14px;
outline: none;
width: 200px;
}
.search-box input::placeholder {
color: var(--text-muted);
}
/* 分页 */
.pagination {
display: flex;
justify-content: center;
gap: 8px;
padding: 20px;
}
.pagination-btn {
padding: 10px 16px;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.25s;
font-weight: 500;
}
.pagination-btn:hover {
border-color: var(--primary);
color: var(--primary-light);
}
.pagination-btn.active {
background: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%);
border-color: transparent;
color: white;
box-shadow: 0 4px 12px var(--primary-glow);
}
/* 空状态 */
.empty {
text-align: center;
padding: 48px;
color: var(--text-secondary);
}
/* 远程控制面板 */
.remote-card {
margin-bottom: 24px;
}
.remote-controls {
padding: 24px;
}
.device-select-row {
display: flex;
align-items: center;
gap: 16px;
}
.device-selector {
flex: 1;
max-width: 500px;
}
.device-selector .form-label {
display: block;
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 8px;
}
/* 自定义下拉选择框 */
.custom-select-wrapper {
position: relative;
}
.custom-select {
width: 100%;
height: 48px;
padding: 0 44px 0 16px;
background: var(--bg-primary);
border: 2px solid var(--border-color);
border-radius: 10px;
color: var(--text-primary);
font-size: 14px;
font-weight: 500;
cursor: pointer;
appearance: none;
-webkit-appearance: none;
transition: all 0.2s ease;
}
.custom-select:hover {
border-color: var(--primary);
}
.custom-select:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
}
.custom-select option {
background: var(--bg-secondary);
color: var(--text-primary);
padding: 12px;
}
.select-icon {
position: absolute;
right: 14px;
top: 50%;
transform: translateY(-50%);
color: var(--text-secondary);
pointer-events: none;
transition: transform 0.2s ease;
}
.custom-select:focus+.select-icon {
transform: translateY(-50%) rotate(180deg);
color: var(--primary);
}
.device-info {
margin-top: 8px;
font-size: 13px;
color: var(--text-secondary);
min-height: 20px;
display: flex;
align-items: center;
gap: 6px;
}
.device-info.connected {
color: var(--success);
}
.device-info .info-icon {
width: 16px;
height: 16px;
background: var(--success);
color: white;
border-radius: 50%;
font-size: 10px;
line-height: 16px;
text-align: center;
display: inline-flex;
align-items: center;
justify-content: center;
}
.remote-actions {
display: flex;
align-items: center;
gap: 12px;
}
.remote-btn {
height: 48px;
padding: 0 20px;
min-width: 120px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
font-weight: 500;
}
/* 流媒体设置 */
.stream-settings {
display: flex;
gap: 16px;
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--border-color);
}
.setting-item {
flex: 1;
max-width: 180px;
}
.setting-item .form-label {
display: block;
font-size: 11px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 6px;
}
.setting-select {
width: 100%;
height: 36px;
padding: 0 32px 0 12px;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--text-primary);
font-size: 13px;
cursor: pointer;
appearance: none;
-webkit-appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 10px center;
transition: all 0.2s;
}
.setting-select:hover {
border-color: var(--primary);
}
.setting-select:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
}
.setting-select option {
background: var(--bg-secondary);
color: var(--text-primary);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-danger {
background: transparent;
border: 2px solid var(--error);
color: var(--error);
}
.btn-danger:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.1);
}
/* 远程屏幕 */
.remote-screen-container {
position: relative;
padding: 0;
}
.remote-screen {
background: linear-gradient(135deg, #0a0a0a 0%, #1a1a2e 100%);
border-radius: 0 0 12px 12px;
aspect-ratio: 16/9;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
overflow: hidden;
}
.remote-placeholder {
text-align: center;
}
.remote-placeholder svg {
margin-bottom: 16px;
opacity: 0.3;
}
.remote-placeholder p {
font-size: 14px;
opacity: 0.6;
}
#remote-canvas {
width: 100%;
height: 100%;
object-fit: contain;
}
.remote-toolbar {
position: absolute;
bottom: 16px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 8px;
padding: 8px;
background: rgba(0, 0, 0, 0.8);
border-radius: 8px;
backdrop-filter: blur(10px);
}
.toolbar-btn {
width: 40px;
height: 40px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--text-primary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.toolbar-btn:hover {
background: var(--bg-tertiary);
}
.remote-status {
display: flex;
align-items: center;
}
/* 连接动画 */
.connecting-animation {
display: flex;
align-items: center;
gap: 8px;
}
.connecting-animation::before {
content: '';
width: 16px;
height: 16px;
border: 2px solid var(--primary);
border-top-color: transparent;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* 登录表单 */
.login-container {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background:
radial-gradient(ellipse 80% 50% at 50% -20%, rgba(99, 102, 241, 0.2) 0%, transparent 50%),
radial-gradient(ellipse 60% 40% at 100% 100%, rgba(139, 92, 246, 0.15) 0%, transparent 40%),
linear-gradient(180deg, var(--bg-primary) 0%, #08090d 100%);
}
.login-card {
background: var(--bg-card);
backdrop-filter: blur(20px);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 48px;
width: 100%;
max-width: 420px;
position: relative;
overflow: hidden;
box-shadow: var(--shadow-lg);
}
.login-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.15), transparent);
}
.login-header {
text-align: center;
margin-bottom: 36px;
}
.login-logo {
font-size: 56px;
margin-bottom: 20px;
filter: drop-shadow(0 0 20px var(--primary-glow));
}
.login-title {
font-size: 28px;
font-weight: 700;
letter-spacing: -0.02em;
background: linear-gradient(135deg, var(--text-primary) 0%, var(--primary-light) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.form-group {
margin-bottom: 24px;
}
.form-label {
display: block;
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
margin-bottom: 10px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.form-input {
width: 100%;
padding: 14px 18px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
color: var(--text-primary);
font-size: 14px;
outline: none;
transition: all 0.25s;
}
.form-input:focus {
border-color: var(--primary);
background: var(--bg-tertiary);
box-shadow: 0 0 0 4px var(--primary-bg), 0 0 20px var(--primary-glow);
}
.form-input::placeholder {
color: var(--text-muted);
}
.login-btn {
width: 100%;
padding: 16px;
background: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%);
color: white;
border: none;
border-radius: var(--radius-md);
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: all 0.25s;
box-shadow: 0 4px 16px var(--primary-glow);
}
.login-btn:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px var(--primary-glow);
}
/* 隐藏内容 */
.hidden {
display: none !important;
}
/* 设置页面样式 */
.settings-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 16px;
padding: 24px;
}
.settings-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: var(--bg-tertiary);
border-radius: 8px;
}
.settings-item.full-width {
grid-column: 1 / -1;
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.settings-label {
font-size: 14px;
color: var(--text-secondary);
}
.settings-value {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
font-family: 'SF Mono', 'Consolas', monospace;
}
.settings-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
width: 100%;
}
.settings-list .stun-tag {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: rgba(59, 130, 246, 0.15);
border-radius: 6px;
font-size: 13px;
color: var(--primary);
font-family: 'SF Mono', 'Consolas', monospace;
}
.settings-list .stun-tag::before {
content: '';
width: 6px;
height: 6px;
background: var(--success);
border-radius: 50%;
}
.settings-info {
padding: 16px 24px;
border-top: 1px solid var(--border-color);
}
.settings-info p {
font-size: 13px;
color: var(--text-secondary);
margin: 0;
}
.settings-actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
padding: 24px;
}
.settings-actions .btn {
display: inline-flex;
align-items: center;
gap: 8px;
}
/* 表单样式增强 */
.form-row {
display: flex;
gap: 16px;
margin-bottom: 16px;
}
.form-row .form-group {
flex: 1;
margin-bottom: 0;
}
/* 代码/配置块 */
.config-block {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 16px;
font-family: 'SF Mono', 'Consolas', monospace;
font-size: 13px;
color: var(--text-secondary);
overflow-x: auto;
white-space: pre-wrap;
word-break: break-all;
}
/* 警告/提示框 */
.alert {
padding: 12px 16px;
border-radius: 8px;
font-size: 14px;
margin-bottom: 16px;
}
.alert-warning {
background: rgba(245, 158, 11, 0.15);
border: 1px solid rgba(245, 158, 11, 0.3);
color: var(--warning);
}
.alert-success {
background: rgba(34, 197, 94, 0.15);
border: 1px solid rgba(34, 197, 94, 0.3);
color: var(--success);
}
.alert-info {
background: rgba(59, 130, 246, 0.15);
border: 1px solid rgba(59, 130, 246, 0.3);
color: var(--primary);
}
/* 配置编辑器 */
.config-editor-container {
padding: 20px 24px;
}
.config-editor {
width: 100%;
min-height: 300px;
padding: 16px;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-primary);
font-family: 'SF Mono', 'Consolas', 'Monaco', monospace;
font-size: 13px;
line-height: 1.6;
resize: vertical;
outline: none;
transition: border-color 0.2s;
}
.config-editor:focus {
border-color: var(--primary);
}
.config-help {
margin-top: 16px;
}
.config-help summary {
cursor: pointer;
font-size: 14px;
color: var(--primary);
padding: 8px 0;
}
.config-help summary:hover {
text-decoration: underline;
}
.config-help-content {
padding: 16px;
background: var(--bg-tertiary);
border-radius: 8px;
margin-top: 8px;
}
.config-help-content p {
font-size: 13px;
color: var(--text-primary);
margin: 8px 0;
}
.config-help-content ul {
margin: 8px 0;
padding-left: 20px;
}
.config-help-content li {
font-size: 13px;
color: var(--text-secondary);
margin: 4px 0;
}
.config-help-content code {
background: var(--bg-primary);
padding: 2px 6px;
border-radius: 4px;
font-family: 'SF Mono', 'Consolas', monospace;
color: var(--primary);
}
/* Toast 提示 */
.toast {
position: fixed;
bottom: 24px;
right: 24px;
padding: 12px 24px;
border-radius: 8px;
font-size: 14px;
z-index: 1000;
animation: slideIn 0.3s ease;
}
.toast.success {
background: var(--success);
color: white;
}
.toast.error {
background: var(--error);
color: white;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>
</head>
<body>
<!-- 初始化配置页面 -->
<div id="setup-page" class="login-container hidden">
<div class="login-card">
<div class="login-header">
<div class="login-logo">⚙️</div>
<h1 class="login-title">系统初始化</h1>
<p style="color: var(--text-secondary); margin-top: 8px; font-size: 14px;">首次运行,请创建管理员账户</p>
</div>
<form id="setup-form">
<div class="form-group">
<label class="form-label">管理员用户名</label>
<input type="text" class="form-input" id="setup-username" placeholder="至少3个字符" minlength="3" required>
</div>
<div class="form-group">
<label class="form-label">管理员密码</label>
<input type="password" class="form-input" id="setup-password" placeholder="至少6位" minlength="6" required>
</div>
<div class="form-group">
<label class="form-label">确认密码</label>
<input type="password" class="form-input" id="setup-password-confirm" placeholder="再次输入密码" required>
</div>
<div id="setup-error" class="hidden" style="color: var(--error); font-size: 14px; margin-bottom: 16px;">
</div>
<button type="submit" class="login-btn">创建管理员并初始化</button>
</form>
</div>
</div>
<!-- 登录页面 -->
<div id="login-page" class="login-container hidden">
<div class="login-card">
<div class="login-header">
<div class="login-logo">🔒</div>
<h1 class="login-title">管理后台</h1>
</div>
<form id="login-form">
<div class="form-group">
<label class="form-label">用户名</label>
<input type="text" class="form-input" id="username" placeholder="请输入管理员用户名" required>
</div>
<div class="form-group">
<label class="form-label">密码</label>
<input type="password" class="form-input" id="password" placeholder="请输入密码" required>
</div>
<div id="login-error" class="hidden" style="color: var(--error); font-size: 14px; margin-bottom: 16px;">
</div>
<button type="submit" class="login-btn">登录</button>
</form>
</div>
</div>
<!-- 管理面板 -->
<div id="admin-panel" class="layout hidden">
<aside class="sidebar">
<div class="logo">EasyRemote 管理</div>
<nav>
<div class="nav-item active" data-page="dashboard">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="7" height="7" />
<rect x="14" y="3" width="7" height="7" />
<rect x="14" y="14" width="7" height="7" />
<rect x="3" y="14" width="7" height="7" />
</svg>
仪表盘
</div>
<div class="nav-item" data-page="users">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
用户管理
</div>
<div class="nav-item" data-page="devices">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
<line x1="8" y1="21" x2="16" y2="21" />
<line x1="12" y1="17" x2="12" y2="21" />
</svg>
设备管理
</div>
<div class="nav-item" data-page="sessions">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
</svg>
会话管理
</div>
<div class="nav-item" data-page="remote">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4" />
<polyline points="10 17 15 12 10 7" />
<line x1="15" y1="12" x2="3" y2="12" />
</svg>
远程控制
</div>
<div class="nav-item" data-page="settings">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3" />
<path
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
系统设置
</div>
</nav>
</aside>
<main class="main">
<!-- 仪表盘 -->
<div id="page-dashboard" class="page-content">
<div class="header">
<h1 class="page-title">仪表盘</h1>
</div>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-label">总用户数</div>
<div class="stat-value" id="stat-users">-</div>
</div>
<div class="stat-card">
<div class="stat-label">设备总数</div>
<div class="stat-value" id="stat-devices">-</div>
</div>
<div class="stat-card">
<div class="stat-label">在线设备</div>
<div class="stat-value" id="stat-online">-</div>
</div>
<div class="stat-card">
<div class="stat-label">活跃会话</div>
<div class="stat-value" id="stat-sessions">-</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h3 class="card-title">最近会话</h3>
</div>
<table class="table">
<thead>
<tr>
<th>控制端</th>
<th>被控端</th>
<th>开始时间</th>
<th>状态</th>
</tr>
</thead>
<tbody id="recent-sessions">
<tr>
<td colspan="4" class="empty">暂无数据</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 用户管理 -->
<div id="page-users" class="page-content hidden">
<div class="header">
<h1 class="page-title">用户管理</h1>
<div class="search-box">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<input type="text" placeholder="搜索用户...">
</div>
</div>
<div class="card">
<table class="table">
<thead>
<tr>
<th>用户名</th>
<th>邮箱</th>
<th>角色</th>
<th>注册时间</th>
<th>操作</th>
</tr>
</thead>
<tbody id="users-table">
<tr>
<td colspan="5" class="empty">加载中...</td>
</tr>
</tbody>
</table>
<div class="pagination" id="users-pagination"></div>
</div>
</div>
<!-- 设备管理 -->
<div id="page-devices" class="page-content hidden">
<div class="header">
<h1 class="page-title">设备管理</h1>
<div class="search-box">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<input type="text" placeholder="搜索设备...">
</div>
</div>
<div class="card">
<table class="table">
<thead>
<tr>
<th>设备ID</th>
<th>名称</th>
<th>绑定用户</th>
<th>系统</th>
<th>状态</th>
<th>最后在线</th>
<th>操作</th>
</tr>
</thead>
<tbody id="devices-table">
<tr>
<td colspan="7" class="empty">加载中...</td>
</tr>
</tbody>
</table>
<div class="pagination" id="devices-pagination"></div>
</div>
</div>
<!-- 会话管理 -->
<div id="page-sessions" class="page-content hidden">
<div class="header">
<h1 class="page-title">会话管理</h1>
</div>
<div class="card">
<table class="table">
<thead>
<tr>
<th>会话ID</th>
<th>控制端</th>
<th>被控端</th>
<th>类型</th>
<th>开始时间</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody id="sessions-table">
<tr>
<td colspan="7" class="empty">加载中...</td>
</tr>
</tbody>
</table>
<div class="pagination" id="sessions-pagination"></div>
</div>
</div>
<!-- 远程控制 -->
<div id="page-remote" class="page-content hidden">
<div class="header">
<h1 class="page-title">远程控制</h1>
</div>
<!-- 设备选择卡片 -->
<div class="card remote-card">
<div class="card-header">
<h3 class="card-title">🖥️ 选择设备</h3>
</div>
<div class="remote-controls">
<div class="device-select-row">
<div class="device-selector">
<label class="form-label">在线设备</label>
<div class="custom-select-wrapper">
<select class="custom-select" id="remote-device-select">
<option value="">-- 选择在线设备 --</option>
</select>
<div class="select-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9" />
</svg>
</div>
</div>
</div>
<div class="remote-actions" style="align-self: flex-end;">
<button class="btn btn-primary remote-btn" id="connect-btn" disabled>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4" />
<polyline points="10 17 15 12 10 7" />
<line x1="15" y1="12" x2="3" y2="12" />
</svg>
连接
</button>
<button class="btn btn-danger remote-btn hidden" id="disconnect-btn">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<path d="M18.36 6.64a9 9 0 1 1-12.73 0" />
<line x1="12" y1="2" x2="12" y2="12" />
</svg>
断开连接
</button>
</div>
</div>
<div class="device-info" id="selected-device-info"></div>
<!-- 画质设置 -->
<div class="stream-settings">
<div class="setting-item">
<label class="form-label">显示器</label>
<select class="setting-select" id="display-select">
<option value="0">主显示器</option>
</select>
</div>
<div class="setting-item">
<label class="form-label">分辨率</label>
<select class="setting-select" id="resolution-select">
<option value="0.25">25% (流畅)</option>
<option value="0.5" selected>50% (平衡)</option>
<option value="0.75">75% (高清)</option>
<option value="1">100% (原画)</option>
</select>
</div>
<div class="setting-item">
<label class="form-label">帧率</label>
<select class="setting-select" id="fps-select">
<option value="5">5 FPS (省流)</option>
<option value="10">10 FPS (流畅)</option>
<option value="15" selected>15 FPS (平衡)</option>
<option value="24">24 FPS (高帧)</option>
<option value="30">30 FPS (极限)</option>
</select>
</div>
<div class="setting-item">
<label class="form-label">画质</label>
<select class="setting-select" id="quality-select">
<option value="30">低 (省流)</option>
<option value="50">中 (平衡)</option>
<option value="70" selected>高 (清晰)</option>
<option value="85">极高 (高清)</option>
</select>
</div>
</div>
</div>
</div>
<!-- 远程屏幕 -->
<div class="card remote-card">
<div class="card-header">
<h3 class="card-title">📺 远程桌面</h3>
<div class="remote-status" id="remote-status">
<span class="status offline">
<span class="status-dot"></span>未连接
</span>
</div>
</div>
<div class="remote-screen-container">
<div class="remote-screen" id="remote-screen">
<div class="remote-placeholder">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="1">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
<line x1="8" y1="21" x2="16" y2="21" />
<line x1="12" y1="17" x2="12" y2="21" />
</svg>
<p>选择在线设备并点击连接开始远程控制</p>
</div>
<canvas id="remote-canvas" class="hidden"></canvas>
</div>
<div class="remote-toolbar hidden" id="remote-toolbar">
<button class="toolbar-btn" title="全屏" onclick="toggleFullscreen()">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<polyline points="15 3 21 3 21 9" />
<polyline points="9 21 3 21 3 15" />
<line x1="21" y1="3" x2="14" y2="10" />
<line x1="3" y1="21" x2="10" y2="14" />
</svg>
</button>
<button class="toolbar-btn" title="刷新" onclick="refreshRemote()">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<polyline points="23 4 23 10 17 10" />
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
</svg>
</button>
</div>
</div>
</div>
</div>
<!-- 系统设置 -->
<div id="page-settings" class="page-content hidden">
<div class="header">
<h1 class="page-title">系统设置</h1>
</div>
<!-- 服务器信息 -->
<div class="card">
<div class="card-header">
<h3 class="card-title">🖥️ 服务器信息</h3>
</div>
<div class="settings-grid">
<div class="settings-item">
<span class="settings-label">服务器状态</span>
<span class="status online">
<span class="status-dot"></span>运行中
</span>
</div>
<div class="settings-item">
<span class="settings-label">HTTP 端口</span>
<span class="settings-value" id="server-http-port">-</span>
</div>
<div class="settings-item">
<span class="settings-label">服务器地址</span>
<span class="settings-value" id="server-address">-</span>
</div>
<div class="settings-item">
<span class="settings-label">API 版本</span>
<span class="settings-value">v1.0</span>
</div>
</div>
</div>
<!-- STUN 配置 -->
<div class="card">
<div class="card-header">
<h3 class="card-title">📡 STUN 服务配置</h3>
<span class="status" id="stun-status">
<span class="status-dot"></span><span id="stun-status-text">检测中...</span>
</span>
</div>
<div class="settings-grid">
<div class="settings-item">
<span class="settings-label">本地 STUN 服务</span>
<span class="settings-value" id="stun-enabled">-</span>
</div>
<div class="settings-item">
<span class="settings-label">STUN 端口</span>
<span class="settings-value" id="stun-port">-</span>
</div>
<div class="settings-item full-width">
<span class="settings-label">STUN 服务器列表</span>
<div class="settings-list" id="stun-servers-list">
<span class="settings-value">加载中...</span>
</div>
</div>
</div>
<div class="settings-info">
<p>💡 STUN (Session Traversal Utilities for NAT) 服务用于 NAT 穿透,帮助客户端发现其公网 IP 地址。</p>
</div>
</div>
<!-- TURN 配置 -->
<div class="card">
<div class="card-header">
<h3 class="card-title">🔄 TURN 服务配置</h3>
<span class="status" id="turn-status">
<span class="status-dot"></span><span id="turn-status-text">检测中...</span>
</span>
</div>
<div class="settings-grid">
<div class="settings-item">
<span class="settings-label">本地 TURN 服务</span>
<span class="settings-value" id="turn-enabled">-</span>
</div>
<div class="settings-item">
<span class="settings-label">TURN 端口</span>
<span class="settings-value" id="turn-port">-</span>
</div>
<div class="settings-item">
<span class="settings-label">TURN 服务器地址</span>
<span class="settings-value" id="turn-server">-</span>
</div>
<div class="settings-item">
<span class="settings-label">TURN 用户名</span>
<span class="settings-value" id="turn-username">-</span>
</div>
<div class="settings-item">
<span class="settings-label">TURN Realm</span>
<span class="settings-value" id="turn-realm">-</span>
</div>
</div>
<div class="settings-info">
<p>💡 TURN (Traversal Using Relays around NAT) 服务用于在 P2P 直连失败时中转流量,确保连接的可靠性。</p>
</div>
</div>
<!-- 配置文件编辑 -->
<div class="card">
<div class="card-header">
<h3 class="card-title">📝 配置文件</h3>
<div>
<button class="btn btn-secondary" onclick="loadEnvConfig()">刷新</button>
<button class="btn btn-primary" onclick="saveEnvConfig()">保存配置</button>
</div>
</div>
<div class="config-editor-container">
<div class="alert alert-warning">
⚠️ 修改配置后需要重启服务器才能生效。请谨慎修改以下配置。
</div>
<textarea id="env-config-editor" class="config-editor" placeholder="加载中..."></textarea>
<div class="config-help">
<details>
<summary>📖 配置说明</summary>
<div class="config-help-content">
<p><strong>STUN 配置:</strong></p>
<ul>
<li><code>ENABLE_LOCAL_STUN</code> - 启用本地 STUN 服务 (true/false)</li>
<li><code>STUN_PORT</code> - STUN 服务端口 (默认 3478)</li>
</ul>
<p><strong>TURN 配置:</strong></p>
<ul>
<li><code>ENABLE_LOCAL_TURN</code> - 启用本地 TURN 服务 (true/false)</li>
<li><code>TURN_PORT</code> - TURN 服务端口 (默认 3479)</li>
<li><code>TURN_USERNAME</code> - TURN 认证用户名</li>
<li><code>TURN_PASSWORD</code> - TURN 认证密码</li>
<li><code>TURN_REALM</code> - TURN Realm 域</li>
</ul>
<p><strong>其他配置:</strong></p>
<ul>
<li><code>PUBLIC_IP</code> - 服务器公网 IP (用于生成 STUN/TURN URL)</li>
<li><code>JWT_EXPIRY</code> - JWT 令牌有效期 (秒)</li>
</ul>
</div>
</details>
</div>
</div>
</div>
<!-- 数据库信息 -->
<div class="card">
<div class="card-header">
<h3 class="card-title">🗄️ 数据库信息</h3>
</div>
<div class="settings-grid">
<div class="settings-item">
<span class="settings-label">数据库类型</span>
<span class="settings-value">SQLite</span>
</div>
<div class="settings-item">
<span class="settings-label">用户总数</span>
<span class="settings-value" id="db-users-count">-</span>
</div>
<div class="settings-item">
<span class="settings-label">设备总数</span>
<span class="settings-value" id="db-devices-count">-</span>
</div>
<div class="settings-item">
<span class="settings-label">历史记录数</span>
<span class="settings-value" id="db-sessions-count">-</span>
</div>
</div>
</div>
<!-- 安全设置 -->
<div class="card">
<div class="card-header">
<h3 class="card-title">🔐 安全设置</h3>
</div>
<div class="settings-grid">
<div class="settings-item">
<span class="settings-label">JWT 有效期</span>
<span class="settings-value" id="jwt-expiry">-</span>
</div>
<div class="settings-item">
<span class="settings-label">加密算法</span>
<span class="settings-value">AES-256-GCM</span>
</div>
<div class="settings-item">
<span class="settings-label">密码哈希</span>
<span class="settings-value">Argon2id</span>
</div>
</div>
</div>
<!-- 系统操作 -->
<div class="card">
<div class="card-header">
<h3 class="card-title">⚙️ 系统操作</h3>
</div>
<div class="settings-actions">
<button class="btn btn-secondary" onclick="clearOfflineDevices()">
🗑️ 清理离线设备
</button>
<button class="btn btn-secondary" onclick="clearExpiredSessions()">
🧹 清理过期会话
</button>
<button class="btn btn-danger" onclick="handleLogout()">
🚪 退出登录
</button>
</div>
</div>
</div>
</main>
</div>
<script>
// API 基础URL
const API_BASE = '/api';
let authToken = localStorage.getItem('admin_token');
// 页面初始化
document.addEventListener('DOMContentLoaded', async () => {
// 先检查是否需要初始化
await checkSetupStatus();
setupEventListeners();
});
// 检查系统是否需要初始化
async function checkSetupStatus() {
try {
const response = await fetch(`${API_BASE}/setup/status`);
const data = await response.json();
if (data.success && data.data.need_setup) {
// 需要初始化,显示初始化页面
document.getElementById('setup-page').classList.remove('hidden');
document.getElementById('login-page').classList.add('hidden');
} else {
// 已初始化,显示登录页面
document.getElementById('setup-page').classList.add('hidden');
document.getElementById('login-page').classList.remove('hidden');
// 如果有 token检查认证
if (authToken) {
await checkAuth();
}
}
} catch (e) {
console.error('检查初始化状态失败:', e);
// 出错时默认显示登录页面
document.getElementById('login-page').classList.remove('hidden');
}
}
// 检查认证状态
async function checkAuth() {
try {
const response = await fetch(`${API_BASE}/users/me`, {
headers: { 'Authorization': `Bearer ${authToken}` }
});
if (response.ok) {
const data = await response.json();
if (data.data.role === 'admin') {
showAdminPanel();
loadDashboard();
return;
}
}
} catch (e) { }
localStorage.removeItem('admin_token');
authToken = null;
}
// 设置事件监听
function setupEventListeners() {
// 初始化表单
document.getElementById('setup-form').addEventListener('submit', handleSetup);
// 登录表单
document.getElementById('login-form').addEventListener('submit', handleLogin);
// 导航
document.querySelectorAll('.nav-item').forEach(item => {
item.addEventListener('click', () => {
const page = item.dataset.page;
navigateTo(page);
});
});
}
// 处理初始化
async function handleSetup(e) {
e.preventDefault();
const username = document.getElementById('setup-username').value;
const password = document.getElementById('setup-password').value;
const passwordConfirm = document.getElementById('setup-password-confirm').value;
const errorEl = document.getElementById('setup-error');
// 验证密码
if (password !== passwordConfirm) {
errorEl.textContent = '两次输入的密码不一致';
errorEl.classList.remove('hidden');
return;
}
try {
const response = await fetch(`${API_BASE}/setup/init`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
admin_username: username,
admin_password: password
})
});
const data = await response.json();
if (response.ok && data.success) {
// 初始化成功,保存 token 并进入管理面板
authToken = data.data.token;
localStorage.setItem('admin_token', authToken);
document.getElementById('setup-page').classList.add('hidden');
showAdminPanel();
loadDashboard();
showToast('系统初始化成功!', 'success');
} else {
throw new Error(data.error || '初始化失败');
}
} catch (error) {
errorEl.textContent = error.message;
errorEl.classList.remove('hidden');
}
}
// 处理登录
async function handleLogin(e) {
e.preventDefault();
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
const errorEl = document.getElementById('login-error');
try {
const response = await fetch(`${API_BASE}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
const data = await response.json();
if (response.ok && data.success) {
if (data.data.user.role !== 'admin') {
throw new Error('需要管理员权限');
}
authToken = data.data.token;
localStorage.setItem('admin_token', authToken);
showAdminPanel();
loadDashboard();
} else {
throw new Error(data.error || '登录失败');
}
} catch (error) {
errorEl.textContent = error.message;
errorEl.classList.remove('hidden');
}
}
// 显示管理面板
function showAdminPanel() {
document.getElementById('login-page').classList.add('hidden');
document.getElementById('admin-panel').classList.remove('hidden');
}
// 页面导航
function navigateTo(page) {
// 更新导航状态
document.querySelectorAll('.nav-item').forEach(item => {
item.classList.toggle('active', item.dataset.page === page);
});
// 切换页面内容
document.querySelectorAll('.page-content').forEach(content => {
content.classList.add('hidden');
});
document.getElementById(`page-${page}`).classList.remove('hidden');
// 加载数据
switch (page) {
case 'dashboard': loadDashboard(); break;
case 'users': loadUsers(); break;
case 'devices': loadDevices(); break;
case 'sessions': loadSessions(); break;
case 'remote': loadRemoteDevices(); break;
case 'settings': loadSettings(); break;
}
}
// API 请求辅助函数
async function apiRequest(endpoint, options = {}) {
const response = await fetch(`${API_BASE}${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authToken}`,
...options.headers
}
});
return response.json();
}
// 加载仪表盘
async function loadDashboard() {
try {
const stats = await apiRequest('/admin/stats');
if (stats.success) {
document.getElementById('stat-users').textContent = stats.data.total_users;
document.getElementById('stat-devices').textContent = stats.data.total_devices;
document.getElementById('stat-online').textContent = stats.data.online_devices;
document.getElementById('stat-sessions').textContent = stats.data.active_sessions;
}
const sessions = await apiRequest('/admin/sessions?limit=5');
const tbody = document.getElementById('recent-sessions');
if (sessions.success && sessions.data.items.length > 0) {
tbody.innerHTML = sessions.data.items.map(s => `
<tr>
<td>${s.controller_device_id}</td>
<td>${s.controlled_device_id}</td>
<td>${new Date(s.started_at).toLocaleString('zh-CN')}</td>
<td><span class="status ${s.status === 'connected' ? 'active' : 'offline'}">
<span class="status-dot"></span>${s.status}
</span></td>
</tr>
`).join('');
} else {
tbody.innerHTML = '<tr><td colspan="4" class="empty">暂无数据</td></tr>';
}
} catch (e) {
console.error('加载仪表盘失败:', e);
}
}
// 加载用户列表
async function loadUsers(page = 1) {
try {
const data = await apiRequest(`/admin/users?page=${page}&limit=10`);
const tbody = document.getElementById('users-table');
if (data.success && data.data.items.length > 0) {
tbody.innerHTML = data.data.items.map(u => `
<tr>
<td>${u.username}</td>
<td>${u.email || '-'}</td>
<td><span class="status ${u.role === 'admin' ? 'active' : ''}">${u.role}</span></td>
<td>${new Date(u.created_at).toLocaleString('zh-CN')}</td>
<td>
<button class="btn btn-danger" onclick="deleteUser('${u.id}')">删除</button>
</td>
</tr>
`).join('');
renderPagination('users-pagination', data.data, (p) => loadUsers(p));
} else {
tbody.innerHTML = '<tr><td colspan="5" class="empty">暂无数据</td></tr>';
}
} catch (e) {
console.error('加载用户失败:', e);
}
}
// 加载设备列表
async function loadDevices(page = 1) {
try {
const data = await apiRequest(`/admin/devices?page=${page}&limit=10`);
const tbody = document.getElementById('devices-table');
if (data.success && data.data.items.length > 0) {
tbody.innerHTML = data.data.items.map(d => `
<tr>
<td style="font-family: monospace;">${d.device_id}</td>
<td>${d.name}</td>
<td>${d.username ? `<span class="status active"><span class="status-dot"></span>${d.username}</span>` : '<span style="color: var(--text-secondary);">未绑定</span>'}</td>
<td>${d.os_type}</td>
<td><span class="status ${d.is_online ? 'online' : 'offline'}">
<span class="status-dot"></span>${d.is_online ? '在线' : '离线'}
</span></td>
<td>${new Date(d.last_seen).toLocaleString('zh-CN')}</td>
<td>
${d.is_online ? `<button class="btn btn-secondary" onclick="forceOffline('${d.device_id}')">强制下线</button>` : ''}
</td>
</tr>
`).join('');
renderPagination('devices-pagination', data.data, (p) => loadDevices(p));
} else {
tbody.innerHTML = '<tr><td colspan="7" class="empty">暂无数据</td></tr>';
}
} catch (e) {
console.error('加载设备失败:', e);
}
}
// 加载会话列表
async function loadSessions(page = 1) {
try {
const data = await apiRequest(`/admin/sessions?page=${page}&limit=10`);
const tbody = document.getElementById('sessions-table');
if (data.success && data.data.items.length > 0) {
tbody.innerHTML = data.data.items.map(s => `
<tr>
<td style="font-family: monospace; font-size: 12px;">${s.id.substring(0, 8)}...</td>
<td>${s.controller_device_id}</td>
<td>${s.controlled_device_id}</td>
<td>${s.connection_type}</td>
<td>${new Date(s.started_at).toLocaleString('zh-CN')}</td>
<td><span class="status ${s.status === 'connected' ? 'active' : 'offline'}">
<span class="status-dot"></span>${s.status}
</span></td>
<td>
${s.status === 'connected' ? `<button class="btn btn-danger" onclick="endSession('${s.id}')">结束</button>` : ''}
</td>
</tr>
`).join('');
renderPagination('sessions-pagination', data.data, (p) => loadSessions(p));
} else {
tbody.innerHTML = '<tr><td colspan="7" class="empty">暂无数据</td></tr>';
}
} catch (e) {
console.error('加载会话失败:', e);
}
}
// 远程控制状态
let remoteWs = null;
let selectedDevice = null;
let currentDisplays = []; // 存储显示器列表
// 请求获取显示器列表
function requestDisplaysList() {
if (remoteWs && remoteWs.readyState === WebSocket.OPEN && selectedDevice) {
remoteWs.send(JSON.stringify({
type: 'get_displays',
session_id: '',
from_device: 'browser',
to_device: selectedDevice.id
}));
}
}
// 更新显示器选择框
function updateDisplaySelect(displays, currentDisplay) {
const select = document.getElementById('display-select');
select.innerHTML = '';
displays.forEach((d, i) => {
const option = document.createElement('option');
option.value = d.index;
option.textContent = d.name || `显示器 ${d.index + 1} (${d.width}x${d.height})`;
if (d.index === currentDisplay) {
option.selected = true;
}
select.appendChild(option);
});
currentDisplays = displays;
}
// 切换显示器
function switchDisplay() {
if (remoteWs && remoteWs.readyState === WebSocket.OPEN && selectedDevice) {
const displayIndex = parseInt(document.getElementById('display-select').value);
remoteWs.send(JSON.stringify({
type: 'switch_display',
session_id: '',
from_device: 'browser',
to_device: selectedDevice.id,
display_index: displayIndex
}));
showToast(`正在切换到显示器 ${displayIndex + 1}`, 'success');
}
}
// 获取流媒体设置
function getStreamSettings() {
return {
resolution: parseFloat(document.getElementById('resolution-select').value),
fps: parseInt(document.getElementById('fps-select').value),
quality: parseInt(document.getElementById('quality-select').value)
};
}
// 发送设置更新
function sendSettingsUpdate() {
if (remoteWs && remoteWs.readyState === WebSocket.OPEN && selectedDevice) {
const settings = getStreamSettings();
remoteWs.send(JSON.stringify({
type: 'stream_settings',
session_id: '',
from_device: 'browser',
to_device: selectedDevice.id,
resolution: settings.resolution,
fps: settings.fps,
quality: settings.quality
}));
showToast('设置已更新', 'success');
}
}
// 监听设置变化
document.addEventListener('DOMContentLoaded', function() {
['resolution-select', 'fps-select', 'quality-select'].forEach(id => {
const el = document.getElementById(id);
if (el) {
el.addEventListener('change', sendSettingsUpdate);
}
});
// 显示器选择变化
const displaySelect = document.getElementById('display-select');
if (displaySelect) {
displaySelect.addEventListener('change', switchDisplay);
}
});
// 加载在线设备(用于远程控制)
async function loadRemoteDevices() {
try {
const data = await apiRequest('/admin/devices?limit=100');
const select = document.getElementById('remote-device-select');
const connectBtn = document.getElementById('connect-btn');
const deviceInfo = document.getElementById('selected-device-info');
select.innerHTML = '<option value="">-- 选择在线设备 --</option>';
let onlineCount = 0;
if (data.success) {
const onlineDevices = data.data.items.filter(d => d.is_online);
onlineCount = onlineDevices.length;
onlineDevices.forEach(d => {
const option = document.createElement('option');
option.value = d.device_id;
option.textContent = `${d.name} (${d.device_id})`;
option.dataset.name = d.name;
option.dataset.os = d.os_type;
option.dataset.username = d.username || '';
select.appendChild(option);
});
}
deviceInfo.textContent = `${onlineCount} 台设备在线`;
// 设备选择变化事件
select.onchange = function () {
const deviceId = this.value;
const selectedOption = this.options[this.selectedIndex];
if (deviceId) {
connectBtn.disabled = false;
selectedDevice = {
id: deviceId,
name: selectedOption.dataset.name,
os: selectedOption.dataset.os,
username: selectedOption.dataset.username
};
const userInfo = selectedDevice.username ? ` | 用户: ${selectedDevice.username}` : '';
deviceInfo.innerHTML = `<span style="color: var(--success);">✓ 已选择:</span> ${selectedDevice.name} | ${selectedDevice.os}${userInfo}`;
} else {
connectBtn.disabled = true;
selectedDevice = null;
deviceInfo.textContent = `${onlineCount} 台设备在线`;
}
};
// 连接按钮事件
document.getElementById('connect-btn').onclick = connectToDevice;
document.getElementById('disconnect-btn').onclick = disconnectFromDevice;
} catch (e) {
console.error('加载设备失败:', e);
}
}
// 连接到设备
async function connectToDevice() {
if (!selectedDevice) {
showToast('请先选择设备', 'error');
return;
}
const connectBtn = document.getElementById('connect-btn');
const disconnectBtn = document.getElementById('disconnect-btn');
const remoteStatus = document.getElementById('remote-status');
const remoteScreen = document.getElementById('remote-screen');
const toolbar = document.getElementById('remote-toolbar');
// 更新UI状态
connectBtn.disabled = true;
connectBtn.innerHTML = '<span class="connecting-animation"></span> 连接中...';
remoteStatus.innerHTML = '<span class="status" style="color: var(--warning);"><span class="status-dot"></span>连接中...</span>';
try {
// 建立 WebSocket 连接
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${wsProtocol}//${window.location.host}/ws/remote/${selectedDevice.id}?token=${authToken}`;
remoteWs = new WebSocket(wsUrl);
remoteWs.onopen = function () {
console.log('Remote WebSocket connected');
connectBtn.classList.add('hidden');
disconnectBtn.classList.remove('hidden');
remoteStatus.innerHTML = '<span class="status online"><span class="status-dot"></span>已连接</span>';
toolbar.classList.remove('hidden');
// 发送流媒体设置
const settings = getStreamSettings();
remoteWs.send(JSON.stringify({
type: 'stream_settings',
session_id: '',
from_device: 'browser',
to_device: selectedDevice.id,
resolution: settings.resolution,
fps: settings.fps,
quality: settings.quality
}));
// 请求显示器列表
setTimeout(() => {
requestDisplaysList();
}, 500);
// 显示连接成功信息
remoteScreen.innerHTML = `
<div class="remote-placeholder">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="var(--success)" stroke-width="1">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
<p style="color: var(--success);">已连接到 ${selectedDevice.name}</p>
<p style="font-size: 12px; margin-top: 8px;">等待远程画面...</p>
</div>
`;
showToast(`已连接到 ${selectedDevice.name}`, 'success');
};
remoteWs.onmessage = function (event) {
// 处理接收到的远程帧数据
handleRemoteFrame(event.data);
};
remoteWs.onclose = function () {
console.log('Remote WebSocket closed');
handleDisconnect();
};
remoteWs.onerror = function (error) {
console.error('Remote WebSocket error:', error);
showToast('连接失败,请重试', 'error');
handleDisconnect();
};
} catch (e) {
console.error('连接失败:', e);
showToast('连接失败: ' + e.message, 'error');
handleDisconnect();
}
}
// 断开连接
function disconnectFromDevice() {
if (remoteWs) {
remoteWs.close();
}
handleDisconnect();
showToast('已断开连接', 'success');
}
// 处理断开连接
function handleDisconnect() {
remoteWs = null;
const connectBtn = document.getElementById('connect-btn');
const disconnectBtn = document.getElementById('disconnect-btn');
const remoteStatus = document.getElementById('remote-status');
const remoteScreen = document.getElementById('remote-screen');
const toolbar = document.getElementById('remote-toolbar');
connectBtn.classList.remove('hidden');
connectBtn.disabled = !selectedDevice;
connectBtn.innerHTML = `
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/>
<polyline points="10 17 15 12 10 7"/>
<line x1="15" y1="12" x2="3" y2="12"/>
</svg>
连接
`;
disconnectBtn.classList.add('hidden');
remoteStatus.innerHTML = '<span class="status offline"><span class="status-dot"></span>未连接</span>';
toolbar.classList.add('hidden');
remoteScreen.innerHTML = `
<div class="remote-placeholder">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/>
<line x1="8" y1="21" x2="16" y2="21"/>
<line x1="12" y1="17" x2="12" y2="21"/>
</svg>
<p>选择在线设备并点击连接开始远程控制</p>
</div>
`;
}
// 处理远程帧
function handleRemoteFrame(data) {
try {
const msg = JSON.parse(data);
if (msg.type === 'screen_frame') {
displayFrame(msg.width, msg.height, msg.data);
} else if (msg.type === 'error') {
showToast(msg.message || '远程控制错误', 'error');
} else if (msg.type === 'connect_response') {
if (msg.accepted) {
console.log('Connection accepted');
} else {
showToast('连接被拒绝: ' + (msg.reason || '未知原因'), 'error');
handleDisconnect();
}
} else if (msg.type === 'displays_list') {
// 收到显示器列表
console.log('收到显示器列表:', msg.displays);
updateDisplaySelect(msg.displays, msg.current_display);
}
} catch (e) {
console.error('Failed to parse remote message:', e);
}
}
// 显示屏幕帧
let frameCanvas = null;
let frameCtx = null;
function displayFrame(width, height, base64Data) {
const remoteScreen = document.getElementById('remote-screen');
// 初始化 canvas
if (!frameCanvas) {
remoteScreen.innerHTML = '';
frameCanvas = document.createElement('canvas');
frameCanvas.style.width = '100%';
frameCanvas.style.height = '100%';
frameCanvas.style.objectFit = 'contain';
remoteScreen.appendChild(frameCanvas);
frameCtx = frameCanvas.getContext('2d');
// 添加鼠标事件监听
frameCanvas.addEventListener('mousemove', handleMouseMove);
frameCanvas.addEventListener('mousedown', handleMouseDown);
frameCanvas.addEventListener('mouseup', handleMouseUp);
frameCanvas.addEventListener('click', handleMouseClick);
frameCanvas.addEventListener('wheel', handleMouseWheel);
frameCanvas.addEventListener('contextmenu', e => e.preventDefault());
// 添加键盘事件监听
frameCanvas.tabIndex = 1;
frameCanvas.addEventListener('keydown', handleKeyDown);
frameCanvas.addEventListener('keyup', handleKeyUp);
}
// 设置 canvas 尺寸
if (frameCanvas.width !== width || frameCanvas.height !== height) {
frameCanvas.width = width;
frameCanvas.height = height;
}
// 解码并绘制图像
const img = new Image();
img.onload = function () {
frameCtx.drawImage(img, 0, 0);
};
img.src = 'data:image/jpeg;base64,' + base64Data;
}
// 计算相对坐标
function getRelativeCoords(e) {
if (!frameCanvas) return null;
const rect = frameCanvas.getBoundingClientRect();
const scaleX = frameCanvas.width / rect.width;
const scaleY = frameCanvas.height / rect.height;
return {
x: (e.clientX - rect.left) * scaleX,
y: (e.clientY - rect.top) * scaleY
};
}
// 发送鼠标事件
function sendMouseEvent(eventType, x, y, button, delta) {
if (!remoteWs || !selectedDevice) return;
remoteWs.send(JSON.stringify({
type: 'mouse_event',
session_id: '',
from_device: 'browser',
to_device: selectedDevice.id,
x: x,
y: y,
event_type: eventType,
button: button,
delta: delta
}));
}
function handleMouseMove(e) {
const coords = getRelativeCoords(e);
if (coords) sendMouseEvent('move', coords.x, coords.y, null, null);
}
function handleMouseDown(e) {
const coords = getRelativeCoords(e);
if (coords) sendMouseEvent('down', coords.x, coords.y, e.button, null);
}
function handleMouseUp(e) {
const coords = getRelativeCoords(e);
if (coords) sendMouseEvent('up', coords.x, coords.y, e.button, null);
}
function handleMouseClick(e) {
const coords = getRelativeCoords(e);
if (coords) sendMouseEvent('click', coords.x, coords.y, e.button, null);
}
function handleMouseWheel(e) {
e.preventDefault();
const coords = getRelativeCoords(e);
if (coords) sendMouseEvent('scroll', coords.x, coords.y, null, e.deltaY);
}
// 发送键盘事件
function sendKeyboardEvent(eventType, key) {
if (!remoteWs || !selectedDevice) return;
remoteWs.send(JSON.stringify({
type: 'keyboard_event',
session_id: '',
from_device: 'browser',
to_device: selectedDevice.id,
key: key,
event_type: eventType
}));
}
function handleKeyDown(e) {
e.preventDefault();
sendKeyboardEvent('down', e.key);
}
function handleKeyUp(e) {
e.preventDefault();
sendKeyboardEvent('up', e.key);
}
// 全屏切换
function toggleFullscreen() {
const screen = document.getElementById('remote-screen');
if (!document.fullscreenElement) {
screen.requestFullscreen();
} else {
document.exitFullscreen();
}
}
// 刷新远程连接
function refreshRemote() {
if (remoteWs && remoteWs.readyState === WebSocket.OPEN) {
remoteWs.send(JSON.stringify({ type: 'refresh' }));
}
}
// 渲染分页
function renderPagination(containerId, data, loadFunc) {
const container = document.getElementById(containerId);
if (data.total_pages <= 1) {
container.innerHTML = '';
return;
}
let html = '';
for (let i = 1; i <= data.total_pages; i++) {
html += `<button class="pagination-btn ${i === data.page ? 'active' : ''}" onclick="(${loadFunc.toString()})(${i})">${i}</button>`;
}
container.innerHTML = html;
}
// 删除用户
async function deleteUser(userId) {
if (!confirm('确定要删除该用户吗?')) return;
try {
await apiRequest(`/admin/users/${userId}`, { method: 'DELETE' });
loadUsers();
} catch (e) {
alert('删除失败');
}
}
// 强制下线
async function forceOffline(deviceId) {
if (!confirm('确定要强制该设备下线吗?')) return;
try {
await apiRequest(`/devices/${deviceId}/offline`, { method: 'POST' });
loadDevices();
} catch (e) {
alert('操作失败');
}
}
// 结束会话
async function endSession(sessionId) {
if (!confirm('确定要结束该会话吗?')) return;
try {
await apiRequest(`/sessions/${sessionId}/end`, { method: 'POST' });
loadSessions();
} catch (e) {
alert('操作失败');
}
}
// 加载设置页面
async function loadSettings() {
try {
// 加载服务器配置
const configData = await apiRequest('/admin/config');
if (configData.success) {
const config = configData.data;
document.getElementById('server-address').textContent = window.location.host;
document.getElementById('server-http-port').textContent = config.http_port;
document.getElementById('jwt-expiry').textContent = `${config.jwt_expiry_hours} 小时`;
// STUN 配置
document.getElementById('stun-enabled').textContent = config.stun_enabled ? '✅ 已启用' : '❌ 已禁用';
document.getElementById('stun-port').textContent = config.stun_port;
updateStatus('stun-status', 'stun-status-text', config.stun_enabled);
// 更新 STUN 服务器列表
const stunList = document.getElementById('stun-servers-list');
if (config.stun_servers && config.stun_servers.length > 0) {
stunList.innerHTML = config.stun_servers.map(server =>
`<span class="stun-tag">${server}</span>`
).join('');
} else {
stunList.innerHTML = '<span class="settings-value">未配置</span>';
}
// TURN 配置
document.getElementById('turn-enabled').textContent = config.turn_enabled ? '✅ 已启用' : '❌ 已禁用';
document.getElementById('turn-port').textContent = config.turn_port;
document.getElementById('turn-server').textContent = config.turn_server || '未配置';
document.getElementById('turn-username').textContent = config.turn_username || '-';
document.getElementById('turn-realm').textContent = config.turn_realm || '-';
updateStatus('turn-status', 'turn-status-text', config.turn_enabled);
} else {
// 回退到基本信息
document.getElementById('server-address').textContent = window.location.host;
document.getElementById('server-http-port').textContent = window.location.port || '80';
await refreshIceServers();
}
// 加载统计信息
const stats = await apiRequest('/admin/stats');
if (stats.success) {
document.getElementById('db-users-count').textContent = stats.data.total_users;
document.getElementById('db-devices-count').textContent = stats.data.total_devices;
document.getElementById('db-sessions-count').textContent = stats.data.total_sessions || '-';
}
// 加载配置文件
await loadEnvConfig();
} catch (e) {
console.error('加载设置失败:', e);
}
}
// 更新状态标签
function updateStatus(statusId, textId, enabled) {
const statusEl = document.getElementById(statusId);
const textEl = document.getElementById(textId);
if (enabled) {
statusEl.className = 'status online';
textEl.textContent = '运行中';
} else {
statusEl.className = 'status offline';
textEl.textContent = '未启用';
}
}
// 加载环境配置
async function loadEnvConfig() {
try {
const response = await apiRequest('/admin/env-config');
const editor = document.getElementById('env-config-editor');
if (response.success && response.data) {
editor.value = response.data.content || generateDefaultConfig();
} else {
editor.value = generateDefaultConfig();
}
} catch (e) {
document.getElementById('env-config-editor').value = generateDefaultConfig();
}
}
// Generate default config
function generateDefaultConfig() {
return `# EasyRemote Server Configuration
# Server Settings
HOST=0.0.0.0
PORT=8080
# STUN Server Settings
ENABLE_LOCAL_STUN=true
STUN_PORT=3478
# TURN Server Settings
ENABLE_LOCAL_TURN=true
TURN_PORT=3479
TURN_USERNAME=easyremote
TURN_PASSWORD=easyremote123
TURN_REALM=easyremote
# Public IP (optional, for generating correct STUN/TURN URLs)
# PUBLIC_IP=your.public.ip
# Database Settings
DATABASE_URL=sqlite:easyremote.db?mode=rwc
# JWT Settings
JWT_EXPIRY=86400
# Log Level
RUST_LOG=info,tower_http=debug
`;
}
// 保存环境配置
async function saveEnvConfig() {
const editor = document.getElementById('env-config-editor');
const content = editor.value;
try {
const response = await apiRequest('/admin/env-config', {
method: 'POST',
body: JSON.stringify({ content })
});
if (response.success) {
showToast('配置已保存,重启服务器后生效', 'success');
} else {
showToast('保存失败: ' + (response.error || '未知错误'), 'error');
}
} catch (e) {
showToast('保存失败: ' + e.message, 'error');
}
}
// 显示 Toast 提示
function showToast(message, type = 'success') {
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => {
toast.remove();
}, 3000);
}
// 刷新 ICE 服务器配置
async function refreshIceServers() {
try {
const response = await fetch('/api/ice-servers');
const data = await response.json();
const stunList = document.getElementById('stun-servers-list');
if (data.stun_servers && data.stun_servers.length > 0) {
stunList.innerHTML = data.stun_servers.map(server =>
`<span class="stun-tag">${server}</span>`
).join('');
} else {
stunList.innerHTML = '<span class="settings-value">未配置</span>';
}
const turnServer = document.getElementById('turn-server');
if (data.turn_server) {
turnServer.textContent = data.turn_server.url;
} else {
turnServer.textContent = '未配置';
}
} catch (e) {
console.error('获取 ICE 配置失败:', e);
document.getElementById('stun-servers-list').innerHTML = '<span class="settings-value" style="color: var(--error);">获取失败</span>';
}
}
// 清理离线设备
async function clearOfflineDevices() {
if (!confirm('确定要清理所有离线超过7天的设备吗此操作不可恢复。')) return;
try {
// TODO: 实现清理离线设备的 API
alert('功能开发中...');
} catch (e) {
alert('操作失败');
}
}
// 清理过期会话
async function clearExpiredSessions() {
if (!confirm('确定要清理所有已结束的会话记录吗?')) return;
try {
// TODO: 实现清理过期会话的 API
alert('功能开发中...');
} catch (e) {
alert('操作失败');
}
}
// 退出登录
function handleLogout() {
if (!confirm('确定要退出登录吗?')) return;
localStorage.removeItem('admin_token');
authToken = null;
window.location.reload();
}
</script>
</body>
</html>