mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-04-30 01:46:37 +08:00
feat: CLI 改密、自定义 TLS、移动端适配与扩展校验
- 新增 one-kvm user set-password(交互式),改密后吊销该用户全部会话 - /api/config/web 支持 PEM 证书/密钥上传与清除,响应含 has_custom_cert - 移动端:ActionBar 溢出菜单、ATX/粘贴底部 Sheet、BrandMark 与控制台等响应式优化 - GOSTC:校验服务器地址非空,管理器启动条件与 HTTP 热更新一致 - RustDesk:中继密钥 relay_key 校验为标准 Base64 且解码后恰好 32 字节 - StatusCard、InfoBar:合并精简冗余状态信息
This commit is contained in:
@@ -126,6 +126,15 @@ impl SessionStore {
|
|||||||
Ok(result.rows_affected())
|
Ok(result.rows_affected())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Delete all sessions for a specific user
|
||||||
|
pub async fn delete_by_user_id(&self, user_id: &str) -> Result<u64> {
|
||||||
|
let result = sqlx::query("DELETE FROM sessions WHERE user_id = ?1")
|
||||||
|
.bind(user_id)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(result.rows_affected())
|
||||||
|
}
|
||||||
|
|
||||||
/// List all session IDs
|
/// List all session IDs
|
||||||
pub async fn list_ids(&self) -> Result<Vec<String>> {
|
pub async fn list_ids(&self) -> Result<Vec<String>> {
|
||||||
let rows: Vec<(String,)> = sqlx::query_as("SELECT id FROM sessions")
|
let rows: Vec<(String,)> = sqlx::query_as("SELECT id FROM sessions")
|
||||||
|
|||||||
@@ -703,7 +703,9 @@ impl StreamConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Web server configuration
|
/// Web server configuration persisted in the database (includes on-disk TLS paths).
|
||||||
|
///
|
||||||
|
/// The HTTP API for `/api/config/web` uses `WebConfigResponse` instead: no path fields, includes `has_custom_cert`.
|
||||||
#[typeshare]
|
#[typeshare]
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
|||||||
@@ -280,6 +280,9 @@ impl ExtensionManager {
|
|||||||
|
|
||||||
ExtensionId::Gostc => {
|
ExtensionId::Gostc => {
|
||||||
let c = &config.gostc;
|
let c = &config.gostc;
|
||||||
|
if c.addr.trim().is_empty() {
|
||||||
|
return Err("GOSTC server address is required".into());
|
||||||
|
}
|
||||||
if c.key.is_empty() {
|
if c.key.is_empty() {
|
||||||
return Err("GOSTC client key is required".into());
|
return Err("GOSTC client key is required".into());
|
||||||
}
|
}
|
||||||
@@ -291,10 +294,8 @@ impl ExtensionManager {
|
|||||||
args.push("--tls=true".to_string());
|
args.push("--tls=true".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add server address
|
// Server address (validated non-empty above)
|
||||||
if !c.addr.is_empty() {
|
args.extend(["-addr".to_string(), c.addr.trim().to_string()]);
|
||||||
args.extend(["-addr".to_string(), c.addr.clone()]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add client key
|
// Add client key
|
||||||
args.extend(["-key".to_string(), c.key.clone()]);
|
args.extend(["-key".to_string(), c.key.clone()]);
|
||||||
@@ -375,7 +376,11 @@ impl ExtensionManager {
|
|||||||
.filter_map(|id| {
|
.filter_map(|id| {
|
||||||
let should_run = match id {
|
let should_run = match id {
|
||||||
ExtensionId::Ttyd => config.ttyd.enabled,
|
ExtensionId::Ttyd => config.ttyd.enabled,
|
||||||
ExtensionId::Gostc => config.gostc.enabled && !config.gostc.key.is_empty(),
|
ExtensionId::Gostc => {
|
||||||
|
config.gostc.enabled
|
||||||
|
&& !config.gostc.key.is_empty()
|
||||||
|
&& !config.gostc.addr.trim().is_empty()
|
||||||
|
}
|
||||||
ExtensionId::Easytier => {
|
ExtensionId::Easytier => {
|
||||||
config.easytier.enabled && !config.easytier.network_name.is_empty()
|
config.easytier.enabled && !config.easytier.network_name.is_empty()
|
||||||
}
|
}
|
||||||
@@ -435,6 +440,7 @@ impl ExtensionManager {
|
|||||||
|
|
||||||
if config.gostc.enabled
|
if config.gostc.enabled
|
||||||
&& !config.gostc.key.is_empty()
|
&& !config.gostc.key.is_empty()
|
||||||
|
&& !config.gostc.addr.trim().is_empty()
|
||||||
&& self.check_available(ExtensionId::Gostc)
|
&& self.check_available(ExtensionId::Gostc)
|
||||||
{
|
{
|
||||||
start_futures.push(Box::pin(async {
|
start_futures.push(Box::pin(async {
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ impl Default for TtydConfig {
|
|||||||
pub struct GostcConfig {
|
pub struct GostcConfig {
|
||||||
/// Enable auto-start
|
/// Enable auto-start
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
/// Server address (e.g., gostc.mofeng.run)
|
/// Server address (hostname or IP)
|
||||||
pub addr: String,
|
pub addr: String,
|
||||||
/// Client key from GOSTC management panel
|
/// Client key from GOSTC management panel
|
||||||
#[serde(skip_serializing_if = "String::is_empty")]
|
#[serde(skip_serializing_if = "String::is_empty")]
|
||||||
@@ -134,7 +134,7 @@ impl Default for GostcConfig {
|
|||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
addr: "gostc.mofeng.run".to_string(),
|
addr: String::new(),
|
||||||
key: String::new(),
|
key: String::new(),
|
||||||
tls: true,
|
tls: true,
|
||||||
}
|
}
|
||||||
|
|||||||
108
src/main.rs
108
src/main.rs
@@ -1,10 +1,11 @@
|
|||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
use std::io::Write;
|
||||||
use std::net::{IpAddr, SocketAddr};
|
use std::net::{IpAddr, SocketAddr};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use axum_server::tls_rustls::RustlsConfig;
|
use axum_server::tls_rustls::RustlsConfig;
|
||||||
use clap::{Parser, ValueEnum};
|
use clap::{Args, Parser, Subcommand, ValueEnum};
|
||||||
use futures::{stream::FuturesUnordered, StreamExt};
|
use futures::{stream::FuturesUnordered, StreamExt};
|
||||||
use rustls::crypto::{ring, CryptoProvider};
|
use rustls::crypto::{ring, CryptoProvider};
|
||||||
use tokio::sync::{broadcast, mpsc};
|
use tokio::sync::{broadcast, mpsc};
|
||||||
@@ -49,6 +50,10 @@ enum LogLevel {
|
|||||||
#[command(name = "one-kvm")]
|
#[command(name = "one-kvm")]
|
||||||
#[command(version, about = "A open and lightweight IP-KVM solution", long_about = None)]
|
#[command(version, about = "A open and lightweight IP-KVM solution", long_about = None)]
|
||||||
struct CliArgs {
|
struct CliArgs {
|
||||||
|
/// User management commands
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: Option<CliCommand>,
|
||||||
|
|
||||||
/// Listen address (overrides database config)
|
/// Listen address (overrides database config)
|
||||||
#[arg(short = 'a', long, value_name = "ADDRESS")]
|
#[arg(short = 'a', long, value_name = "ADDRESS")]
|
||||||
address: Option<String>,
|
address: Option<String>,
|
||||||
@@ -86,6 +91,24 @@ struct CliArgs {
|
|||||||
verbose: u8,
|
verbose: u8,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand, Debug)]
|
||||||
|
enum CliCommand {
|
||||||
|
/// Manage local users
|
||||||
|
User(UserCommand),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args, Debug)]
|
||||||
|
struct UserCommand {
|
||||||
|
#[command(subcommand)]
|
||||||
|
action: UserAction,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand, Debug)]
|
||||||
|
enum UserAction {
|
||||||
|
/// Set password for the single local user (interactive terminal prompt)
|
||||||
|
SetPassword,
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> anyhow::Result<()> {
|
async fn main() -> anyhow::Result<()> {
|
||||||
// Parse command line arguments
|
// Parse command line arguments
|
||||||
@@ -101,9 +124,15 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
tracing::info!("Starting One-KVM v{}", env!("CARGO_PKG_VERSION"));
|
tracing::info!("Starting One-KVM v{}", env!("CARGO_PKG_VERSION"));
|
||||||
|
|
||||||
// Determine data directory (CLI arg takes precedence)
|
// Determine data directory (CLI arg takes precedence)
|
||||||
let data_dir = args.data_dir.unwrap_or_else(get_data_dir);
|
let data_dir = args.data_dir.clone().unwrap_or_else(get_data_dir);
|
||||||
tracing::info!("Data directory: {}", data_dir.display());
|
tracing::info!("Data directory: {}", data_dir.display());
|
||||||
|
|
||||||
|
// Run one-off CLI command and exit.
|
||||||
|
if let Some(command) = args.command {
|
||||||
|
run_cli_command(command, data_dir).await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure data directory exists
|
// Ensure data directory exists
|
||||||
tokio::fs::create_dir_all(&data_dir).await?;
|
tokio::fs::create_dir_all(&data_dir).await?;
|
||||||
|
|
||||||
@@ -765,6 +794,81 @@ fn get_data_dir() -> PathBuf {
|
|||||||
PathBuf::from("/etc/one-kvm")
|
PathBuf::from("/etc/one-kvm")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn run_cli_command(command: CliCommand, data_dir: PathBuf) -> anyhow::Result<()> {
|
||||||
|
tokio::fs::create_dir_all(&data_dir).await?;
|
||||||
|
let db_path = data_dir.join("one-kvm.db");
|
||||||
|
let config_store = ConfigStore::new(&db_path).await?;
|
||||||
|
let users = UserStore::new(config_store.pool().clone());
|
||||||
|
let sessions = SessionStore::new(config_store.pool().clone(), 0);
|
||||||
|
|
||||||
|
match command {
|
||||||
|
CliCommand::User(user) => run_user_action(user.action, &users, &sessions).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_user_action(
|
||||||
|
action: UserAction,
|
||||||
|
users: &UserStore,
|
||||||
|
sessions: &SessionStore,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
match action {
|
||||||
|
UserAction::SetPassword => set_user_password(users, sessions).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_user_password(users: &UserStore, sessions: &SessionStore) -> anyhow::Result<()> {
|
||||||
|
let all = users.list().await?;
|
||||||
|
let user = match all.len() {
|
||||||
|
0 => anyhow::bail!("No local user exists yet; complete setup in the web UI first."),
|
||||||
|
1 => &all[0],
|
||||||
|
_ => anyhow::bail!(
|
||||||
|
"Expected exactly one local user (single-user design), found {}. Remove extra users from the database or contact support.",
|
||||||
|
all.len()
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
let new_password = read_new_password_interactive()?;
|
||||||
|
if new_password.len() < 4 {
|
||||||
|
anyhow::bail!("Password must be at least 4 characters");
|
||||||
|
}
|
||||||
|
|
||||||
|
users.update_password(&user.id, &new_password).await?;
|
||||||
|
let revoked = sessions.delete_by_user_id(&user.id).await?;
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
"Password updated for user '{}' and {} sessions revoked",
|
||||||
|
user.username,
|
||||||
|
revoked
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"Password updated for user '{}' (revoked {} sessions).",
|
||||||
|
user.username, revoked
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_new_password_interactive() -> anyhow::Result<String> {
|
||||||
|
let once = |label: &str| -> anyhow::Result<String> {
|
||||||
|
print!("{}", label);
|
||||||
|
std::io::stdout().flush()?;
|
||||||
|
|
||||||
|
let mut line = String::new();
|
||||||
|
std::io::stdin().read_line(&mut line)?;
|
||||||
|
let s = line.trim_end_matches(['\r', '\n']).to_string();
|
||||||
|
if s.is_empty() {
|
||||||
|
anyhow::bail!("Password cannot be empty");
|
||||||
|
}
|
||||||
|
Ok(s)
|
||||||
|
};
|
||||||
|
|
||||||
|
let a = once("New password: ")?;
|
||||||
|
let b = once("Confirm password: ")?;
|
||||||
|
if a != b {
|
||||||
|
anyhow::bail!("Passwords do not match");
|
||||||
|
}
|
||||||
|
Ok(a)
|
||||||
|
}
|
||||||
|
|
||||||
/// Resolve bind IPs from config, preferring bind_addresses when set.
|
/// Resolve bind IPs from config, preferring bind_addresses when set.
|
||||||
fn resolve_bind_addresses(web: &config::WebConfig) -> anyhow::Result<Vec<IpAddr>> {
|
fn resolve_bind_addresses(web: &config::WebConfig) -> anyhow::Result<Vec<IpAddr>> {
|
||||||
let raw_addrs = if !web.bind_addresses.is_empty() {
|
let raw_addrs = if !web.bind_addresses.is_empty() {
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ use crate::error::AppError;
|
|||||||
use crate::rtsp::RtspServiceStatus;
|
use crate::rtsp::RtspServiceStatus;
|
||||||
use crate::rustdesk::config::RustDeskConfig;
|
use crate::rustdesk::config::RustDeskConfig;
|
||||||
use crate::video::encoder::BitratePreset;
|
use crate::video::encoder::BitratePreset;
|
||||||
use serde::Deserialize;
|
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use typeshare::typeshare;
|
use typeshare::typeshare;
|
||||||
|
|
||||||
@@ -660,6 +661,26 @@ impl AudioConfigUpdate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ===== RustDesk Config =====
|
// ===== RustDesk Config =====
|
||||||
|
|
||||||
|
/// hbbs/hbbr `-k` relay key: standard Base64 encoding of exactly 32 bytes (typically 44 chars with padding).
|
||||||
|
fn validate_rustdesk_relay_key(key: &str) -> Result<(), AppError> {
|
||||||
|
let decoded = STANDARD.decode(key.as_bytes()).map_err(|_| {
|
||||||
|
AppError::BadRequest(
|
||||||
|
"Relay key must be standard Base64 (32 raw bytes, e.g. hbbs/hbbr -k output)".into(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if decoded.len() != 32 {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
format!(
|
||||||
|
"Relay key must decode to exactly 32 bytes (got {} bytes after Base64 decode)",
|
||||||
|
decoded.len()
|
||||||
|
)
|
||||||
|
.into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[typeshare]
|
#[typeshare]
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct RustDeskConfigUpdate {
|
pub struct RustDeskConfigUpdate {
|
||||||
@@ -698,6 +719,12 @@ impl RustDeskConfigUpdate {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let Some(ref key) = self.relay_key {
|
||||||
|
let trimmed = key.trim();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
validate_rustdesk_relay_key(trimmed)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -716,10 +743,11 @@ impl RustDeskConfigUpdate {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
if let Some(ref key) = self.relay_key {
|
if let Some(ref key) = self.relay_key {
|
||||||
config.relay_key = if key.is_empty() {
|
let trimmed = key.trim();
|
||||||
|
config.relay_key = if trimmed.is_empty() {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
Some(key.clone())
|
Some(trimmed.to_string())
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if let Some(ref password) = self.device_password {
|
if let Some(ref password) = self.device_password {
|
||||||
@@ -849,6 +877,45 @@ impl RtspConfigUpdate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ===== Web Config =====
|
// ===== Web Config =====
|
||||||
|
|
||||||
|
/// Web server settings returned by `GET` / `PATCH /api/config/web`.
|
||||||
|
///
|
||||||
|
/// Public API shape: certificate paths on disk are not exposed. The full stored model is `WebConfig` in `config::schema`.
|
||||||
|
#[typeshare]
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct WebConfigResponse {
|
||||||
|
pub http_port: u16,
|
||||||
|
pub https_port: u16,
|
||||||
|
pub bind_addresses: Vec<String>,
|
||||||
|
pub bind_address: String,
|
||||||
|
pub https_enabled: bool,
|
||||||
|
/// Whether a custom TLS certificate is active (non-empty cert + key paths in stored config).
|
||||||
|
pub has_custom_cert: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WebConfigResponse {
|
||||||
|
pub fn from_stored(web: &WebConfig) -> Self {
|
||||||
|
let has_custom_cert = web
|
||||||
|
.ssl_cert_path
|
||||||
|
.as_deref()
|
||||||
|
.map(|p| !p.is_empty())
|
||||||
|
.unwrap_or(false)
|
||||||
|
&& web
|
||||||
|
.ssl_key_path
|
||||||
|
.as_deref()
|
||||||
|
.map(|p| !p.is_empty())
|
||||||
|
.unwrap_or(false);
|
||||||
|
Self {
|
||||||
|
http_port: web.http_port,
|
||||||
|
https_port: web.https_port,
|
||||||
|
bind_addresses: web.bind_addresses.clone(),
|
||||||
|
bind_address: web.bind_address.clone(),
|
||||||
|
https_enabled: web.https_enabled,
|
||||||
|
has_custom_cert,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[typeshare]
|
#[typeshare]
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct WebConfigUpdate {
|
pub struct WebConfigUpdate {
|
||||||
@@ -857,6 +924,12 @@ pub struct WebConfigUpdate {
|
|||||||
pub bind_addresses: Option<Vec<String>>,
|
pub bind_addresses: Option<Vec<String>>,
|
||||||
pub bind_address: Option<String>,
|
pub bind_address: Option<String>,
|
||||||
pub https_enabled: Option<bool>,
|
pub https_enabled: Option<bool>,
|
||||||
|
/// PEM-encoded certificate content (must be provided together with ssl_key_pem)
|
||||||
|
pub ssl_cert_pem: Option<String>,
|
||||||
|
/// PEM-encoded private key content (must be provided together with ssl_cert_pem)
|
||||||
|
pub ssl_key_pem: Option<String>,
|
||||||
|
/// Set to true to remove the custom certificate and revert to self-signed
|
||||||
|
pub clear_custom_cert: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WebConfigUpdate {
|
impl WebConfigUpdate {
|
||||||
@@ -883,6 +956,22 @@ impl WebConfigUpdate {
|
|||||||
return Err(AppError::BadRequest("Invalid bind address".into()));
|
return Err(AppError::BadRequest("Invalid bind address".into()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Cert and key must be provided together (cryptographic validity is checked in the
|
||||||
|
// handler via `RustlsConfig::from_pem`, same stack as the running HTTPS server).
|
||||||
|
match (&self.ssl_cert_pem, &self.ssl_key_pem) {
|
||||||
|
(Some(_cert), Some(_key)) => {}
|
||||||
|
(Some(_), None) => {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"ssl_key_pem is required when ssl_cert_pem is provided".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
(None, Some(_)) => {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"ssl_cert_pem is required when ssl_key_pem is provided".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
(None, None) => {}
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -907,6 +996,8 @@ impl WebConfigUpdate {
|
|||||||
if let Some(enabled) = self.https_enabled {
|
if let Some(enabled) = self.https_enabled {
|
||||||
config.https_enabled = enabled;
|
config.https_enabled = enabled;
|
||||||
}
|
}
|
||||||
|
// ssl_cert_pem, ssl_key_pem, clear_custom_cert are handled at the handler level
|
||||||
|
// (they require async file I/O before updating config paths)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -988,4 +1079,30 @@ mod tests {
|
|||||||
|
|
||||||
assert!(update.validate_with_current(¤t).is_err());
|
assert!(update.validate_with_current(¤t).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rustdesk_relay_key_accepts_hbbs_style_base64_32_bytes() {
|
||||||
|
let update = RustDeskConfigUpdate {
|
||||||
|
enabled: None,
|
||||||
|
rendezvous_server: None,
|
||||||
|
relay_server: None,
|
||||||
|
relay_key: Some("pLU0pEj2IZnNVKzrIO1pIdwGA3dOVJJLkFIYGOCGH1E=".to_string()),
|
||||||
|
device_password: None,
|
||||||
|
};
|
||||||
|
assert!(update.validate().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rustdesk_relay_key_rejects_non_32_byte_payload() {
|
||||||
|
// Standard Base64 for 16 zero bytes (not 32).
|
||||||
|
let not_32 = "AAAAAAAAAAAAAAAAAAAAAA==".to_string();
|
||||||
|
let update = RustDeskConfigUpdate {
|
||||||
|
enabled: None,
|
||||||
|
rendezvous_server: None,
|
||||||
|
relay_server: None,
|
||||||
|
relay_key: Some(not_32),
|
||||||
|
device_password: None,
|
||||||
|
};
|
||||||
|
assert!(update.validate().is_err());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,103 @@
|
|||||||
//! Web 服务器配置 Handler
|
//! Web 服务器配置 Handler
|
||||||
|
|
||||||
use axum::{extract::State, Json};
|
use axum::{extract::State, Json};
|
||||||
|
use axum_server::tls_rustls::RustlsConfig;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::config::WebConfig;
|
use crate::error::{AppError, Result};
|
||||||
use crate::error::Result;
|
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
use super::types::WebConfigUpdate;
|
use super::types::{WebConfigResponse, WebConfigUpdate};
|
||||||
|
|
||||||
/// 获取 Web 配置
|
/// 获取 Web 配置
|
||||||
pub async fn get_web_config(State(state): State<Arc<AppState>>) -> Json<WebConfig> {
|
pub async fn get_web_config(
|
||||||
Json(state.config.get().web.clone())
|
State(state): State<Arc<AppState>>,
|
||||||
|
) -> Json<WebConfigResponse> {
|
||||||
|
Json(WebConfigResponse::from_stored(&state.config.get().web))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 更新 Web 配置
|
/// 更新 Web 配置(支持 PEM 证书上传)
|
||||||
pub async fn update_web_config(
|
pub async fn update_web_config(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Json(req): Json<WebConfigUpdate>,
|
Json(req): Json<WebConfigUpdate>,
|
||||||
) -> Result<Json<WebConfig>> {
|
) -> Result<Json<WebConfigResponse>> {
|
||||||
req.validate()?;
|
req.validate()?;
|
||||||
|
|
||||||
|
// Determine certificate path changes (requires async file I/O before config update)
|
||||||
|
// Some(Some((cert, key))) = write new cert
|
||||||
|
// Some(None) = clear custom cert
|
||||||
|
// None = no cert change
|
||||||
|
let cert_path_update: Option<Option<(String, String)>> =
|
||||||
|
if let (Some(cert_pem), Some(key_pem)) = (&req.ssl_cert_pem, &req.ssl_key_pem) {
|
||||||
|
RustlsConfig::from_pem(cert_pem.as_bytes().to_vec(), key_pem.as_bytes().to_vec())
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
AppError::BadRequest(
|
||||||
|
format!(
|
||||||
|
"Invalid TLS certificate or private key (PEM must match what the HTTPS server can load): {e}"
|
||||||
|
)
|
||||||
|
.into(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let cert_dir = state.data_dir().join("certs");
|
||||||
|
tokio::fs::create_dir_all(&cert_dir)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(format!("Failed to create cert dir: {e}")))?;
|
||||||
|
let cert_path = cert_dir.join("custom.crt");
|
||||||
|
let key_path = cert_dir.join("custom.key");
|
||||||
|
tokio::fs::write(&cert_path, cert_pem.as_bytes())
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(format!("Failed to write certificate: {e}")))?;
|
||||||
|
tokio::fs::write(&key_path, key_pem.as_bytes())
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(format!("Failed to write private key: {e}")))?;
|
||||||
|
Some(Some((
|
||||||
|
cert_path.to_string_lossy().into_owned(),
|
||||||
|
key_path.to_string_lossy().into_owned(),
|
||||||
|
)))
|
||||||
|
} else if req.clear_custom_cert.unwrap_or(false) {
|
||||||
|
let cert_dir = state.data_dir().join("certs");
|
||||||
|
let _ = tokio::fs::remove_file(cert_dir.join("custom.crt")).await;
|
||||||
|
let _ = tokio::fs::remove_file(cert_dir.join("custom.key")).await;
|
||||||
|
Some(None)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
state
|
state
|
||||||
.config
|
.config
|
||||||
.update(|config| {
|
.update(move |config| {
|
||||||
req.apply_to(&mut config.web);
|
req.apply_to(&mut config.web);
|
||||||
|
match cert_path_update {
|
||||||
|
Some(Some((cert_path, key_path))) => {
|
||||||
|
config.web.ssl_cert_path = Some(cert_path);
|
||||||
|
config.web.ssl_key_path = Some(key_path);
|
||||||
|
}
|
||||||
|
Some(None) => {
|
||||||
|
config.web.ssl_cert_path = None;
|
||||||
|
config.web.ssl_key_path = None;
|
||||||
|
}
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(Json(state.config.get().web.clone()))
|
Ok(Json(WebConfigResponse::from_stored(&state.config.get().web)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use rustls::crypto::{ring, CryptoProvider};
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rustls_accepts_rcgen_self_signed_pem() {
|
||||||
|
let _ = CryptoProvider::install_default(ring::default_provider());
|
||||||
|
let cert = rcgen::generate_simple_self_signed(vec!["localhost".into()]).unwrap();
|
||||||
|
let cert_pem = cert.cert.pem();
|
||||||
|
let key_pem = cert.signing_key.serialize_pem();
|
||||||
|
RustlsConfig::from_pem(cert_pem.into_bytes(), key_pem.into_bytes())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -256,12 +256,14 @@ pub async fn update_gostc_config(
|
|||||||
let new_config = state.config.get();
|
let new_config = state.config.get();
|
||||||
let is_enabled = new_config.extensions.gostc.enabled;
|
let is_enabled = new_config.extensions.gostc.enabled;
|
||||||
let has_key = !new_config.extensions.gostc.key.is_empty();
|
let has_key = !new_config.extensions.gostc.key.is_empty();
|
||||||
|
let has_addr = !new_config.extensions.gostc.addr.trim().is_empty();
|
||||||
|
|
||||||
if was_enabled && !is_enabled {
|
if was_enabled && !is_enabled {
|
||||||
state.extensions.stop(ExtensionId::Gostc).await.ok();
|
state.extensions.stop(ExtensionId::Gostc).await.ok();
|
||||||
} else if !was_enabled
|
} else if !was_enabled
|
||||||
&& is_enabled
|
&& is_enabled
|
||||||
&& has_key
|
&& has_key
|
||||||
|
&& has_addr
|
||||||
&& state.extensions.check_available(ExtensionId::Gostc)
|
&& state.extensions.check_available(ExtensionId::Gostc)
|
||||||
{
|
{
|
||||||
state
|
state
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ import type {
|
|||||||
GostcConfigUpdate,
|
GostcConfigUpdate,
|
||||||
EasytierConfig,
|
EasytierConfig,
|
||||||
EasytierConfigUpdate,
|
EasytierConfigUpdate,
|
||||||
|
WebConfigResponse,
|
||||||
|
WebConfigUpdate,
|
||||||
} from '@/types/generated'
|
} from '@/types/generated'
|
||||||
|
|
||||||
import { request } from './request'
|
import { request } from './request'
|
||||||
@@ -384,36 +386,24 @@ export const rtspConfigApi = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ===== Web 服务器配置 API =====
|
// ===== Web 服务器配置 API =====
|
||||||
|
// `/config/web` 使用 `WebConfigResponse` / `WebConfigUpdate`(由 typeshare 自 Rust 生成)。
|
||||||
|
|
||||||
/** Web 服务器配置 */
|
/** REST `/config/web` 响应(`WebConfigResponse` 别名,兼容旧命名) */
|
||||||
export interface WebConfig {
|
export type WebConfig = WebConfigResponse
|
||||||
http_port: number
|
|
||||||
https_port: number
|
|
||||||
bind_addresses: string[]
|
|
||||||
bind_address: string
|
|
||||||
https_enabled: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Web 服务器配置更新 */
|
export type { WebConfigUpdate }
|
||||||
export interface WebConfigUpdate {
|
|
||||||
http_port?: number
|
|
||||||
https_port?: number
|
|
||||||
bind_addresses?: string[]
|
|
||||||
bind_address?: string
|
|
||||||
https_enabled?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export const webConfigApi = {
|
export const webConfigApi = {
|
||||||
/**
|
/**
|
||||||
* 获取 Web 服务器配置
|
* 获取 Web 服务器配置
|
||||||
*/
|
*/
|
||||||
get: () => request<WebConfig>('/config/web'),
|
get: () => request<WebConfigResponse>('/config/web'),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 更新 Web 服务器配置
|
* 更新 Web 服务器配置(含可选的证书上传)
|
||||||
*/
|
*/
|
||||||
update: (config: WebConfigUpdate) =>
|
update: (config: WebConfigUpdate) =>
|
||||||
request<WebConfig>('/config/web', {
|
request<WebConfigResponse>('/config/web', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: JSON.stringify(config),
|
body: JSON.stringify(config),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -8,10 +8,14 @@ const API_BASE = '/api'
|
|||||||
// Auth API
|
// Auth API
|
||||||
export const authApi = {
|
export const authApi = {
|
||||||
login: (username: string, password: string) =>
|
login: (username: string, password: string) =>
|
||||||
request<{ success: boolean; message?: string }>('/auth/login', {
|
request<{ success: boolean; message?: string }>(
|
||||||
|
'/auth/login',
|
||||||
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ username, password }),
|
body: JSON.stringify({ username, password }),
|
||||||
}),
|
},
|
||||||
|
{ toastOnError: false },
|
||||||
|
),
|
||||||
|
|
||||||
logout: () =>
|
logout: () =>
|
||||||
request<{ success: boolean }>('/auth/logout', { method: 'POST' }),
|
request<{ success: boolean }>('/auth/logout', { method: 'POST' }),
|
||||||
@@ -688,6 +692,7 @@ export {
|
|||||||
type RtspConfigUpdate,
|
type RtspConfigUpdate,
|
||||||
type RtspStatusResponse,
|
type RtspStatusResponse,
|
||||||
type WebConfig,
|
type WebConfig,
|
||||||
|
type WebConfigUpdate,
|
||||||
} from './config'
|
} from './config'
|
||||||
|
|
||||||
// 导出生成的类型
|
// 导出生成的类型
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu'
|
} from '@/components/ui/dropdown-menu'
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
} from '@/components/ui/sheet'
|
||||||
import {
|
import {
|
||||||
ClipboardPaste,
|
ClipboardPaste,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
@@ -74,6 +80,7 @@ const emit = defineEmits<{
|
|||||||
(e: 'openTerminal'): void
|
(e: 'openTerminal'): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
// Desktop toolbar popover/dialog state
|
||||||
const pasteOpen = ref(false)
|
const pasteOpen = ref(false)
|
||||||
const atxOpen = ref(false)
|
const atxOpen = ref(false)
|
||||||
const videoPopoverOpen = ref(false)
|
const videoPopoverOpen = ref(false)
|
||||||
@@ -81,13 +88,52 @@ const hidPopoverOpen = ref(false)
|
|||||||
const audioPopoverOpen = ref(false)
|
const audioPopoverOpen = ref(false)
|
||||||
const msdDialogOpen = ref(false)
|
const msdDialogOpen = ref(false)
|
||||||
const extensionOpen = ref(false)
|
const extensionOpen = ref(false)
|
||||||
|
|
||||||
|
// Mobile Sheet state — opened from the overflow menu.
|
||||||
|
// We use Sheet (bottom drawer) instead of Popover because Popover relies on an
|
||||||
|
// anchor element that is hidden / clipped on small screens, causing it to
|
||||||
|
// immediately close after opening.
|
||||||
|
const mobileAtxOpen = ref(false)
|
||||||
|
const mobilePasteOpen = ref(false)
|
||||||
|
|
||||||
|
// Timestamps used to suppress spurious "interact-outside" events that arrive
|
||||||
|
// within ~300 ms of the Sheet opening (e.g. delayed synthetic pointer events
|
||||||
|
// from the same touch gesture that opened the overflow menu).
|
||||||
|
const mobileAtxOpenTime = ref(0)
|
||||||
|
const mobilePasteOpenTime = ref(0)
|
||||||
|
|
||||||
|
const OPEN_GUARD_MS = 350
|
||||||
|
|
||||||
|
const guardOutside = (openTime: number, e: Event) => {
|
||||||
|
if (Date.now() - openTime < OPEN_GUARD_MS) {
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// On mobile, clicking a DropdownMenuItem generates pointer events that can
|
||||||
|
// immediately dismiss any overlay opened in the same tick. Close the dropdown
|
||||||
|
// first, then open the target after a short delay.
|
||||||
|
const openFromOverflow = (setter: () => void) => {
|
||||||
|
overflowMenuOpen.value = false
|
||||||
|
setTimeout(setter, 50)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openMobileAtx = () => openFromOverflow(() => {
|
||||||
|
mobileAtxOpen.value = true
|
||||||
|
mobileAtxOpenTime.value = Date.now()
|
||||||
|
})
|
||||||
|
|
||||||
|
const openMobilePaste = () => openFromOverflow(() => {
|
||||||
|
mobilePasteOpen.value = true
|
||||||
|
mobilePasteOpenTime.value = Date.now()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="w-full border-b border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900">
|
<div class="w-full border-b border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900">
|
||||||
<div class="flex flex-wrap items-center gap-x-2 gap-y-2 px-4 py-1.5">
|
<div class="flex items-center px-2 sm:px-4 py-1 sm:py-1.5">
|
||||||
<!-- Left side buttons -->
|
<!-- Left side buttons — overflow hidden so it never pushes into right side -->
|
||||||
<div class="flex flex-wrap items-center gap-1.5 w-full sm:flex-1 sm:min-w-0">
|
<div class="flex items-center gap-0.5 sm:gap-1.5 flex-1 min-w-0 overflow-hidden">
|
||||||
<!-- Video Config - Always visible -->
|
<!-- Video Config - Always visible -->
|
||||||
<VideoConfigPopover
|
<VideoConfigPopover
|
||||||
v-model:open="videoPopoverOpen"
|
v-model:open="videoPopoverOpen"
|
||||||
@@ -95,7 +141,7 @@ const extensionOpen = ref(false)
|
|||||||
@update:video-mode="emit('update:videoMode', $event)"
|
@update:video-mode="emit('update:videoMode', $event)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Audio Config - Always visible -->
|
<!-- Audio Config - Always visible (xs shows icon only) -->
|
||||||
<AudioConfigPopover v-model:open="audioPopoverOpen" />
|
<AudioConfigPopover v-model:open="audioPopoverOpen" />
|
||||||
|
|
||||||
<!-- HID Config - Always visible -->
|
<!-- HID Config - Always visible -->
|
||||||
@@ -105,14 +151,14 @@ const extensionOpen = ref(false)
|
|||||||
@update:mouse-mode="emit('toggleMouseMode')"
|
@update:mouse-mode="emit('toggleMouseMode')"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Virtual Media (MSD) - Hidden on small screens, shown in overflow -->
|
<!-- Virtual Media (MSD) - Hidden below md, shown in overflow -->
|
||||||
<!-- Also hidden when HID backend is CH9329 (no USB gadget support) -->
|
<div v-if="showMsd" class="hidden md:block">
|
||||||
<TooltipProvider v-if="showMsd" class="hidden sm:block">
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger as-child>
|
<TooltipTrigger as-child>
|
||||||
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs" @click="msdDialogOpen = true">
|
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs" @click="msdDialogOpen = true">
|
||||||
<HardDrive class="h-4 w-4" />
|
<HardDrive class="h-4 w-4" />
|
||||||
<span class="hidden md:inline">{{ t('actionbar.virtualMedia') }}</span>
|
<span class="hidden lg:inline">{{ t('actionbar.virtualMedia') }}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
@@ -120,16 +166,18 @@ const extensionOpen = ref(false)
|
|||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ATX Power Control - Hidden on small screens -->
|
<!-- ATX Power Control - Hidden below md; shown as Sheet on mobile -->
|
||||||
<Popover v-model:open="atxOpen" class="hidden sm:block">
|
<div class="hidden md:block">
|
||||||
|
<Popover v-model:open="atxOpen">
|
||||||
<PopoverTrigger as-child>
|
<PopoverTrigger as-child>
|
||||||
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs">
|
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs">
|
||||||
<Power class="h-4 w-4" />
|
<Power class="h-4 w-4" />
|
||||||
<span class="hidden md:inline">{{ t('actionbar.power') }}</span>
|
<span class="hidden lg:inline">{{ t('actionbar.power') }}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent class="w-[280px] p-0" align="start">
|
<PopoverContent class="w-[min(280px,90vw)] p-0" align="start">
|
||||||
<AtxPopover
|
<AtxPopover
|
||||||
@close="atxOpen = false"
|
@close="atxOpen = false"
|
||||||
@power-short="emit('powerShort')"
|
@power-short="emit('powerShort')"
|
||||||
@@ -139,29 +187,33 @@ const extensionOpen = ref(false)
|
|||||||
/>
|
/>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Paste Text - Hidden on small screens -->
|
<!-- Paste Text - Hidden below lg; shown as Sheet on mobile -->
|
||||||
<Popover v-model:open="pasteOpen" class="hidden md:block">
|
<div class="hidden lg:block">
|
||||||
|
<Popover v-model:open="pasteOpen">
|
||||||
<PopoverTrigger as-child>
|
<PopoverTrigger as-child>
|
||||||
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs">
|
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs">
|
||||||
<ClipboardPaste class="h-4 w-4" />
|
<ClipboardPaste class="h-4 w-4" />
|
||||||
<span class="hidden lg:inline">{{ t('actionbar.paste') }}</span>
|
<span class="hidden xl:inline">{{ t('actionbar.paste') }}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent class="w-[400px] p-0" align="start">
|
<PopoverContent class="w-[min(400px,90vw)] p-0" align="start">
|
||||||
<PasteModal @close="pasteOpen = false" />
|
<PasteModal @close="pasteOpen = false" />
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Right side buttons -->
|
<!-- Right side buttons — always shrink-0, never compressed -->
|
||||||
<div class="flex items-center gap-1.5 w-full justify-end sm:w-auto sm:ml-auto shrink-0">
|
<div class="flex items-center gap-0.5 sm:gap-1.5 shrink-0 ml-1 sm:ml-2">
|
||||||
<!-- Extension Menu - Hidden on small screens -->
|
<!-- Extension Menu - Hidden below xl -->
|
||||||
<Popover v-model:open="extensionOpen" class="hidden lg:block">
|
<div class="hidden xl:block">
|
||||||
|
<Popover v-model:open="extensionOpen">
|
||||||
<PopoverTrigger as-child>
|
<PopoverTrigger as-child>
|
||||||
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs">
|
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs">
|
||||||
<Cable class="h-4 w-4" />
|
<Cable class="h-4 w-4" />
|
||||||
<span class="hidden xl:inline">{{ t('actionbar.extension') }}</span>
|
{{ t('actionbar.extension') }}
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent class="w-48 p-1" align="start">
|
<PopoverContent class="w-48 p-1" align="start">
|
||||||
@@ -179,14 +231,16 @@ const extensionOpen = ref(false)
|
|||||||
</div>
|
</div>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Settings - Hidden on small screens -->
|
<!-- Settings - Hidden below xl -->
|
||||||
<TooltipProvider class="hidden lg:block">
|
<div class="hidden xl:block">
|
||||||
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger as-child>
|
<TooltipTrigger as-child>
|
||||||
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs" @click="router.push('/settings')">
|
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs" @click="router.push('/settings')">
|
||||||
<Settings class="h-4 w-4" />
|
<Settings class="h-4 w-4" />
|
||||||
<span class="hidden xl:inline">{{ t('actionbar.settings') }}</span>
|
{{ t('actionbar.settings') }}
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
@@ -194,9 +248,11 @@ const extensionOpen = ref(false)
|
|||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Connection Stats - Hidden on very small screens -->
|
<!-- Connection Stats - Hidden below md -->
|
||||||
<TooltipProvider class="hidden sm:block">
|
<div class="hidden md:block">
|
||||||
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger as-child>
|
<TooltipTrigger as-child>
|
||||||
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs" @click="emit('toggleStats')">
|
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs" @click="emit('toggleStats')">
|
||||||
@@ -209,8 +265,9 @@ const extensionOpen = ref(false)
|
|||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="h-5 w-px bg-slate-200 dark:bg-slate-700 hidden sm:block" />
|
<div class="h-5 w-px bg-slate-200 dark:bg-slate-700 hidden md:block" />
|
||||||
|
|
||||||
<!-- Virtual Keyboard - Always visible (important for mobile) -->
|
<!-- Virtual Keyboard - Always visible (important for mobile) -->
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
@@ -219,10 +276,10 @@ const extensionOpen = ref(false)
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="h-8 gap-1.5 text-xs"
|
class="h-7 w-7 sm:h-8 sm:w-auto p-0 sm:px-2 sm:gap-1.5 text-xs"
|
||||||
@click="emit('toggleVirtualKeyboard')"
|
@click="emit('toggleVirtualKeyboard')"
|
||||||
>
|
>
|
||||||
<Keyboard class="h-4 w-4" />
|
<Keyboard class="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
||||||
<span class="hidden xl:inline">{{ t('actionbar.keyboard') }}</span>
|
<span class="hidden xl:inline">{{ t('actionbar.keyboard') }}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
@@ -239,10 +296,10 @@ const extensionOpen = ref(false)
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="h-8 gap-1.5 text-xs"
|
class="h-7 w-7 sm:h-8 sm:w-auto p-0 sm:px-2 sm:gap-1.5 text-xs"
|
||||||
@click="emit('toggleFullscreen')"
|
@click="emit('toggleFullscreen')"
|
||||||
>
|
>
|
||||||
<Maximize class="h-4 w-4" />
|
<Maximize class="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
||||||
<span class="hidden xl:inline">{{ t('actionbar.fullscreen') }}</span>
|
<span class="hidden xl:inline">{{ t('actionbar.fullscreen') }}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
@@ -252,52 +309,52 @@ const extensionOpen = ref(false)
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|
||||||
<!-- Overflow Menu - Shows hidden items on small screens -->
|
<!-- Overflow Menu - Shows hidden items on smaller screens -->
|
||||||
<DropdownMenu v-model:open="overflowMenuOpen">
|
<DropdownMenu v-model:open="overflowMenuOpen">
|
||||||
<DropdownMenuTrigger as-child>
|
<DropdownMenuTrigger as-child>
|
||||||
<Button variant="ghost" size="sm" class="h-8 w-8 p-0 lg:hidden">
|
<Button variant="ghost" size="sm" class="h-7 w-7 sm:h-8 sm:w-8 p-0 xl:hidden">
|
||||||
<MoreHorizontal class="h-4 w-4" />
|
<MoreHorizontal class="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" class="w-48">
|
<DropdownMenuContent align="end" class="w-48">
|
||||||
<!-- MSD - Mobile only, hidden when CH9329 backend -->
|
<!-- MSD - Below md, hidden when CH9329 backend -->
|
||||||
<DropdownMenuItem v-if="showMsd" class="sm:hidden" @click="msdDialogOpen = true; overflowMenuOpen = false">
|
<DropdownMenuItem v-if="showMsd" class="md:hidden" @click="openFromOverflow(() => msdDialogOpen = true)">
|
||||||
<HardDrive class="h-4 w-4 mr-2" />
|
<HardDrive class="h-4 w-4 mr-2" />
|
||||||
{{ t('actionbar.virtualMedia') }}
|
{{ t('actionbar.virtualMedia') }}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<!-- ATX - Mobile only -->
|
<!-- ATX - Opens a Sheet on mobile (below md) -->
|
||||||
<DropdownMenuItem class="sm:hidden" @click="atxOpen = true; overflowMenuOpen = false">
|
<DropdownMenuItem class="md:hidden" @click="openMobileAtx">
|
||||||
<Power class="h-4 w-4 mr-2" />
|
<Power class="h-4 w-4 mr-2" />
|
||||||
{{ t('actionbar.power') }}
|
{{ t('actionbar.power') }}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<!-- Paste - Tablet and below -->
|
<!-- Paste - Opens a Sheet on mobile (below lg) -->
|
||||||
<DropdownMenuItem class="md:hidden" @click="pasteOpen = true; overflowMenuOpen = false">
|
<DropdownMenuItem class="lg:hidden" @click="openMobilePaste">
|
||||||
<ClipboardPaste class="h-4 w-4 mr-2" />
|
<ClipboardPaste class="h-4 w-4 mr-2" />
|
||||||
{{ t('actionbar.paste') }}
|
{{ t('actionbar.paste') }}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuSeparator class="lg:hidden" />
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
<!-- Stats - Mobile only -->
|
<!-- Stats - Below md -->
|
||||||
<DropdownMenuItem class="sm:hidden" @click="emit('toggleStats'); overflowMenuOpen = false">
|
<DropdownMenuItem class="md:hidden" @click="openFromOverflow(() => emit('toggleStats'))">
|
||||||
<BarChart3 class="h-4 w-4 mr-2" />
|
<BarChart3 class="h-4 w-4 mr-2" />
|
||||||
{{ t('actionbar.stats') }}
|
{{ t('actionbar.stats') }}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<!-- Extension - Tablet and below -->
|
<!-- Extension - Below xl -->
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
class="lg:hidden"
|
class="xl:hidden"
|
||||||
:disabled="!props.ttydRunning"
|
:disabled="!props.ttydRunning"
|
||||||
@click="emit('openTerminal'); overflowMenuOpen = false"
|
@click="openFromOverflow(() => emit('openTerminal'))"
|
||||||
>
|
>
|
||||||
<Terminal class="h-4 w-4 mr-2" />
|
<Terminal class="h-4 w-4 mr-2" />
|
||||||
{{ t('extensions.ttyd.title') }}
|
{{ t('extensions.ttyd.title') }}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<!-- Settings - Tablet and below -->
|
<!-- Settings - Below xl -->
|
||||||
<DropdownMenuItem class="lg:hidden" @click="router.push('/settings'); overflowMenuOpen = false">
|
<DropdownMenuItem class="xl:hidden" @click="openFromOverflow(() => router.push('/settings'))">
|
||||||
<Settings class="h-4 w-4 mr-2" />
|
<Settings class="h-4 w-4 mr-2" />
|
||||||
{{ t('actionbar.settings') }}
|
{{ t('actionbar.settings') }}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -309,4 +366,41 @@ const extensionOpen = ref(false)
|
|||||||
|
|
||||||
<!-- MSD Dialog -->
|
<!-- MSD Dialog -->
|
||||||
<MsdDialog v-if="showMsd" v-model:open="msdDialogOpen" />
|
<MsdDialog v-if="showMsd" v-model:open="msdDialogOpen" />
|
||||||
|
|
||||||
|
<!-- Mobile ATX Sheet — used when ATX is opened from the overflow menu.
|
||||||
|
A Sheet avoids the Popover anchor-positioning issues on mobile. -->
|
||||||
|
<Sheet v-model:open="mobileAtxOpen">
|
||||||
|
<SheetContent
|
||||||
|
side="bottom"
|
||||||
|
class="max-h-[90dvh] overflow-y-auto"
|
||||||
|
@pointer-down-outside="(e) => guardOutside(mobileAtxOpenTime, e)"
|
||||||
|
@interact-outside="(e) => guardOutside(mobileAtxOpenTime, e)"
|
||||||
|
>
|
||||||
|
<SheetHeader class="mb-2">
|
||||||
|
<SheetTitle>{{ t('actionbar.power') }}</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
<AtxPopover
|
||||||
|
@close="mobileAtxOpen = false"
|
||||||
|
@power-short="emit('powerShort')"
|
||||||
|
@power-long="emit('powerLong')"
|
||||||
|
@reset="emit('reset')"
|
||||||
|
@wol="(mac) => emit('wol', mac)"
|
||||||
|
/>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
|
||||||
|
<!-- Mobile Paste Sheet — used when Paste is opened from the overflow menu. -->
|
||||||
|
<Sheet v-model:open="mobilePasteOpen">
|
||||||
|
<SheetContent
|
||||||
|
side="bottom"
|
||||||
|
class="max-h-[90dvh] overflow-y-auto"
|
||||||
|
@pointer-down-outside="(e) => guardOutside(mobilePasteOpenTime, e)"
|
||||||
|
@interact-outside="(e) => guardOutside(mobilePasteOpenTime, e)"
|
||||||
|
>
|
||||||
|
<SheetHeader class="mb-2">
|
||||||
|
<SheetTitle>{{ t('actionbar.paste') }}</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
<PasteModal @close="mobilePasteOpen = false" />
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,21 +1,18 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { RouterLink, useRouter } from 'vue-router'
|
||||||
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useSystemStore } from '@/stores/system'
|
import { useSystemStore } from '@/stores/system'
|
||||||
import LanguageToggleButton from '@/components/LanguageToggleButton.vue'
|
import LanguageToggleButton from '@/components/LanguageToggleButton.vue'
|
||||||
|
import BrandMark from '@/components/BrandMark.vue'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu'
|
} from '@/components/ui/dropdown-menu'
|
||||||
import {
|
import {
|
||||||
Monitor,
|
|
||||||
Settings,
|
|
||||||
LogOut,
|
LogOut,
|
||||||
Sun,
|
Sun,
|
||||||
Moon,
|
Moon,
|
||||||
@@ -23,16 +20,10 @@ import {
|
|||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const route = useRoute()
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const systemStore = useSystemStore()
|
const systemStore = useSystemStore()
|
||||||
|
|
||||||
const navItems = computed(() => [
|
|
||||||
{ path: '/', name: 'Console', icon: Monitor, label: t('nav.console') },
|
|
||||||
{ path: '/settings', name: 'Settings', icon: Settings, label: t('nav.settings') },
|
|
||||||
])
|
|
||||||
|
|
||||||
function toggleTheme() {
|
function toggleTheme() {
|
||||||
const isDark = document.documentElement.classList.contains('dark')
|
const isDark = document.documentElement.classList.contains('dark')
|
||||||
document.documentElement.classList.toggle('dark', !isDark)
|
document.documentElement.classList.toggle('dark', !isDark)
|
||||||
@@ -49,38 +40,22 @@ async function handleLogout() {
|
|||||||
<div class="h-screen h-dvh flex flex-col bg-background overflow-hidden">
|
<div class="h-screen h-dvh flex flex-col bg-background overflow-hidden">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<header class="shrink-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
<header class="shrink-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||||
<div class="flex h-14 items-center px-4 max-w-full">
|
<div class="flex h-11 sm:h-14 items-center px-3 sm:px-4 max-w-full">
|
||||||
<!-- Logo -->
|
<!-- Logo -->
|
||||||
<RouterLink to="/" class="flex items-center gap-2 font-semibold">
|
<RouterLink to="/" class="flex items-center gap-1.5 sm:gap-2 font-semibold">
|
||||||
<Monitor class="h-5 w-5" />
|
<BrandMark size="sm" />
|
||||||
<span class="hidden sm:inline">One-KVM</span>
|
<span class="hidden sm:inline">One-KVM</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
|
||||||
<!-- Navigation -->
|
|
||||||
<nav class="hidden md:flex items-center gap-1 ml-6">
|
|
||||||
<RouterLink
|
|
||||||
v-for="item in navItems"
|
|
||||||
:key="item.path"
|
|
||||||
:to="item.path"
|
|
||||||
class="flex items-center gap-2 px-3 py-2 text-sm font-medium rounded-md transition-colors"
|
|
||||||
:class="route.path === item.path
|
|
||||||
? 'bg-accent text-accent-foreground'
|
|
||||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent/50'"
|
|
||||||
>
|
|
||||||
<component :is="item.icon" class="h-4 w-4" />
|
|
||||||
{{ item.label }}
|
|
||||||
</RouterLink>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<!-- Right Side -->
|
<!-- Right Side -->
|
||||||
<div class="flex items-center gap-2 ml-auto">
|
<div class="flex items-center gap-1 sm:gap-2 ml-auto">
|
||||||
<!-- Version Badge -->
|
<!-- Version Badge -->
|
||||||
<span v-if="systemStore.version" class="hidden sm:inline text-xs text-muted-foreground">
|
<span v-if="systemStore.version" class="hidden sm:inline text-xs text-muted-foreground">
|
||||||
v{{ systemStore.version }}
|
v{{ systemStore.version }}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<!-- Theme Toggle -->
|
<!-- Theme Toggle -->
|
||||||
<Button variant="ghost" size="icon" :aria-label="t('common.toggleTheme')" @click="toggleTheme">
|
<Button variant="ghost" size="icon" class="h-8 w-8" :aria-label="t('common.toggleTheme')" @click="toggleTheme">
|
||||||
<Sun class="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
<Sun class="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||||
<Moon class="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
<Moon class="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||||
<span class="sr-only">{{ t('common.toggleTheme') }}</span>
|
<span class="sr-only">{{ t('common.toggleTheme') }}</span>
|
||||||
@@ -92,16 +67,11 @@ async function handleLogout() {
|
|||||||
<!-- Mobile Menu -->
|
<!-- Mobile Menu -->
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger as-child class="md:hidden">
|
<DropdownMenuTrigger as-child class="md:hidden">
|
||||||
<Button variant="ghost" size="icon" :aria-label="t('common.menu')">
|
<Button variant="ghost" size="icon" class="h-8 w-8" :aria-label="t('common.menu')">
|
||||||
<Menu class="h-4 w-4" />
|
<Menu class="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem v-for="item in navItems" :key="item.path" @click="router.push(item.path)">
|
|
||||||
<component :is="item.icon" class="h-4 w-4 mr-2" />
|
|
||||||
{{ item.label }}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem @click="handleLogout">
|
<DropdownMenuItem @click="handleLogout">
|
||||||
<LogOut class="h-4 w-4 mr-2" />
|
<LogOut class="h-4 w-4 mr-2" />
|
||||||
{{ t('nav.logout') }}
|
{{ t('nav.logout') }}
|
||||||
@@ -110,7 +80,7 @@ async function handleLogout() {
|
|||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
||||||
<!-- Logout Button (Desktop) -->
|
<!-- Logout Button (Desktop) -->
|
||||||
<Button variant="ghost" size="icon" class="hidden md:flex" :aria-label="t('nav.logout')" @click="handleLogout">
|
<Button variant="ghost" size="icon" class="hidden md:flex h-8 w-8" :aria-label="t('nav.logout')" @click="handleLogout">
|
||||||
<LogOut class="h-4 w-4" />
|
<LogOut class="h-4 w-4" />
|
||||||
<span class="sr-only">{{ t('nav.logout') }}</span>
|
<span class="sr-only">{{ t('nav.logout') }}</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -169,12 +169,12 @@ watch(() => props.open, (isOpen) => {
|
|||||||
<template>
|
<template>
|
||||||
<Popover :open="open" @update:open="emit('update:open', $event)">
|
<Popover :open="open" @update:open="emit('update:open', $event)">
|
||||||
<PopoverTrigger as-child>
|
<PopoverTrigger as-child>
|
||||||
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs">
|
<Button variant="ghost" size="sm" class="h-7 w-7 sm:h-8 sm:w-auto p-0 sm:px-2 sm:gap-1.5 text-xs">
|
||||||
<Volume2 class="h-4 w-4" />
|
<Volume2 class="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
||||||
<span class="hidden sm:inline">{{ t('actionbar.audioConfig') }}</span>
|
<span class="hidden sm:inline">{{ t('actionbar.audioConfig') }}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent class="w-[320px] p-3" align="start">
|
<PopoverContent class="w-[min(320px,92vw)] p-3" align="start">
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<h4 class="text-sm font-medium">{{ t('actionbar.audioConfig') }}</h4>
|
<h4 class="text-sm font-medium">{{ t('actionbar.audioConfig') }}</h4>
|
||||||
|
|
||||||
|
|||||||
39
web/src/components/BrandMark.vue
Normal file
39
web/src/components/BrandMark.vue
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
|
||||||
|
class?: string
|
||||||
|
}>(),
|
||||||
|
{ size: 'md' },
|
||||||
|
)
|
||||||
|
|
||||||
|
const dim = computed(() => {
|
||||||
|
switch (props.size) {
|
||||||
|
case 'xs':
|
||||||
|
return 'h-4 w-4'
|
||||||
|
case 'sm':
|
||||||
|
return 'h-5 w-5'
|
||||||
|
case 'md':
|
||||||
|
return 'h-6 w-6'
|
||||||
|
case 'lg':
|
||||||
|
return 'h-10 w-10'
|
||||||
|
case 'xl':
|
||||||
|
return 'h-14 w-14'
|
||||||
|
default:
|
||||||
|
return 'h-6 w-6'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<img
|
||||||
|
src="/favicon.png"
|
||||||
|
alt=""
|
||||||
|
:class="cn('shrink-0 object-contain select-none', dim, props.class)"
|
||||||
|
decoding="async"
|
||||||
|
draggable="false"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
@@ -247,13 +247,13 @@ watch(() => props.open, (isOpen) => {
|
|||||||
<template>
|
<template>
|
||||||
<Popover :open="open" @update:open="emit('update:open', $event)">
|
<Popover :open="open" @update:open="emit('update:open', $event)">
|
||||||
<PopoverTrigger as-child>
|
<PopoverTrigger as-child>
|
||||||
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs">
|
<Button variant="ghost" size="sm" class="h-7 w-7 sm:h-8 sm:w-auto p-0 sm:px-2 sm:gap-1.5 text-xs">
|
||||||
<MousePointer v-if="mouseMode === 'absolute'" class="h-4 w-4" />
|
<MousePointer v-if="mouseMode === 'absolute'" class="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
||||||
<Move v-else class="h-4 w-4" />
|
<Move v-else class="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
||||||
<span class="hidden sm:inline">{{ buttonText }}</span>
|
<span class="hidden sm:inline">{{ buttonText }}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent class="w-[320px] p-3" align="start">
|
<PopoverContent class="w-[min(320px,92vw)] p-3" align="start">
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<h4 class="text-sm font-medium">{{ t('actionbar.hidConfig') }}</h4>
|
<h4 class="text-sm font-medium">{{ t('actionbar.hidConfig') }}</h4>
|
||||||
|
|
||||||
|
|||||||
@@ -42,15 +42,13 @@ const keysDisplay = computed(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="w-full border-t border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900">
|
<div class="w-full border-t border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900">
|
||||||
<!-- Compact mode for small screens -->
|
<!-- Compact mode (explicit prop or auto on small screens via sm:hidden) -->
|
||||||
<div v-if="compact" class="flex items-center justify-between text-xs px-2 py-0.5">
|
<div :class="compact ? '' : 'sm:hidden'">
|
||||||
<!-- LED indicator only in compact mode -->
|
<div class="flex items-center justify-between text-xs px-2 py-0.5">
|
||||||
<div v-if="keyboardLedEnabled" class="flex items-center gap-1">
|
<div v-if="keyboardLedEnabled" class="flex items-center gap-1">
|
||||||
<span
|
<span
|
||||||
v-if="capsLock"
|
:class="capsLock ? 'px-1.5 py-0.5 bg-primary/10 text-primary rounded text-[10px] font-medium' : 'text-muted-foreground/40 text-[10px]'"
|
||||||
class="px-1.5 py-0.5 bg-primary/10 text-primary rounded text-[10px] font-medium"
|
|
||||||
>C</span>
|
>C</span>
|
||||||
<span v-else class="text-muted-foreground/40 text-[10px]">C</span>
|
|
||||||
<span
|
<span
|
||||||
:class="numLock ? 'px-1.5 py-0.5 bg-primary/10 text-primary rounded text-[10px] font-medium' : 'text-muted-foreground/40 text-[10px]'"
|
:class="numLock ? 'px-1.5 py-0.5 bg-primary/10 text-primary rounded text-[10px] font-medium' : 'text-muted-foreground/40 text-[10px]'"
|
||||||
>N</span>
|
>N</span>
|
||||||
@@ -61,23 +59,22 @@ const keysDisplay = computed(() => {
|
|||||||
<div v-else class="text-[10px] text-muted-foreground/60">
|
<div v-else class="text-[10px] text-muted-foreground/60">
|
||||||
{{ t('infobar.keyboardLedUnavailable') }}
|
{{ t('infobar.keyboardLedUnavailable') }}
|
||||||
</div>
|
</div>
|
||||||
<!-- Keys in compact mode -->
|
<div v-if="keysDisplay" class="text-[10px] text-muted-foreground truncate max-w-[200px]">
|
||||||
<div v-if="keysDisplay" class="text-[10px] text-muted-foreground truncate max-w-[150px]">
|
|
||||||
{{ keysDisplay }}
|
{{ keysDisplay }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Normal mode -->
|
<!-- Normal mode (hidden on small screens unless compact is explicitly set) -->
|
||||||
<div v-else class="flex flex-wrap items-center justify-between text-xs">
|
<div :class="compact ? 'hidden' : 'hidden sm:block'">
|
||||||
|
<div class="flex flex-wrap items-center justify-between text-xs">
|
||||||
<!-- Left side: Debug info and pressed keys -->
|
<!-- Left side: Debug info and pressed keys -->
|
||||||
<div class="flex items-center gap-4 px-3 py-1 min-w-0 flex-1">
|
<div class="flex items-center gap-4 px-3 py-1 min-w-0 flex-1">
|
||||||
<!-- Pressed Keys -->
|
|
||||||
<div class="flex items-center gap-1.5 min-w-0">
|
<div class="flex items-center gap-1.5 min-w-0">
|
||||||
<span class="font-medium text-muted-foreground shrink-0 hidden sm:inline">{{ t('infobar.keys') }}:</span>
|
<span class="font-medium text-muted-foreground shrink-0">{{ t('infobar.keys') }}:</span>
|
||||||
<span class="text-foreground truncate">{{ keysDisplay || '-' }}</span>
|
<span class="text-foreground truncate">{{ keysDisplay || '-' }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Debug: Mouse Position -->
|
|
||||||
<div v-if="debugMode && mousePosition" class="flex items-center gap-1.5 hidden md:flex">
|
<div v-if="debugMode && mousePosition" class="flex items-center gap-1.5 hidden md:flex">
|
||||||
<span class="font-medium text-muted-foreground">{{ t('infobar.pointer') }}:</span>
|
<span class="font-medium text-muted-foreground">{{ t('infobar.pointer') }}:</span>
|
||||||
<span class="text-foreground">{{ mousePosition.x }}, {{ mousePosition.y }}</span>
|
<span class="text-foreground">{{ mousePosition.x }}, {{ mousePosition.y }}</span>
|
||||||
@@ -92,28 +89,19 @@ const keysDisplay = computed(() => {
|
|||||||
'px-2 py-1 select-none transition-colors',
|
'px-2 py-1 select-none transition-colors',
|
||||||
capsLock ? 'text-foreground font-medium bg-primary/5' : 'text-muted-foreground/40'
|
capsLock ? 'text-foreground font-medium bg-primary/5' : 'text-muted-foreground/40'
|
||||||
)"
|
)"
|
||||||
>
|
>{{ t('infobar.caps') }}</div>
|
||||||
<span class="hidden sm:inline">{{ t('infobar.caps') }}</span>
|
|
||||||
<span class="sm:hidden">C</span>
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
:class="cn(
|
:class="cn(
|
||||||
'px-2 py-1 select-none transition-colors',
|
'px-2 py-1 select-none transition-colors',
|
||||||
numLock ? 'text-foreground font-medium bg-primary/5' : 'text-muted-foreground/40'
|
numLock ? 'text-foreground font-medium bg-primary/5' : 'text-muted-foreground/40'
|
||||||
)"
|
)"
|
||||||
>
|
>{{ t('infobar.num') }}</div>
|
||||||
<span class="hidden sm:inline">{{ t('infobar.num') }}</span>
|
|
||||||
<span class="sm:hidden">N</span>
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
:class="cn(
|
:class="cn(
|
||||||
'px-2 py-1 select-none transition-colors',
|
'px-2 py-1 select-none transition-colors',
|
||||||
scrollLock ? 'text-foreground font-medium bg-primary/5' : 'text-muted-foreground/40'
|
scrollLock ? 'text-foreground font-medium bg-primary/5' : 'text-muted-foreground/40'
|
||||||
)"
|
)"
|
||||||
>
|
>{{ t('infobar.scroll') }}</div>
|
||||||
<span class="hidden sm:inline">{{ t('infobar.scroll') }}</span>
|
|
||||||
<span class="sm:hidden">S</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
<div v-else class="px-3 py-1 text-muted-foreground/60">
|
<div v-else class="px-3 py-1 text-muted-foreground/60">
|
||||||
{{ t('infobar.keyboardLedUnavailable') }}
|
{{ t('infobar.keyboardLedUnavailable') }}
|
||||||
@@ -121,4 +109,5 @@ const keysDisplay = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -134,12 +134,20 @@ const statusBadgeText = computed(() => {
|
|||||||
:aria-label="`${title}: ${quickInfo || subtitle || statusText}`"
|
:aria-label="`${title}: ${quickInfo || subtitle || statusText}`"
|
||||||
:class="cn(
|
:class="cn(
|
||||||
'flex flex-col gap-0.5 rounded-md border cursor-pointer transition-colors text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
'flex flex-col gap-0.5 rounded-md border cursor-pointer transition-colors text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||||
compact ? 'px-2 py-1 text-xs min-w-[80px]' : 'px-3 py-1.5 text-sm min-w-[100px]',
|
compact ? 'px-1.5 py-0.5 text-xs' : 'px-3 py-1.5 text-sm min-w-[100px]',
|
||||||
'bg-white dark:bg-slate-800 hover:bg-slate-50 dark:hover:bg-slate-700',
|
'bg-white dark:bg-slate-800 hover:bg-slate-50 dark:hover:bg-slate-700',
|
||||||
'border-slate-200 dark:border-slate-700',
|
'border-slate-200 dark:border-slate-700',
|
||||||
status === 'error' && 'border-red-300 dark:border-red-800'
|
status === 'error' && 'border-red-300 dark:border-red-800'
|
||||||
)"
|
)"
|
||||||
>
|
>
|
||||||
|
<template v-if="compact">
|
||||||
|
<!-- Compact: single row with dot + abbreviated title -->
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<span :class="cn('h-1.5 w-1.5 rounded-full shrink-0', statusColor)" />
|
||||||
|
<span class="text-[10px] text-muted-foreground leading-tight truncate">{{ title }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
<!-- Top: Title -->
|
<!-- Top: Title -->
|
||||||
<span class="font-medium text-foreground text-xs truncate">{{ title }}</span>
|
<span class="font-medium text-foreground text-xs truncate">{{ title }}</span>
|
||||||
<!-- Bottom: Status dot + Quick info -->
|
<!-- Bottom: Status dot + Quick info -->
|
||||||
@@ -149,6 +157,7 @@ const statusBadgeText = computed(() => {
|
|||||||
{{ quickInfo || subtitle || statusText }}
|
{{ quickInfo || subtitle || statusText }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
</button>
|
</button>
|
||||||
</HoverCardTrigger>
|
</HoverCardTrigger>
|
||||||
|
|
||||||
@@ -188,17 +197,6 @@ const statusBadgeText = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Error Message -->
|
|
||||||
<div
|
|
||||||
v-if="status === 'error' && errorMessage"
|
|
||||||
class="p-2 rounded-md bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800"
|
|
||||||
>
|
|
||||||
<p class="text-xs text-red-600 dark:text-red-400">
|
|
||||||
<AlertCircle class="h-3.5 w-3.5 inline mr-1" />
|
|
||||||
{{ errorMessage }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Details -->
|
<!-- Details -->
|
||||||
<div v-if="details && details.length > 0" class="space-y-2">
|
<div v-if="details && details.length > 0" class="space-y-2">
|
||||||
<Separator />
|
<Separator />
|
||||||
@@ -206,12 +204,12 @@ const statusBadgeText = computed(() => {
|
|||||||
<div
|
<div
|
||||||
v-for="(detail, index) in details"
|
v-for="(detail, index) in details"
|
||||||
:key="index"
|
:key="index"
|
||||||
class="flex items-center justify-between text-xs"
|
class="flex items-start justify-between gap-3 text-xs"
|
||||||
>
|
>
|
||||||
<span class="text-muted-foreground">{{ detail.label }}</span>
|
<span class="text-muted-foreground shrink-0">{{ detail.label }}</span>
|
||||||
<span
|
<span
|
||||||
:class="cn(
|
:class="cn(
|
||||||
'font-medium',
|
'font-medium text-right break-words min-w-0',
|
||||||
detail.status === 'ok' ? 'text-green-600 dark:text-green-400' :
|
detail.status === 'ok' ? 'text-green-600 dark:text-green-400' :
|
||||||
detail.status === 'warning' ? 'text-yellow-600 dark:text-yellow-400' :
|
detail.status === 'warning' ? 'text-yellow-600 dark:text-yellow-400' :
|
||||||
detail.status === 'error' ? 'text-red-600 dark:text-red-400' :
|
detail.status === 'error' ? 'text-red-600 dark:text-red-400' :
|
||||||
@@ -235,12 +233,20 @@ const statusBadgeText = computed(() => {
|
|||||||
:aria-label="`${title}: ${quickInfo || subtitle || statusText}`"
|
:aria-label="`${title}: ${quickInfo || subtitle || statusText}`"
|
||||||
:class="cn(
|
:class="cn(
|
||||||
'flex flex-col gap-0.5 rounded-md border cursor-pointer transition-colors text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
'flex flex-col gap-0.5 rounded-md border cursor-pointer transition-colors text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||||
compact ? 'px-2 py-1 text-xs min-w-[80px]' : 'px-3 py-1.5 text-sm min-w-[100px]',
|
compact ? 'px-1.5 py-0.5 text-xs' : 'px-3 py-1.5 text-sm min-w-[100px]',
|
||||||
'bg-white dark:bg-slate-800 hover:bg-slate-50 dark:hover:bg-slate-700',
|
'bg-white dark:bg-slate-800 hover:bg-slate-50 dark:hover:bg-slate-700',
|
||||||
'border-slate-200 dark:border-slate-700',
|
'border-slate-200 dark:border-slate-700',
|
||||||
status === 'error' && 'border-red-300 dark:border-red-800'
|
status === 'error' && 'border-red-300 dark:border-red-800'
|
||||||
)"
|
)"
|
||||||
>
|
>
|
||||||
|
<template v-if="compact">
|
||||||
|
<!-- Compact: single row with dot + abbreviated title -->
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<span :class="cn('h-1.5 w-1.5 rounded-full shrink-0', statusColor)" />
|
||||||
|
<span class="text-[10px] text-muted-foreground leading-tight truncate">{{ title }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
<!-- Top: Title -->
|
<!-- Top: Title -->
|
||||||
<span class="font-medium text-foreground text-xs truncate">{{ title }}</span>
|
<span class="font-medium text-foreground text-xs truncate">{{ title }}</span>
|
||||||
<!-- Bottom: Status dot + Quick info -->
|
<!-- Bottom: Status dot + Quick info -->
|
||||||
@@ -250,10 +256,11 @@ const statusBadgeText = computed(() => {
|
|||||||
{{ quickInfo || subtitle || statusText }}
|
{{ quickInfo || subtitle || statusText }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
</button>
|
</button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
|
|
||||||
<PopoverContent class="w-80" :align="hoverAlign">
|
<PopoverContent class="w-[min(320px,90vw)]" :align="hoverAlign">
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
@@ -289,17 +296,6 @@ const statusBadgeText = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Error Message -->
|
|
||||||
<div
|
|
||||||
v-if="status === 'error' && errorMessage"
|
|
||||||
class="p-2 rounded-md bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800"
|
|
||||||
>
|
|
||||||
<p class="text-xs text-red-600 dark:text-red-400">
|
|
||||||
<AlertCircle class="h-3.5 w-3.5 inline mr-1" />
|
|
||||||
{{ errorMessage }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Details -->
|
<!-- Details -->
|
||||||
<div v-if="details && details.length > 0" class="space-y-2">
|
<div v-if="details && details.length > 0" class="space-y-2">
|
||||||
<Separator />
|
<Separator />
|
||||||
@@ -307,12 +303,12 @@ const statusBadgeText = computed(() => {
|
|||||||
<div
|
<div
|
||||||
v-for="(detail, index) in details"
|
v-for="(detail, index) in details"
|
||||||
:key="index"
|
:key="index"
|
||||||
class="flex items-center justify-between text-xs"
|
class="flex items-start justify-between gap-3 text-xs"
|
||||||
>
|
>
|
||||||
<span class="text-muted-foreground">{{ detail.label }}</span>
|
<span class="text-muted-foreground shrink-0">{{ detail.label }}</span>
|
||||||
<span
|
<span
|
||||||
:class="cn(
|
:class="cn(
|
||||||
'font-medium',
|
'font-medium text-right break-words min-w-0',
|
||||||
detail.status === 'ok' ? 'text-green-600 dark:text-green-400' :
|
detail.status === 'ok' ? 'text-green-600 dark:text-green-400' :
|
||||||
detail.status === 'warning' ? 'text-yellow-600 dark:text-yellow-400' :
|
detail.status === 'warning' ? 'text-yellow-600 dark:text-yellow-400' :
|
||||||
detail.status === 'error' ? 'text-red-600 dark:text-red-400' :
|
detail.status === 'error' ? 'text-red-600 dark:text-red-400' :
|
||||||
|
|||||||
@@ -632,12 +632,12 @@ watch(
|
|||||||
<template>
|
<template>
|
||||||
<Popover :open="open" @update:open="emit('update:open', $event)">
|
<Popover :open="open" @update:open="emit('update:open', $event)">
|
||||||
<PopoverTrigger as-child>
|
<PopoverTrigger as-child>
|
||||||
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs">
|
<Button variant="ghost" size="sm" class="h-7 w-7 sm:h-8 sm:w-auto p-0 sm:px-2 sm:gap-1.5 text-xs">
|
||||||
<Monitor class="h-4 w-4" />
|
<Monitor class="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
||||||
<span class="hidden sm:inline">{{ buttonText }}</span>
|
<span class="hidden sm:inline">{{ buttonText }}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent class="w-[320px] p-3" align="start">
|
<PopoverContent class="w-[min(320px,92vw)] p-3" align="start">
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<h4 class="text-sm font-medium">{{ t('actionbar.videoConfig') }}</h4>
|
<h4 class="text-sm font-medium">{{ t('actionbar.videoConfig') }}</h4>
|
||||||
|
|
||||||
|
|||||||
@@ -1202,12 +1202,17 @@ html.dark .hg-theme-default .hg-button.down-key,
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
|
.vkb-body {
|
||||||
|
padding: 4px;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.vkb .simple-keyboard .hg-button {
|
.vkb .simple-keyboard .hg-button {
|
||||||
height: 30px;
|
height: 28px;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
padding: 0 4px;
|
padding: 0 3px;
|
||||||
margin: 0 1px 3px 0;
|
margin: 0 1px 2px 0;
|
||||||
min-width: 26px;
|
min-width: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vkb .simple-keyboard .hg-button.combination-key {
|
.vkb .simple-keyboard .hg-button.combination-key {
|
||||||
@@ -1275,6 +1280,85 @@ html.dark .hg-theme-default .hg-button.down-key,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 400px) {
|
||||||
|
.vkb .simple-keyboard .hg-button {
|
||||||
|
height: 26px;
|
||||||
|
font-size: 9px;
|
||||||
|
padding: 0 2px;
|
||||||
|
margin: 0 1px 2px 0;
|
||||||
|
min-width: 20px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vkb .simple-keyboard .hg-button[data-skbtn="Space"] {
|
||||||
|
min-width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vkb .simple-keyboard .hg-button[data-skbtn="Backspace"],
|
||||||
|
.vkb .simple-keyboard .hg-button[data-skbtn="Tab"],
|
||||||
|
.vkb .simple-keyboard .hg-button[data-skbtn="Backslash"],
|
||||||
|
.vkb .simple-keyboard .hg-button[data-skbtn="(Backslash)"],
|
||||||
|
.vkb .simple-keyboard .hg-button[data-skbtn="CapsLock"] {
|
||||||
|
min-width: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vkb .simple-keyboard .hg-button[data-skbtn="Enter"],
|
||||||
|
.vkb .simple-keyboard .hg-button[data-skbtn="ShiftLeft"],
|
||||||
|
.vkb .simple-keyboard .hg-button[data-skbtn="ShiftRight"] {
|
||||||
|
min-width: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vkb .simple-keyboard .hg-button[data-skbtn="ControlLeft"],
|
||||||
|
.vkb .simple-keyboard .hg-button[data-skbtn="ControlRight"],
|
||||||
|
.vkb .simple-keyboard .hg-button[data-skbtn="MetaLeft"],
|
||||||
|
.vkb .simple-keyboard .hg-button[data-skbtn="MetaRight"],
|
||||||
|
.vkb .simple-keyboard .hg-button[data-skbtn="AltLeft"],
|
||||||
|
.vkb .simple-keyboard .hg-button[data-skbtn="AltRight"],
|
||||||
|
.vkb .simple-keyboard .hg-button[data-skbtn="ContextMenu"] {
|
||||||
|
min-width: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vkb .simple-keyboard .hg-button.combination-key {
|
||||||
|
font-size: 8px;
|
||||||
|
height: 22px;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-control-container .hg-button {
|
||||||
|
min-width: 34px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-arrows-container .hg-button {
|
||||||
|
min-width: 30px !important;
|
||||||
|
width: 30px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vkb-media-btn {
|
||||||
|
padding: 3px 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
min-width: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vkb-header {
|
||||||
|
padding: 2px 6px;
|
||||||
|
min-height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vkb-btn {
|
||||||
|
padding: 1px 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vkb-os-btn {
|
||||||
|
padding: 1px 6px;
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vkb-title {
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Floating mode - slightly smaller keys but still readable */
|
/* Floating mode - slightly smaller keys but still readable */
|
||||||
.vkb--floating .vkb-body {
|
.vkb--floating .vkb-body {
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export default {
|
|||||||
expand: 'Expand',
|
expand: 'Expand',
|
||||||
toggleTheme: 'Toggle theme',
|
toggleTheme: 'Toggle theme',
|
||||||
toggleLanguage: 'Toggle language',
|
toggleLanguage: 'Toggle language',
|
||||||
|
retry: 'Retry',
|
||||||
},
|
},
|
||||||
api: {
|
api: {
|
||||||
operationFailed: 'Operation Failed',
|
operationFailed: 'Operation Failed',
|
||||||
@@ -64,6 +65,7 @@ export default {
|
|||||||
enterPassword: 'Enter password',
|
enterPassword: 'Enter password',
|
||||||
loginFailed: 'Login failed',
|
loginFailed: 'Login failed',
|
||||||
invalidPassword: 'Invalid username or password',
|
invalidPassword: 'Invalid username or password',
|
||||||
|
systemNotInitialized: 'System not initialized. Complete setup first.',
|
||||||
changePassword: 'Change Password',
|
changePassword: 'Change Password',
|
||||||
currentPassword: 'Current Password',
|
currentPassword: 'Current Password',
|
||||||
currentPasswordPlaceholder: 'Enter current password',
|
currentPasswordPlaceholder: 'Enter current password',
|
||||||
@@ -78,6 +80,9 @@ export default {
|
|||||||
userNotFound: 'User not found',
|
userNotFound: 'User not found',
|
||||||
sessionExpired: 'Session expired',
|
sessionExpired: 'Session expired',
|
||||||
loggedInElsewhere: 'Logged in elsewhere',
|
loggedInElsewhere: 'Logged in elsewhere',
|
||||||
|
forgotPassword: 'Forgot password',
|
||||||
|
forgotPasswordHint:
|
||||||
|
'Forgot your password? On the host running One-KVM, open a terminal and run one-kvm user set-password, then enter and confirm the new password when prompted.',
|
||||||
},
|
},
|
||||||
status: {
|
status: {
|
||||||
connected: 'Connected',
|
connected: 'Connected',
|
||||||
@@ -105,7 +110,7 @@ export default {
|
|||||||
fullscreen: 'Fullscreen',
|
fullscreen: 'Fullscreen',
|
||||||
fullscreenTip: 'Toggle fullscreen mode',
|
fullscreenTip: 'Toggle fullscreen mode',
|
||||||
// Video Config
|
// Video Config
|
||||||
videoConfig: 'Video Config',
|
videoConfig: 'Video',
|
||||||
streamSettings: 'Stream Settings',
|
streamSettings: 'Stream Settings',
|
||||||
deviceSettings: 'Device Settings',
|
deviceSettings: 'Device Settings',
|
||||||
videoMode: 'Mode',
|
videoMode: 'Mode',
|
||||||
@@ -137,7 +142,7 @@ export default {
|
|||||||
multiSourceCodecLocked: '{sources} are enabled. Current codec is locked.',
|
multiSourceCodecLocked: '{sources} are enabled. Current codec is locked.',
|
||||||
multiSourceVideoParamsWarning: '{sources} are enabled. Changing video device and input parameters will interrupt the stream.',
|
multiSourceVideoParamsWarning: '{sources} are enabled. Changing video device and input parameters will interrupt the stream.',
|
||||||
// HID Config
|
// HID Config
|
||||||
hidConfig: 'Mouse & HID',
|
hidConfig: 'HID',
|
||||||
mouseSettings: 'Mouse Settings',
|
mouseSettings: 'Mouse Settings',
|
||||||
hidDeviceSettings: 'HID Device Settings',
|
hidDeviceSettings: 'HID Device Settings',
|
||||||
positioningMode: 'Positioning Mode',
|
positioningMode: 'Positioning Mode',
|
||||||
@@ -497,7 +502,6 @@ export default {
|
|||||||
buildInfo: 'Build Info',
|
buildInfo: 'Build Info',
|
||||||
detectDevices: 'Detect Devices',
|
detectDevices: 'Detect Devices',
|
||||||
detecting: 'Detecting...',
|
detecting: 'Detecting...',
|
||||||
builtWith: "Copyright {'@'}2025 SilentWind",
|
|
||||||
networkSettings: 'Network Settings',
|
networkSettings: 'Network Settings',
|
||||||
msdSettings: 'MSD Settings',
|
msdSettings: 'MSD Settings',
|
||||||
atxSettings: 'ATX Settings',
|
atxSettings: 'ATX Settings',
|
||||||
@@ -524,10 +528,43 @@ export default {
|
|||||||
addBindAddress: 'Add address',
|
addBindAddress: 'Add address',
|
||||||
bindAddressListEmpty: 'Add at least one IP address.',
|
bindAddressListEmpty: 'Add at least one IP address.',
|
||||||
httpsEnabled: 'Enable HTTPS',
|
httpsEnabled: 'Enable HTTPS',
|
||||||
httpsEnabledDesc: 'Enable HTTPS encrypted connection (self-signed certificate will be auto-generated)',
|
httpsEnabledDesc: 'Enable HTTPS encrypted connection (a self-signed certificate is generated if none is specified)',
|
||||||
|
// Port config
|
||||||
|
portConfig: 'Port & Protocol',
|
||||||
|
portConfigDesc: 'The service runs on a single port at a time, determined by the HTTPS toggle',
|
||||||
|
httpPortReserved: 'HTTP port (reserved)',
|
||||||
|
httpsPortReserved: 'HTTPS port (reserved)',
|
||||||
|
previewUrl: 'Access URL preview',
|
||||||
|
// Listen address
|
||||||
|
listenAddress: 'Listen Address',
|
||||||
|
listenAddressDesc: 'Configure which network interfaces the web server listens on',
|
||||||
|
bindModeAllDesc: '0.0.0.0 — Listen on all network interfaces',
|
||||||
|
bindModeLocalDesc: '127.0.0.1 — Allow local access only',
|
||||||
|
bindModeCustomDesc: 'Specify a list of IP addresses',
|
||||||
|
effectiveAddresses: 'Listen address preview',
|
||||||
|
// SSL certificate
|
||||||
|
sslCertificate: 'SSL Certificate',
|
||||||
|
sslCertificateDesc: 'Upload a custom PEM certificate to replace the self-signed one, restart required',
|
||||||
|
sslCertCustom: 'Custom Certificate',
|
||||||
|
sslCertSelfSigned: 'Self-Signed',
|
||||||
|
sslCertActive: 'Custom certificate is active',
|
||||||
|
sslCertClear: 'Revert to Self-Signed',
|
||||||
|
sslCertSave: 'Save Certificate',
|
||||||
|
sslCertPem: 'Certificate (.crt / .pem)',
|
||||||
|
sslKeyPem: 'Private Key (.key)',
|
||||||
|
sslCertPemPlaceholder: '-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----',
|
||||||
|
sslKeyPemPlaceholder: '-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----',
|
||||||
|
sslCertSaved: 'Certificate saved, restart to apply',
|
||||||
|
sslCertCleared: 'Reverted to self-signed certificate, restart to apply',
|
||||||
restartRequired: 'Restart Required',
|
restartRequired: 'Restart Required',
|
||||||
restartMessage: 'Web server configuration saved. A restart is required for changes to take effect.',
|
restartMessage: 'Web server configuration saved. A restart is required for changes to take effect.',
|
||||||
restarting: 'Restarting...',
|
restarting: 'Restarting...',
|
||||||
|
autoRestarting: 'Restarting automatically',
|
||||||
|
autoRestartingDesc: 'Configuration saved. Will redirect to the new address once the service is back...',
|
||||||
|
autoRestartingHttpsDesc: 'Service is restarting. A redirect link will appear in {sec}s...',
|
||||||
|
autoRestartFailed: 'Auto-restart timed out. Please refresh the page or check the service status.',
|
||||||
|
httpsManualRedirectTitle: 'Click the link below to open the new address',
|
||||||
|
httpsManualRedirectDesc: 'HTTPS with a self-signed certificate requires browser approval. Click the link and choose "Proceed" on the security warning.',
|
||||||
onlineUpgrade: 'Online Upgrade',
|
onlineUpgrade: 'Online Upgrade',
|
||||||
onlineUpgradeDesc: 'Check and upgrade One-KVM',
|
onlineUpgradeDesc: 'Check and upgrade One-KVM',
|
||||||
updateChannel: 'Update Channel',
|
updateChannel: 'Update Channel',
|
||||||
@@ -557,7 +594,6 @@ export default {
|
|||||||
authSettingsDesc: 'Single-user access and session behavior',
|
authSettingsDesc: 'Single-user access and session behavior',
|
||||||
allowMultipleSessions: 'Allow multiple web sessions',
|
allowMultipleSessions: 'Allow multiple web sessions',
|
||||||
allowMultipleSessionsDesc: 'When disabled, a new login will kick the previous session.',
|
allowMultipleSessionsDesc: 'When disabled, a new login will kick the previous session.',
|
||||||
singleUserSessionNote: 'Single-user mode is enforced; only session concurrency is configurable.',
|
|
||||||
// User management
|
// User management
|
||||||
userManagement: 'User Management',
|
userManagement: 'User Management',
|
||||||
userManagementDesc: 'Manage user accounts and permissions',
|
userManagementDesc: 'Manage user accounts and permissions',
|
||||||
@@ -817,27 +853,19 @@ export default {
|
|||||||
version: 'Version',
|
version: 'Version',
|
||||||
uptime: 'Uptime',
|
uptime: 'Uptime',
|
||||||
running: 'Running',
|
running: 'Running',
|
||||||
mode: 'Mode',
|
|
||||||
format: 'Format',
|
format: 'Format',
|
||||||
resolution: 'Resolution',
|
resolution: 'Resolution',
|
||||||
targetFps: 'Target FPS',
|
fps: 'FPS',
|
||||||
fps: 'Actual FPS',
|
|
||||||
clients: 'Clients',
|
clients: 'Clients',
|
||||||
backend: 'Backend',
|
backend: 'Backend',
|
||||||
initialized: 'Initialized',
|
|
||||||
yes: 'Yes',
|
|
||||||
no: 'No',
|
|
||||||
mouse: 'Mouse',
|
mouse: 'Mouse',
|
||||||
mouseSupport: 'Mouse Support',
|
mouseSupport: 'Mouse Support',
|
||||||
currentMode: 'Current Mode',
|
currentMode: 'Current Mode',
|
||||||
absolute: 'Absolute',
|
absolute: 'Absolute',
|
||||||
relative: 'Relative',
|
relative: 'Relative',
|
||||||
connection: 'Connection',
|
|
||||||
channel: 'Channel',
|
channel: 'Channel',
|
||||||
networkError: 'Network Error',
|
networkError: 'Network Error',
|
||||||
disconnected: 'Disconnected',
|
disconnected: 'Disconnected',
|
||||||
availability: 'Availability',
|
|
||||||
errorCode: 'Error Code',
|
|
||||||
hidUnavailable: 'HID Unavailable',
|
hidUnavailable: 'HID Unavailable',
|
||||||
sampleRate: 'Sample Rate',
|
sampleRate: 'Sample Rate',
|
||||||
channels: 'Channels',
|
channels: 'Channels',
|
||||||
@@ -889,6 +917,7 @@ export default {
|
|||||||
title: 'GOSTC NAT Traversal',
|
title: 'GOSTC NAT Traversal',
|
||||||
desc: 'NAT traversal via GOSTC',
|
desc: 'NAT traversal via GOSTC',
|
||||||
addr: 'Server Address',
|
addr: 'Server Address',
|
||||||
|
addrPlaceholder: 'Hostname or IP (required)',
|
||||||
key: 'Client Key',
|
key: 'Client Key',
|
||||||
tls: 'Enable TLS',
|
tls: 'Enable TLS',
|
||||||
},
|
},
|
||||||
@@ -915,9 +944,9 @@ export default {
|
|||||||
relayServerPlaceholder: 'hbbr.example.com:21117',
|
relayServerPlaceholder: 'hbbr.example.com:21117',
|
||||||
relayServerHint: 'Relay server address (port optional, defaults to 21117). Auto-derived if empty',
|
relayServerHint: 'Relay server address (port optional, defaults to 21117). Auto-derived if empty',
|
||||||
relayKey: 'Relay Key',
|
relayKey: 'Relay Key',
|
||||||
relayKeyPlaceholder: 'Enter relay server key',
|
relayKeyPlaceholder: 'e.g. pLU0pEj2IZnNVKzrIO1pIdwGA3dOVJJLkFIYGOCGH1E=',
|
||||||
relayKeySet: '••••••••',
|
relayKeySet: 'Saved (32-byte Base64, usually 44 chars; leave empty and save to keep)',
|
||||||
relayKeyHint: 'Authentication key for relay server (if server uses -k option)',
|
relayKeyHint: 'Same as hbbs/hbbr -k: standard Base64 decoding to exactly 32 bytes (typically 44 characters including trailing =)',
|
||||||
deviceInfo: 'Device Info',
|
deviceInfo: 'Device Info',
|
||||||
deviceId: 'Device ID',
|
deviceId: 'Device ID',
|
||||||
deviceIdHint: 'Use this ID in RustDesk client to connect',
|
deviceIdHint: 'Use this ID in RustDesk client to connect',
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export default {
|
|||||||
expand: '展开',
|
expand: '展开',
|
||||||
toggleTheme: '切换主题',
|
toggleTheme: '切换主题',
|
||||||
toggleLanguage: '切换语言',
|
toggleLanguage: '切换语言',
|
||||||
|
retry: '重试',
|
||||||
},
|
},
|
||||||
api: {
|
api: {
|
||||||
operationFailed: '操作失败',
|
operationFailed: '操作失败',
|
||||||
@@ -64,6 +65,7 @@ export default {
|
|||||||
enterPassword: '请输入密码',
|
enterPassword: '请输入密码',
|
||||||
loginFailed: '登录失败',
|
loginFailed: '登录失败',
|
||||||
invalidPassword: '用户名或密码错误',
|
invalidPassword: '用户名或密码错误',
|
||||||
|
systemNotInitialized: '系统尚未初始化,请先完成向导设置。',
|
||||||
changePassword: '修改密码',
|
changePassword: '修改密码',
|
||||||
currentPassword: '当前密码',
|
currentPassword: '当前密码',
|
||||||
currentPasswordPlaceholder: '请输入当前密码',
|
currentPasswordPlaceholder: '请输入当前密码',
|
||||||
@@ -78,6 +80,9 @@ export default {
|
|||||||
userNotFound: '用户不存在',
|
userNotFound: '用户不存在',
|
||||||
sessionExpired: '会话已过期',
|
sessionExpired: '会话已过期',
|
||||||
loggedInElsewhere: '已在别处登录',
|
loggedInElsewhere: '已在别处登录',
|
||||||
|
forgotPassword: '忘记密码',
|
||||||
|
forgotPasswordHint:
|
||||||
|
'忘记密码?在运行本服务的设备上打开终端,执行 one-kvm user set-password,按提示输入并确认新密码即可重置。',
|
||||||
},
|
},
|
||||||
status: {
|
status: {
|
||||||
connected: '已连接',
|
connected: '已连接',
|
||||||
@@ -497,7 +502,6 @@ export default {
|
|||||||
buildInfo: '构建信息',
|
buildInfo: '构建信息',
|
||||||
detectDevices: '探测设备',
|
detectDevices: '探测设备',
|
||||||
detecting: '探测中...',
|
detecting: '探测中...',
|
||||||
builtWith: "版权信息 {'@'}2025 SilentWind",
|
|
||||||
networkSettings: '网络设置',
|
networkSettings: '网络设置',
|
||||||
msdSettings: 'MSD 设置',
|
msdSettings: 'MSD 设置',
|
||||||
atxSettings: 'ATX 设置',
|
atxSettings: 'ATX 设置',
|
||||||
@@ -524,10 +528,43 @@ export default {
|
|||||||
addBindAddress: '添加地址',
|
addBindAddress: '添加地址',
|
||||||
bindAddressListEmpty: '请至少填写一个 IP 地址。',
|
bindAddressListEmpty: '请至少填写一个 IP 地址。',
|
||||||
httpsEnabled: '启用 HTTPS',
|
httpsEnabled: '启用 HTTPS',
|
||||||
httpsEnabledDesc: '启用 HTTPS 加密连接(将自动生成自签名证书)',
|
httpsEnabledDesc: '启用 HTTPS 加密连接(未指定证书将生成自签证书)',
|
||||||
|
// Port config
|
||||||
|
portConfig: '端口与协议',
|
||||||
|
portConfigDesc: '服务一次只运行在一个端口上,由 HTTPS 开关决定使用哪个端口',
|
||||||
|
httpPortReserved: 'HTTP 端口(备用)',
|
||||||
|
httpsPortReserved: 'HTTPS 端口(备用)',
|
||||||
|
previewUrl: '访问地址预览',
|
||||||
|
// Listen address
|
||||||
|
listenAddress: '监听地址',
|
||||||
|
listenAddressDesc: '配置 Web 服务监听哪些网络接口',
|
||||||
|
bindModeAllDesc: '0.0.0.0 — 监听所有网络接口',
|
||||||
|
bindModeLocalDesc: '127.0.0.1 — 仅允许本机访问',
|
||||||
|
bindModeCustomDesc: '指定一组 IP 地址',
|
||||||
|
effectiveAddresses: '监听地址预览',
|
||||||
|
// SSL certificate
|
||||||
|
sslCertificate: 'SSL 证书',
|
||||||
|
sslCertificateDesc: '上传自定义 PEM 证书替换自签名证书,修改后需要重启生效',
|
||||||
|
sslCertCustom: '自定义证书',
|
||||||
|
sslCertSelfSigned: '自签名证书',
|
||||||
|
sslCertActive: '自定义证书已启用',
|
||||||
|
sslCertClear: '恢复自签名',
|
||||||
|
sslCertSave: '保存证书',
|
||||||
|
sslCertPem: '证书内容 (.crt / .pem)',
|
||||||
|
sslKeyPem: '私钥内容 (.key)',
|
||||||
|
sslCertPemPlaceholder: '-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----',
|
||||||
|
sslKeyPemPlaceholder: '-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----',
|
||||||
|
sslCertSaved: '证书已保存,重启后生效',
|
||||||
|
sslCertCleared: '已恢复自签名证书,重启后生效',
|
||||||
restartRequired: '需要重启',
|
restartRequired: '需要重启',
|
||||||
restartMessage: 'Web 服务器配置已保存,需要重启程序才能生效。',
|
restartMessage: 'Web 服务器配置已保存,需要重启程序才能生效。',
|
||||||
restarting: '正在重启...',
|
restarting: '正在重启...',
|
||||||
|
autoRestarting: '正在自动重启',
|
||||||
|
autoRestartingDesc: '配置已保存,服务恢复后将自动跳转到新地址...',
|
||||||
|
autoRestartingHttpsDesc: '服务即将重启,{sec} 秒后将显示跳转链接...',
|
||||||
|
autoRestartFailed: '自动重启超时,请手动刷新页面或检查服务状态。',
|
||||||
|
httpsManualRedirectTitle: '请点击下方链接前往新地址',
|
||||||
|
httpsManualRedirectDesc: 'HTTPS 自签名证书需要在浏览器中手动接受,点击链接后在安全警告页选择"继续访问"即可。',
|
||||||
onlineUpgrade: '在线升级',
|
onlineUpgrade: '在线升级',
|
||||||
onlineUpgradeDesc: '检查并升级 One-KVM',
|
onlineUpgradeDesc: '检查并升级 One-KVM',
|
||||||
updateChannel: '升级通道',
|
updateChannel: '升级通道',
|
||||||
@@ -557,7 +594,6 @@ export default {
|
|||||||
authSettingsDesc: '单用户访问与会话策略',
|
authSettingsDesc: '单用户访问与会话策略',
|
||||||
allowMultipleSessions: '允许多个 Web 会话',
|
allowMultipleSessions: '允许多个 Web 会话',
|
||||||
allowMultipleSessionsDesc: '关闭后,新登录会踢掉旧会话。',
|
allowMultipleSessionsDesc: '关闭后,新登录会踢掉旧会话。',
|
||||||
singleUserSessionNote: '系统固定为单用户模式,仅可配置会话并发方式。',
|
|
||||||
// User management
|
// User management
|
||||||
userManagement: '用户管理',
|
userManagement: '用户管理',
|
||||||
userManagementDesc: '管理用户账号和权限',
|
userManagementDesc: '管理用户账号和权限',
|
||||||
@@ -817,27 +853,19 @@ export default {
|
|||||||
version: '版本',
|
version: '版本',
|
||||||
uptime: '运行时间',
|
uptime: '运行时间',
|
||||||
running: '运行中',
|
running: '运行中',
|
||||||
mode: '模式',
|
|
||||||
format: '格式',
|
format: '格式',
|
||||||
resolution: '分辨率',
|
resolution: '分辨率',
|
||||||
targetFps: '目标帧率',
|
fps: '帧率',
|
||||||
fps: '实际帧率',
|
|
||||||
clients: '客户端',
|
clients: '客户端',
|
||||||
backend: '后端',
|
backend: '后端',
|
||||||
initialized: '已初始化',
|
|
||||||
yes: '是',
|
|
||||||
no: '否',
|
|
||||||
mouse: '鼠标',
|
mouse: '鼠标',
|
||||||
mouseSupport: '鼠标支持',
|
mouseSupport: '鼠标支持',
|
||||||
currentMode: '当前模式',
|
currentMode: '当前模式',
|
||||||
absolute: '绝对定位',
|
absolute: '绝对定位',
|
||||||
relative: '相对定位',
|
relative: '相对定位',
|
||||||
connection: '连接',
|
|
||||||
channel: '通道',
|
channel: '通道',
|
||||||
networkError: '网络错误',
|
networkError: '网络错误',
|
||||||
disconnected: '已断开',
|
disconnected: '已断开',
|
||||||
availability: '可用性',
|
|
||||||
errorCode: '错误码',
|
|
||||||
hidUnavailable: 'HID不可用',
|
hidUnavailable: 'HID不可用',
|
||||||
sampleRate: '采样率',
|
sampleRate: '采样率',
|
||||||
channels: '声道',
|
channels: '声道',
|
||||||
@@ -889,6 +917,7 @@ export default {
|
|||||||
title: 'GOSTC 内网穿透',
|
title: 'GOSTC 内网穿透',
|
||||||
desc: '通过 GOSTC 实现内网穿透',
|
desc: '通过 GOSTC 实现内网穿透',
|
||||||
addr: '服务器地址',
|
addr: '服务器地址',
|
||||||
|
addrPlaceholder: '主机名或 IP(必填)',
|
||||||
key: '客户端密钥',
|
key: '客户端密钥',
|
||||||
tls: '启用 TLS',
|
tls: '启用 TLS',
|
||||||
},
|
},
|
||||||
@@ -915,9 +944,9 @@ export default {
|
|||||||
relayServerPlaceholder: 'hbbr.example.com:21117',
|
relayServerPlaceholder: 'hbbr.example.com:21117',
|
||||||
relayServerHint: '中继服务器地址(端口可省略,默认 21117),留空则自动从 ID 服务器推导',
|
relayServerHint: '中继服务器地址(端口可省略,默认 21117),留空则自动从 ID 服务器推导',
|
||||||
relayKey: '中继密钥',
|
relayKey: '中继密钥',
|
||||||
relayKeyPlaceholder: '输入中继服务器密钥',
|
relayKeyPlaceholder: '例如 pLU0pEj2IZnNVKzrIO1pIdwGA3dOVJJLkFIYGOCGH1E=',
|
||||||
relayKeySet: '••••••••',
|
relayKeySet: '已保存(32 字节 Base64,通常 44 字符;留空保存则保留)',
|
||||||
relayKeyHint: '中继服务器认证密钥(如果服务器使用 -k 选项)',
|
relayKeyHint: '与 hbbs/hbbr 的 -k 一致:标准 Base64,解码后固定 32 字节(一般为 44 个字符,含末尾 =)',
|
||||||
deviceInfo: '设备信息',
|
deviceInfo: '设备信息',
|
||||||
deviceId: '设备 ID',
|
deviceId: '设备 ID',
|
||||||
deviceIdHint: '此 ID 用于 RustDesk 客户端连接',
|
deviceIdHint: '此 ID 用于 RustDesk 客户端连接',
|
||||||
|
|||||||
@@ -27,8 +27,6 @@ import type {
|
|||||||
StreamConfigUpdate,
|
StreamConfigUpdate,
|
||||||
VideoConfig,
|
VideoConfig,
|
||||||
VideoConfigUpdate,
|
VideoConfigUpdate,
|
||||||
WebConfig,
|
|
||||||
WebConfigUpdate,
|
|
||||||
} from '@/types/generated'
|
} from '@/types/generated'
|
||||||
import type {
|
import type {
|
||||||
RtspConfigResponse as ApiRtspConfigResponse,
|
RtspConfigResponse as ApiRtspConfigResponse,
|
||||||
@@ -38,6 +36,8 @@ import type {
|
|||||||
RustDeskConfigUpdate as ApiRustDeskConfigUpdate,
|
RustDeskConfigUpdate as ApiRustDeskConfigUpdate,
|
||||||
RustDeskStatusResponse as ApiRustDeskStatusResponse,
|
RustDeskStatusResponse as ApiRustDeskStatusResponse,
|
||||||
RustDeskPasswordResponse as ApiRustDeskPasswordResponse,
|
RustDeskPasswordResponse as ApiRustDeskPasswordResponse,
|
||||||
|
WebConfig,
|
||||||
|
WebConfigUpdate,
|
||||||
} from '@/api'
|
} from '@/api'
|
||||||
|
|
||||||
function normalizeErrorMessage(error: unknown): string {
|
function normalizeErrorMessage(error: unknown): string {
|
||||||
|
|||||||
@@ -289,7 +289,11 @@ export interface StreamConfig {
|
|||||||
turn_password?: string;
|
turn_password?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Web server configuration */
|
/**
|
||||||
|
* Web server configuration persisted in the database (includes on-disk TLS paths).
|
||||||
|
*
|
||||||
|
* The HTTP API for `/api/config/web` uses `WebConfigResponse` instead: no path fields, includes `has_custom_cert`.
|
||||||
|
*/
|
||||||
export interface WebConfig {
|
export interface WebConfig {
|
||||||
/** HTTP port */
|
/** HTTP port */
|
||||||
http_port: number;
|
http_port: number;
|
||||||
@@ -321,7 +325,7 @@ export interface TtydConfig {
|
|||||||
export interface GostcConfig {
|
export interface GostcConfig {
|
||||||
/** Enable auto-start */
|
/** Enable auto-start */
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
/** Server address (e.g., gostc.mofeng.run) */
|
/** Server address (hostname or IP) */
|
||||||
addr: string;
|
addr: string;
|
||||||
/** Client key from GOSTC management panel */
|
/** Client key from GOSTC management panel */
|
||||||
key: string;
|
key: string;
|
||||||
@@ -686,12 +690,33 @@ export interface VideoConfigUpdate {
|
|||||||
quality?: number;
|
quality?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Web server settings returned by `GET` / `PATCH /api/config/web`.
|
||||||
|
*
|
||||||
|
* Public API shape: certificate paths on disk are not exposed. The full stored model is `WebConfig` in `config::schema`.
|
||||||
|
*/
|
||||||
|
export interface WebConfigResponse {
|
||||||
|
http_port: number;
|
||||||
|
https_port: number;
|
||||||
|
bind_addresses: string[];
|
||||||
|
bind_address: string;
|
||||||
|
https_enabled: boolean;
|
||||||
|
/** Whether a custom TLS certificate is active (non-empty cert + key paths in stored config). */
|
||||||
|
has_custom_cert: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface WebConfigUpdate {
|
export interface WebConfigUpdate {
|
||||||
http_port?: number;
|
http_port?: number;
|
||||||
https_port?: number;
|
https_port?: number;
|
||||||
bind_addresses?: string[];
|
bind_addresses?: string[];
|
||||||
bind_address?: string;
|
bind_address?: string;
|
||||||
https_enabled?: boolean;
|
https_enabled?: boolean;
|
||||||
|
/** PEM-encoded certificate content (must be provided together with ssl_key_pem) */
|
||||||
|
ssl_cert_pem?: string;
|
||||||
|
/** PEM-encoded private key content (must be provided together with ssl_cert_pem) */
|
||||||
|
ssl_key_pem?: string;
|
||||||
|
/** Set to true to remove the custom certificate and revert to self-signed */
|
||||||
|
clear_custom_cert?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import InfoBar from '@/components/InfoBar.vue'
|
|||||||
import VirtualKeyboard from '@/components/VirtualKeyboard.vue'
|
import VirtualKeyboard from '@/components/VirtualKeyboard.vue'
|
||||||
import StatsSheet from '@/components/StatsSheet.vue'
|
import StatsSheet from '@/components/StatsSheet.vue'
|
||||||
import LanguageToggleButton from '@/components/LanguageToggleButton.vue'
|
import LanguageToggleButton from '@/components/LanguageToggleButton.vue'
|
||||||
|
import BrandMark from '@/components/BrandMark.vue'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Spinner } from '@/components/ui/spinner'
|
import { Spinner } from '@/components/ui/spinner'
|
||||||
import {
|
import {
|
||||||
@@ -46,7 +47,6 @@ import {
|
|||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import {
|
import {
|
||||||
Monitor,
|
|
||||||
MonitorOff,
|
MonitorOff,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
LogOut,
|
LogOut,
|
||||||
@@ -224,25 +224,23 @@ const videoQuickInfo = computed(() => {
|
|||||||
const videoDetails = computed<StatusDetail[]>(() => {
|
const videoDetails = computed<StatusDetail[]>(() => {
|
||||||
const stream = systemStore.stream
|
const stream = systemStore.stream
|
||||||
if (!stream) return []
|
if (!stream) return []
|
||||||
// Use backend-provided FPS from WebSocket
|
|
||||||
const receivedFps = backendFps.value
|
const receivedFps = backendFps.value
|
||||||
// Display mode: use local videoMode which is synced with server
|
|
||||||
const modeDisplay = videoMode.value === 'mjpeg' ? 'MJPEG' : `${videoMode.value.toUpperCase()} (WebRTC)`
|
// Input (capture) format → output (delivery) mode
|
||||||
const details: StatusDetail[] = [
|
const inputFmt = stream.format || 'MJPEG'
|
||||||
|
const outputFmt = videoMode.value === 'mjpeg' ? 'MJPEG' : `${videoMode.value.toUpperCase()} (WebRTC)`
|
||||||
|
const formatDisplay = inputFmt === outputFmt ? inputFmt : `${inputFmt} → ${outputFmt}`
|
||||||
|
|
||||||
|
// Target / actual FPS combined
|
||||||
|
const fpsDisplay = `${formatFpsValue(stream.targetFps ?? 0)} / ${formatFpsValue(receivedFps)}`
|
||||||
|
const fpsStatus: StatusDetail['status'] = receivedFps > 5 ? 'ok' : receivedFps > 0 ? 'warning' : undefined
|
||||||
|
|
||||||
|
return [
|
||||||
{ label: t('statusCard.device'), value: stream.device || '-' },
|
{ label: t('statusCard.device'), value: stream.device || '-' },
|
||||||
{ label: t('statusCard.mode'), value: modeDisplay, status: 'ok' },
|
{ label: t('statusCard.format'), value: formatDisplay },
|
||||||
{ label: t('statusCard.format'), value: stream.format || 'MJPEG' },
|
|
||||||
{ label: t('statusCard.resolution'), value: stream.resolution ? `${stream.resolution[0]}x${stream.resolution[1]}` : '-' },
|
{ label: t('statusCard.resolution'), value: stream.resolution ? `${stream.resolution[0]}x${stream.resolution[1]}` : '-' },
|
||||||
{ label: t('statusCard.targetFps'), value: formatFpsValue(stream.targetFps ?? 0) },
|
{ label: t('statusCard.fps'), value: fpsDisplay, status: fpsStatus },
|
||||||
{ label: t('statusCard.fps'), value: formatFpsValue(receivedFps), status: receivedFps > 5 ? 'ok' : receivedFps > 0 ? 'warning' : undefined },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
// Show network error if WebSocket has network issue
|
|
||||||
if (wsNetworkError.value) {
|
|
||||||
details.push({ label: t('statusCard.connection'), value: t('statusCard.networkError'), status: 'warning' })
|
|
||||||
}
|
|
||||||
|
|
||||||
return details
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const hidStatus = computed<'connected' | 'connecting' | 'disconnected' | 'error'>(() => {
|
const hidStatus = computed<'connected' | 'connecting' | 'disconnected' | 'error'>(() => {
|
||||||
@@ -358,54 +356,59 @@ const hidDetails = computed<StatusDetail[]>(() => {
|
|||||||
const hidErrorStatus: StatusDetail['status'] =
|
const hidErrorStatus: StatusDetail['status'] =
|
||||||
hid.errorCode === 'udc_not_configured' ? 'warning' : 'error'
|
hid.errorCode === 'udc_not_configured' ? 'warning' : 'error'
|
||||||
|
|
||||||
const details: StatusDetail[] = [
|
const details: StatusDetail[] = []
|
||||||
{ label: t('statusCard.device'), value: hid.device || '-' },
|
|
||||||
{ label: t('statusCard.backend'), value: hid.backend || t('common.unknown') },
|
|
||||||
{ label: t('statusCard.initialized'), value: hid.initialized ? t('statusCard.yes') : t('statusCard.no'), status: hid.error && hid.errorCode !== 'udc_not_configured' ? 'error' : hid.initialized ? 'ok' : 'warning' },
|
|
||||||
{ label: t('statusCard.online'), value: hid.online ? t('statusCard.yes') : t('statusCard.no'), status: hid.online ? 'ok' : hid.initialized ? 'warning' : 'error' },
|
|
||||||
{ label: t('statusCard.currentMode'), value: mouseMode.value === 'absolute' ? t('statusCard.absolute') : t('statusCard.relative'), status: 'ok' },
|
|
||||||
{
|
|
||||||
label: t('settings.otgKeyboardLeds'),
|
|
||||||
value: hid.keyboardLedsEnabled
|
|
||||||
? `Caps:${hid.ledState.capsLock ? t('common.on') : t('common.off')} Num:${hid.ledState.numLock ? t('common.on') : t('common.off')} Scroll:${hid.ledState.scrollLock ? t('common.on') : t('common.off')}`
|
|
||||||
: t('infobar.keyboardLedUnavailable'),
|
|
||||||
status: hid.keyboardLedsEnabled ? 'ok' : undefined,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
if (hid.errorCode) {
|
// Backend + device combined
|
||||||
details.push({ label: t('statusCard.errorCode'), value: hid.errorCode, status: hidErrorStatus })
|
const backendStr = hid.backend || t('common.unknown')
|
||||||
}
|
const deviceStr = hid.device ? ` @ ${hid.device}` : ''
|
||||||
|
details.push({ label: t('statusCard.backend'), value: `${backendStr}${deviceStr}` })
|
||||||
|
|
||||||
|
// Error message (with error code as suffix when present) OR normal-state info
|
||||||
if (errorMessage) {
|
if (errorMessage) {
|
||||||
details.push({ label: t('common.error'), value: errorMessage, status: hidErrorStatus })
|
const codeSuffix = hid.errorCode ? ` (${hid.errorCode})` : ''
|
||||||
|
details.push({ label: t('common.error'), value: `${errorMessage}${codeSuffix}`, status: hidErrorStatus })
|
||||||
|
} else if (hid.online) {
|
||||||
|
details.push({ label: t('statusCard.currentMode'), value: mouseMode.value === 'absolute' ? t('statusCard.absolute') : t('statusCard.relative'), status: 'ok' })
|
||||||
|
if (hid.keyboardLedsEnabled) {
|
||||||
|
details.push({
|
||||||
|
label: t('settings.otgKeyboardLeds'),
|
||||||
|
value: `Caps:${hid.ledState.capsLock ? t('common.on') : t('common.off')} Num:${hid.ledState.numLock ? t('common.on') : t('common.off')} Scroll:${hid.ledState.scrollLock ? t('common.on') : t('common.off')}`,
|
||||||
|
status: 'ok',
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add HID channel info based on video mode
|
// Channel (merged with availability / connection state)
|
||||||
|
let channelValue: string
|
||||||
|
let channelStatus: StatusDetail['status']
|
||||||
if (videoMode.value !== 'mjpeg') {
|
if (videoMode.value !== 'mjpeg') {
|
||||||
// WebRTC mode - show DataChannel status
|
|
||||||
if (webrtc.dataChannelReady.value) {
|
if (webrtc.dataChannelReady.value) {
|
||||||
details.push({ label: t('statusCard.channel'), value: 'DataChannel (WebRTC)', status: 'ok' })
|
channelValue = 'DataChannel (WebRTC)'
|
||||||
|
channelStatus = 'ok'
|
||||||
} else if (webrtc.isConnecting.value || webrtc.isConnected.value) {
|
} else if (webrtc.isConnecting.value || webrtc.isConnected.value) {
|
||||||
details.push({ label: t('statusCard.channel'), value: 'DataChannel', status: 'warning' })
|
channelValue = 'DataChannel'
|
||||||
|
channelStatus = 'warning'
|
||||||
} else {
|
} else {
|
||||||
// Fallback to WebSocket
|
channelValue = 'WebSocket (fallback)'
|
||||||
details.push({ label: t('statusCard.channel'), value: 'WebSocket (fallback)', status: hidWs.connected.value ? 'ok' : 'warning' })
|
channelStatus = hidWs.connected.value ? 'ok' : 'warning'
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// MJPEG mode - WebSocket HID
|
channelValue = 'WebSocket'
|
||||||
details.push({ label: t('statusCard.channel'), value: 'WebSocket', status: hidWs.connected.value ? 'ok' : 'warning' })
|
channelStatus = hidWs.connected.value ? 'ok' : 'warning'
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add connection status for WebSocket (only relevant for MJPEG or fallback)
|
|
||||||
if (videoMode.value === 'mjpeg' || !webrtc.dataChannelReady.value) {
|
if (videoMode.value === 'mjpeg' || !webrtc.dataChannelReady.value) {
|
||||||
if (hidWs.networkError.value) {
|
if (hidWs.networkError.value) {
|
||||||
details.push({ label: t('statusCard.connection'), value: t('statusCard.networkError'), status: 'warning' })
|
channelValue += ` (${t('statusCard.networkError')})`
|
||||||
|
channelStatus = 'warning'
|
||||||
} else if (!hidWs.connected.value) {
|
} else if (!hidWs.connected.value) {
|
||||||
details.push({ label: t('statusCard.connection'), value: t('statusCard.disconnected'), status: 'warning' })
|
channelValue += ` (${t('statusCard.disconnected')})`
|
||||||
|
channelStatus = 'warning'
|
||||||
} else if (hidWs.hidUnavailable.value) {
|
} else if (hidWs.hidUnavailable.value) {
|
||||||
details.push({ label: t('statusCard.availability'), value: t('statusCard.hidUnavailable'), status: 'warning' })
|
channelValue += ` (${t('statusCard.hidUnavailable')})`
|
||||||
|
channelStatus = 'warning'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
details.push({ label: t('statusCard.channel'), value: channelValue, status: channelStatus })
|
||||||
|
|
||||||
return details
|
return details
|
||||||
})
|
})
|
||||||
@@ -2347,18 +2350,42 @@ onUnmounted(() => {
|
|||||||
<div class="h-screen h-dvh flex flex-col bg-background">
|
<div class="h-screen h-dvh flex flex-col bg-background">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<header class="shrink-0 border-b border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900">
|
<header class="shrink-0 border-b border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900">
|
||||||
<div class="px-4">
|
<div class="px-2 sm:px-4">
|
||||||
<div class="h-14 flex items-center justify-between">
|
<div class="h-10 sm:h-14 flex items-center justify-between">
|
||||||
<!-- Left: Logo -->
|
<!-- Left: Logo -->
|
||||||
<div class="flex items-center gap-6">
|
<div class="flex items-center gap-2 sm:gap-6">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-1.5 sm:gap-2">
|
||||||
<Monitor class="h-6 w-6 text-primary" />
|
<BrandMark size="md" class="hidden sm:block" />
|
||||||
<span class="font-bold text-lg">One-KVM</span>
|
<BrandMark size="sm" class="sm:hidden" />
|
||||||
|
<span class="font-bold text-sm sm:text-lg">One-KVM</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile Status Indicators (inline, minimal) -->
|
||||||
|
<div class="flex md:hidden items-center gap-1">
|
||||||
|
<StatusCard
|
||||||
|
:title="t('statusCard.video')"
|
||||||
|
type="video"
|
||||||
|
:status="videoStatus"
|
||||||
|
:quick-info="videoQuickInfo"
|
||||||
|
:error-message="videoErrorMessage"
|
||||||
|
:details="videoDetails"
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
|
||||||
|
<StatusCard
|
||||||
|
:title="t('statusCard.hid')"
|
||||||
|
type="hid"
|
||||||
|
:status="hidStatus"
|
||||||
|
:quick-info="hidQuickInfo"
|
||||||
|
:details="hidDetails"
|
||||||
|
:hover-align="hidHoverAlign"
|
||||||
|
compact
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right: Status Cards + User Menu -->
|
<!-- Right: Status Cards + User Menu -->
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-1 sm:gap-2">
|
||||||
<div class="hidden md:flex items-center gap-2">
|
<div class="hidden md:flex items-center gap-2">
|
||||||
<!-- Video Status -->
|
<!-- Video Status -->
|
||||||
<StatusCard
|
<StatusCard
|
||||||
@@ -2420,9 +2447,9 @@ onUnmounted(() => {
|
|||||||
<!-- User Menu -->
|
<!-- User Menu -->
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger as-child>
|
<DropdownMenuTrigger as-child>
|
||||||
<Button variant="outline" size="sm" class="gap-1.5">
|
<Button variant="outline" size="sm" class="gap-1 sm:gap-1.5 h-7 sm:h-9 px-2 sm:px-3">
|
||||||
<span class="text-xs max-w-[100px] truncate">{{ authStore.user || 'admin' }}</span>
|
<span class="text-xs max-w-[60px] sm:max-w-[100px] truncate">{{ authStore.user || 'admin' }}</span>
|
||||||
<ChevronDown class="h-3.5 w-3.5" />
|
<ChevronDown class="h-3 w-3 sm:h-3.5 sm:w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
@@ -2453,60 +2480,6 @@ onUnmounted(() => {
|
|||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mobile Status Row -->
|
|
||||||
<div class="md:hidden pb-2">
|
|
||||||
<div class="flex items-center gap-2 overflow-x-auto">
|
|
||||||
<div class="shrink-0">
|
|
||||||
<StatusCard
|
|
||||||
:title="t('statusCard.video')"
|
|
||||||
type="video"
|
|
||||||
:status="videoStatus"
|
|
||||||
:quick-info="videoQuickInfo"
|
|
||||||
:error-message="videoErrorMessage"
|
|
||||||
:details="videoDetails"
|
|
||||||
compact
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="systemStore.audio?.available" class="shrink-0">
|
|
||||||
<StatusCard
|
|
||||||
:title="t('statusCard.audio')"
|
|
||||||
type="audio"
|
|
||||||
:status="audioStatus"
|
|
||||||
:quick-info="audioQuickInfo"
|
|
||||||
:error-message="audioErrorMessage"
|
|
||||||
:details="audioDetails"
|
|
||||||
compact
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="shrink-0">
|
|
||||||
<StatusCard
|
|
||||||
:title="t('statusCard.hid')"
|
|
||||||
type="hid"
|
|
||||||
:status="hidStatus"
|
|
||||||
:quick-info="hidQuickInfo"
|
|
||||||
:details="hidDetails"
|
|
||||||
:hover-align="hidHoverAlign"
|
|
||||||
compact
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="showMsdStatusCard" class="shrink-0">
|
|
||||||
<StatusCard
|
|
||||||
:title="t('statusCard.msd')"
|
|
||||||
type="msd"
|
|
||||||
:status="msdStatus"
|
|
||||||
:quick-info="msdQuickInfo"
|
|
||||||
:error-message="msdErrorMessage"
|
|
||||||
:details="msdDetails"
|
|
||||||
hover-align="end"
|
|
||||||
compact
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -2539,7 +2512,7 @@ onUnmounted(() => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Video Container -->
|
<!-- Video Container -->
|
||||||
<div class="relative h-full w-full flex items-center justify-center p-2 sm:p-4">
|
<div class="relative h-full w-full flex items-center justify-center p-1 sm:p-4">
|
||||||
<div
|
<div
|
||||||
ref="videoContainerRef"
|
ref="videoContainerRef"
|
||||||
class="relative bg-black overflow-hidden flex items-center justify-center"
|
class="relative bg-black overflow-hidden flex items-center justify-center"
|
||||||
@@ -2547,8 +2520,7 @@ onUnmounted(() => {
|
|||||||
aspectRatio: videoAspectRatio ?? '16/9',
|
aspectRatio: videoAspectRatio ?? '16/9',
|
||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
maxHeight: '100%',
|
maxHeight: '100%',
|
||||||
minWidth: '320px',
|
minHeight: '120px',
|
||||||
minHeight: '180px',
|
|
||||||
}"
|
}"
|
||||||
:class="{
|
:class="{
|
||||||
'opacity-60': videoLoading || videoError,
|
'opacity-60': videoLoading || videoError,
|
||||||
@@ -2602,11 +2574,11 @@ onUnmounted(() => {
|
|||||||
<div class="absolute w-full h-0.5 bg-gradient-to-r from-transparent via-primary/40 to-transparent animate-pulse" style="top: 50%; animation-duration: 1.5s;" />
|
<div class="absolute w-full h-0.5 bg-gradient-to-r from-transparent via-primary/40 to-transparent animate-pulse" style="top: 50%; animation-duration: 1.5s;" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Spinner class="h-16 w-16 text-white mb-4" />
|
<Spinner class="h-10 w-10 sm:h-16 sm:w-16 text-white mb-2 sm:mb-4" />
|
||||||
<p class="text-white/90 text-lg font-medium">
|
<p class="text-white/90 text-sm sm:text-lg font-medium text-center px-4">
|
||||||
{{ webrtcLoadingMessage }}
|
{{ webrtcLoadingMessage }}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-white/50 text-sm mt-2">
|
<p class="text-white/50 text-xs sm:text-sm mt-1 sm:mt-2">
|
||||||
{{ t('console.pleaseWait') }}
|
{{ t('console.pleaseWait') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -2618,10 +2590,10 @@ onUnmounted(() => {
|
|||||||
v-if="videoError && !videoLoading"
|
v-if="videoError && !videoLoading"
|
||||||
class="absolute inset-0 flex flex-col items-center justify-center bg-black/85 text-white gap-4 transition-opacity duration-300 p-4"
|
class="absolute inset-0 flex flex-col items-center justify-center bg-black/85 text-white gap-4 transition-opacity duration-300 p-4"
|
||||||
>
|
>
|
||||||
<MonitorOff class="h-16 w-16 text-slate-400" />
|
<MonitorOff class="h-10 w-10 sm:h-16 sm:w-16 text-slate-400" />
|
||||||
<div class="text-center max-w-md">
|
<div class="text-center max-w-md px-2">
|
||||||
<p class="font-medium text-lg mb-2">{{ t('console.connectionFailed') }}</p>
|
<p class="font-medium text-sm sm:text-lg mb-1 sm:mb-2">{{ t('console.connectionFailed') }}</p>
|
||||||
<p class="text-sm text-slate-300 mb-3">{{ t('console.connectionFailedDesc') }}</p>
|
<p class="text-xs sm:text-sm text-slate-300 mb-2 sm:mb-3">{{ t('console.connectionFailedDesc') }}</p>
|
||||||
<!-- Expandable error details -->
|
<!-- Expandable error details -->
|
||||||
<div v-if="videoErrorMessage" class="bg-slate-800/60 rounded-lg p-3 text-left">
|
<div v-if="videoErrorMessage" class="bg-slate-800/60 rounded-lg p-3 text-left">
|
||||||
<p class="text-xs text-slate-400 mb-1">{{ t('console.errorDetails') }}:</p>
|
<p class="text-xs text-slate-400 mb-1">{{ t('console.errorDetails') }}:</p>
|
||||||
@@ -2679,22 +2651,22 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<!-- Terminal Dialog -->
|
<!-- Terminal Dialog -->
|
||||||
<Dialog v-model:open="showTerminalDialog">
|
<Dialog v-model:open="showTerminalDialog">
|
||||||
<DialogContent class="w-[95vw] max-w-5xl h-[85dvh] max-h-[720px] p-0 flex flex-col overflow-hidden">
|
<DialogContent class="w-[98vw] sm:w-[95vw] max-w-5xl h-[90dvh] sm:h-[85dvh] max-h-[720px] p-0 flex flex-col overflow-hidden">
|
||||||
<DialogHeader class="px-4 py-3 border-b shrink-0">
|
<DialogHeader class="px-3 sm:px-4 py-2 sm:py-3 border-b shrink-0">
|
||||||
<DialogTitle class="flex items-center justify-between w-full">
|
<DialogTitle class="flex items-center justify-between w-full">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Terminal class="h-5 w-5" />
|
<Terminal class="h-4 w-4 sm:h-5 sm:w-5" />
|
||||||
{{ t('extensions.ttyd.title') }}
|
<span class="text-sm sm:text-base">{{ t('extensions.ttyd.title') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
class="h-8 w-8 mr-8"
|
class="h-7 w-7 sm:h-8 sm:w-8 mr-6 sm:mr-8"
|
||||||
@click="openTerminalInNewTab"
|
@click="openTerminalInNewTab"
|
||||||
:aria-label="t('extensions.ttyd.openInNewTab')"
|
:aria-label="t('extensions.ttyd.openInNewTab')"
|
||||||
:title="t('extensions.ttyd.openInNewTab')"
|
:title="t('extensions.ttyd.openInNewTab')"
|
||||||
>
|
>
|
||||||
<ExternalLink class="h-4 w-4" />
|
<ExternalLink class="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@@ -2712,11 +2684,11 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<!-- Change Password Dialog -->
|
<!-- Change Password Dialog -->
|
||||||
<Dialog v-model:open="changePasswordDialogOpen">
|
<Dialog v-model:open="changePasswordDialogOpen">
|
||||||
<DialogContent class="sm:max-w-md">
|
<DialogContent class="w-[95vw] max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{{ t('auth.changePassword') }}</DialogTitle>
|
<DialogTitle>{{ t('auth.changePassword') }}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div class="space-y-4 py-4">
|
<div class="space-y-3 sm:space-y-4 py-2 sm:py-4">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="currentPassword">{{ t('auth.currentPassword') }}</Label>
|
<Label for="currentPassword">{{ t('auth.currentPassword') }}</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -7,14 +7,24 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||||
import LanguageToggleButton from '@/components/LanguageToggleButton.vue'
|
import LanguageToggleButton from '@/components/LanguageToggleButton.vue'
|
||||||
import { Monitor, Lock, Eye, EyeOff, User } from 'lucide-vue-next'
|
import BrandMark from '@/components/BrandMark.vue'
|
||||||
|
import { Lock, Eye, EyeOff, User, CircleHelp } from 'lucide-vue-next'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
/** Map backend English messages to locale strings (API returns fixed English copy). */
|
||||||
|
function localizedLoginError(raw: string | null): string {
|
||||||
|
if (!raw) return t('auth.loginFailed')
|
||||||
|
if (raw.includes('Invalid username or password')) return t('auth.invalidPassword')
|
||||||
|
if (raw.includes('System not initialized')) return t('auth.systemNotInitialized')
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
const username = ref('')
|
const username = ref('')
|
||||||
const password = ref('')
|
const password = ref('')
|
||||||
const showPassword = ref(false)
|
const showPassword = ref(false)
|
||||||
@@ -40,7 +50,7 @@ async function handleLogin() {
|
|||||||
const redirect = route.query.redirect as string
|
const redirect = route.query.redirect as string
|
||||||
router.push(redirect || '/')
|
router.push(redirect || '/')
|
||||||
} else {
|
} else {
|
||||||
error.value = authStore.error || t('auth.loginFailed')
|
error.value = localizedLoginError(authStore.error)
|
||||||
}
|
}
|
||||||
|
|
||||||
loading.value = false
|
loading.value = false
|
||||||
@@ -55,8 +65,8 @@ async function handleLogin() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CardHeader class="space-y-2 pt-10 text-center sm:pt-12">
|
<CardHeader class="space-y-2 pt-10 text-center sm:pt-12">
|
||||||
<div class="inline-flex h-16 w-16 items-center justify-center rounded-full bg-primary/10 mx-auto">
|
<div class="mx-auto flex justify-center">
|
||||||
<Monitor class="w-8 h-8 text-primary" />
|
<BrandMark size="xl" />
|
||||||
</div>
|
</div>
|
||||||
<CardTitle class="text-xl sm:text-2xl">One-KVM</CardTitle>
|
<CardTitle class="text-xl sm:text-2xl">One-KVM</CardTitle>
|
||||||
<CardDescription>{{ t('auth.login') }}</CardDescription>
|
<CardDescription>{{ t('auth.login') }}</CardDescription>
|
||||||
@@ -107,6 +117,25 @@ async function handleLogin() {
|
|||||||
<span v-if="loading">{{ t('common.loading') }}</span>
|
<span v-if="loading">{{ t('common.loading') }}</span>
|
||||||
<span v-else>{{ t('auth.login') }}</span>
|
<span v-else>{{ t('auth.login') }}</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<div class="text-right">
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger as-child>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
{{ t('auth.forgotPassword') }}
|
||||||
|
<CircleHelp class="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent class="w-80 p-3" align="end">
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
{{ t('auth.forgotPasswordHint') }}
|
||||||
|
</p>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ import { Switch } from '@/components/ui/switch'
|
|||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet'
|
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet'
|
||||||
|
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -84,6 +86,7 @@ import {
|
|||||||
Copy,
|
Copy,
|
||||||
ScreenShare,
|
ScreenShare,
|
||||||
Radio,
|
Radio,
|
||||||
|
Globe,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
|
|
||||||
const { t, te } = useI18n()
|
const { t, te } = useI18n()
|
||||||
@@ -100,7 +103,7 @@ const saved = ref(false)
|
|||||||
const SETTINGS_SECTION_IDS = new Set([
|
const SETTINGS_SECTION_IDS = new Set([
|
||||||
'appearance',
|
'appearance',
|
||||||
'account',
|
'account',
|
||||||
'access',
|
'network',
|
||||||
'video',
|
'video',
|
||||||
'hid',
|
'hid',
|
||||||
'msd',
|
'msd',
|
||||||
@@ -120,7 +123,7 @@ const navGroups = computed(() => [
|
|||||||
items: [
|
items: [
|
||||||
{ id: 'appearance', label: t('settings.appearance'), icon: Sun },
|
{ id: 'appearance', label: t('settings.appearance'), icon: Sun },
|
||||||
{ id: 'account', label: t('settings.account'), icon: User },
|
{ id: 'account', label: t('settings.account'), icon: User },
|
||||||
{ id: 'access', label: t('settings.access'), icon: Lock },
|
{ id: 'network', label: t('settings.network'), icon: Globe },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -156,7 +159,9 @@ function selectSection(id: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizeSettingsSection(value: unknown): string | null {
|
function normalizeSettingsSection(value: unknown): string | null {
|
||||||
return typeof value === 'string' && SETTINGS_SECTION_IDS.has(value) ? value : null
|
if (typeof value !== 'string') return null
|
||||||
|
if (value === 'access-control') return 'account'
|
||||||
|
return SETTINGS_SECTION_IDS.has(value) ? value : null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Theme
|
// Theme
|
||||||
@@ -205,7 +210,7 @@ const showTerminalDialog = ref(false)
|
|||||||
// Extension config (local edit state)
|
// Extension config (local edit state)
|
||||||
const extConfig = ref({
|
const extConfig = ref({
|
||||||
ttyd: { enabled: false, shell: '/bin/bash' },
|
ttyd: { enabled: false, shell: '/bin/bash' },
|
||||||
gostc: { enabled: false, addr: 'gostc.mofeng.run', key: '', tls: true },
|
gostc: { enabled: false, addr: '', key: '', tls: true },
|
||||||
easytier: { enabled: false, network_name: '', network_secret: '', peer_urls: [] as string[], virtual_ip: '' },
|
easytier: { enabled: false, network_name: '', network_secret: '', peer_urls: [] as string[], virtual_ip: '' },
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -258,10 +263,22 @@ const webServerConfig = ref<WebConfig>({
|
|||||||
bind_address: '0.0.0.0',
|
bind_address: '0.0.0.0',
|
||||||
bind_addresses: ['0.0.0.0'],
|
bind_addresses: ['0.0.0.0'],
|
||||||
https_enabled: false,
|
https_enabled: false,
|
||||||
|
has_custom_cert: false,
|
||||||
})
|
})
|
||||||
const webServerLoading = ref(false)
|
const webServerLoading = ref(false)
|
||||||
|
// SSL certificate state
|
||||||
|
const sslCertPem = ref('')
|
||||||
|
const sslKeyPem = ref('')
|
||||||
|
const certSaving = ref(false)
|
||||||
|
const certClearing = ref(false)
|
||||||
const showRestartDialog = ref(false)
|
const showRestartDialog = ref(false)
|
||||||
const restarting = ref(false)
|
const restarting = ref(false)
|
||||||
|
// Auto-restart flow (no dialog needed for web-config saves)
|
||||||
|
const autoRestarting = ref(false)
|
||||||
|
const autoRestartFailed = ref(false)
|
||||||
|
// For HTTPS targets: can't poll (self-signed cert), show manual link instead
|
||||||
|
const autoRestartManualUrl = ref<string | null>(null)
|
||||||
|
const autoRestartCountdown = ref(0)
|
||||||
const updateChannel = ref<UpdateChannel>('stable')
|
const updateChannel = ref<UpdateChannel>('stable')
|
||||||
const updateOverview = ref<UpdateOverviewResponse | null>(null)
|
const updateOverview = ref<UpdateOverviewResponse | null>(null)
|
||||||
const updateStatus = ref<UpdateStatusResponse | null>(null)
|
const updateStatus = ref<UpdateStatusResponse | null>(null)
|
||||||
@@ -299,6 +316,18 @@ const effectiveBindAddresses = computed(() => {
|
|||||||
return normalizeBindAddresses(bindAddressList.value)
|
return normalizeBindAddresses(bindAddressList.value)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** 预览当前配置生效后的访问 URL(取第一个非通配地址显示) */
|
||||||
|
const previewAccessUrl = computed(() => {
|
||||||
|
const https = webServerConfig.value.https_enabled
|
||||||
|
const port = https ? webServerConfig.value.https_port : webServerConfig.value.http_port
|
||||||
|
const scheme = https ? 'https' : 'http'
|
||||||
|
// 对通配地址,用当前浏览器 hostname 替代
|
||||||
|
const addrs = effectiveBindAddresses.value
|
||||||
|
const firstAddr = addrs.find(a => a !== '0.0.0.0' && a !== '::') ?? window.location.hostname
|
||||||
|
const host = firstAddr.includes(':') ? `[${firstAddr}]` : firstAddr
|
||||||
|
return `${scheme}://${host}:${port}`
|
||||||
|
})
|
||||||
|
|
||||||
// Config
|
// Config
|
||||||
interface DeviceConfig {
|
interface DeviceConfig {
|
||||||
video: Array<{
|
video: Array<{
|
||||||
@@ -1435,6 +1464,12 @@ function normalizeRustdeskServer(value: string, defaultPort: number): string | u
|
|||||||
return `${trimmed}:${defaultPort}`
|
return `${trimmed}:${defaultPort}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Strip line breaks from pasted keys; empty means “do not change” on PATCH. */
|
||||||
|
function normalizeRustdeskRelayKey(value: string): string | undefined {
|
||||||
|
const cleaned = value.replace(/\r?\n/g, '').trim()
|
||||||
|
return cleaned || undefined
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeRtspPath(path: string): string {
|
function normalizeRtspPath(path: string): string {
|
||||||
return path.trim().replace(/^\/+|\/+$/g, '') || 'live'
|
return path.trim().replace(/^\/+|\/+$/g, '') || 'live'
|
||||||
}
|
}
|
||||||
@@ -1496,16 +1531,15 @@ async function saveWebServerConfig() {
|
|||||||
if (bindAddressError.value) return
|
if (bindAddressError.value) return
|
||||||
webServerLoading.value = true
|
webServerLoading.value = true
|
||||||
try {
|
try {
|
||||||
const update = {
|
const updated = await configStore.updateWeb({
|
||||||
http_port: webServerConfig.value.http_port,
|
http_port: webServerConfig.value.http_port,
|
||||||
https_port: webServerConfig.value.https_port,
|
https_port: webServerConfig.value.https_port,
|
||||||
https_enabled: webServerConfig.value.https_enabled,
|
https_enabled: webServerConfig.value.https_enabled,
|
||||||
bind_addresses: effectiveBindAddresses.value,
|
bind_addresses: effectiveBindAddresses.value,
|
||||||
}
|
})
|
||||||
const updated = await configStore.updateWeb(update)
|
|
||||||
webServerConfig.value = updated
|
webServerConfig.value = updated
|
||||||
applyBindStateFromConfig(updated)
|
applyBindStateFromConfig(updated)
|
||||||
showRestartDialog.value = true
|
await triggerAutoRestart()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to save web server config:', e)
|
console.error('Failed to save web server config:', e)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1513,19 +1547,50 @@ async function saveWebServerConfig() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function saveCertificate() {
|
||||||
|
if (!sslCertPem.value.trim() || !sslKeyPem.value.trim()) return
|
||||||
|
certSaving.value = true
|
||||||
|
try {
|
||||||
|
const updated = await configStore.updateWeb({
|
||||||
|
ssl_cert_pem: sslCertPem.value,
|
||||||
|
ssl_key_pem: sslKeyPem.value,
|
||||||
|
})
|
||||||
|
webServerConfig.value = updated
|
||||||
|
sslCertPem.value = ''
|
||||||
|
sslKeyPem.value = ''
|
||||||
|
await triggerAutoRestart()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to save certificate:', e)
|
||||||
|
} finally {
|
||||||
|
certSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearCertificate() {
|
||||||
|
certClearing.value = true
|
||||||
|
try {
|
||||||
|
const updated = await configStore.updateWeb({ clear_custom_cert: true })
|
||||||
|
webServerConfig.value = updated
|
||||||
|
await triggerAutoRestart()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to clear certificate:', e)
|
||||||
|
} finally {
|
||||||
|
certClearing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 手动点重启按钮(仅用于弹窗场景,保留兼容) */
|
||||||
async function restartServer() {
|
async function restartServer() {
|
||||||
restarting.value = true
|
restarting.value = true
|
||||||
try {
|
try {
|
||||||
await systemApi.restart()
|
await systemApi.restart()
|
||||||
// Wait for server to restart, then reload page
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const protocol = webServerConfig.value.https_enabled ? 'https' : 'http'
|
const protocol = webServerConfig.value.https_enabled ? 'https' : 'http'
|
||||||
const port = webServerConfig.value.https_enabled
|
const port = webServerConfig.value.https_enabled
|
||||||
? webServerConfig.value.https_port
|
? webServerConfig.value.https_port
|
||||||
: webServerConfig.value.http_port
|
: webServerConfig.value.http_port
|
||||||
const host = formatHostForUrl(window.location.hostname || '127.0.0.1')
|
const host = formatHostForUrl(window.location.hostname || '127.0.0.1')
|
||||||
const newUrl = `${protocol}://${host}:${port}`
|
window.location.href = `${protocol}://${host}:${port}`
|
||||||
window.location.href = newUrl
|
|
||||||
}, 3000)
|
}, 3000)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to restart server:', e)
|
console.error('Failed to restart server:', e)
|
||||||
@@ -1533,6 +1598,75 @@ async function restartServer() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 轮询目标地址 /api/health,最多等待 maxMs 毫秒 */
|
||||||
|
async function pollUntilReady(targetOrigin: string, maxMs = 30000): Promise<boolean> {
|
||||||
|
const deadline = Date.now() + maxMs
|
||||||
|
const healthUrl = targetOrigin.replace(/\/$/, '') + '/api/health'
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
await new Promise(r => setTimeout(r, 800))
|
||||||
|
try {
|
||||||
|
const ctrl = new AbortController()
|
||||||
|
const tid = setTimeout(() => ctrl.abort(), 1500)
|
||||||
|
const res = await fetch(healthUrl, { signal: ctrl.signal })
|
||||||
|
clearTimeout(tid)
|
||||||
|
if (res.ok) return true
|
||||||
|
} catch {
|
||||||
|
// server still restarting — keep polling
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存网络配置后自动重启并跳转。
|
||||||
|
*
|
||||||
|
* - HTTP 目标:轮询 /api/health,服务恢复后自动跳转。
|
||||||
|
* - HTTPS 目标:自签名证书导致 fetch 被浏览器拦截(ERR_CERT_AUTHORITY_INVALID),
|
||||||
|
* 无法自动轮询。改为倒计时结束后展示跳转链接,由用户点击并在浏览器中手动接受证书。
|
||||||
|
*/
|
||||||
|
async function triggerAutoRestart() {
|
||||||
|
const https = webServerConfig.value.https_enabled
|
||||||
|
const port = https ? webServerConfig.value.https_port : webServerConfig.value.http_port
|
||||||
|
const protocol = https ? 'https' : 'http'
|
||||||
|
const host = formatHostForUrl(window.location.hostname || '127.0.0.1')
|
||||||
|
const targetOrigin = `${protocol}://${host}:${port}`
|
||||||
|
|
||||||
|
autoRestarting.value = true
|
||||||
|
autoRestartFailed.value = false
|
||||||
|
autoRestartManualUrl.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
await systemApi.restart()
|
||||||
|
|
||||||
|
if (https) {
|
||||||
|
// HTTPS:浏览器拒绝自签名证书,无法轮询。
|
||||||
|
// 等待固定时间后展示手动跳转链接。
|
||||||
|
const WAIT_SEC = 6
|
||||||
|
autoRestartCountdown.value = WAIT_SEC
|
||||||
|
for (let i = WAIT_SEC - 1; i >= 0; i--) {
|
||||||
|
await new Promise(r => setTimeout(r, 1000))
|
||||||
|
autoRestartCountdown.value = i
|
||||||
|
}
|
||||||
|
autoRestartManualUrl.value = targetOrigin
|
||||||
|
autoRestarting.value = false
|
||||||
|
} else {
|
||||||
|
// HTTP:可以安全轮询,服务恢复后自动跳转。
|
||||||
|
await new Promise(r => setTimeout(r, 1200))
|
||||||
|
const ready = await pollUntilReady(targetOrigin)
|
||||||
|
if (ready) {
|
||||||
|
window.location.href = targetOrigin
|
||||||
|
} else {
|
||||||
|
autoRestartFailed.value = true
|
||||||
|
autoRestarting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Auto restart failed:', e)
|
||||||
|
autoRestartFailed.value = true
|
||||||
|
autoRestarting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadUpdateOverview() {
|
async function loadUpdateOverview() {
|
||||||
updateLoading.value = true
|
updateLoading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -1642,7 +1776,7 @@ async function saveRustdeskConfig() {
|
|||||||
enabled: rustdeskLocalConfig.value.enabled,
|
enabled: rustdeskLocalConfig.value.enabled,
|
||||||
rendezvous_server: rendezvousServer,
|
rendezvous_server: rendezvousServer,
|
||||||
relay_server: relayServer,
|
relay_server: relayServer,
|
||||||
relay_key: rustdeskLocalConfig.value.relay_key || undefined,
|
relay_key: normalizeRustdeskRelayKey(rustdeskLocalConfig.value.relay_key),
|
||||||
})
|
})
|
||||||
await loadRustdeskConfig()
|
await loadRustdeskConfig()
|
||||||
// Clear relay_key input after save (it's a password field)
|
// Clear relay_key input after save (it's a password field)
|
||||||
@@ -1919,16 +2053,16 @@ watch(() => route.query.tab, (tab) => {
|
|||||||
<AppLayout>
|
<AppLayout>
|
||||||
<div class="flex h-full overflow-hidden">
|
<div class="flex h-full overflow-hidden">
|
||||||
<!-- Mobile Header -->
|
<!-- Mobile Header -->
|
||||||
<div class="lg:hidden fixed top-16 left-0 right-0 z-20 flex items-center px-4 py-3 border-b bg-background">
|
<div class="lg:hidden fixed top-11 sm:top-14 left-0 right-0 z-20 flex items-center px-3 sm:px-4 py-2 sm:py-3 border-b bg-background">
|
||||||
<Sheet v-model:open="mobileMenuOpen">
|
<Sheet v-model:open="mobileMenuOpen">
|
||||||
<SheetTrigger as-child>
|
<SheetTrigger as-child>
|
||||||
<Button variant="ghost" size="icon" class="mr-2 h-9 w-9">
|
<Button variant="ghost" size="icon" class="mr-1.5 sm:mr-2 h-8 w-8 sm:h-9 sm:w-9">
|
||||||
<Menu class="h-4 w-4" />
|
<Menu class="h-4 w-4" />
|
||||||
<span class="sr-only">{{ t('common.menu') }}</span>
|
<span class="sr-only">{{ t('common.menu') }}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</SheetTrigger>
|
</SheetTrigger>
|
||||||
<SheetContent side="left" class="w-72 p-0">
|
<SheetContent side="left" class="w-72 p-0">
|
||||||
<div class="p-6">
|
<div class="p-4 sm:p-6">
|
||||||
<h2 class="text-lg font-semibold mb-4">{{ t('settings.title') }}</h2>
|
<h2 class="text-lg font-semibold mb-4">{{ t('settings.title') }}</h2>
|
||||||
<nav class="space-y-6">
|
<nav class="space-y-6">
|
||||||
<div v-for="group in navGroups" :key="group.title" class="space-y-1">
|
<div v-for="group in navGroups" :key="group.title" class="space-y-1">
|
||||||
@@ -1954,7 +2088,7 @@ watch(() => route.query.tab, (tab) => {
|
|||||||
</div>
|
</div>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
<h1 class="text-lg font-semibold">{{ t('settings.title') }}</h1>
|
<h1 class="text-base sm:text-lg font-semibold">{{ t('settings.title') }}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Desktop Sidebar -->
|
<!-- Desktop Sidebar -->
|
||||||
@@ -1987,7 +2121,7 @@ watch(() => route.query.tab, (tab) => {
|
|||||||
|
|
||||||
<!-- Main Content -->
|
<!-- Main Content -->
|
||||||
<main class="flex-1 overflow-y-auto">
|
<main class="flex-1 overflow-y-auto">
|
||||||
<div class="max-w-2xl mx-auto p-6 lg:p-8 pt-20 lg:pt-8 space-y-6">
|
<div class="max-w-2xl mx-auto p-3 sm:p-6 lg:p-8 pt-16 sm:pt-20 lg:pt-8 space-y-4 sm:space-y-6">
|
||||||
|
|
||||||
<!-- Appearance Section -->
|
<!-- Appearance Section -->
|
||||||
<div v-show="activeSection === 'appearance'" class="space-y-6">
|
<div v-show="activeSection === 'appearance'" class="space-y-6">
|
||||||
@@ -1997,15 +2131,15 @@ watch(() => route.query.tab, (tab) => {
|
|||||||
<CardDescription>{{ t('settings.themeDesc') }}</CardDescription>
|
<CardDescription>{{ t('settings.themeDesc') }}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div class="flex gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
<Button :variant="theme === 'light' ? 'default' : 'outline'" size="sm" @click="setTheme('light')">
|
<Button :variant="theme === 'light' ? 'default' : 'outline'" size="sm" @click="setTheme('light')">
|
||||||
<Sun class="h-4 w-4 mr-2" />{{ t('settings.lightMode') }}
|
<Sun class="h-4 w-4 mr-1.5" />{{ t('settings.lightMode') }}
|
||||||
</Button>
|
</Button>
|
||||||
<Button :variant="theme === 'dark' ? 'default' : 'outline'" size="sm" @click="setTheme('dark')">
|
<Button :variant="theme === 'dark' ? 'default' : 'outline'" size="sm" @click="setTheme('dark')">
|
||||||
<Moon class="h-4 w-4 mr-2" />{{ t('settings.darkMode') }}
|
<Moon class="h-4 w-4 mr-1.5" />{{ t('settings.darkMode') }}
|
||||||
</Button>
|
</Button>
|
||||||
<Button :variant="theme === 'system' ? 'default' : 'outline'" size="sm" @click="setTheme('system')">
|
<Button :variant="theme === 'system' ? 'default' : 'outline'" size="sm" @click="setTheme('system')">
|
||||||
<Monitor class="h-4 w-4 mr-2" />{{ t('settings.systemMode') }}
|
<Monitor class="h-4 w-4 mr-1.5" />{{ t('settings.systemMode') }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -2079,6 +2213,31 @@ watch(() => route.query.tab, (tab) => {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{{ t('settings.authSettings') }}</CardTitle>
|
||||||
|
<CardDescription>{{ t('settings.authSettingsDesc') }}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="space-y-0.5">
|
||||||
|
<Label>{{ t('settings.allowMultipleSessions') }}</Label>
|
||||||
|
<p class="text-xs text-muted-foreground">{{ t('settings.allowMultipleSessionsDesc') }}</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
v-model="authConfig.single_user_allow_multiple_sessions"
|
||||||
|
:disabled="authConfigLoading"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end pt-2">
|
||||||
|
<Button @click="saveAuthConfig" :disabled="authConfigLoading">
|
||||||
|
<Save class="h-4 w-4 mr-2" />
|
||||||
|
{{ t('common.save') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Video Section -->
|
<!-- Video Section -->
|
||||||
@@ -2585,14 +2744,70 @@ watch(() => route.query.tab, (tab) => {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Access Section -->
|
<!-- Network Section -->
|
||||||
<div v-show="activeSection === 'access'" class="space-y-6">
|
<div v-show="activeSection === 'network'" class="space-y-6">
|
||||||
|
|
||||||
|
<!-- Auto-restart: restarting progress -->
|
||||||
|
<div
|
||||||
|
v-if="autoRestarting"
|
||||||
|
class="flex items-center gap-3 rounded-lg border bg-card px-4 py-3 text-sm shadow-sm"
|
||||||
|
>
|
||||||
|
<RefreshCw class="h-4 w-4 animate-spin text-primary shrink-0" />
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="font-medium">{{ t('settings.autoRestarting') }}</p>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
{{ webServerConfig.https_enabled
|
||||||
|
? t('settings.autoRestartingHttpsDesc', { sec: autoRestartCountdown })
|
||||||
|
: t('settings.autoRestartingDesc') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span v-if="webServerConfig.https_enabled && autoRestartCountdown > 0"
|
||||||
|
class="tabular-nums text-lg font-bold text-primary shrink-0">
|
||||||
|
{{ autoRestartCountdown }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Auto-restart: HTTPS manual redirect (cert must be accepted by user) -->
|
||||||
|
<div
|
||||||
|
v-if="autoRestartManualUrl"
|
||||||
|
class="rounded-lg border border-amber-300 bg-amber-50 dark:bg-amber-950/20 dark:border-amber-700 px-4 py-4 space-y-3"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-2 text-sm text-amber-800 dark:text-amber-300">
|
||||||
|
<Lock class="h-4 w-4 shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p class="font-medium">{{ t('settings.httpsManualRedirectTitle') }}</p>
|
||||||
|
<p class="text-xs mt-0.5 opacity-80">{{ t('settings.httpsManualRedirectDesc') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
:href="autoRestartManualUrl"
|
||||||
|
class="flex items-center justify-center gap-2 w-full rounded-md bg-amber-600 hover:bg-amber-700 text-white text-sm font-medium px-4 py-2 transition-colors"
|
||||||
|
>
|
||||||
|
<ExternalLink class="h-4 w-4" />
|
||||||
|
{{ autoRestartManualUrl }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Auto-restart: failure / timeout -->
|
||||||
|
<div
|
||||||
|
v-if="autoRestartFailed"
|
||||||
|
class="flex items-center justify-between rounded-lg border border-destructive/40 bg-destructive/5 px-4 py-3 text-sm"
|
||||||
|
>
|
||||||
|
<p class="text-destructive">{{ t('settings.autoRestartFailed') }}</p>
|
||||||
|
<Button variant="outline" size="sm" @click="triggerAutoRestart">
|
||||||
|
<RefreshCw class="h-3 w-3 mr-1" />
|
||||||
|
{{ t('common.retry') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Port Configuration Card -->
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{{ t('settings.webServer') }}</CardTitle>
|
<CardTitle>{{ t('settings.portConfig') }}</CardTitle>
|
||||||
<CardDescription>{{ t('settings.webServerDesc') }}</CardDescription>
|
<CardDescription>{{ t('settings.portConfigDesc') }}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="space-y-4">
|
<CardContent class="space-y-4">
|
||||||
|
<!-- HTTPS toggle -->
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="space-y-0.5">
|
<div class="space-y-0.5">
|
||||||
<Label>{{ t('settings.httpsEnabled') }}</Label>
|
<Label>{{ t('settings.httpsEnabled') }}</Label>
|
||||||
@@ -2603,46 +2818,113 @@ watch(() => route.query.tab, (tab) => {
|
|||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<div class="grid gap-4 sm:grid-cols-2">
|
<!-- Single active-port input, label follows the HTTPS toggle -->
|
||||||
<div class="space-y-2">
|
<div class="flex items-end gap-3">
|
||||||
<Label>{{ t('settings.httpPort') }}</Label>
|
<div class="space-y-2 flex-1 max-w-[180px]">
|
||||||
<Input v-model.number="webServerConfig.http_port" type="number" min="1" max="65535" />
|
<Label>
|
||||||
|
{{ webServerConfig.https_enabled ? t('settings.httpsPort') : t('settings.httpPort') }}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
v-if="webServerConfig.https_enabled"
|
||||||
|
v-model.number="webServerConfig.https_port"
|
||||||
|
type="number" min="1" max="65535"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
v-else
|
||||||
|
v-model.number="webServerConfig.http_port"
|
||||||
|
type="number" min="1" max="65535"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<!-- Inactive-port reference (read-only hint) -->
|
||||||
<Label>{{ t('settings.httpsPort') }}</Label>
|
<div class="space-y-2 flex-1 max-w-[180px]">
|
||||||
<Input v-model.number="webServerConfig.https_port" type="number" min="1" max="65535" />
|
<Label class="text-muted-foreground text-xs">
|
||||||
|
{{ webServerConfig.https_enabled ? t('settings.httpPortReserved') : t('settings.httpsPortReserved') }}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
v-if="webServerConfig.https_enabled"
|
||||||
|
v-model.number="webServerConfig.http_port"
|
||||||
|
type="number" min="1" max="65535"
|
||||||
|
class="opacity-50"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
v-else
|
||||||
|
v-model.number="webServerConfig.https_port"
|
||||||
|
type="number" min="1" max="65535"
|
||||||
|
class="opacity-50"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-2">
|
<!-- Preview URL -->
|
||||||
<Label>{{ t('settings.bindMode') }}</Label>
|
<div class="flex items-center gap-2 rounded-md bg-muted px-3 py-2 text-sm">
|
||||||
<select v-model="bindMode" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm">
|
<span class="text-muted-foreground shrink-0">{{ t('settings.previewUrl') }}:</span>
|
||||||
<option value="all">{{ t('settings.bindModeAll') }}</option>
|
<span class="font-mono text-xs break-all">{{ previewAccessUrl }}</span>
|
||||||
<option value="loopback">{{ t('settings.bindModeLocal') }}</option>
|
|
||||||
<option value="custom">{{ t('settings.bindModeCustom') }}</option>
|
|
||||||
</select>
|
|
||||||
<p class="text-sm text-muted-foreground">{{ t('settings.bindModeDesc') }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="bindMode === 'all'" class="flex items-center justify-between">
|
<!-- Save row -->
|
||||||
<div class="space-y-0.5">
|
<div class="flex items-center justify-between pt-2">
|
||||||
<Label>{{ t('settings.bindIpv6') }}</Label>
|
<p class="text-xs text-muted-foreground">⚠ {{ t('settings.restartRequired') }}</p>
|
||||||
<p class="text-xs text-muted-foreground">{{ t('settings.bindAllDesc') }}</p>
|
<Button @click="saveWebServerConfig" :disabled="webServerLoading || autoRestarting">
|
||||||
|
<RefreshCw v-if="autoRestarting" class="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
<Save v-else class="h-4 w-4 mr-2" />
|
||||||
|
{{ autoRestarting ? t('settings.restarting') : t('common.save') }}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- Listen Address Card -->
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{{ t('settings.listenAddress') }}</CardTitle>
|
||||||
|
<CardDescription>{{ t('settings.listenAddressDesc') }}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-4">
|
||||||
|
<RadioGroup v-model="bindMode" class="space-y-3">
|
||||||
|
<!-- All addresses -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<RadioGroupItem value="all" id="bind-all" class="mt-0.5" />
|
||||||
|
<div class="flex-1">
|
||||||
|
<Label for="bind-all" class="cursor-pointer">{{ t('settings.bindModeAll') }}</Label>
|
||||||
|
<p class="text-xs text-muted-foreground mt-0.5">{{ t('settings.bindModeAllDesc') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="bindMode === 'all'" class="ml-7 flex items-center justify-between rounded-md border border-dashed px-3 py-2">
|
||||||
|
<Label class="text-sm font-normal">{{ t('settings.bindIpv6') }}</Label>
|
||||||
<Switch v-model="bindAllIpv6" />
|
<Switch v-model="bindAllIpv6" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="bindMode === 'loopback'" class="flex items-center justify-between">
|
|
||||||
<div class="space-y-0.5">
|
|
||||||
<Label>{{ t('settings.bindIpv6') }}</Label>
|
|
||||||
<p class="text-xs text-muted-foreground">{{ t('settings.bindLocalDesc') }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<!-- Loopback only -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<RadioGroupItem value="loopback" id="bind-loopback" class="mt-0.5" />
|
||||||
|
<div class="flex-1">
|
||||||
|
<Label for="bind-loopback" class="cursor-pointer">{{ t('settings.bindModeLocal') }}</Label>
|
||||||
|
<p class="text-xs text-muted-foreground mt-0.5">{{ t('settings.bindModeLocalDesc') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="bindMode === 'loopback'" class="ml-7 flex items-center justify-between rounded-md border border-dashed px-3 py-2">
|
||||||
|
<Label class="text-sm font-normal">{{ t('settings.bindIpv6') }}</Label>
|
||||||
<Switch v-model="bindLocalIpv6" />
|
<Switch v-model="bindLocalIpv6" />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="bindMode === 'custom'" class="space-y-2">
|
<Separator />
|
||||||
<Label>{{ t('settings.bindAddressList') }}</Label>
|
|
||||||
|
<!-- Custom addresses -->
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<RadioGroupItem value="custom" id="bind-custom" class="mt-0.5" />
|
||||||
|
<div class="flex-1">
|
||||||
|
<Label for="bind-custom" class="cursor-pointer">{{ t('settings.bindModeCustom') }}</Label>
|
||||||
|
<p class="text-xs text-muted-foreground mt-0.5">{{ t('settings.bindModeCustomDesc') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="bindMode === 'custom'" class="ml-7 space-y-2">
|
||||||
<div v-for="(_, i) in bindAddressList" :key="`bind-${i}`" class="flex gap-2">
|
<div v-for="(_, i) in bindAddressList" :key="`bind-${i}`" class="flex gap-2">
|
||||||
<Input v-model="bindAddressList[i]" placeholder="192.168.1.10" />
|
<Input v-model="bindAddressList[i]" placeholder="192.168.1.10" />
|
||||||
<Button variant="ghost" size="icon" :aria-label="t('common.delete')" @click="removeBindAddress(i)">
|
<Button variant="ghost" size="icon" :aria-label="t('common.delete')" @click="removeBindAddress(i)">
|
||||||
@@ -2653,41 +2935,95 @@ watch(() => route.query.tab, (tab) => {
|
|||||||
<Plus class="h-4 w-4 mr-1" />
|
<Plus class="h-4 w-4 mr-1" />
|
||||||
{{ t('settings.addBindAddress') }}
|
{{ t('settings.addBindAddress') }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
<p class="text-xs text-muted-foreground">{{ t('settings.bindAddressListDesc') }}</p>
|
|
||||||
<p v-if="bindAddressError" class="text-xs text-destructive">{{ bindAddressError }}</p>
|
<p v-if="bindAddressError" class="text-xs text-destructive">{{ bindAddressError }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
|
||||||
<div class="flex justify-end pt-4">
|
<!-- Effective addresses preview -->
|
||||||
<Button @click="saveWebServerConfig" :disabled="webServerLoading || !!bindAddressError">
|
<div v-if="effectiveBindAddresses.length > 0" class="flex items-center gap-2 rounded-md bg-muted px-3 py-2 text-sm">
|
||||||
<Save class="h-4 w-4 mr-2" />
|
<span class="text-muted-foreground shrink-0">{{ t('settings.effectiveAddresses') }}:</span>
|
||||||
{{ t('common.save') }}
|
<span class="font-mono text-xs break-all">{{ effectiveBindAddresses.join(', ') }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between pt-2">
|
||||||
|
<p class="text-xs text-muted-foreground">⚠ {{ t('settings.restartRequired') }}</p>
|
||||||
|
<Button @click="saveWebServerConfig" :disabled="webServerLoading || !!bindAddressError || autoRestarting">
|
||||||
|
<RefreshCw v-if="autoRestarting" class="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
<Save v-else class="h-4 w-4 mr-2" />
|
||||||
|
{{ autoRestarting ? t('settings.restarting') : t('common.save') }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<!-- SSL Certificate Card -->
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader class="flex flex-row items-start justify-between space-y-0 pb-3">
|
||||||
<CardTitle>{{ t('settings.authSettings') }}</CardTitle>
|
<div class="space-y-1.5">
|
||||||
<CardDescription>{{ t('settings.authSettingsDesc') }}</CardDescription>
|
<CardTitle>{{ t('settings.sslCertificate') }}</CardTitle>
|
||||||
|
<CardDescription>{{ t('settings.sslCertificateDesc') }}</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Badge :variant="webServerConfig.has_custom_cert ? 'default' : 'secondary'" class="mt-1 shrink-0">
|
||||||
|
{{ webServerConfig.has_custom_cert ? t('settings.sslCertCustom') : t('settings.sslCertSelfSigned') }}
|
||||||
|
</Badge>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="space-y-4">
|
<CardContent class="space-y-4">
|
||||||
<div class="flex items-center justify-between">
|
<!-- Active custom cert notice -->
|
||||||
<div class="space-y-0.5">
|
<div
|
||||||
<Label>{{ t('settings.allowMultipleSessions') }}</Label>
|
v-if="webServerConfig.has_custom_cert"
|
||||||
<p class="text-xs text-muted-foreground">{{ t('settings.allowMultipleSessionsDesc') }}</p>
|
class="flex items-center justify-between rounded-md border border-emerald-200 bg-emerald-50 dark:bg-emerald-950/20 dark:border-emerald-800 px-3 py-2"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2 text-sm text-emerald-700 dark:text-emerald-400">
|
||||||
|
<Check class="h-4 w-4 shrink-0" />
|
||||||
|
{{ t('settings.sslCertActive') }}
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Button
|
||||||
v-model="authConfig.single_user_allow_multiple_sessions"
|
variant="ghost"
|
||||||
:disabled="authConfigLoading"
|
size="sm"
|
||||||
|
class="text-destructive hover:text-destructive h-7 text-xs"
|
||||||
|
:disabled="certClearing || autoRestarting"
|
||||||
|
@click="clearCertificate"
|
||||||
|
>
|
||||||
|
<RefreshCw v-if="certClearing || autoRestarting" class="h-3 w-3 mr-1 animate-spin" />
|
||||||
|
<Trash2 v-else class="h-3 w-3 mr-1" />
|
||||||
|
{{ autoRestarting ? t('settings.restarting') : t('settings.sslCertClear') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Certificate textarea -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>{{ t('settings.sslCertPem') }}</Label>
|
||||||
|
<Textarea
|
||||||
|
v-model="sslCertPem"
|
||||||
|
:placeholder="t('settings.sslCertPemPlaceholder')"
|
||||||
|
class="font-mono text-xs min-h-[110px] resize-y"
|
||||||
|
spellcheck="false"
|
||||||
|
autocomplete="off"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
|
||||||
<p class="text-xs text-muted-foreground">{{ t('settings.singleUserSessionNote') }}</p>
|
<!-- Key textarea -->
|
||||||
<div class="flex justify-end pt-2">
|
<div class="space-y-2">
|
||||||
<Button @click="saveAuthConfig" :disabled="authConfigLoading">
|
<Label>{{ t('settings.sslKeyPem') }}</Label>
|
||||||
<Save class="h-4 w-4 mr-2" />
|
<Textarea
|
||||||
{{ t('common.save') }}
|
v-model="sslKeyPem"
|
||||||
|
:placeholder="t('settings.sslKeyPemPlaceholder')"
|
||||||
|
class="font-mono text-xs min-h-[110px] resize-y"
|
||||||
|
spellcheck="false"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between pt-1">
|
||||||
|
<p class="text-xs text-muted-foreground">⚠ {{ t('settings.restartRequired') }}</p>
|
||||||
|
<Button
|
||||||
|
:disabled="certSaving || autoRestarting || !sslCertPem.trim() || !sslKeyPem.trim()"
|
||||||
|
@click="saveCertificate"
|
||||||
|
>
|
||||||
|
<RefreshCw v-if="certSaving || autoRestarting" class="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
<Save v-else class="h-4 w-4 mr-2" />
|
||||||
|
{{ autoRestarting ? t('settings.restarting') : t('settings.sslCertSave') }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -3089,7 +3425,7 @@ watch(() => route.query.tab, (tab) => {
|
|||||||
v-if="!isExtRunning(extensions?.gostc?.status)"
|
v-if="!isExtRunning(extensions?.gostc?.status)"
|
||||||
size="sm"
|
size="sm"
|
||||||
@click="startExtension('gostc')"
|
@click="startExtension('gostc')"
|
||||||
:disabled="extensionsLoading || !extConfig.gostc.key"
|
:disabled="extensionsLoading || !extConfig.gostc.key || !extConfig.gostc.addr?.trim()"
|
||||||
>
|
>
|
||||||
<Play class="h-4 w-4 mr-1" />
|
<Play class="h-4 w-4 mr-1" />
|
||||||
{{ t('extensions.start') }}
|
{{ t('extensions.start') }}
|
||||||
@@ -3115,7 +3451,7 @@ watch(() => route.query.tab, (tab) => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||||
<Label class="sm:text-right">{{ t('extensions.gostc.addr') }}</Label>
|
<Label class="sm:text-right">{{ t('extensions.gostc.addr') }}</Label>
|
||||||
<Input v-model="extConfig.gostc.addr" class="sm:col-span-3" placeholder="gostc.mofeng.run" :disabled="isExtRunning(extensions?.gostc?.status)" />
|
<Input v-model="extConfig.gostc.addr" class="sm:col-span-3" :placeholder="t('extensions.gostc.addrPlaceholder')" :disabled="isExtRunning(extensions?.gostc?.status)" />
|
||||||
</div>
|
</div>
|
||||||
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||||
<Label class="sm:text-right">{{ t('extensions.gostc.key') }}</Label>
|
<Label class="sm:text-right">{{ t('extensions.gostc.key') }}</Label>
|
||||||
@@ -3461,7 +3797,11 @@ watch(() => route.query.tab, (tab) => {
|
|||||||
<div class="sm:col-span-3 space-y-1">
|
<div class="sm:col-span-3 space-y-1">
|
||||||
<Input
|
<Input
|
||||||
v-model="rustdeskLocalConfig.relay_key"
|
v-model="rustdeskLocalConfig.relay_key"
|
||||||
type="password"
|
type="text"
|
||||||
|
maxlength="44"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
class="font-mono"
|
||||||
:placeholder="rustdeskStatus?.config?.has_relay_key ? t('extensions.rustdesk.relayKeySet') : t('extensions.rustdesk.relayKeyPlaceholder')"
|
:placeholder="rustdeskStatus?.config?.has_relay_key ? t('extensions.rustdesk.relayKeySet') : t('extensions.rustdesk.relayKeyPlaceholder')"
|
||||||
/>
|
/>
|
||||||
<p class="text-xs text-muted-foreground">{{ t('extensions.rustdesk.relayKeyHint') }}</p>
|
<p class="text-xs text-muted-foreground">{{ t('extensions.rustdesk.relayKeyHint') }}</p>
|
||||||
@@ -3636,12 +3976,12 @@ watch(() => route.query.tab, (tab) => {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div class="flex justify-between items-center py-2 border-b">
|
<div class="flex justify-between items-center py-2 border-b gap-2">
|
||||||
<span class="text-sm text-muted-foreground">{{ t('settings.hostname') }}</span>
|
<span class="text-sm text-muted-foreground shrink-0">{{ t('settings.hostname') }}</span>
|
||||||
<span class="text-sm font-medium">{{ systemStore.deviceInfo.hostname }}</span>
|
<span class="text-sm font-medium truncate">{{ systemStore.deviceInfo.hostname }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between items-center py-2 border-b">
|
<div class="flex justify-between items-center py-2 border-b gap-2">
|
||||||
<span class="text-sm text-muted-foreground">{{ t('settings.cpuModel') }}</span>
|
<span class="text-sm text-muted-foreground shrink-0">{{ t('settings.cpuModel') }}</span>
|
||||||
<span class="text-sm font-medium truncate max-w-[60%] text-right">{{ systemStore.deviceInfo.cpu_model }}</span>
|
<span class="text-sm font-medium truncate max-w-[60%] text-right">{{ systemStore.deviceInfo.cpu_model }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between items-center py-2 border-b">
|
<div class="flex justify-between items-center py-2 border-b">
|
||||||
@@ -3668,22 +4008,20 @@ watch(() => route.query.tab, (tab) => {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<p class="text-xs text-muted-foreground text-center">{{ t('settings.builtWith') }}</p>
|
<p class="text-xs text-muted-foreground text-center">@2025-2026 SilentWind</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Save Button (sticky) -->
|
<!-- Save Button (sticky) -->
|
||||||
<div v-if="['video', 'hid', 'msd'].includes(activeSection)" class="sticky bottom-0 pt-4 pb-2 bg-background border-t -mx-6 px-6 lg:-mx-8 lg:px-8">
|
<div v-if="['video', 'hid', 'msd'].includes(activeSection)" class="sticky bottom-0 pt-3 sm:pt-4 pb-2 bg-background border-t -mx-3 px-3 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8">
|
||||||
<div class="flex justify-end">
|
<div class="flex items-center justify-between sm:justify-end gap-2 sm:gap-3">
|
||||||
<div class="flex items-center gap-3">
|
<p v-if="activeSection === 'hid' && !isHidFunctionSelectionValid" class="text-xs text-amber-600 dark:text-amber-400 flex-1 min-w-0">
|
||||||
<p v-if="activeSection === 'hid' && !isHidFunctionSelectionValid" class="text-xs text-amber-600 dark:text-amber-400">
|
|
||||||
{{ t('settings.otgFunctionMinWarning') }}
|
{{ t('settings.otgFunctionMinWarning') }}
|
||||||
</p>
|
</p>
|
||||||
<Button :disabled="loading || (activeSection === 'hid' && !isHidFunctionSelectionValid)" @click="saveConfig">
|
<Button class="shrink-0" :disabled="loading || (activeSection === 'hid' && !isHidFunctionSelectionValid)" @click="saveConfig">
|
||||||
<Check v-if="saved" class="h-4 w-4 mr-2" /><Save v-else class="h-4 w-4 mr-2" />{{ saved ? t('common.success') : t('common.save') }}
|
<Check v-if="saved" class="h-4 w-4 mr-2" /><Save v-else class="h-4 w-4 mr-2" />{{ saved ? t('common.success') : t('common.save') }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
@@ -3691,17 +4029,17 @@ watch(() => route.query.tab, (tab) => {
|
|||||||
|
|
||||||
<!-- Terminal Dialog -->
|
<!-- Terminal Dialog -->
|
||||||
<Dialog v-model:open="showTerminalDialog">
|
<Dialog v-model:open="showTerminalDialog">
|
||||||
<DialogContent class="w-[95vw] max-w-5xl h-[85dvh] max-h-[720px] p-0 flex flex-col overflow-hidden">
|
<DialogContent class="w-[98vw] sm:w-[95vw] max-w-5xl h-[90dvh] sm:h-[85dvh] max-h-[720px] p-0 flex flex-col overflow-hidden">
|
||||||
<DialogHeader class="px-4 py-3 border-b shrink-0">
|
<DialogHeader class="px-3 sm:px-4 py-2 sm:py-3 border-b shrink-0">
|
||||||
<DialogTitle class="flex items-center justify-between w-full">
|
<DialogTitle class="flex items-center justify-between w-full">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Terminal class="h-5 w-5" />
|
<Terminal class="h-4 w-4 sm:h-5 sm:w-5" />
|
||||||
{{ t('extensions.ttyd.title') }}
|
<span class="text-sm sm:text-base">{{ t('extensions.ttyd.title') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
class="h-8 w-8 mr-8"
|
class="h-7 w-7 sm:h-8 sm:w-8 mr-6 sm:mr-8"
|
||||||
@click="openTerminalInNewTab"
|
@click="openTerminalInNewTab"
|
||||||
:aria-label="t('extensions.ttyd.openInNewTab')"
|
:aria-label="t('extensions.ttyd.openInNewTab')"
|
||||||
:title="t('extensions.ttyd.openInNewTab')"
|
:title="t('extensions.ttyd.openInNewTab')"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useAuthStore } from '@/stores/auth'
|
|||||||
import { configApi, streamApi, type EncoderBackendInfo } from '@/api'
|
import { configApi, streamApi, type EncoderBackendInfo } from '@/api'
|
||||||
import { formatFpsLabel, toConfigFps } from '@/lib/fps'
|
import { formatFpsLabel, toConfigFps } from '@/lib/fps'
|
||||||
import LanguageToggleButton from '@/components/LanguageToggleButton.vue'
|
import LanguageToggleButton from '@/components/LanguageToggleButton.vue'
|
||||||
|
import BrandMark from '@/components/BrandMark.vue'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
@@ -24,7 +25,6 @@ import {
|
|||||||
} from '@/components/ui/hover-card'
|
} from '@/components/ui/hover-card'
|
||||||
import { Switch } from '@/components/ui/switch'
|
import { Switch } from '@/components/ui/switch'
|
||||||
import {
|
import {
|
||||||
Monitor,
|
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
@@ -572,10 +572,8 @@ const stepIcons = [User, Video, Keyboard, Puzzle]
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CardHeader class="text-center space-y-2 pt-10 sm:pt-12">
|
<CardHeader class="text-center space-y-2 pt-10 sm:pt-12">
|
||||||
<div
|
<div class="mx-auto flex justify-center">
|
||||||
class="inline-flex items-center justify-center w-16 h-16 mx-auto rounded-full bg-primary/10"
|
<BrandMark size="xl" />
|
||||||
>
|
|
||||||
<Monitor class="w-8 h-8 text-primary" />
|
|
||||||
</div>
|
</div>
|
||||||
<CardTitle class="text-xl sm:text-2xl">{{ t('setup.welcome') }}</CardTitle>
|
<CardTitle class="text-xl sm:text-2xl">{{ t('setup.welcome') }}</CardTitle>
|
||||||
<CardDescription>{{ t('setup.description') }}</CardDescription>
|
<CardDescription>{{ t('setup.description') }}</CardDescription>
|
||||||
|
|||||||
Reference in New Issue
Block a user