Enhance server and client functionality by adding support for display management, including fetching and switching displays. Update README with build instructions for Linux and Windows. Improve UI styles and integrate a setup page for initial configuration. Update server port to 9099 and adjust client-server communication for new features.
This commit is contained in:
parent
8e6862dcb6
commit
fd34c415f5
149
.github/workflows/build.yml
vendored
Normal file
149
.github/workflows/build.yml
vendored
Normal file
@ -0,0 +1,149 @@
|
||||
name: Build Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
# 构建 Linux 服务端 (musl 静态链接,兼容所有 Linux 发行版)
|
||||
build-server-linux:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: x86_64-unknown-linux-musl
|
||||
|
||||
- name: Install musl-tools
|
||||
run: sudo apt-get update && sudo apt-get install -y musl-tools
|
||||
|
||||
- name: Build Server (musl static)
|
||||
run: cargo build --release --package easyremote-server --target x86_64-unknown-linux-musl
|
||||
|
||||
- name: Prepare release
|
||||
run: |
|
||||
mkdir -p release/server
|
||||
cp target/x86_64-unknown-linux-musl/release/easyremote-server release/server/
|
||||
cp -r crates/server/static release/server/
|
||||
echo '#!/bin/bash' > release/server/start.sh
|
||||
echo 'cd "$(dirname "$0")"' >> release/server/start.sh
|
||||
echo 'chmod +x easyremote-server' >> release/server/start.sh
|
||||
echo './easyremote-server' >> release/server/start.sh
|
||||
chmod +x release/server/start.sh
|
||||
cd release && tar -czvf ../easyremote-server-linux-x86_64.tar.gz server/
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: easyremote-server-linux-x86_64
|
||||
path: easyremote-server-linux-x86_64.tar.gz
|
||||
|
||||
# 构建 Windows 服务端
|
||||
build-server-windows:
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: x86_64-pc-windows-msvc
|
||||
|
||||
- name: Build Server
|
||||
run: cargo build --release --package easyremote-server --target x86_64-pc-windows-msvc
|
||||
|
||||
- name: Prepare release
|
||||
shell: pwsh
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force -Path release/server
|
||||
Copy-Item target/x86_64-pc-windows-msvc/release/easyremote-server.exe release/server/
|
||||
Copy-Item -Recurse crates/server/static release/server/
|
||||
Compress-Archive -Path release/server/* -DestinationPath easyremote-server-windows-x86_64.zip
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: easyremote-server-windows-x86_64
|
||||
path: easyremote-server-windows-x86_64.zip
|
||||
|
||||
# 构建 Windows 客户端 (Tauri)
|
||||
build-client-windows:
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: crates/client-tauri/ui/package-lock.json
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: crates/client-tauri/ui
|
||||
run: npm ci
|
||||
|
||||
- name: Build frontend
|
||||
working-directory: crates/client-tauri/ui
|
||||
run: npm run build
|
||||
|
||||
- name: Install Tauri CLI
|
||||
run: cargo install tauri-cli
|
||||
|
||||
- name: Build Tauri app
|
||||
working-directory: crates/client-tauri
|
||||
run: cargo tauri build
|
||||
|
||||
- name: Upload MSI installer
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: easyremote-client-windows-x86_64-msi
|
||||
path: target/release/bundle/msi/*.msi
|
||||
|
||||
- name: Upload EXE
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: easyremote-client-windows-x86_64-exe
|
||||
path: target/release/easyremote-client.exe
|
||||
|
||||
# 创建 Release (仅在 tag 推送时)
|
||||
create-release:
|
||||
needs: [build-server-linux, build-server-windows, build-client-windows]
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
|
||||
steps:
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Display structure
|
||||
run: ls -R artifacts
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
files: |
|
||||
artifacts/easyremote-server-linux-x86_64/*.tar.gz
|
||||
artifacts/easyremote-server-windows-x86_64/*.zip
|
||||
artifacts/easyremote-client-windows-x86_64-msi/*.msi
|
||||
artifacts/easyremote-client-windows-x86_64-exe/*.exe
|
||||
draft: false
|
||||
prerelease: false
|
||||
generate_release_notes: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
41
Dockerfile.server
Normal file
41
Dockerfile.server
Normal file
@ -0,0 +1,41 @@
|
||||
# EasyRemote Server Dockerfile
|
||||
# 用于构建 Linux 版本的服务端
|
||||
|
||||
# 构建阶段
|
||||
FROM rust:1.75-bookworm as builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 复制项目文件
|
||||
COPY . .
|
||||
|
||||
# 构建服务端
|
||||
RUN cargo build --release --package easyremote-server
|
||||
|
||||
# 运行阶段
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 从构建阶段复制可执行文件
|
||||
COPY --from=builder /app/target/release/easyremote-server .
|
||||
COPY --from=builder /app/crates/server/static ./static
|
||||
|
||||
# 创建数据目录
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 8080
|
||||
EXPOSE 3478/udp
|
||||
EXPOSE 3479/udp
|
||||
|
||||
# 环境变量
|
||||
ENV RUST_LOG=info
|
||||
ENV DATABASE_URL=sqlite:///app/data/easyremote.db
|
||||
|
||||
# 启动服务
|
||||
CMD ["./easyremote-server"]
|
||||
46
README.md
46
README.md
@ -50,18 +50,60 @@ easyremote/
|
||||
- Node.js 18+ (客户端前端)
|
||||
- SQLite (服务端数据库)
|
||||
|
||||
### 构建服务端
|
||||
### 构建服务端 (Windows)
|
||||
|
||||
```bash
|
||||
# 编译服务端
|
||||
cargo build --release -p easyremote-server
|
||||
|
||||
# 运行服务端
|
||||
./target/release/easyremote-server
|
||||
.\target\release\easyremote-server.exe
|
||||
```
|
||||
|
||||
服务端默认运行在 `http://localhost:8080`,管理后台访问 `http://localhost:8080/`
|
||||
|
||||
### 构建服务端 (Linux)
|
||||
|
||||
**方式一:直接在 Linux 上构建**
|
||||
```bash
|
||||
# 克隆项目
|
||||
git clone https://github.com/your-repo/easyremote.git
|
||||
cd easyremote
|
||||
|
||||
# 运行构建脚本
|
||||
chmod +x scripts/build-linux.sh
|
||||
./scripts/build-linux.sh
|
||||
|
||||
# 启动服务
|
||||
./release/linux/start.sh
|
||||
```
|
||||
|
||||
**方式二:使用 Docker**
|
||||
```bash
|
||||
# 使用 docker-compose 一键部署
|
||||
docker-compose up -d
|
||||
|
||||
# 或者手动构建和运行
|
||||
docker build -f Dockerfile.server -t easyremote-server .
|
||||
docker run -d -p 8080:8080 -p 3478:3478/udp --name easyremote easyremote-server
|
||||
```
|
||||
|
||||
**方式三:使用 systemd 服务**
|
||||
```bash
|
||||
# 复制文件到 /opt/easyremote
|
||||
sudo mkdir -p /opt/easyremote
|
||||
sudo cp -r release/linux/* /opt/easyremote/
|
||||
|
||||
# 安装 systemd 服务
|
||||
sudo cp /opt/easyremote/easyremote.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable easyremote
|
||||
sudo systemctl start easyremote
|
||||
|
||||
# 查看状态
|
||||
sudo systemctl status easyremote
|
||||
```
|
||||
|
||||
### 构建客户端
|
||||
|
||||
```bash
|
||||
|
||||
@ -8,6 +8,6 @@ pub mod codec;
|
||||
pub mod config;
|
||||
|
||||
pub use config::ClientConfig;
|
||||
pub use signal::{SignalClient, SignalMessage};
|
||||
pub use signal::{SignalClient, SignalMessage, DisplayInfoMsg};
|
||||
pub use capture::ScreenCapturer;
|
||||
pub use input::{InputController, MouseButton};
|
||||
@ -9,6 +9,16 @@ use tokio::sync::{mpsc, RwLock};
|
||||
use tokio_tungstenite::{connect_async, tungstenite::Message, MaybeTlsStream, WebSocketStream};
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
|
||||
|
||||
/// 显示器信息(用于消息传递)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DisplayInfoMsg {
|
||||
pub index: usize,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub is_primary: bool,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// 信令消息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
@ -118,6 +128,30 @@ pub enum SignalMessage {
|
||||
key: String,
|
||||
event_type: String, // "down", "up"
|
||||
},
|
||||
/// 获取显示器列表
|
||||
#[serde(rename = "get_displays")]
|
||||
GetDisplays {
|
||||
session_id: String,
|
||||
from_device: String,
|
||||
to_device: String,
|
||||
},
|
||||
/// 显示器列表响应
|
||||
#[serde(rename = "displays_list")]
|
||||
DisplaysList {
|
||||
session_id: String,
|
||||
from_device: String,
|
||||
to_device: String,
|
||||
displays: Vec<DisplayInfoMsg>,
|
||||
current_display: usize,
|
||||
},
|
||||
/// 切换显示器
|
||||
#[serde(rename = "switch_display")]
|
||||
SwitchDisplay {
|
||||
session_id: String,
|
||||
from_device: String,
|
||||
to_device: String,
|
||||
display_index: usize,
|
||||
},
|
||||
/// 流媒体设置
|
||||
#[serde(rename = "stream_settings")]
|
||||
StreamSettings {
|
||||
@ -188,9 +222,16 @@ impl SignalClient {
|
||||
tokio::spawn(async move {
|
||||
while let Some(Ok(msg)) = read.next().await {
|
||||
if let Message::Text(text) = msg {
|
||||
if let Ok(signal_msg) = serde_json::from_str::<SignalMessage>(&text) {
|
||||
println!("★★★ [客户端] 收到WebSocket消息: {}", &text[..text.len().min(200)]);
|
||||
match serde_json::from_str::<SignalMessage>(&text) {
|
||||
Ok(signal_msg) => {
|
||||
println!("★★★ [客户端] 解析成功: {:?}", std::mem::discriminant(&signal_msg));
|
||||
on_message(signal_msg);
|
||||
}
|
||||
Err(e) => {
|
||||
println!("★★★ [客户端] 解析失败: {}, 原始消息: {}", e, &text[..text.len().min(100)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
*connected.write().await = false;
|
||||
|
||||
@ -45,6 +45,7 @@ anyhow = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
dirs = "5.0"
|
||||
image = { workspace = true }
|
||||
once_cell = "1.19"
|
||||
|
||||
[features]
|
||||
default = ["custom-protocol"]
|
||||
|
||||
@ -18,7 +18,7 @@ static FORCE_OFFLINE_FLAG: AtomicBool = AtomicBool::new(false);
|
||||
/// 当前活跃的屏幕流会话
|
||||
static ACTIVE_SESSION: tokio::sync::OnceCell<Arc<RwLock<Option<ActiveScreenSession>>>> = tokio::sync::OnceCell::const_new();
|
||||
|
||||
/// 流媒体设置
|
||||
/// 流媒体设置 (使用 std::sync::RwLock 以便在普通线程中使用)
|
||||
#[derive(Clone)]
|
||||
struct StreamSettings {
|
||||
resolution: f64,
|
||||
@ -36,13 +36,29 @@ impl Default for StreamSettings {
|
||||
}
|
||||
}
|
||||
|
||||
/// 全局流媒体设置
|
||||
static STREAM_SETTINGS: tokio::sync::OnceCell<Arc<RwLock<StreamSettings>>> = tokio::sync::OnceCell::const_new();
|
||||
/// 全局流媒体设置 (使用标准库的 RwLock)
|
||||
use std::sync::RwLock as StdRwLock;
|
||||
use once_cell::sync::Lazy;
|
||||
|
||||
async fn get_stream_settings() -> &'static Arc<RwLock<StreamSettings>> {
|
||||
STREAM_SETTINGS.get_or_init(|| async {
|
||||
Arc::new(RwLock::new(StreamSettings::default()))
|
||||
}).await
|
||||
static STREAM_SETTINGS: Lazy<Arc<StdRwLock<StreamSettings>>> = Lazy::new(|| {
|
||||
Arc::new(StdRwLock::new(StreamSettings::default()))
|
||||
});
|
||||
|
||||
fn get_stream_settings_sync() -> Arc<StdRwLock<StreamSettings>> {
|
||||
STREAM_SETTINGS.clone()
|
||||
}
|
||||
|
||||
/// 当前显示器索引
|
||||
static CURRENT_DISPLAY: Lazy<Arc<std::sync::atomic::AtomicUsize>> = Lazy::new(|| {
|
||||
Arc::new(std::sync::atomic::AtomicUsize::new(0))
|
||||
});
|
||||
|
||||
fn get_current_display() -> usize {
|
||||
CURRENT_DISPLAY.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
fn set_current_display(index: usize) {
|
||||
CURRENT_DISPLAY.store(index, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// 活跃屏幕会话
|
||||
@ -85,7 +101,8 @@ fn set_force_offline() {
|
||||
|
||||
/// 启动屏幕流
|
||||
async fn start_screen_streaming(session_id: String, controller_device: String, my_device_id: String) {
|
||||
tracing::info!("启动屏幕流: session={}, controller={}", session_id, controller_device);
|
||||
tracing::info!("★★★ 启动屏幕流: session={}, controller={}, my_device={}", session_id, controller_device, my_device_id);
|
||||
println!("★★★ 启动屏幕流: session={}, controller={}, my_device={}", session_id, controller_device, my_device_id);
|
||||
|
||||
// 检查是否已有活跃会话
|
||||
let active_session = get_active_session().await;
|
||||
@ -109,20 +126,42 @@ async fn start_screen_streaming(session_id: String, controller_device: String, m
|
||||
// 获取信令客户端
|
||||
let signal_client = get_signal_client().await;
|
||||
|
||||
// 获取 stop_flag 用于停止控制
|
||||
let stop_flag_clone = {
|
||||
let session = active_session.read().await;
|
||||
session.as_ref().map(|s| s.stop_flag.clone()).unwrap_or_else(|| Arc::new(AtomicBool::new(true)))
|
||||
};
|
||||
|
||||
// 启动屏幕捕获线程
|
||||
let session_id_capture = session_id.clone();
|
||||
let controller_device_capture = controller_device.clone();
|
||||
let my_device_id_capture = my_device_id.clone();
|
||||
let _my_device_id_capture = my_device_id.clone();
|
||||
let signal_client_clone = signal_client.clone();
|
||||
let active_session_clone = active_session.clone();
|
||||
|
||||
let stream_settings = get_stream_settings().await.clone();
|
||||
let stream_settings = get_stream_settings_sync();
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
// 创建屏幕捕获器
|
||||
let mut capturer = match ScreenCapturer::new(0) {
|
||||
Ok(c) => c,
|
||||
std::thread::spawn(move || {
|
||||
println!("★★★ 开始创建屏幕捕获器...");
|
||||
|
||||
// 创建一个 tokio runtime 用于发送帧
|
||||
let rt = match tokio::runtime::Runtime::new() {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
println!("★★★ 创建 tokio runtime 失败: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// 创建屏幕捕获器(使用当前选择的显示器)
|
||||
let display_index = get_current_display();
|
||||
println!("★★★ 使用显示器: {}", display_index);
|
||||
let mut capturer = match ScreenCapturer::new(display_index) {
|
||||
Ok(c) => {
|
||||
println!("★★★ 屏幕捕获器创建成功");
|
||||
c
|
||||
}
|
||||
Err(e) => {
|
||||
println!("★★★ 创建屏幕捕获器失败: {}", e);
|
||||
tracing::error!("创建屏幕捕获器失败: {}", e);
|
||||
return;
|
||||
}
|
||||
@ -130,6 +169,7 @@ async fn start_screen_streaming(session_id: String, controller_device: String, m
|
||||
|
||||
let width = capturer.width();
|
||||
let height = capturer.height();
|
||||
println!("★★★ 屏幕分辨率: {}x{}", width, height);
|
||||
tracing::info!("屏幕分辨率: {}x{}", width, height);
|
||||
|
||||
let mut frame_count = 0u64;
|
||||
@ -138,24 +178,15 @@ async fn start_screen_streaming(session_id: String, controller_device: String, m
|
||||
loop {
|
||||
// 获取当前设置
|
||||
let (resolution, fps, _quality) = {
|
||||
let rt = tokio::runtime::Handle::current();
|
||||
rt.block_on(async {
|
||||
let s = stream_settings.read().await;
|
||||
let s = stream_settings.read().unwrap();
|
||||
(s.resolution, s.fps, s.quality)
|
||||
})
|
||||
};
|
||||
|
||||
// 计算帧间隔
|
||||
let frame_interval = std::time::Duration::from_millis(1000 / fps as u64);
|
||||
|
||||
// 检查是否需要停止
|
||||
let should_stop = {
|
||||
let rt = tokio::runtime::Handle::current();
|
||||
rt.block_on(async {
|
||||
let session = active_session_clone.read().await;
|
||||
session.as_ref().map(|s| s.stop_flag.load(Ordering::SeqCst)).unwrap_or(true)
|
||||
})
|
||||
};
|
||||
let should_stop = stop_flag_clone.load(Ordering::SeqCst);
|
||||
|
||||
if should_stop {
|
||||
tracing::info!("屏幕流停止");
|
||||
@ -180,12 +211,11 @@ async fn start_screen_streaming(session_id: String, controller_device: String, m
|
||||
let jpeg_len = jpeg_data.len();
|
||||
|
||||
// 发送帧
|
||||
let rt = tokio::runtime::Handle::current();
|
||||
let session_id_send = session_id_capture.clone();
|
||||
let controller_device_send = controller_device_capture.clone();
|
||||
let signal_client_send = signal_client_clone.clone();
|
||||
|
||||
rt.block_on(async move {
|
||||
rt.block_on(async {
|
||||
let client = signal_client_send.read().await;
|
||||
if let Some(c) = client.as_ref() {
|
||||
let _ = c.send_frame(
|
||||
@ -201,9 +231,15 @@ async fn start_screen_streaming(session_id: String, controller_device: String, m
|
||||
frame_count += 1;
|
||||
if frame_count % 30 == 0 {
|
||||
let actual_fps = frame_count as f64 / start_time.elapsed().as_secs_f64();
|
||||
println!("★★★ 屏幕流: {:.1} fps, 分辨率: {}x{}, 帧大小: {} KB",
|
||||
actual_fps, scaled_width, scaled_height, jpeg_len / 1024);
|
||||
tracing::debug!("屏幕流: {:.1} fps, 分辨率: {}x{}, 帧大小: {} KB",
|
||||
actual_fps, scaled_width, scaled_height, jpeg_len / 1024);
|
||||
}
|
||||
// 首帧日志
|
||||
if frame_count == 1 {
|
||||
println!("★★★ 发送第一帧: {}x{}, {} 字节", scaled_width, scaled_height, jpeg_len);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -220,12 +256,8 @@ async fn start_screen_streaming(session_id: String, controller_device: String, m
|
||||
std::thread::sleep(frame_interval);
|
||||
}
|
||||
|
||||
// 清理会话
|
||||
let rt = tokio::runtime::Handle::current();
|
||||
rt.block_on(async {
|
||||
let mut session = active_session_clone.write().await;
|
||||
*session = None;
|
||||
});
|
||||
// 清理完成,stop_flag 已设置为 true
|
||||
println!("★★★ 屏幕流线程结束");
|
||||
});
|
||||
}
|
||||
|
||||
@ -235,12 +267,9 @@ fn handle_mouse_input(x: f64, y: f64, event_type: &str, button: Option<u8>, delt
|
||||
|
||||
// 获取当前分辨率设置以计算缩放比例
|
||||
let scale = {
|
||||
let rt = tokio::runtime::Handle::current();
|
||||
rt.block_on(async {
|
||||
let settings = get_stream_settings().await;
|
||||
let s = settings.read().await;
|
||||
let settings = get_stream_settings_sync();
|
||||
let s = settings.read().unwrap();
|
||||
1.0 / s.resolution
|
||||
})
|
||||
};
|
||||
let actual_x = (x * scale) as i32;
|
||||
let actual_y = (y * scale) as i32;
|
||||
@ -383,14 +412,18 @@ async fn connect_to_signal_server(device_id: String, server_url: String) -> Resu
|
||||
set_force_offline();
|
||||
}
|
||||
SignalMessage::ConnectRequest { session_id, from_device, to_device, .. } => {
|
||||
tracing::info!("收到连接请求: session_id={}, from={}, to={}", session_id, from_device, to_device);
|
||||
tracing::info!("★★★ 收到连接请求: session_id={}, from={}, to={}", session_id, from_device, to_device);
|
||||
println!("★★★ 收到连接请求: session_id={}, from={}, to={}", session_id, from_device, to_device);
|
||||
// 启动屏幕流
|
||||
let session_id_clone = session_id.clone();
|
||||
let from_device_clone = from_device.clone();
|
||||
let device_id_stream = device_id_inner.clone();
|
||||
tokio::spawn(async move {
|
||||
std::thread::spawn(move || {
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
rt.block_on(async move {
|
||||
start_screen_streaming(session_id_clone, from_device_clone, device_id_stream).await;
|
||||
});
|
||||
});
|
||||
}
|
||||
SignalMessage::MouseEvent { x, y, event_type, button, delta, .. } => {
|
||||
// 处理鼠标事件
|
||||
@ -403,12 +436,98 @@ async fn connect_to_signal_server(device_id: String, server_url: String) -> Resu
|
||||
SignalMessage::StreamSettings { resolution, fps, quality, .. } => {
|
||||
// 更新流媒体设置
|
||||
tracing::info!("更新流媒体设置: resolution={}, fps={}, quality={}", resolution, fps, quality);
|
||||
tokio::spawn(async move {
|
||||
let settings = get_stream_settings().await;
|
||||
let mut s = settings.write().await;
|
||||
println!("★★★ 更新流媒体设置: resolution={}, fps={}, quality={}", resolution, fps, quality);
|
||||
let settings = get_stream_settings_sync();
|
||||
let mut s = settings.write().unwrap();
|
||||
s.resolution = resolution;
|
||||
s.fps = fps;
|
||||
s.quality = quality;
|
||||
}
|
||||
SignalMessage::GetDisplays { session_id, from_device, .. } => {
|
||||
// 获取显示器列表并返回
|
||||
println!("★★★ 收到获取显示器列表请求");
|
||||
use easyremote_client_core::capture::get_displays;
|
||||
match get_displays() {
|
||||
Ok(displays) => {
|
||||
println!("★★★ 获取到 {} 个显示器", displays.len());
|
||||
for d in &displays {
|
||||
println!(" - 显示器 {}: {}x{} (primary: {})", d.index, d.width, d.height, d.is_primary);
|
||||
}
|
||||
let display_msgs: Vec<_> = displays.iter().map(|d| {
|
||||
easyremote_client_core::signal::DisplayInfoMsg {
|
||||
index: d.index,
|
||||
width: d.width,
|
||||
height: d.height,
|
||||
is_primary: d.is_primary,
|
||||
name: format!("显示器 {} ({}x{})", d.index + 1, d.width, d.height),
|
||||
}
|
||||
}).collect();
|
||||
|
||||
let display_count = display_msgs.len();
|
||||
println!("★★★ 发送显示器列表响应: {} 个显示器", display_count);
|
||||
|
||||
let response = SignalMessage::DisplaysList {
|
||||
session_id,
|
||||
from_device: device_id_inner.clone(),
|
||||
to_device: from_device,
|
||||
displays: display_msgs,
|
||||
current_display: get_current_display(),
|
||||
};
|
||||
let signal_client_holder = SIGNAL_CLIENT.get();
|
||||
if let Some(holder) = signal_client_holder {
|
||||
let holder_clone = holder.clone();
|
||||
tokio::spawn(async move {
|
||||
let client = holder_clone.read().await;
|
||||
if let Some(c) = client.as_ref() {
|
||||
let _ = c.send(response).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
println!("★★★ 获取显示器列表失败: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
SignalMessage::SwitchDisplay { display_index, from_device, session_id, .. } => {
|
||||
// 切换显示器
|
||||
println!("★★★ 收到切换显示器请求: display_index={}", display_index);
|
||||
set_current_display(display_index);
|
||||
|
||||
// 停止当前屏幕流并重新启动
|
||||
let device_id_restart = device_id_inner.clone();
|
||||
let from_device_restart = from_device.clone();
|
||||
let session_id_restart = session_id.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
// 停止当前会话
|
||||
if let Some(holder) = ACTIVE_SESSION.get() {
|
||||
let session = holder.read().await;
|
||||
if let Some(s) = session.as_ref() {
|
||||
s.stop_flag.store(true, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
// 等待一会儿再重新启动
|
||||
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||
start_screen_streaming(session_id_restart, from_device_restart, device_id_restart).await;
|
||||
});
|
||||
}
|
||||
SignalMessage::SessionEnd { session_id } => {
|
||||
// 会话结束,停止屏幕流
|
||||
println!("★★★ 收到会话结束消息: session_id={}", session_id);
|
||||
tracing::info!("收到会话结束消息: session_id={}", session_id);
|
||||
|
||||
// 停止屏幕流
|
||||
tokio::spawn(async move {
|
||||
if let Some(holder) = ACTIVE_SESSION.get() {
|
||||
let mut session = holder.write().await;
|
||||
if let Some(s) = session.as_ref() {
|
||||
s.stop_flag.store(true, Ordering::SeqCst);
|
||||
println!("★★★ 已停止屏幕流会话");
|
||||
}
|
||||
*session = None;
|
||||
}
|
||||
});
|
||||
}
|
||||
_ => {
|
||||
@ -519,7 +638,6 @@ pub async fn get_device_info(state: State<'_, AppStateHandle>) -> Result<DeviceI
|
||||
device_id: state.device_id.0.clone(),
|
||||
device_id_formatted: state.device_id.formatted(),
|
||||
verification_code: state.verification_code.0.clone(),
|
||||
allow_remote: state.allow_remote,
|
||||
device_name: state.config.device_name.clone(),
|
||||
os_type: get_os_type().to_string(),
|
||||
})
|
||||
@ -532,14 +650,6 @@ pub async fn refresh_verification_code(state: State<'_, AppStateHandle>) -> Resu
|
||||
Ok(state.refresh_code())
|
||||
}
|
||||
|
||||
/// 设置是否允许远程控制
|
||||
#[tauri::command]
|
||||
pub async fn set_allow_remote(state: State<'_, AppStateHandle>, allow: bool) -> Result<(), String> {
|
||||
let mut state = state.write().await;
|
||||
state.allow_remote = allow;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 用户登录
|
||||
#[tauri::command]
|
||||
pub async fn login(
|
||||
@ -740,9 +850,40 @@ pub async fn connect_to_device(
|
||||
/// 断开连接
|
||||
#[tauri::command]
|
||||
pub async fn disconnect(state: State<'_, AppStateHandle>) -> Result<(), String> {
|
||||
let mut state = state.write().await;
|
||||
state.connection_state = ConnectionState::Disconnected;
|
||||
state.current_session_id = None;
|
||||
let mut app_state = state.write().await;
|
||||
|
||||
// 获取当前会话信息
|
||||
let session_id = app_state.current_session_id.clone();
|
||||
let target_device = match &app_state.connection_state {
|
||||
ConnectionState::Connected { target_device_id, .. } => Some(target_device_id.clone()),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
// 清理状态
|
||||
app_state.connection_state = ConnectionState::Disconnected;
|
||||
app_state.current_session_id = None;
|
||||
drop(app_state);
|
||||
|
||||
// 发送 SessionEnd 消息给被控端
|
||||
if let (Some(session_id), Some(target_device_id)) = (session_id, target_device) {
|
||||
tracing::info!("发送 SessionEnd 到设备: {}", target_device_id);
|
||||
let signal_client = get_signal_client().await;
|
||||
let client = signal_client.read().await;
|
||||
if let Some(c) = client.as_ref() {
|
||||
let _ = c.end_session(session_id).await;
|
||||
}
|
||||
}
|
||||
|
||||
// 停止本地屏幕流(如果是被控端)
|
||||
if let Some(holder) = ACTIVE_SESSION.get() {
|
||||
let mut session = holder.write().await;
|
||||
if let Some(s) = session.as_ref() {
|
||||
s.stop_flag.store(true, Ordering::SeqCst);
|
||||
tracing::info!("已停止本地屏幕流");
|
||||
}
|
||||
*session = None;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -819,6 +960,38 @@ pub async fn save_config(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 重新连接服务器
|
||||
#[tauri::command]
|
||||
pub async fn reconnect_server(
|
||||
state: State<'_, AppStateHandle>,
|
||||
) -> Result<(), String> {
|
||||
let app_state = state.read().await;
|
||||
let server_url = app_state.config.server_url.clone();
|
||||
let device_id = app_state.device_id.0.clone();
|
||||
drop(app_state);
|
||||
|
||||
tracing::info!("重新连接服务器: {}", server_url);
|
||||
|
||||
// 断开现有连接
|
||||
{
|
||||
let signal_client = get_signal_client().await;
|
||||
let mut client = signal_client.write().await;
|
||||
if client.is_some() {
|
||||
*client = None;
|
||||
tracing::info!("已断开旧连接");
|
||||
}
|
||||
}
|
||||
|
||||
// 等待一下再重连
|
||||
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||
|
||||
// 重新连接
|
||||
connect_to_signal_server(device_id, server_url).await?;
|
||||
|
||||
tracing::info!("重新连接成功");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取开机启动状态
|
||||
#[tauri::command]
|
||||
pub fn get_autostart() -> Result<bool, String> {
|
||||
|
||||
@ -212,7 +212,6 @@ fn main() {
|
||||
// 设备信息
|
||||
commands::get_device_info,
|
||||
commands::refresh_verification_code,
|
||||
commands::set_allow_remote,
|
||||
// 账号
|
||||
commands::login,
|
||||
commands::logout,
|
||||
@ -230,6 +229,7 @@ fn main() {
|
||||
// 配置
|
||||
commands::get_config,
|
||||
commands::save_config,
|
||||
commands::reconnect_server,
|
||||
// 开机启动
|
||||
commands::get_autostart,
|
||||
commands::set_autostart,
|
||||
|
||||
@ -13,8 +13,6 @@ pub struct AppState {
|
||||
pub device_id: DeviceId,
|
||||
/// 验证码
|
||||
pub verification_code: VerificationCode,
|
||||
/// 是否允许远程控制
|
||||
pub allow_remote: bool,
|
||||
/// 当前登录用户
|
||||
pub current_user: Option<CurrentUser>,
|
||||
/// 认证令牌
|
||||
@ -73,7 +71,6 @@ impl AppState {
|
||||
config,
|
||||
device_id,
|
||||
verification_code,
|
||||
allow_remote: false,
|
||||
current_user,
|
||||
auth_token,
|
||||
connection_state: ConnectionState::Disconnected,
|
||||
@ -196,7 +193,6 @@ pub struct DeviceInfo {
|
||||
pub device_id: String,
|
||||
pub device_id_formatted: String,
|
||||
pub verification_code: String,
|
||||
pub allow_remote: bool,
|
||||
pub device_name: String,
|
||||
pub os_type: String,
|
||||
}
|
||||
|
||||
@ -51,7 +51,7 @@
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"identifier": "com.easyremote.app",
|
||||
"longDescription": "A remote desktop control application",
|
||||
"longDescription": "简单易用的远程桌面控制软件",
|
||||
"macOS": {
|
||||
"entitlements": null,
|
||||
"exceptionDomain": "",
|
||||
@ -60,12 +60,19 @@
|
||||
"signingIdentity": null
|
||||
},
|
||||
"resources": [],
|
||||
"shortDescription": "Remote Desktop Control",
|
||||
"shortDescription": "远程桌面控制",
|
||||
"targets": "all",
|
||||
"windows": {
|
||||
"certificateThumbprint": null,
|
||||
"digestAlgorithm": "sha256",
|
||||
"timestampUrl": ""
|
||||
"timestampUrl": "",
|
||||
"wix": {
|
||||
"language": "zh-CN"
|
||||
},
|
||||
"nsis": {
|
||||
"languages": ["SimpChinese"],
|
||||
"displayLanguageSelector": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": {
|
||||
|
||||
@ -16,7 +16,7 @@ const autostart = ref(false)
|
||||
|
||||
// 配置
|
||||
const config = ref<ClientConfig>({
|
||||
server_url: 'ws://localhost:8080',
|
||||
server_url: 'ws://localhost:9099',
|
||||
device_name: '',
|
||||
quality: {
|
||||
frame_rate: 30,
|
||||
@ -112,6 +112,7 @@ async function loadConfig() {
|
||||
async function saveConfig() {
|
||||
loading.value = true
|
||||
try {
|
||||
const oldServerUrl = config.value.server_url
|
||||
const newConfig: ClientConfig = {
|
||||
...config.value,
|
||||
server_url: configForm.value.server_url
|
||||
@ -119,7 +120,21 @@ async function saveConfig() {
|
||||
await invoke('save_config', { config: newConfig })
|
||||
config.value = newConfig
|
||||
configEditing.value = false
|
||||
showSuccess('配置已保存,重启后生效')
|
||||
|
||||
// 如果服务器地址改变了,自动重新连接
|
||||
if (oldServerUrl !== newConfig.server_url) {
|
||||
showSuccess('配置已保存,正在重新连接服务器...')
|
||||
try {
|
||||
await invoke('reconnect_server')
|
||||
showSuccess('已连接到新服务器')
|
||||
// 刷新设备状态
|
||||
await loadDeviceInfo()
|
||||
} catch (reconnectErr: any) {
|
||||
error.value = '连接新服务器失败: ' + reconnectErr.toString()
|
||||
}
|
||||
} else {
|
||||
showSuccess('配置已保存')
|
||||
}
|
||||
} catch (e: any) {
|
||||
error.value = e.toString()
|
||||
} finally {
|
||||
@ -159,17 +174,6 @@ async function refreshCode() {
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleAllowRemote() {
|
||||
if (!deviceInfo.value) return
|
||||
try {
|
||||
const newValue = !deviceInfo.value.allow_remote
|
||||
await invoke('set_allow_remote', { allow: newValue })
|
||||
deviceInfo.value.allow_remote = newValue
|
||||
} catch (e) {
|
||||
console.error('设置失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAuth() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
@ -397,15 +401,7 @@ function getDeviceIcon(osType: string): string {
|
||||
<div class="hero-header">
|
||||
<div class="hero-title">
|
||||
<h2>本机设备</h2>
|
||||
<p>开启后,他人可通过本机ID和验证码远程协助您</p>
|
||||
</div>
|
||||
<div class="toggle-section">
|
||||
<span class="toggle-label">{{ deviceInfo?.allow_remote ? '已开启' : '已关闭' }}</span>
|
||||
<div
|
||||
class="switch"
|
||||
:class="{ active: deviceInfo?.allow_remote }"
|
||||
@click="toggleAllowRemote"
|
||||
></div>
|
||||
<p>他人可通过本机ID和验证码远程协助您</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -702,7 +698,7 @@ function getDeviceIcon(osType: string): string {
|
||||
placeholder="ws://服务器IP:端口"
|
||||
v-model="configForm.server_url"
|
||||
/>
|
||||
<p class="input-hint">示例: ws://192.168.1.100:8080 或 wss://example.com</p>
|
||||
<p class="input-hint">示例: ws://192.168.1.100:9099 或 wss://example.com</p>
|
||||
</div>
|
||||
|
||||
<div class="config-actions">
|
||||
|
||||
@ -4,7 +4,6 @@ export interface DeviceInfo {
|
||||
device_id: string;
|
||||
device_id_formatted: string;
|
||||
verification_code: string;
|
||||
allow_remote: boolean;
|
||||
device_name: string;
|
||||
os_type: string;
|
||||
}
|
||||
@ -22,7 +21,6 @@ export interface Device {
|
||||
os_type: string;
|
||||
os_version: string;
|
||||
is_online: boolean;
|
||||
allow_remote: boolean;
|
||||
last_seen: string;
|
||||
}
|
||||
|
||||
|
||||
@ -126,7 +126,7 @@ impl Config {
|
||||
Ok(Self {
|
||||
host: std::env::var("HOST").unwrap_or_else(|_| "0.0.0.0".to_string()),
|
||||
port: std::env::var("PORT")
|
||||
.unwrap_or_else(|_| "8080".to_string())
|
||||
.unwrap_or_else(|_| "9099".to_string())
|
||||
.parse()?,
|
||||
stun_port,
|
||||
turn_port,
|
||||
|
||||
@ -246,7 +246,7 @@ fn generate_default_env_config() -> String {
|
||||
|
||||
# Server Settings
|
||||
HOST=0.0.0.0
|
||||
PORT=8080
|
||||
PORT=9099
|
||||
|
||||
# STUN Server Settings
|
||||
ENABLE_LOCAL_STUN=true
|
||||
|
||||
@ -26,12 +26,6 @@ pub struct SetupStatusResponse {
|
||||
pub struct SetupInitRequest {
|
||||
pub admin_username: String,
|
||||
pub admin_password: String,
|
||||
pub jwt_secret: String,
|
||||
pub jwt_expiry: i64,
|
||||
pub stun_servers: String,
|
||||
pub turn_server: Option<String>,
|
||||
pub turn_username: Option<String>,
|
||||
pub turn_password: Option<String>,
|
||||
}
|
||||
|
||||
/// 初始化响应
|
||||
@ -148,38 +142,42 @@ pub async fn init_setup(
|
||||
}
|
||||
|
||||
/// 保存配置到 .env 文件
|
||||
fn save_env_config(req: &SetupInitRequest) {
|
||||
fn save_env_config(_req: &SetupInitRequest) {
|
||||
// 生成随机 JWT Secret
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
let timestamp = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let jwt_secret = format!("easyremote-jwt-secret-{}", timestamp);
|
||||
|
||||
let env_content = format!(
|
||||
r#"# EasyRemote 服务端配置(自动生成)
|
||||
r#"# EasyRemote Server Configuration (Auto Generated)
|
||||
|
||||
# 服务器配置
|
||||
# Server Settings
|
||||
HOST=0.0.0.0
|
||||
PORT=8080
|
||||
PORT=9099
|
||||
|
||||
# 数据库配置
|
||||
# Database Settings
|
||||
DATABASE_URL=sqlite:easyremote.db?mode=rwc
|
||||
|
||||
# JWT 配置
|
||||
# JWT Settings
|
||||
JWT_SECRET={}
|
||||
JWT_EXPIRY={}
|
||||
JWT_EXPIRY=86400
|
||||
|
||||
# STUN 服务器
|
||||
STUN_SERVERS={}
|
||||
# STUN/TURN Settings
|
||||
ENABLE_LOCAL_STUN=true
|
||||
STUN_PORT=3478
|
||||
ENABLE_LOCAL_TURN=false
|
||||
TURN_PORT=3479
|
||||
TURN_USERNAME=easyremote
|
||||
TURN_PASSWORD=easyremote123
|
||||
TURN_REALM=easyremote.local
|
||||
|
||||
# TURN 服务器
|
||||
{}
|
||||
{}
|
||||
{}
|
||||
|
||||
# 日志级别
|
||||
# Log Level
|
||||
RUST_LOG=info,tower_http=debug
|
||||
"#,
|
||||
req.jwt_secret,
|
||||
req.jwt_expiry,
|
||||
req.stun_servers,
|
||||
req.turn_server.as_ref().map(|s| format!("TURN_SERVER={}", s)).unwrap_or_else(|| "# TURN_SERVER=".to_string()),
|
||||
req.turn_username.as_ref().map(|s| format!("TURN_USERNAME={}", s)).unwrap_or_else(|| "# TURN_USERNAME=".to_string()),
|
||||
req.turn_password.as_ref().map(|s| format!("TURN_PASSWORD={}", s)).unwrap_or_else(|| "# TURN_PASSWORD=".to_string()),
|
||||
jwt_secret
|
||||
);
|
||||
|
||||
// 保存到 .env 文件
|
||||
|
||||
@ -145,6 +145,40 @@ pub enum SignalMessage {
|
||||
fps: u32,
|
||||
quality: u32,
|
||||
},
|
||||
/// 获取显示器列表
|
||||
#[serde(rename = "get_displays")]
|
||||
GetDisplays {
|
||||
session_id: String,
|
||||
from_device: String,
|
||||
to_device: String,
|
||||
},
|
||||
/// 显示器列表响应
|
||||
#[serde(rename = "displays_list")]
|
||||
DisplaysList {
|
||||
session_id: String,
|
||||
from_device: String,
|
||||
to_device: String,
|
||||
displays: Vec<DisplayInfoMsg>,
|
||||
current_display: usize,
|
||||
},
|
||||
/// 切换显示器
|
||||
#[serde(rename = "switch_display")]
|
||||
SwitchDisplay {
|
||||
session_id: String,
|
||||
from_device: String,
|
||||
to_device: String,
|
||||
display_index: usize,
|
||||
},
|
||||
}
|
||||
|
||||
/// 显示器信息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DisplayInfoMsg {
|
||||
pub index: usize,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub is_primary: bool,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// 信令WebSocket处理器
|
||||
@ -367,6 +401,23 @@ async fn handle_signal_message(state: &Arc<AppState>, device_id: &str, text: &st
|
||||
let _ = state.send_to_device(&to_device, text).await;
|
||||
}
|
||||
|
||||
// 转发获取显示器列表请求
|
||||
SignalMessage::GetDisplays { to_device, .. } => {
|
||||
let _ = state.send_to_device(&to_device, text).await;
|
||||
}
|
||||
|
||||
// 转发显示器列表响应
|
||||
SignalMessage::DisplaysList { to_device, ref displays, .. } => {
|
||||
tracing::info!("★★★ 转发显示器列表到 {}, 共 {} 个显示器", to_device, displays.len());
|
||||
let result = state.send_to_device(&to_device, text).await;
|
||||
tracing::info!("★★★ 转发结果: {}", result);
|
||||
}
|
||||
|
||||
// 转发切换显示器请求
|
||||
SignalMessage::SwitchDisplay { to_device, .. } => {
|
||||
let _ = state.send_to_device(&to_device, text).await;
|
||||
}
|
||||
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@ -414,9 +465,18 @@ async fn handle_remote_socket(socket: WebSocket, state: Arc<AppState>, device_id
|
||||
verification_code: String::new(), // 浏览器端需要管理员权限,跳过验证码
|
||||
};
|
||||
|
||||
let _ = state
|
||||
// 打印当前所有在线设备
|
||||
{
|
||||
let connections = state.connections.read().await;
|
||||
let online_devices: Vec<_> = connections.keys().collect();
|
||||
tracing::info!("★★★ 当前在线设备列表: {:?}", online_devices);
|
||||
tracing::info!("★★★ 尝试发送ConnectRequest到设备: {}", device_id);
|
||||
}
|
||||
|
||||
let send_result = state
|
||||
.send_to_device(&device_id, &serde_json::to_string(&connect_req).unwrap())
|
||||
.await;
|
||||
tracing::info!("★★★ 发送ConnectRequest结果: {}", send_result);
|
||||
|
||||
// 发送任务
|
||||
let send_task = tokio::spawn(async move {
|
||||
@ -429,13 +489,24 @@ async fn handle_remote_socket(socket: WebSocket, state: Arc<AppState>, device_id
|
||||
|
||||
// 接收任务
|
||||
let state_clone = state.clone();
|
||||
let _browser_id_clone = browser_device_id.clone();
|
||||
let browser_id_for_recv = browser_device_id.clone();
|
||||
let target_device_id = device_id.clone();
|
||||
let recv_task = tokio::spawn(async move {
|
||||
while let Some(Ok(msg)) = receiver.next().await {
|
||||
if let Message::Text(text) = msg {
|
||||
// 替换 from_device 为正确的浏览器ID
|
||||
let modified_text = if let Ok(mut json) = serde_json::from_str::<serde_json::Value>(&text) {
|
||||
if let Some(obj) = json.as_object_mut() {
|
||||
if obj.get("from_device").map(|v| v.as_str()) == Some(Some("browser")) {
|
||||
obj.insert("from_device".to_string(), serde_json::Value::String(browser_id_for_recv.clone()));
|
||||
}
|
||||
}
|
||||
serde_json::to_string(&json).unwrap_or(text)
|
||||
} else {
|
||||
text
|
||||
};
|
||||
// 转发消息到目标设备
|
||||
let _ = state_clone.send_to_device(&target_device_id, &text).await;
|
||||
let _ = state_clone.send_to_device(&target_device_id, &modified_text).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
@ -445,6 +516,13 @@ async fn handle_remote_socket(socket: WebSocket, state: Arc<AppState>, device_id
|
||||
_ = recv_task => {},
|
||||
}
|
||||
|
||||
// 发送 SessionEnd 消息给客户端,停止屏幕流
|
||||
let session_end = SignalMessage::SessionEnd {
|
||||
session_id: session_id.clone(),
|
||||
};
|
||||
let _ = state.send_to_device(&device_id, &serde_json::to_string(&session_end).unwrap()).await;
|
||||
tracing::info!("★★★ 浏览器断开连接,发送SessionEnd到设备: {}", device_id);
|
||||
|
||||
// 清理
|
||||
state.remove_connection(&browser_device_id).await;
|
||||
}
|
||||
|
||||
@ -6,18 +6,56 @@
|
||||
<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: #3b82f6;
|
||||
--primary-hover: #2563eb;
|
||||
--bg-primary: #0f172a;
|
||||
--bg-secondary: #1e293b;
|
||||
--bg-tertiary: #334155;
|
||||
--text-primary: #f1f5f9;
|
||||
/* 主色调 - 渐变紫蓝(与客户端一致) */
|
||||
--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;
|
||||
--border-color: #334155;
|
||||
--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;
|
||||
}
|
||||
|
||||
* {
|
||||
@ -31,19 +69,56 @@
|
||||
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: var(--bg-secondary);
|
||||
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 {
|
||||
@ -55,45 +130,89 @@
|
||||
margin-bottom: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
gap: 12px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.logo::before {
|
||||
content: '';
|
||||
width: 8px;
|
||||
height: 24px;
|
||||
background: var(--primary);
|
||||
border-radius: 2px;
|
||||
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: 12px 16px;
|
||||
border-radius: 8px;
|
||||
padding: 14px 16px;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
transition: all 0.25s ease;
|
||||
margin-bottom: 4px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.nav-item:hover,
|
||||
.nav-item.active {
|
||||
background: var(--bg-tertiary);
|
||||
.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: rgba(59, 130, 246, 0.15);
|
||||
color: var(--primary);
|
||||
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: 24px;
|
||||
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 {
|
||||
@ -101,11 +220,18 @@
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 32px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
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;
|
||||
}
|
||||
|
||||
/* 统计卡片 */
|
||||
@ -114,25 +240,52 @@
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 32px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--bg-secondary);
|
||||
background: var(--bg-card);
|
||||
backdrop-filter: blur(16px);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
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: 14px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 8px;
|
||||
margin-bottom: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 32px;
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
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 {
|
||||
@ -141,12 +294,31 @@
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* 表格 */
|
||||
/* 卡片 */
|
||||
.card {
|
||||
background: var(--bg-secondary);
|
||||
background: var(--bg-card);
|
||||
backdrop-filter: blur(16px);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
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 {
|
||||
@ -160,8 +332,20 @@
|
||||
.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;
|
||||
@ -175,9 +359,9 @@
|
||||
}
|
||||
|
||||
.table th {
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
@ -190,8 +374,12 @@
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.table tr {
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.table tr:hover {
|
||||
background: var(--bg-tertiary);
|
||||
background: var(--bg-glass);
|
||||
}
|
||||
|
||||
/* 状态标签 */
|
||||
@ -199,14 +387,14 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 12px;
|
||||
padding: 6px 14px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status.online {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
background: var(--success-bg);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
@ -216,8 +404,8 @@
|
||||
}
|
||||
|
||||
.status.active {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
color: var(--primary);
|
||||
background: var(--primary-bg);
|
||||
color: var(--primary-light);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
@ -225,44 +413,75 @@
|
||||
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: 8px 16px;
|
||||
padding: 10px 18px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
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: var(--primary);
|
||||
background: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%);
|
||||
color: white;
|
||||
box-shadow: 0 4px 16px var(--primary-glow);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--primary-hover);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 24px var(--primary-glow);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
background: transparent;
|
||||
border: 1px solid var(--error);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: rgba(239, 68, 68, 0.25);
|
||||
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(--border-color);
|
||||
background: var(--bg-card-hover);
|
||||
border-color: var(--border-light);
|
||||
}
|
||||
|
||||
/* 搜索框 */
|
||||
@ -271,8 +490,15 @@
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 8px;
|
||||
padding: 8px 16px;
|
||||
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 {
|
||||
@ -285,7 +511,7 @@
|
||||
}
|
||||
|
||||
.search-box input::placeholder {
|
||||
color: var(--text-secondary);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* 分页 */
|
||||
@ -297,20 +523,26 @@
|
||||
}
|
||||
|
||||
.pagination-btn {
|
||||
padding: 8px 14px;
|
||||
padding: 10px 16px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
transition: all 0.25s;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.pagination-btn:hover,
|
||||
.pagination-btn.active {
|
||||
background: var(--primary);
|
||||
.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);
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
@ -621,76 +853,109 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, var(--bg-primary) 0%, #0c1222 100%);
|
||||
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-secondary);
|
||||
background: var(--bg-card);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 16px;
|
||||
padding: 40px;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 48px;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
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: 32px;
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
|
||||
.login-logo {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 56px;
|
||||
margin-bottom: 20px;
|
||||
filter: drop-shadow(0 0 20px var(--primary-glow));
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
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: 20px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 8px;
|
||||
margin-bottom: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-tertiary);
|
||||
padding: 14px 18px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: all 0.2s;
|
||||
transition: all 0.25s;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
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: 14px;
|
||||
background: var(--primary);
|
||||
padding: 16px;
|
||||
background: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
transition: all 0.25s;
|
||||
box-shadow: 0 4px 16px var(--primary-glow);
|
||||
}
|
||||
|
||||
.login-btn:hover {
|
||||
background: var(--primary-hover);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 24px var(--primary-glow);
|
||||
}
|
||||
|
||||
/* 隐藏内容 */
|
||||
@ -946,8 +1211,36 @@
|
||||
</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">
|
||||
<div id="login-page" class="login-container hidden">
|
||||
<div class="login-card">
|
||||
<div class="login-header">
|
||||
<div class="login-logo">🔒</div>
|
||||
@ -1219,6 +1512,12 @@
|
||||
|
||||
<!-- 画质设置 -->
|
||||
<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">
|
||||
@ -1508,13 +1807,39 @@
|
||||
let authToken = localStorage.getItem('admin_token');
|
||||
|
||||
// 页面初始化
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
if (authToken) {
|
||||
checkAuth();
|
||||
}
|
||||
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 {
|
||||
@ -1536,6 +1861,9 @@
|
||||
|
||||
// 设置事件监听
|
||||
function setupEventListeners() {
|
||||
// 初始化表单
|
||||
document.getElementById('setup-form').addEventListener('submit', handleSetup);
|
||||
|
||||
// 登录表单
|
||||
document.getElementById('login-form').addEventListener('submit', handleLogin);
|
||||
|
||||
@ -1548,6 +1876,50 @@
|
||||
});
|
||||
}
|
||||
|
||||
// 处理初始化
|
||||
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();
|
||||
@ -1751,6 +2123,50 @@
|
||||
// 远程控制状态
|
||||
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() {
|
||||
@ -1786,6 +2202,12 @@
|
||||
el.addEventListener('change', sendSettingsUpdate);
|
||||
}
|
||||
});
|
||||
|
||||
// 显示器选择变化
|
||||
const displaySelect = document.getElementById('display-select');
|
||||
if (displaySelect) {
|
||||
displaySelect.addEventListener('change', switchDisplay);
|
||||
}
|
||||
});
|
||||
|
||||
// 加载在线设备(用于远程控制)
|
||||
@ -1891,6 +2313,11 @@
|
||||
quality: settings.quality
|
||||
}));
|
||||
|
||||
// 请求显示器列表
|
||||
setTimeout(() => {
|
||||
requestDisplaysList();
|
||||
}, 500);
|
||||
|
||||
// 显示连接成功信息
|
||||
remoteScreen.innerHTML = `
|
||||
<div class="remote-placeholder">
|
||||
@ -1991,6 +2418,10 @@
|
||||
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);
|
||||
|
||||
25
docker-compose.yml
Normal file
25
docker-compose.yml
Normal file
@ -0,0 +1,25 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
easyremote-server:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.server
|
||||
container_name: easyremote-server
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:8080" # HTTP/WebSocket
|
||||
- "3478:3478/udp" # STUN
|
||||
- "3479:3479/udp" # TURN
|
||||
volumes:
|
||||
- easyremote-data:/app/data
|
||||
environment:
|
||||
- RUST_LOG=info
|
||||
- JWT_SECRET=your-secret-key-change-this
|
||||
- ADMIN_USERNAME=admin
|
||||
- ADMIN_PASSWORD=admin123
|
||||
- ENABLE_LOCAL_STUN=true
|
||||
- ENABLE_LOCAL_TURN=true
|
||||
|
||||
volumes:
|
||||
easyremote-data:
|
||||
BIN
release/easyremote-server-linux-x86_64-musl.zip
Normal file
BIN
release/easyremote-server-linux-x86_64-musl.zip
Normal file
Binary file not shown.
BIN
release/linux/easyremote-server
Normal file
BIN
release/linux/easyremote-server
Normal file
Binary file not shown.
4
release/linux/start.sh
Normal file
4
release/linux/start.sh
Normal file
@ -0,0 +1,4 @@
|
||||
#!/bin/bash
|
||||
cd "$(dirname $0)"
|
||||
chmod +x easyremote-server
|
||||
./easyremote-server
|
||||
2826
release/linux/static/index.html
Normal file
2826
release/linux/static/index.html
Normal file
File diff suppressed because it is too large
Load Diff
BIN
release/windows-client/EasyRemote_0.1.0_x64_zh-CN.msi
Normal file
BIN
release/windows-client/EasyRemote_0.1.0_x64_zh-CN.msi
Normal file
Binary file not shown.
77
scripts/build-linux.sh
Normal file
77
scripts/build-linux.sh
Normal file
@ -0,0 +1,77 @@
|
||||
#!/bin/bash
|
||||
# EasyRemote Server Linux 构建脚本
|
||||
# 在 Linux 服务器上运行此脚本来构建服务端
|
||||
|
||||
set -e
|
||||
|
||||
echo "====================================="
|
||||
echo " EasyRemote Server Linux Build"
|
||||
echo "====================================="
|
||||
|
||||
# 检查 Rust 是否安装
|
||||
if ! command -v cargo &> /dev/null; then
|
||||
echo "Rust 未安装,正在安装..."
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
||||
source $HOME/.cargo/env
|
||||
fi
|
||||
|
||||
echo "Rust 版本: $(rustc --version)"
|
||||
echo "Cargo 版本: $(cargo --version)"
|
||||
|
||||
# 构建服务端
|
||||
echo ""
|
||||
echo "正在构建服务端..."
|
||||
cargo build --release --package easyremote-server
|
||||
|
||||
# 创建发布目录
|
||||
RELEASE_DIR="./release/linux"
|
||||
mkdir -p "$RELEASE_DIR"
|
||||
|
||||
# 复制文件
|
||||
cp target/release/easyremote-server "$RELEASE_DIR/"
|
||||
cp -r crates/server/static "$RELEASE_DIR/"
|
||||
|
||||
# 创建启动脚本
|
||||
cat > "$RELEASE_DIR/start.sh" << 'EOF'
|
||||
#!/bin/bash
|
||||
cd "$(dirname "$0")"
|
||||
./easyremote-server
|
||||
EOF
|
||||
chmod +x "$RELEASE_DIR/start.sh"
|
||||
|
||||
# 创建 systemd 服务文件
|
||||
cat > "$RELEASE_DIR/easyremote.service" << 'EOF'
|
||||
[Unit]
|
||||
Description=EasyRemote Server
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=/opt/easyremote
|
||||
ExecStart=/opt/easyremote/easyremote-server
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
echo ""
|
||||
echo "====================================="
|
||||
echo " 构建完成!"
|
||||
echo "====================================="
|
||||
echo ""
|
||||
echo "发布文件位于: $RELEASE_DIR/"
|
||||
echo ""
|
||||
echo "文件列表:"
|
||||
ls -la "$RELEASE_DIR/"
|
||||
echo ""
|
||||
echo "部署步骤:"
|
||||
echo "1. 复制 release/linux/ 目录到服务器 /opt/easyremote/"
|
||||
echo "2. 复制 easyremote.service 到 /etc/systemd/system/"
|
||||
echo "3. 运行: systemctl daemon-reload"
|
||||
echo "4. 运行: systemctl enable easyremote"
|
||||
echo "5. 运行: systemctl start easyremote"
|
||||
echo ""
|
||||
echo "或者直接运行: ./start.sh"
|
||||
@ -6,18 +6,56 @@
|
||||
<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: #3b82f6;
|
||||
--primary-hover: #2563eb;
|
||||
--bg-primary: #0f172a;
|
||||
--bg-secondary: #1e293b;
|
||||
--bg-tertiary: #334155;
|
||||
--text-primary: #f1f5f9;
|
||||
/* 主色调 - 渐变紫蓝(与客户端一致) */
|
||||
--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;
|
||||
--border-color: #334155;
|
||||
--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;
|
||||
}
|
||||
|
||||
* {
|
||||
@ -31,19 +69,56 @@
|
||||
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: var(--bg-secondary);
|
||||
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 {
|
||||
@ -55,45 +130,89 @@
|
||||
margin-bottom: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
gap: 12px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.logo::before {
|
||||
content: '';
|
||||
width: 8px;
|
||||
height: 24px;
|
||||
background: var(--primary);
|
||||
border-radius: 2px;
|
||||
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: 12px 16px;
|
||||
border-radius: 8px;
|
||||
padding: 14px 16px;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
transition: all 0.25s ease;
|
||||
margin-bottom: 4px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.nav-item:hover,
|
||||
.nav-item.active {
|
||||
background: var(--bg-tertiary);
|
||||
.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: rgba(59, 130, 246, 0.15);
|
||||
color: var(--primary);
|
||||
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: 24px;
|
||||
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 {
|
||||
@ -101,11 +220,18 @@
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 32px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
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;
|
||||
}
|
||||
|
||||
/* 统计卡片 */
|
||||
@ -114,25 +240,52 @@
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 32px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--bg-secondary);
|
||||
background: var(--bg-card);
|
||||
backdrop-filter: blur(16px);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
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: 14px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 8px;
|
||||
margin-bottom: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 32px;
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
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 {
|
||||
@ -141,12 +294,31 @@
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* 表格 */
|
||||
/* 卡片 */
|
||||
.card {
|
||||
background: var(--bg-secondary);
|
||||
background: var(--bg-card);
|
||||
backdrop-filter: blur(16px);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
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 {
|
||||
@ -160,8 +332,20 @@
|
||||
.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;
|
||||
@ -175,9 +359,9 @@
|
||||
}
|
||||
|
||||
.table th {
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
@ -190,8 +374,12 @@
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.table tr {
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.table tr:hover {
|
||||
background: var(--bg-tertiary);
|
||||
background: var(--bg-glass);
|
||||
}
|
||||
|
||||
/* 状态标签 */
|
||||
@ -199,14 +387,14 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 12px;
|
||||
padding: 6px 14px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status.online {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
background: var(--success-bg);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
@ -216,8 +404,8 @@
|
||||
}
|
||||
|
||||
.status.active {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
color: var(--primary);
|
||||
background: var(--primary-bg);
|
||||
color: var(--primary-light);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
@ -225,44 +413,75 @@
|
||||
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: 8px 16px;
|
||||
padding: 10px 18px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
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: var(--primary);
|
||||
background: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%);
|
||||
color: white;
|
||||
box-shadow: 0 4px 16px var(--primary-glow);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--primary-hover);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 24px var(--primary-glow);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
background: transparent;
|
||||
border: 1px solid var(--error);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: rgba(239, 68, 68, 0.25);
|
||||
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(--border-color);
|
||||
background: var(--bg-card-hover);
|
||||
border-color: var(--border-light);
|
||||
}
|
||||
|
||||
/* 搜索框 */
|
||||
@ -271,8 +490,15 @@
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 8px;
|
||||
padding: 8px 16px;
|
||||
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 {
|
||||
@ -285,7 +511,7 @@
|
||||
}
|
||||
|
||||
.search-box input::placeholder {
|
||||
color: var(--text-secondary);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* 分页 */
|
||||
@ -297,20 +523,26 @@
|
||||
}
|
||||
|
||||
.pagination-btn {
|
||||
padding: 8px 14px;
|
||||
padding: 10px 16px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
transition: all 0.25s;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.pagination-btn:hover,
|
||||
.pagination-btn.active {
|
||||
background: var(--primary);
|
||||
.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);
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
@ -621,76 +853,109 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, var(--bg-primary) 0%, #0c1222 100%);
|
||||
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-secondary);
|
||||
background: var(--bg-card);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 16px;
|
||||
padding: 40px;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 48px;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
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: 32px;
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
|
||||
.login-logo {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 56px;
|
||||
margin-bottom: 20px;
|
||||
filter: drop-shadow(0 0 20px var(--primary-glow));
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
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: 20px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 8px;
|
||||
margin-bottom: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-tertiary);
|
||||
padding: 14px 18px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: all 0.2s;
|
||||
transition: all 0.25s;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
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: 14px;
|
||||
background: var(--primary);
|
||||
padding: 16px;
|
||||
background: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
transition: all 0.25s;
|
||||
box-shadow: 0 4px 16px var(--primary-glow);
|
||||
}
|
||||
|
||||
.login-btn:hover {
|
||||
background: var(--primary-hover);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 24px var(--primary-glow);
|
||||
}
|
||||
|
||||
/* 隐藏内容 */
|
||||
@ -1219,6 +1484,12 @@
|
||||
|
||||
<!-- 画质设置 -->
|
||||
<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">
|
||||
@ -1751,6 +2022,50 @@
|
||||
// 远程控制状态
|
||||
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() {
|
||||
@ -1786,6 +2101,12 @@
|
||||
el.addEventListener('change', sendSettingsUpdate);
|
||||
}
|
||||
});
|
||||
|
||||
// 显示器选择变化
|
||||
const displaySelect = document.getElementById('display-select');
|
||||
if (displaySelect) {
|
||||
displaySelect.addEventListener('change', switchDisplay);
|
||||
}
|
||||
});
|
||||
|
||||
// 加载在线设备(用于远程控制)
|
||||
@ -1891,6 +2212,11 @@
|
||||
quality: settings.quality
|
||||
}));
|
||||
|
||||
// 请求显示器列表
|
||||
setTimeout(() => {
|
||||
requestDisplaysList();
|
||||
}, 500);
|
||||
|
||||
// 显示连接成功信息
|
||||
remoteScreen.innerHTML = `
|
||||
<div class="remote-placeholder">
|
||||
@ -1991,6 +2317,10 @@
|
||||
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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user