2359 lines
87 KiB
HTML
2359 lines
87 KiB
HTML
<!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>
|
||
:root {
|
||
--primary: #3b82f6;
|
||
--primary-hover: #2563eb;
|
||
--bg-primary: #0f172a;
|
||
--bg-secondary: #1e293b;
|
||
--bg-tertiary: #334155;
|
||
--text-primary: #f1f5f9;
|
||
--text-secondary: #94a3b8;
|
||
--border-color: #334155;
|
||
--success: #22c55e;
|
||
--warning: #f59e0b;
|
||
--error: #ef4444;
|
||
}
|
||
|
||
* {
|
||
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;
|
||
}
|
||
|
||
.layout {
|
||
display: flex;
|
||
min-height: 100vh;
|
||
}
|
||
|
||
/* 侧边栏 */
|
||
.sidebar {
|
||
width: 260px;
|
||
background: var(--bg-secondary);
|
||
border-right: 1px solid var(--border-color);
|
||
padding: 24px 16px;
|
||
}
|
||
|
||
.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: 10px;
|
||
}
|
||
|
||
.logo::before {
|
||
content: '';
|
||
width: 8px;
|
||
height: 24px;
|
||
background: var(--primary);
|
||
border-radius: 2px;
|
||
}
|
||
|
||
.nav-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 12px 16px;
|
||
border-radius: 8px;
|
||
color: var(--text-secondary);
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.nav-item:hover,
|
||
.nav-item.active {
|
||
background: var(--bg-tertiary);
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.nav-item.active {
|
||
background: rgba(59, 130, 246, 0.15);
|
||
color: var(--primary);
|
||
}
|
||
|
||
/* 主内容区 */
|
||
.main {
|
||
flex: 1;
|
||
padding: 24px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 32px;
|
||
}
|
||
|
||
.page-title {
|
||
font-size: 24px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
/* 统计卡片 */
|
||
.stats-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||
gap: 20px;
|
||
margin-bottom: 32px;
|
||
}
|
||
|
||
.stat-card {
|
||
background: var(--bg-secondary);
|
||
border: 1px solid var(--border-color);
|
||
border-radius: 12px;
|
||
padding: 24px;
|
||
}
|
||
|
||
.stat-label {
|
||
font-size: 14px;
|
||
color: var(--text-secondary);
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.stat-value {
|
||
font-size: 32px;
|
||
font-weight: 700;
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.stat-change {
|
||
font-size: 12px;
|
||
color: var(--success);
|
||
margin-top: 8px;
|
||
}
|
||
|
||
/* 表格 */
|
||
.card {
|
||
background: var(--bg-secondary);
|
||
border: 1px solid var(--border-color);
|
||
border-radius: 12px;
|
||
margin-bottom: 24px;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
.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: 12px;
|
||
font-weight: 600;
|
||
color: var(--text-secondary);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
}
|
||
|
||
.table td {
|
||
font-size: 14px;
|
||
}
|
||
|
||
.table tr:last-child td {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.table tr:hover {
|
||
background: var(--bg-tertiary);
|
||
}
|
||
|
||
/* 状态标签 */
|
||
.status {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 4px 12px;
|
||
border-radius: 20px;
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.status.online {
|
||
background: rgba(34, 197, 94, 0.15);
|
||
color: var(--success);
|
||
}
|
||
|
||
.status.offline {
|
||
background: var(--bg-tertiary);
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
.status.active {
|
||
background: rgba(59, 130, 246, 0.15);
|
||
color: var(--primary);
|
||
}
|
||
|
||
.status-dot {
|
||
width: 6px;
|
||
height: 6px;
|
||
border-radius: 50%;
|
||
background: currentColor;
|
||
}
|
||
|
||
/* 按钮 */
|
||
.btn {
|
||
padding: 8px 16px;
|
||
border: none;
|
||
border-radius: 6px;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.btn-primary {
|
||
background: var(--primary);
|
||
color: white;
|
||
}
|
||
|
||
.btn-primary:hover {
|
||
background: var(--primary-hover);
|
||
}
|
||
|
||
.btn-danger {
|
||
background: rgba(239, 68, 68, 0.15);
|
||
color: var(--error);
|
||
}
|
||
|
||
.btn-danger:hover {
|
||
background: rgba(239, 68, 68, 0.25);
|
||
}
|
||
|
||
.btn-secondary {
|
||
background: var(--bg-tertiary);
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.btn-secondary:hover {
|
||
background: var(--border-color);
|
||
}
|
||
|
||
/* 搜索框 */
|
||
.search-box {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
background: var(--bg-tertiary);
|
||
border-radius: 8px;
|
||
padding: 8px 16px;
|
||
}
|
||
|
||
.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-secondary);
|
||
}
|
||
|
||
/* 分页 */
|
||
.pagination {
|
||
display: flex;
|
||
justify-content: center;
|
||
gap: 8px;
|
||
padding: 20px;
|
||
}
|
||
|
||
.pagination-btn {
|
||
padding: 8px 14px;
|
||
border: 1px solid var(--border-color);
|
||
border-radius: 6px;
|
||
background: transparent;
|
||
color: var(--text-secondary);
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.pagination-btn:hover,
|
||
.pagination-btn.active {
|
||
background: var(--primary);
|
||
border-color: var(--primary);
|
||
color: white;
|
||
}
|
||
|
||
/* 空状态 */
|
||
.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: linear-gradient(135deg, var(--bg-primary) 0%, #0c1222 100%);
|
||
}
|
||
|
||
.login-card {
|
||
background: var(--bg-secondary);
|
||
border: 1px solid var(--border-color);
|
||
border-radius: 16px;
|
||
padding: 40px;
|
||
width: 100%;
|
||
max-width: 400px;
|
||
}
|
||
|
||
.login-header {
|
||
text-align: center;
|
||
margin-bottom: 32px;
|
||
}
|
||
|
||
.login-logo {
|
||
font-size: 48px;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.login-title {
|
||
font-size: 24px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.form-group {
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.form-label {
|
||
display: block;
|
||
font-size: 14px;
|
||
color: var(--text-secondary);
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.form-input {
|
||
width: 100%;
|
||
padding: 12px 16px;
|
||
background: var(--bg-tertiary);
|
||
border: 1px solid var(--border-color);
|
||
border-radius: 8px;
|
||
color: var(--text-primary);
|
||
font-size: 14px;
|
||
outline: none;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.form-input:focus {
|
||
border-color: var(--primary);
|
||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||
}
|
||
|
||
.login-btn {
|
||
width: 100%;
|
||
padding: 14px;
|
||
background: var(--primary);
|
||
color: white;
|
||
border: none;
|
||
border-radius: 8px;
|
||
font-size: 15px;
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.login-btn:hover {
|
||
background: var(--primary-hover);
|
||
}
|
||
|
||
/* 隐藏内容 */
|
||
.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="login-page" class="login-container">
|
||
<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="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', () => {
|
||
if (authToken) {
|
||
checkAuth();
|
||
}
|
||
setupEventListeners();
|
||
});
|
||
|
||
// 检查认证状态
|
||
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('login-form').addEventListener('submit', handleLogin);
|
||
|
||
// 导航
|
||
document.querySelectorAll('.nav-item').forEach(item => {
|
||
item.addEventListener('click', () => {
|
||
const page = item.dataset.page;
|
||
navigateTo(page);
|
||
});
|
||
});
|
||
}
|
||
|
||
// 处理登录
|
||
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;
|
||
|
||
// 加载在线设备(用于远程控制)
|
||
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
|
||
}));
|
||
|
||
// 显示连接成功信息
|
||
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();
|
||
}
|
||
}
|
||
} 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> |