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:
Ethanfly 2026-01-05 15:33:10 +08:00
parent 8e6862dcb6
commit fd34c415f5
25 changed files with 4513 additions and 300 deletions

149
.github/workflows/build.yml vendored Normal file
View 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
View 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"]

View File

@ -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

View File

@ -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};

View File

@ -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,8 +222,15 @@ 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) {
on_message(signal_msg);
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)]);
}
}
}
}

View File

@ -45,6 +45,7 @@ anyhow = { workspace = true }
thiserror = { workspace = true }
dirs = "5.0"
image = { workspace = true }
once_cell = "1.19"
[features]
default = ["custom-protocol"]

View File

@ -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;
(s.resolution, s.fps, s.quality)
})
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;
1.0 / s.resolution
})
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,13 +412,17 @@ 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 {
start_screen_streaming(session_id_clone, from_device_clone, device_id_stream).await;
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);
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 {
let settings = get_stream_settings().await;
let mut s = settings.write().await;
s.resolution = resolution;
s.fps = fps;
s.quality = quality;
// 停止当前会话
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> {

View File

@ -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,

View File

@ -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,
}

View File

@ -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": {

View File

@ -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">

View File

@ -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;
}

View File

@ -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,

View File

@ -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

View File

@ -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 文件

View File

@ -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;
}

View File

@ -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
View 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:

Binary file not shown.

Binary file not shown.

4
release/linux/start.sh Normal file
View File

@ -0,0 +1,4 @@
#!/bin/bash
cd "$(dirname $0)"
chmod +x easyremote-server
./easyremote-server

File diff suppressed because it is too large Load Diff

Binary file not shown.

77
scripts/build-linux.sh Normal file
View 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"

View File

@ -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);