feat: add unified Tauri desktop client and secure web access

This commit is contained in:
lbl
2026-08-22 04:09:44 +08:00
parent 0ccd045e78
commit cd5b4431dc
62 changed files with 5018 additions and 500 deletions
+3
View File
@@ -8,6 +8,7 @@ vnt-core.workspace = true
tokio = { workspace = true, features = ["full"] }
axum = "0.8.8"
tower-http = { version = "0.7", features = ["fs", "cors", "trace"] }
tower = { version = "0.5", features = ["util"] }
serde.workspace = true
serde_json.workspace = true
@@ -21,3 +22,5 @@ rust-embed = "8.0"
mime_guess = "2.0"
parking_lot.workspace = true
time = { workspace = true, features = ["local-offset", "formatting", "macros"] }
tokio-util.workspace = true
rand = "0.9"
+1 -1
View File
@@ -1,6 +1,6 @@
mod service_http;
pub use service_http::run_http_server;
pub use service_http::{VntService, generate_access_token, run_http_server};
struct ScopeGuard<F: FnOnce()>(Option<F>);
+165 -35
View File
@@ -1,7 +1,7 @@
use crate::defer;
use anyhow::{Context, anyhow, bail};
use axum::body::Body;
use axum::http::{HeaderMap, HeaderValue, StatusCode, Uri, header};
use axum::body::{Body, to_bytes};
use axum::http::{HeaderMap, HeaderValue, Method, StatusCode, Uri, header};
use axum::response::IntoResponse;
use axum::{
Json, Router,
@@ -13,6 +13,7 @@ use axum::{
use ipnet::Ipv4Net;
use mime_guess::from_path;
use parking_lot::Mutex;
use rand::Rng;
use rust_embed::RustEmbed;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
@@ -23,6 +24,8 @@ use std::time::{Instant, SystemTime, UNIX_EPOCH};
use time::{OffsetDateTime, format_description};
use tokio::fs;
use tokio::net::TcpListener;
use tokio_util::sync::CancellationToken;
use tower::ServiceExt;
use tower_http::cors::{Any, CorsLayer};
use vnt_core::api::VntApi;
use vnt_core::context::config::Config as CoreConfig;
@@ -465,37 +468,86 @@ async fn logging_middleware(req: Request, next: axum::middleware::Next) -> Respo
#[folder = "static/"]
struct Asset;
pub async fn run_http_server(
addr: SocketAddr,
start_config_file_name: Option<PathBuf>,
) -> anyhow::Result<()> {
fs::create_dir_all(CONFIG_DIR)
.await
.context("Failed to create config directory")?;
/// 进程内 VNT 业务服务。HTTP 和 Tauri IPC 共用同一组 handler 与状态。
#[derive(Clone)]
pub struct VntService {
router: Router,
}
let state = HttpAppState {
inner: Arc::new(Default::default()),
};
impl VntService {
pub async fn new(start_config_file_name: Option<PathBuf>) -> anyhow::Result<Self> {
fs::create_dir_all(CONFIG_DIR)
.await
.context("Failed to create config directory")?;
// 自动启动逻辑
let auto_start_files = determine_auto_start_files(start_config_file_name).await;
let state = HttpAppState {
inner: Arc::new(Default::default()),
};
for (file_name, path) in auto_start_files {
log::info!("Auto starting VNT with config: {:?}", path);
let state_clone = state.clone();
tokio::spawn(async move {
if let Err(e) = start_vnt_internal(&state_clone, file_name, path).await {
log::error!("Auto start failed: {:?}", e);
}
});
for (file_name, path) in determine_auto_start_files(start_config_file_name).await {
log::info!("Auto starting VNT with config: {:?}", path);
let state_clone = state.clone();
tokio::spawn(async move {
if let Err(e) = start_vnt_internal(&state_clone, file_name, path).await {
log::error!("Auto start failed: {:?}", e);
}
});
}
Ok(Self {
router: api_router(state),
})
}
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any);
/// 由 Tauri command 调用,不经过 TCP/HTTP 监听端口。
pub async fn request(
&self,
method: &str,
path: &str,
body: Option<String>,
) -> anyhow::Result<serde_json::Value> {
let method = Method::from_bytes(method.as_bytes()).context("Invalid request method")?;
let request = axum::http::Request::builder()
.method(method)
.uri(path)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(body.unwrap_or_default()))?;
let response = self.router.clone().oneshot(request).await?;
let status = response.status();
let bytes = to_bytes(response.into_body(), 8 * 1024 * 1024).await?;
let value: serde_json::Value = serde_json::from_slice(&bytes)
.with_context(|| format!("Invalid service response ({status})"))?;
Ok(value)
}
let app = Router::new()
/// 在当前进程中按需开放带令牌鉴权的 Web 服务。
pub async fn start_http(
&self,
addr: SocketAddr,
token: String,
cancellation: CancellationToken,
) -> anyhow::Result<tokio::task::JoinHandle<anyhow::Result<()>>> {
let listener = TcpListener::bind(addr).await?;
let actual_addr = listener.local_addr()?;
let app = http_router(self.router.clone(), token);
log::info!("HTTP API Listening on http://{}", actual_addr);
Ok(tokio::spawn(async move {
axum::serve(listener, app)
.with_graceful_shutdown(cancellation.cancelled_owned())
.await?;
Ok(())
}))
}
}
pub fn generate_access_token() -> String {
let mut bytes = [0_u8; 24];
rand::rng().fill(&mut bytes);
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
}
fn api_router(state: HttpAppState) -> Router {
Router::new()
.route("/api/version", get(get_version))
.route("/api/info", get(get_info))
.route("/api/peers", get(get_peers))
@@ -511,17 +563,56 @@ pub async fn run_http_server(
"/api/config",
get(get_config).post(save_config).delete(delete_config),
)
.with_state(state)
}
async fn token_auth_middleware(
State(token): State<String>,
req: Request,
next: axum::middleware::Next,
) -> Response {
let authorized = req
.headers()
.get(header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.strip_prefix("Bearer "))
.is_some_and(|provided| provided == token);
if !authorized {
return (
StatusCode::UNAUTHORIZED,
Json(ApiResponse::<()>::error("访问令牌无效或已过期")),
)
.into_response();
}
next.run(req).await
}
fn http_router(api: Router, token: String) -> Router {
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any);
Router::new()
.merge(api.layer(middleware::from_fn_with_state(
token,
token_auth_middleware,
)))
.fallback(static_handler)
.layer(cors)
.layer(middleware::from_fn(logging_middleware))
.with_state(state)
.fallback(static_handler);
log::info!("HTTP API Listening on http://{}", addr);
let listener = TcpListener::bind(addr).await?;
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await?;
}
pub async fn run_http_server(
addr: SocketAddr,
start_config_file_name: Option<PathBuf>,
token: String,
) -> anyhow::Result<()> {
let service = VntService::new(start_config_file_name).await?;
let cancellation = CancellationToken::new();
let handle = service.start_http(addr, token, cancellation.clone()).await?;
shutdown_signal().await;
cancellation.cancel();
handle.await??;
Ok(())
}
@@ -1498,6 +1589,45 @@ async fn get_routes(
mod tests {
use super::*;
#[tokio::test]
async fn test_ipc_request_uses_in_process_router() {
let service = VntService {
router: api_router(new_test_state()),
};
let response = service.request("GET", "/api/version", None).await.unwrap();
assert_eq!(response["code"], 0);
assert!(response["data"].as_str().is_some_and(|value| !value.is_empty()));
}
#[tokio::test]
async fn test_http_api_requires_bearer_token() {
let token = "test-token-with-enough-entropy".to_string();
let app = http_router(api_router(new_test_state()), token.clone());
let unauthorized = app
.clone()
.oneshot(
axum::http::Request::builder()
.uri("/api/version")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED);
let authorized = app
.oneshot(
axum::http::Request::builder()
.uri("/api/version")
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(authorized.status(), StatusCode::OK);
}
#[test]
fn test_normalize_config_file_name() {
// 无扩展名补 .toml
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

+2 -2
View File
@@ -19,8 +19,8 @@
if (dark) document.documentElement.classList.add("dark");
})();
</script>
<script type="module" crossorigin src="/assets/index-DM6YOQ55.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-xUGHQxai.css">
<script type="module" crossorigin src="/assets/index-BxCqIugQ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BgNGjSZS.css">
</head>
<body>
<div id="app"></div>
+7 -7
View File
@@ -8,14 +8,14 @@
"build": "vite build"
},
"dependencies": {
"pinia": "^3.0.3",
"vue": "^3.5.18",
"vue-router": "^4.5.1"
"pinia": "catalog:",
"vue": "catalog:",
"vue-router": "catalog:"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.12",
"@vitejs/plugin-vue": "^6.0.1",
"tailwindcss": "^4.1.12",
"vite": "^7.1.3"
"@tailwindcss/vite": "catalog:",
"@vitejs/plugin-vue": "catalog:",
"tailwindcss": "catalog:",
"vite": "catalog:"
}
}
-1312
View File
@@ -1,1312 +0,0 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
pinia:
specifier: ^3.0.3
version: 3.0.4([email protected])
vue:
specifier: ^3.5.18
version: 3.5.41
vue-router:
specifier: ^4.5.1
version: 4.6.4([email protected])
devDependencies:
'@tailwindcss/vite':
specifier: ^4.1.12
version: 4.3.3([email protected]([email protected])([email protected]))
'@vitejs/plugin-vue':
specifier: ^6.0.1
version: 6.0.8([email protected]([email protected])([email protected]))([email protected])
tailwindcss:
specifier: ^4.1.12
version: 4.3.3
vite:
specifier: ^7.1.3
version: 7.3.6([email protected])([email protected])
packages:
'@babel/[email protected]':
resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
engines: {node: '>=6.9.0'}
'@babel/[email protected]':
resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
engines: {node: '>=6.9.0'}
'@babel/[email protected]':
resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==}
engines: {node: '>=6.0.0'}
hasBin: true
'@babel/[email protected]':
resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==}
engines: {node: '>=6.9.0'}
'@esbuild/[email protected]':
resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/[email protected]':
resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/[email protected]':
resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/[email protected]':
resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/[email protected]':
resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/[email protected]':
resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/[email protected]':
resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/[email protected]':
resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/[email protected]':
resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/[email protected]':
resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/[email protected]':
resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/[email protected]':
resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/[email protected]':
resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/[email protected]':
resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/[email protected]':
resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/[email protected]':
resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/[email protected]':
resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/[email protected]':
resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/[email protected]':
resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/[email protected]':
resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/[email protected]':
resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/[email protected]':
resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
'@esbuild/[email protected]':
resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/[email protected]':
resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/[email protected]':
resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/[email protected]':
resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
'@jridgewell/[email protected]':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
'@jridgewell/[email protected]':
resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
'@jridgewell/[email protected]':
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
engines: {node: '>=6.0.0'}
'@jridgewell/[email protected]':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
'@jridgewell/[email protected]':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@napi-rs/[email protected]':
resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==}
engines: {node: ^22.20 || ^24.12 || >=25}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rolldown/[email protected]':
resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
'@rollup/[email protected]':
resolution: {integrity: sha512-jfkGfTwhQpsiSckPF8r9bU3pn3vyd72NlWaO+TgEO6WPSDnUhXzrNYCHBMOYj0ACaUgjm6eERLF+XV9a6RstoA==}
cpu: [arm]
os: [android]
'@rollup/[email protected]':
resolution: {integrity: sha512-oGVqyQlxnrz9/ty89oHpU857VUHEl5/Xu4R2lS+aivCTrNnSsbiENzTnNaBsjxH0CNWGPhzHArOLFwo+oKXveA==}
cpu: [arm64]
os: [android]
'@rollup/[email protected]':
resolution: {integrity: sha512-bW7B8xMEq8n99Q3ieEcPRGuphurdZAaFzQc9Efyyw3FL6DZO6pMy9xhdN+kBoD7Sy05xNXSr4OyPPnpkYriS/A==}
cpu: [arm64]
os: [darwin]
'@rollup/[email protected]':
resolution: {integrity: sha512-YSwBS86QeHOGlrxJ1PSOIZSkzRL/JmKeunhc+lV6M1a6En8QuVCD/T/qIA0J4Gd2Y86RIOBYrLcOUtqGh9+/1w==}
cpu: [x64]
os: [darwin]
'@rollup/[email protected]':
resolution: {integrity: sha512-2fST8lILgl7cKbme/1KDdPCmbXbG+gqoV3bHp19L0ypX/3akYMBVdOunPleRCwonoLnXOZ/0F+Mt/v8POFmfcQ==}
cpu: [arm64]
os: [freebsd]
'@rollup/[email protected]':
resolution: {integrity: sha512-cpIxQCP9J+EVad0a6LO1kY3ZGODlk80VlI+2I96B8xMcdHZ4pLVhfQ49JFpYqjPF91FFkQWftf57YlDcTiw9yQ==}
cpu: [x64]
os: [freebsd]
'@rollup/[email protected]':
resolution: {integrity: sha512-r9fGh3eFs3e/udWh5ZjXQtxiYK/xoFxQaYR/cELxac/Udkl5Th+IsFm0CX3Kl9hmUH/we7EoMpjJgeQNnE0+IA==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/[email protected]':
resolution: {integrity: sha512-xdvFdp7OM6KLJviJT2g/YuRSUjnZgGHk4RNgwIbN7X6cPugOucV60DdHXWzsBVCUdrGb6qSXnJQrrAKMmQuj3Q==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/[email protected]':
resolution: {integrity: sha512-rRqILAndyzHzP7T9NFQrq+4HFWNhqkqkKur7eiBpfLmz01PO0JKx5Vchu3YllE4YXI/Ftgq/szrDWg5GJ0mI8g==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/[email protected]':
resolution: {integrity: sha512-Gf4X3qVMucayUvux6aXXPgXovocSFUC0rrffDuPI/S2nHhNMhjcZxsrAFYCOF350PRreW1XwzFj3CT/3bKsWCw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/[email protected]':
resolution: {integrity: sha512-+s5qA0TNM0qm8PK/a5gt/1Hpx+NV08uSuCncvhziIlQzT6AEV2fnUQo7eBtFTFO0nA9scauvoR2HusfXmQnO4w==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/[email protected]':
resolution: {integrity: sha512-ybb6QvWwWJCbBWqERpc8K3pYVGIrXlG8MEQ8IIuJY6Y9KdHQxoFoNyfkAOtKn1VHu3KuLidXvwrvGR1mEjeWCw==}
cpu: [loong64]
os: [linux]
libc: [musl]
'@rollup/[email protected]':
resolution: {integrity: sha512-nZb1DtnOyhCmYvsC8A2CwOkopVg+IS1+fPUa7rMOAXtNw5+lLCLLPqd6XAiNrGtoQKsbvIBOwsHnBH/3wnb4HQ==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/[email protected]':
resolution: {integrity: sha512-yMbj63Sp89ryrXLWyz+sy+fYD2HpOnMCLGbe4Oa1smclFSUukdtD/BgdiHaAetJNb74URD8U4hM+qG5KVzMEkg==}
cpu: [ppc64]
os: [linux]
libc: [musl]
'@rollup/[email protected]':
resolution: {integrity: sha512-mhoan3OJw2kYV/e1jtIdmvUZgyBFeA6zGWsOswmR0Tg19TQbowZuR+JMLID6spbbBN7Zee2ejrgmy3+FxGrIdA==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/[email protected]':
resolution: {integrity: sha512-5ZTLmjWbb1VZdjuyhe83K/8QO0/h11midQCBP+X5OYn32ra7eOBoM0ZqtaY4nkgNsYgmdVhMYPoyVPTjUpHf3w==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/[email protected]':
resolution: {integrity: sha512-m53kG+br6PGxOTmgBEM2DHSDs9RVjsyEbUwjJPJGTFm1grWOG8EKJggDCTb60unD4Tjby8fi7/m9XfkEWasVWg==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/[email protected]':
resolution: {integrity: sha512-6RHPJR1g/uvdYU8uXBnfq3nlqyZCP82Fr6NHgfGoaIeSh0YEqnX/x6uA9MmJJbnSH7swqX4F+CkGdUF+6doiQA==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/[email protected]':
resolution: {integrity: sha512-xs+OXQtEXgpXT0DmA5+U3qnRZHdCST/5HRQxS8wSPZTUZN/EMWeHuSIod32LQklTBZBV9DyfncKBQ8n5V3eFdw==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/[email protected]':
resolution: {integrity: sha512-e7hD+sl3s+mcLQDZ8pbudBVsdG6r5yN4w3LqG2TJ8sQHDpblWj5lrJs/3m01Cvlxbt4x13zu5thLjgypgtkYzw==}
cpu: [x64]
os: [openbsd]
'@rollup/[email protected]':
resolution: {integrity: sha512-GiyJaCf+WpMub/17aPcKk27QMl5W6f+KhdPTjlFOn5akH5Wa/DCM9Stdx5cDfmasyKB08MqpVQ1uJE2RkkpbXg==}
cpu: [arm64]
os: [openharmony]
'@rollup/[email protected]':
resolution: {integrity: sha512-+OQ8U2DdoEfXl8T4Fb18AjmEwbXMerKDKCL8yCPAYhKCEEKoul7rkbeGCBFCbAlaGaa7pmtRTpkAJM2LE/i5FA==}
cpu: [arm64]
os: [win32]
'@rollup/[email protected]':
resolution: {integrity: sha512-KanvAZrPKbDBFwrgiU9yEVpQoox9QPV1WZOXX7HudJQY+eSlu82CtWxDU8WtuRRvtN5EGkLczkd6Y6DTcvm9wA==}
cpu: [ia32]
os: [win32]
'@rollup/[email protected]':
resolution: {integrity: sha512-1aC3UEWTtRl3RK3VpDJ/Tqk1XI4SLTmXIthAq6wRWo8XiSXJNd+VprJM4/1P4+i6HIaFEFlVi9sTTziniD2tOQ==}
cpu: [x64]
os: [win32]
'@rollup/[email protected]':
resolution: {integrity: sha512-/gDJaRs4gl0NPIwqCz+6PkpmhhjRAD2j6P4rSNHBzUkO3naEx2mIU0pRle1vUNRQ7mE/+8OOeXLTv/J56FKiQg==}
cpu: [x64]
os: [win32]
'@tailwindcss/[email protected]':
resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==}
'@tailwindcss/[email protected]':
resolution: {integrity: sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [android]
'@tailwindcss/[email protected]':
resolution: {integrity: sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [darwin]
'@tailwindcss/[email protected]':
resolution: {integrity: sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==}
engines: {node: '>= 20'}
cpu: [x64]
os: [darwin]
'@tailwindcss/[email protected]':
resolution: {integrity: sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==}
engines: {node: '>= 20'}
cpu: [x64]
os: [freebsd]
'@tailwindcss/[email protected]':
resolution: {integrity: sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==}
engines: {node: '>= 20'}
cpu: [arm]
os: [linux]
'@tailwindcss/[email protected]':
resolution: {integrity: sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tailwindcss/[email protected]':
resolution: {integrity: sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tailwindcss/[email protected]':
resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tailwindcss/[email protected]':
resolution: {integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tailwindcss/[email protected]':
resolution: {integrity: sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==}
engines: {node: '>=14.0.0'}
cpu: [wasm32]
bundledDependencies:
- '@napi-rs/wasm-runtime'
- '@emnapi/core'
- '@emnapi/runtime'
- '@tybys/wasm-util'
- '@emnapi/wasi-threads'
- tslib
'@tailwindcss/[email protected]':
resolution: {integrity: sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [win32]
'@tailwindcss/[email protected]':
resolution: {integrity: sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==}
engines: {node: '>= 20'}
cpu: [x64]
os: [win32]
'@tailwindcss/[email protected]':
resolution: {integrity: sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==}
engines: {node: '>= 20'}
'@tailwindcss/[email protected]':
resolution: {integrity: sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==}
peerDependencies:
vite: ^5.2.0 || ^6 || ^7 || ^8
'@types/[email protected]':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
'@vitejs/[email protected]':
resolution: {integrity: sha512-0ZjgOg7oO6farnNGup7yvoM/YXZV84OZxHAwtflItNa/6zzQyVb5LNxyea3FEKEX2XlagIKzrlH7wwxkKgtiew==}
engines: {node: ^20.19.0 || >=22.12.0}
peerDependencies:
vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
vue: ^3.2.25
'@vue/[email protected]':
resolution: {integrity: sha512-q0Xtv/F9w2YO/7htQhtiL+Ev2WCJbe5N2hc+XfgyKkEKqWpSxknmT8QOuGdEKNdjPq0c3F7rNpFkTo3Kfrm7pg==}
'@vue/[email protected]':
resolution: {integrity: sha512-oKacVfNglLvGjnS6BXOlGL7EyG2h8X03pqXCjzotRZUaXGjbrTJUnVAQjrCqUnS+lyu31nwQjZY/d817GmCnfw==}
'@vue/[email protected]':
resolution: {integrity: sha512-XJhip7R2wy6vX3knCxdZN4KracFaZUef58s1KYewqluedHIJaPIVfXoYT7MF1F8nCvv6k8bWWxDC8opMkg1VTQ==}
'@vue/[email protected]':
resolution: {integrity: sha512-U3v5OejKEGqOI0Wy0+Sz7hGuIFZHA4LSXzrNM3IMIeDyJEBBfTpX26n3SDgToRpP2bLc9FfI2j/kSgcJ8Emq5A==}
'@vue/[email protected]':
resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==}
'@vue/[email protected]':
resolution: {integrity: sha512-KxtEpUOOpFz/qOGRrAwA36QF7DqIA+FXgCYit9mk9wjbaZt0sXOFz81ElOZtKA4HbWHUdwNjZHBFsFFyp5BZiA==}
'@vue/[email protected]':
resolution: {integrity: sha512-3WNi2Kq4tbpVbmhml7RiphmAt0279oh3fKNeWMQIrltfX8Q91b4i5PL8DtyNKdwmcsGrV4fg+erwWOmD05CLIw==}
'@vue/[email protected]':
resolution: {integrity: sha512-wOPslzB8vTvpxwdaOcR2qAbwmuSP0L+rhpoC6Cf56V3Jip+HWb7PQQXOUPgBNQARpXsbQX/+mvi8kKucmBGRwQ==}
'@vue/[email protected]':
resolution: {integrity: sha512-rznsqKM0np0x18EjzF8x88MpEhdNsffbvFbckLL5+oUKz1BxAImEmO7J1ArRYSyo6aQaVoBDp7jEkT91OOxydA==}
'@vue/[email protected]':
resolution: {integrity: sha512-Vcry58hiAKwGen9Z1jUZE0feFsNArPCMOImYI8el48A9Idf6DuQYD0U05zZIF2Iad1hGhPSvcbBbAOhNr55fhg==}
'@vue/[email protected]':
resolution: {integrity: sha512-3vVBahVBS9+U6cmXBLyb8nE6/yYo4J/CGI9eVFs3KiMc0YHuudwKyShTD65jtJy/L9PUUxNAFu4cj4LiJ0UFbw==}
'@vue/[email protected]':
resolution: {integrity: sha512-n6hx/pNFfbD6SuyeuMVkvqox8bwf/ET9JlA/kAz/imw8sw++wkqKe2mHX5KutjPpbKE4Z56yTHszoOjGMI9igQ==}
'@vue/[email protected]':
resolution: {integrity: sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA==}
[email protected]:
resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==}
[email protected]:
resolution: {integrity: sha512-ufbM3smX/Jbnpk5wcQjzd1MgBpzmqfNETUAyZNrGwU9foRlyHoGzMMBBCRzEhQLBjZfFDE1W2ufPXX2vdWkV8Q==}
engines: {node: '>=18'}
[email protected]:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
[email protected]:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
[email protected]:
resolution: {integrity: sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==}
engines: {node: '>=10.13.0'}
[email protected]:
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
engines: {node: '>=0.12'}
[email protected]:
resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==}
engines: {node: '>=18'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
[email protected]:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
peerDependencies:
picomatch: ^3 || ^4
peerDependenciesMeta:
picomatch:
optional: true
[email protected]:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
[email protected]:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
[email protected]:
resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
[email protected]:
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
hasBin: true
[email protected]:
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [android]
[email protected]:
resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [darwin]
[email protected]:
resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [darwin]
[email protected]:
resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [freebsd]
[email protected]:
resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==}
engines: {node: '>= 12.0.0'}
cpu: [arm]
os: [linux]
[email protected]:
resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
[email protected]:
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
[email protected]:
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
[email protected]:
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
[email protected]:
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [win32]
[email protected]:
resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [win32]
[email protected]:
resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
engines: {node: '>= 12.0.0'}
[email protected]:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
[email protected]:
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
[email protected]:
resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
[email protected]:
resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
[email protected]:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
[email protected]:
resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==}
engines: {node: '>=12'}
[email protected]:
resolution: {integrity: sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==}
peerDependencies:
typescript: '>=4.5.0'
vue: ^3.5.11
peerDependenciesMeta:
typescript:
optional: true
[email protected]:
resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==}
engines: {node: ^10 || ^12 || >=14}
[email protected]:
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
[email protected]:
resolution: {integrity: sha512-/tqMfgP7GPA3PHhCmuiS4vIjrSVhHLgY++i+dhbG462euyAj7FpM4D9uq1X3BgjlqRdpcOrYhcQtfiQLNc8tqw==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
[email protected]:
resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==}
engines: {node: '>=0.10.0'}
[email protected]:
resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==}
engines: {node: '>=16'}
[email protected]:
resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==}
[email protected]:
resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
engines: {node: '>=6'}
[email protected]:
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
engines: {node: '>=12.0.0'}
[email protected]:
resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
'@types/node': ^20.19.0 || >=22.12.0
jiti: '>=1.21.0'
less: ^4.0.0
lightningcss: ^1.21.0
sass: ^1.70.0
sass-embedded: ^1.70.0
stylus: '>=0.54.8'
sugarss: ^5.0.0
terser: ^5.16.0
tsx: ^4.8.1
yaml: ^2.4.2
peerDependenciesMeta:
'@types/node':
optional: true
jiti:
optional: true
less:
optional: true
lightningcss:
optional: true
sass:
optional: true
sass-embedded:
optional: true
stylus:
optional: true
sugarss:
optional: true
terser:
optional: true
tsx:
optional: true
yaml:
optional: true
[email protected]:
resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==}
peerDependencies:
vue: ^3.5.0
[email protected]:
resolution: {integrity: sha512-2laE0p+aK+/AOPG/XL/WepOs/GlK755LJ1XECi9kDUrz1FKNw8rb2Xzlw9JS1rqEV55nb0ttsKxVlTCcd+R5cg==}
peerDependencies:
typescript: '*'
peerDependenciesMeta:
typescript:
optional: true
snapshots:
'@babel/[email protected]': {}
'@babel/[email protected]': {}
'@babel/[email protected]':
dependencies:
'@babel/types': 7.29.8
'@babel/[email protected]':
dependencies:
'@babel/helper-string-parser': 7.29.7
'@babel/helper-validator-identifier': 7.29.7
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@jridgewell/[email protected]':
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
'@jridgewell/trace-mapping': 0.3.31
'@jridgewell/[email protected]':
dependencies:
'@jridgewell/gen-mapping': 0.3.13
'@jridgewell/trace-mapping': 0.3.31
'@jridgewell/[email protected]': {}
'@jridgewell/[email protected]': {}
'@jridgewell/[email protected]':
dependencies:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@napi-rs/[email protected]':
optional: true
'@rolldown/[email protected]': {}
'@rollup/[email protected]':
optional: true
'@rollup/[email protected]':
optional: true
'@rollup/[email protected]':
optional: true
'@rollup/[email protected]':
optional: true
'@rollup/[email protected]':
optional: true
'@rollup/[email protected]':
optional: true
'@rollup/[email protected]':
optional: true
'@rollup/[email protected]':
optional: true
'@rollup/[email protected]':
optional: true
'@rollup/[email protected]':
optional: true
'@rollup/[email protected]':
optional: true
'@rollup/[email protected]':
optional: true
'@rollup/[email protected]':
optional: true
'@rollup/[email protected]':
optional: true
'@rollup/[email protected]':
optional: true
'@rollup/[email protected]':
optional: true
'@rollup/[email protected]':
optional: true
'@rollup/[email protected]':
optional: true
'@rollup/[email protected]':
optional: true
'@rollup/[email protected]':
optional: true
'@rollup/[email protected]':
optional: true
'@rollup/[email protected]':
optional: true
'@rollup/[email protected]':
optional: true
'@rollup/[email protected]':
optional: true
'@rollup/[email protected]':
optional: true
'@tailwindcss/[email protected]':
dependencies:
'@jridgewell/remapping': 2.3.5
enhanced-resolve: 5.24.5
jiti: 2.7.0
lightningcss: 1.32.0
magic-string: 0.30.21
source-map-js: 1.2.1
tailwindcss: 4.3.3
'@tailwindcss/[email protected]':
optional: true
'@tailwindcss/[email protected]':
optional: true
'@tailwindcss/[email protected]':
optional: true
'@tailwindcss/[email protected]':
optional: true
'@tailwindcss/[email protected]':
optional: true
'@tailwindcss/[email protected]':
optional: true
'@tailwindcss/[email protected]':
optional: true
'@tailwindcss/[email protected]':
optional: true
'@tailwindcss/[email protected]':
optional: true
'@tailwindcss/[email protected]':
optional: true
'@tailwindcss/[email protected]':
optional: true
'@tailwindcss/[email protected]':
optional: true
'@tailwindcss/[email protected]':
optionalDependencies:
'@tailwindcss/oxide-android-arm64': 4.3.3
'@tailwindcss/oxide-darwin-arm64': 4.3.3
'@tailwindcss/oxide-darwin-x64': 4.3.3
'@tailwindcss/oxide-freebsd-x64': 4.3.3
'@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.3
'@tailwindcss/oxide-linux-arm64-gnu': 4.3.3
'@tailwindcss/oxide-linux-arm64-musl': 4.3.3
'@tailwindcss/oxide-linux-x64-gnu': 4.3.3
'@tailwindcss/oxide-linux-x64-musl': 4.3.3
'@tailwindcss/oxide-wasm32-wasi': 4.3.3
'@tailwindcss/oxide-win32-arm64-msvc': 4.3.3
'@tailwindcss/oxide-win32-x64-msvc': 4.3.3
'@tailwindcss/[email protected]([email protected]([email protected])([email protected]))':
dependencies:
'@tailwindcss/node': 4.3.3
'@tailwindcss/oxide': 4.3.3
tailwindcss: 4.3.3
vite: 7.3.6([email protected])([email protected])
'@types/[email protected]': {}
'@vitejs/[email protected]([email protected]([email protected])([email protected]))([email protected])':
dependencies:
'@rolldown/pluginutils': 1.0.1
vite: 7.3.6([email protected])([email protected])
vue: 3.5.41
'@vue/[email protected]':
dependencies:
'@babel/parser': 7.29.8
'@vue/shared': 3.5.41
entities: 7.0.1
estree-walker: 2.0.2
source-map-js: 1.2.1
'@vue/[email protected]':
dependencies:
'@vue/compiler-core': 3.5.41
'@vue/shared': 3.5.41
'@vue/[email protected]':
dependencies:
'@babel/parser': 7.29.8
'@vue/compiler-core': 3.5.41
'@vue/compiler-dom': 3.5.41
'@vue/compiler-ssr': 3.5.41
'@vue/shared': 3.5.41
estree-walker: 2.0.2
magic-string: 0.30.21
postcss: 8.5.26
source-map-js: 1.2.1
'@vue/[email protected]':
dependencies:
'@vue/compiler-dom': 3.5.41
'@vue/shared': 3.5.41
'@vue/[email protected]': {}
'@vue/[email protected]':
dependencies:
'@vue/devtools-kit': 7.7.10
'@vue/[email protected]':
dependencies:
'@vue/devtools-shared': 7.7.10
birpc: 2.9.0
hookable: 5.5.3
mitt: 3.0.1
perfect-debounce: 1.0.0
speakingurl: 14.0.1
superjson: 2.2.6
'@vue/[email protected]':
dependencies:
rfdc: 1.4.1
'@vue/[email protected]':
dependencies:
'@vue/shared': 3.5.41
'@vue/[email protected]':
dependencies:
'@vue/reactivity': 3.5.41
'@vue/shared': 3.5.41
'@vue/[email protected]':
dependencies:
'@vue/reactivity': 3.5.41
'@vue/runtime-core': 3.5.41
'@vue/shared': 3.5.41
csstype: 3.2.3
'@vue/[email protected]':
dependencies:
'@vue/compiler-ssr': 3.5.41
'@vue/runtime-dom': 3.5.41
'@vue/shared': 3.5.41
'@vue/[email protected]': {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
graceful-fs: 4.2.11
tapable: 2.3.3
[email protected]: {}
[email protected]:
optionalDependencies:
'@esbuild/aix-ppc64': 0.28.2
'@esbuild/android-arm': 0.28.2
'@esbuild/android-arm64': 0.28.2
'@esbuild/android-x64': 0.28.2
'@esbuild/darwin-arm64': 0.28.2
'@esbuild/darwin-x64': 0.28.2
'@esbuild/freebsd-arm64': 0.28.2
'@esbuild/freebsd-x64': 0.28.2
'@esbuild/linux-arm': 0.28.2
'@esbuild/linux-arm64': 0.28.2
'@esbuild/linux-ia32': 0.28.2
'@esbuild/linux-loong64': 0.28.2
'@esbuild/linux-mips64el': 0.28.2
'@esbuild/linux-ppc64': 0.28.2
'@esbuild/linux-riscv64': 0.28.2
'@esbuild/linux-s390x': 0.28.2
'@esbuild/linux-x64': 0.28.2
'@esbuild/netbsd-arm64': 0.28.2
'@esbuild/netbsd-x64': 0.28.2
'@esbuild/openbsd-arm64': 0.28.2
'@esbuild/openbsd-x64': 0.28.2
'@esbuild/openharmony-arm64': 0.28.2
'@esbuild/sunos-x64': 0.28.2
'@esbuild/win32-arm64': 0.28.2
'@esbuild/win32-ia32': 0.28.2
'@esbuild/win32-x64': 0.28.2
[email protected]: {}
[email protected]([email protected]):
optionalDependencies:
picomatch: 4.0.5
[email protected]:
optional: true
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
optional: true
[email protected]:
optional: true
[email protected]:
optional: true
[email protected]:
optional: true
[email protected]:
optional: true
[email protected]:
optional: true
[email protected]:
optional: true
[email protected]:
optional: true
[email protected]:
optional: true
[email protected]:
optional: true
[email protected]:
optional: true
[email protected]:
dependencies:
detect-libc: 2.1.2
optionalDependencies:
lightningcss-android-arm64: 1.32.0
lightningcss-darwin-arm64: 1.32.0
lightningcss-darwin-x64: 1.32.0
lightningcss-freebsd-x64: 1.32.0
lightningcss-linux-arm-gnueabihf: 1.32.0
lightningcss-linux-arm64-gnu: 1.32.0
lightningcss-linux-arm64-musl: 1.32.0
lightningcss-linux-x64-gnu: 1.32.0
lightningcss-linux-x64-musl: 1.32.0
lightningcss-win32-arm64-msvc: 1.32.0
lightningcss-win32-x64-msvc: 1.32.0
[email protected]:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]([email protected]):
dependencies:
'@vue/devtools-api': 7.7.10
vue: 3.5.41
[email protected]:
dependencies:
nanoid: 3.3.18
picocolors: 1.1.1
source-map-js: 1.2.1
[email protected]: {}
[email protected]:
dependencies:
'@types/estree': 1.0.9
optionalDependencies:
'@napi-rs/lzma-linux-x64-gnu': 1.5.1
'@rollup/rollup-android-arm-eabi': 4.62.5
'@rollup/rollup-android-arm64': 4.62.5
'@rollup/rollup-darwin-arm64': 4.62.5
'@rollup/rollup-darwin-x64': 4.62.5
'@rollup/rollup-freebsd-arm64': 4.62.5
'@rollup/rollup-freebsd-x64': 4.62.5
'@rollup/rollup-linux-arm-gnueabihf': 4.62.5
'@rollup/rollup-linux-arm-musleabihf': 4.62.5
'@rollup/rollup-linux-arm64-gnu': 4.62.5
'@rollup/rollup-linux-arm64-musl': 4.62.5
'@rollup/rollup-linux-loong64-gnu': 4.62.5
'@rollup/rollup-linux-loong64-musl': 4.62.5
'@rollup/rollup-linux-ppc64-gnu': 4.62.5
'@rollup/rollup-linux-ppc64-musl': 4.62.5
'@rollup/rollup-linux-riscv64-gnu': 4.62.5
'@rollup/rollup-linux-riscv64-musl': 4.62.5
'@rollup/rollup-linux-s390x-gnu': 4.62.5
'@rollup/rollup-linux-x64-gnu': 4.62.5
'@rollup/rollup-linux-x64-musl': 4.62.5
'@rollup/rollup-openbsd-x64': 4.62.5
'@rollup/rollup-openharmony-arm64': 4.62.5
'@rollup/rollup-win32-arm64-msvc': 4.62.5
'@rollup/rollup-win32-ia32-msvc': 4.62.5
'@rollup/rollup-win32-x64-gnu': 4.62.5
'@rollup/rollup-win32-x64-msvc': 4.62.5
fsevents: 2.3.3
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
copy-anything: 4.1.0
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
fdir: 6.5.0([email protected])
picomatch: 4.0.5
[email protected]([email protected])([email protected]):
dependencies:
esbuild: 0.28.2
fdir: 6.5.0([email protected])
picomatch: 4.0.5
postcss: 8.5.26
rollup: 4.62.5
tinyglobby: 0.2.17
optionalDependencies:
fsevents: 2.3.3
jiti: 2.7.0
lightningcss: 1.32.0
[email protected]([email protected]):
dependencies:
'@vue/devtools-api': 6.6.4
vue: 3.5.41
[email protected]:
dependencies:
'@vue/compiler-dom': 3.5.41
'@vue/compiler-sfc': 3.5.41
'@vue/runtime-dom': 3.5.41
'@vue/server-renderer': 3.5.41
'@vue/shared': 3.5.41
+93 -215
View File
@@ -1,203 +1,116 @@
<script setup>
import { ref, provide } from "vue";
import { computed, onBeforeUnmount, onMounted, provide, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useAppStore } from "./stores/app";
import { useStartLogStore } from "./stores/startLog";
import { visibleNavItems } from "./navigation";
import AppSidebar from "./components/AppSidebar.vue";
import AppModal from "./components/AppModal.vue";
import ToastHost from "./components/ToastHost.vue";
import ConfirmHost from "./components/ConfirmHost.vue";
import StatusDot from "./components/StatusDot.vue";
import AppTooltip from "./components/AppTooltip.vue";
import ConfirmHost from "./components/ConfirmHost.vue";
import ToastHost from "./components/ToastHost.vue";
import AccessGate from "./components/AccessGate.vue";
import { authorized, isDesktop } from "./auth";
const app = useAppStore();
const startLog = useStartLogStore();
const route = useRoute();
const router = useRouter();
const tooltipRef = ref(null);
provide("peerTooltip", tooltipRef);
// :localStorage ,
const isDark = ref(document.documentElement.classList.contains("dark"));
const mobileNavOpen = ref(false);
const items = visibleNavItems();
const pageMeta = computed(() => items.find((item) => item.to === route.path) || items[0]);
const savedTheme = localStorage.getItem("vnt-theme");
const isDark = ref(savedTheme
? savedTheme === "dark"
: window.matchMedia("(prefers-color-scheme: dark)").matches);
const applyTheme = () => document.documentElement.classList.toggle("dark", isDark.value);
const toggleTheme = () => {
isDark.value = !isDark.value;
document.documentElement.classList.toggle("dark", isDark.value);
localStorage.setItem("vnt-theme", isDark.value ? "dark" : "light");
applyTheme();
};
applyTheme();
watch(() => route.path, () => { mobileNavOpen.value = false; });
const handleKeydown = (event) => {
if (!(event.ctrlKey || event.metaKey) || event.altKey) return;
const index = Number(event.key) - 1;
if (index >= 0 && index < items.length) {
event.preventDefault();
router.push(items[index].to);
}
};
const mobileNavOpen = ref(false);
const closeMobileNav = () => {
mobileNavOpen.value = false;
};
const navItems = [
{
to: "/",
label: "总览",
exact: true,
icon: "M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6",
},
{
to: "/instances",
label: "实例",
icon: "M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01",
},
{
to: "/config",
label: "配置",
icon: "M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z",
icon2: "M15 12a3 3 0 11-6 0 3 3 0 016 0z",
},
{
to: "/peers",
label: "设备列表",
icon: "M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z",
},
{
to: "/routes",
label: "路由",
icon: "M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 01-.806-.98l-3.747-1.874M12 7v13m3-13v13m-3 0l3 3",
},
];
onMounted(() => window.addEventListener("keydown", handleKeydown));
onBeforeUnmount(() => window.removeEventListener("keydown", handleKeydown));
</script>
<template>
<div class="flex min-h-screen flex-col">
<!-- 顶部导航栏 -->
<header
class="sticky top-0 z-30 border-b border-slate-200 bg-white/80 backdrop-blur dark:border-slate-800 dark:bg-slate-900/80"
>
<div class="mx-auto flex h-14 max-w-6xl items-center gap-3 px-4 lg:px-8">
<!-- 移动端汉堡 -->
<AccessGate v-if="!isDesktop && !authorized" />
<div v-else class="flex h-[100dvh] min-h-0 overflow-hidden bg-slate-50 text-slate-700 dark:bg-slate-950 dark:text-slate-200">
<aside class="hidden w-60 shrink-0 border-r border-slate-200 dark:border-slate-800 lg:block">
<AppSidebar />
</aside>
<transition name="drawer">
<div v-if="mobileNavOpen" class="fixed inset-0 z-40 lg:hidden">
<button class="absolute inset-0 bg-slate-950/45 backdrop-blur-[2px]" aria-label="关闭导航" @click="mobileNavOpen = false"></button>
<aside class="drawer-panel relative h-full w-[min(82vw,288px)] border-r border-slate-200 shadow-2xl dark:border-slate-700">
<AppSidebar @navigate="mobileNavOpen = false" />
</aside>
</div>
</transition>
<div class="flex min-w-0 flex-1 flex-col">
<header class="flex h-16 shrink-0 items-center gap-3 border-b border-slate-200 bg-white/90 px-4 backdrop-blur lg:px-6 dark:border-slate-800 dark:bg-slate-900/90">
<button
class="rounded-lg p-1.5 text-slate-500 hover:bg-slate-100 hover:text-slate-900 lg:hidden dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white"
@click="mobileNavOpen = !mobileNavOpen"
class="grid h-9 w-9 shrink-0 place-items-center rounded-lg text-slate-500 hover:bg-slate-100 hover:text-slate-900 lg:hidden dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white"
aria-label="打开导航"
@click="mobileNavOpen = true"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
<svg class="h-5 w-5 fill-none stroke-current" viewBox="0 0 24 24"><path d="M4 7h16M4 12h16M4 17h16" stroke-linecap="round" stroke-width="2" /></svg>
</button>
<!-- Logo -->
<router-link to="/" class="flex items-center gap-2" @click="closeMobileNav">
<span class="text-lg font-bold tracking-wide text-indigo-600 dark:text-indigo-400">VNT</span>
<span class="text-sm font-medium text-slate-400 dark:text-slate-500">Web</span>
</router-link>
<div class="min-w-0">
<h1 class="truncate text-lg font-bold text-slate-900 dark:text-white">{{ pageMeta.label }}</h1>
<p class="hidden text-xs text-slate-400 sm:block">VNT 虚拟局域网管理</p>
</div>
<!-- 桌面导航 -->
<nav class="ml-6 hidden h-14 items-stretch gap-1 lg:flex">
<router-link
v-for="item in navItems"
:key="item.to"
:to="item.to"
custom
v-slot="{ navigate, isActive, isExactActive }"
>
<button
@click="navigate"
class="relative flex items-center px-3 text-sm font-medium transition-colors"
:class="
(item.exact ? isExactActive : isActive)
? 'text-indigo-600 dark:text-indigo-400'
: 'text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white'
"
>
{{ item.label }}
<span
v-if="item.exact ? isExactActive : isActive"
class="absolute inset-x-3 bottom-0 h-0.5 rounded-full bg-indigo-600 dark:bg-indigo-400"
></span>
</button>
</router-link>
</nav>
<div class="ml-auto flex items-center gap-3">
<!-- 全局状态点 -->
<div
class="flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1 dark:border-slate-700 dark:bg-slate-800"
:title="app.headerStatusText"
>
<StatusDot
:status="app.runningCount > 0 ? 'running' : app.startingCount > 0 ? 'starting' : 'stopped'"
/>
<span class="hidden text-xs font-medium text-slate-600 sm:inline dark:text-slate-300">{{
app.headerStatusText
}}</span>
<div class="ml-auto flex items-center gap-2">
<div class="flex h-8 items-center gap-2 rounded-lg border border-slate-200 bg-white px-2.5 text-xs font-medium text-slate-500 sm:px-3 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300">
<span
class="h-1.5 w-1.5 rounded-full"
:class="app.runningCount > 0 ? 'bg-green-500' : app.startingCount > 0 ? 'animate-pulse bg-amber-400' : 'bg-slate-300 dark:bg-slate-600'"
></span>
<span class="hidden sm:inline">{{ app.headerStatusText }}</span>
</div>
<span class="hidden text-xs text-slate-400 md:inline dark:text-slate-500"
>v{{ app.version || "-" }}</span
>
<!-- 主题切换 -->
<button
class="rounded-lg border border-slate-200 bg-white p-1.5 text-slate-500 shadow-sm transition-colors hover:bg-slate-50 hover:text-slate-900 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-white"
:title="isDark ? '切换浅色模式' : '切换深色模式'"
class="grid h-8 w-8 place-items-center rounded-lg border border-slate-200 bg-white text-slate-500 hover:border-indigo-300 hover:text-indigo-600 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400 dark:hover:border-indigo-500 dark:hover:text-indigo-400"
:title="isDark ? '切换浅色模式' : '切换深色模式'"
:aria-label="isDark ? '切换浅色模式' : '切换深色模式'"
@click="toggleTheme"
>
<svg v-if="isDark" class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
<svg v-else class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
/>
</svg>
<svg v-if="isDark" class="h-4 w-4 fill-none stroke-current" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2m0 16v2M4.9 4.9l1.4 1.4m11.4 11.4 1.4 1.4M2 12h2m16 0h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" stroke-linecap="round" stroke-width="1.7"/></svg>
<svg v-else class="h-4 w-4 fill-none stroke-current" viewBox="0 0 24 24"><path d="M20.5 15.2A8.5 8.5 0 0 1 8.8 3.5 8.5 8.5 0 1 0 20.5 15.2Z" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.7"/></svg>
</button>
</div>
</div>
</header>
<!-- 移动端下拉导航 -->
<transition name="navdrop">
<nav
v-if="mobileNavOpen"
class="border-t border-slate-200 bg-white px-4 py-2 lg:hidden dark:border-slate-800 dark:bg-slate-900"
>
<router-link
v-for="item in navItems"
:key="item.to"
:to="item.to"
custom
v-slot="{ navigate, isActive, isExactActive }"
>
<button
@click="
navigate();
closeMobileNav();
"
class="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors"
:class="
(item.exact ? isExactActive : isActive)
? 'bg-indigo-50 text-indigo-600 dark:bg-indigo-500/10 dark:text-indigo-400'
: 'text-slate-500 hover:bg-slate-50 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white'
"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="item.icon" />
<path v-if="item.icon2" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="item.icon2" />
</svg>
{{ item.label }}
</button>
</router-link>
</nav>
</transition>
</header>
<main class="custom-scrollbar min-h-0 flex-1 overflow-x-hidden overflow-y-auto">
<div class="mx-auto w-full max-w-7xl px-4 py-5 sm:px-5 lg:px-7 lg:py-6">
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in"><component :is="Component" /></transition>
</router-view>
</div>
</main>
</div>
<!-- 内容区 -->
<main class="flex-1 overflow-x-hidden">
<div class="mx-auto w-full max-w-6xl px-4 py-6 lg:px-8">
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</div>
</main>
<!-- 启动日志弹窗 (全局) -->
<AppModal
:show="startLog.showStartLog"
:mask-closable="startLog.startStatus !== 'starting'"
@@ -206,66 +119,31 @@ const navItems = [
@close="startLog.close"
>
<template #header>
<div class="flex items-center gap-3">
<div
v-if="startLog.startStatus === 'starting'"
class="h-3 w-3 animate-ping rounded-full bg-blue-500"
></div>
<div v-else-if="startLog.startStatus === 'running'" class="h-3 w-3 rounded-full bg-green-500"></div>
<div v-else class="h-3 w-3 rounded-full bg-red-500"></div>
<h3 class="text-lg font-bold text-slate-900 dark:text-white">
{{
startLog.startStatus === "starting"
? "正在启动组网..."
: startLog.startStatus === "running"
? "启动成功"
: "启动失败"
}}
<div class="flex min-w-0 items-center gap-3">
<span
class="h-2 w-2 shrink-0 rounded-full"
:class="startLog.startStatus === 'starting' ? 'animate-pulse bg-amber-400' : startLog.startStatus === 'running' ? 'bg-green-500' : 'bg-red-500'"
></span>
<h3 class="truncate text-base font-bold text-slate-900 sm:text-lg dark:text-white">
{{ startLog.startStatus === "starting" ? "正在建立虚拟网络" : startLog.startStatus === "running" ? "网络已连接" : "连接未完成" }}
</h3>
</div>
<div class="flex items-center gap-3">
<span
class="max-w-[200px] truncate text-sm font-medium text-indigo-600 dark:text-indigo-400"
:title="startLog.logFileName"
>{{ startLog.logConfigName }}</span
>
<span class="font-mono text-xs uppercase tracking-widest text-slate-400">{{
startLog.startStatus
}}</span>
</div>
<span class="max-w-[40%] truncate font-mono text-xs text-indigo-600 dark:text-indigo-400">{{ startLog.logConfigName }}</span>
</template>
<template #body>
<div
:ref="(el) => (startLog.logContainer = el)"
class="scrollbar-hide h-80 space-y-2 overflow-y-auto bg-slate-50 p-6 font-mono text-sm dark:bg-black/20"
class="custom-scrollbar h-64 space-y-2 overflow-y-auto border-y border-slate-200 bg-slate-50 p-4 font-mono text-xs text-slate-600 sm:h-80 sm:p-6 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-300"
>
<div v-for="(log, idx) in startLog.startLogs" :key="idx" class="flex gap-3">
<span class="shrink-0 text-indigo-500 dark:text-indigo-400">>>></span>
<span class="break-all text-slate-600 dark:text-slate-300">{{ log }}</span>
</div>
<div v-if="startLog.startStatus === 'starting'" class="mt-4 animate-pulse italic text-blue-500">
等待后续步骤...
</div>
<div
v-if="startLog.startStatus === 'stopped' && startLog.startLogs.length > 0"
class="mt-4 rounded-lg border border-red-200 bg-red-50 p-3 text-red-600 dark:border-red-900/50 dark:bg-red-900/20 dark:text-red-400"
>
<strong>启动失败:</strong>
请检查配置或网络连接
<span class="text-indigo-600 dark:text-indigo-400"></span><span class="break-all">{{ log }}</span>
</div>
<div v-if="startLog.startStatus === 'starting'" class="animate-pulse text-indigo-600 dark:text-indigo-400">等待下一阶段</div>
</div>
</template>
<template #footer>
<button v-if="startLog.startStatus === 'starting'" class="btn-ghost" @click="startLog.cancelStart">
取消组网
</button>
<button
v-if="startLog.startStatus === 'stopped' || startLog.startStatus === 'running'"
class="btn-primary"
@click="startLog.close"
>
关闭窗口
</button>
<button v-if="startLog.startStatus === 'starting'" class="btn-ghost" @click="startLog.cancelStart">取消连接</button>
<button v-else class="btn-primary" @click="startLog.close">完成</button>
</template>
</AppModal>
+19 -3
View File
@@ -1,7 +1,23 @@
// fetch 封装:统一解包 ApiResponse{code,msg,data},code!==0 抛出带 msg 的错误
const request = async (url, options) => {
const res = await fetch(url, options);
import { clearAccessToken, getAccessToken } from "../auth";
// HTTP 与 Tauri IPC 共用相同的 ApiResponse{code,msg,data} 协议。
const request = async (url, options = {}) => {
if (globalThis.__VNT_IPC_REQUEST__) {
const json = await globalThis.__VNT_IPC_REQUEST__({
method: options.method || "GET",
path: url,
body: options.body || null,
});
if (json.code !== 0) throw new Error(json.msg || "请求失败");
return json.data;
}
const headers = new Headers(options.headers || {});
const token = getAccessToken();
if (token) headers.set("Authorization", `Bearer ${token}`);
const res = await fetch(url, { ...options, headers });
const json = await res.json();
if (res.status === 401) clearAccessToken();
if (json.code !== 0) {
throw new Error(json.msg || "请求失败");
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

+32
View File
@@ -0,0 +1,32 @@
import { ref } from "vue";
const STORAGE_KEY = "vnt-web-access-token";
export const isDesktop = Boolean(globalThis.__VNT_DESKTOP__);
const url = new URL(window.location.href);
const tokenFromUrl = url.searchParams.get("token") || "";
if (tokenFromUrl) {
localStorage.setItem(STORAGE_KEY, tokenFromUrl);
url.searchParams.delete("token");
window.history.replaceState({}, "", `${url.pathname}${url.search}${url.hash}`);
}
export const accessToken = ref(
isDesktop ? "" : tokenFromUrl || localStorage.getItem(STORAGE_KEY) || "",
);
export const authorized = ref(isDesktop || Boolean(accessToken.value));
export const getAccessToken = () => accessToken.value;
export const saveAccessToken = (token) => {
const normalized = token.trim();
localStorage.setItem(STORAGE_KEY, normalized);
accessToken.value = normalized;
authorized.value = Boolean(normalized);
};
export const clearAccessToken = () => {
localStorage.removeItem(STORAGE_KEY);
accessToken.value = "";
authorized.value = false;
};
+30
View File
@@ -0,0 +1,30 @@
<script setup>
import { ref } from "vue";
import { saveAccessToken } from "../auth";
import vntIcon from "../assets/vnt-icon.png";
const token = ref("");
const submit = () => {
if (!token.value.trim()) return;
saveAccessToken(token.value);
window.location.reload();
};
</script>
<template>
<main class="grid h-[100dvh] place-items-center overflow-y-auto bg-slate-50 p-4 text-slate-700 dark:bg-slate-950 dark:text-slate-200">
<form class="w-full max-w-md rounded-2xl border border-slate-200 bg-white p-6 shadow-sm sm:p-8 dark:border-slate-800 dark:bg-slate-900" @submit.prevent="submit">
<div class="mb-7 flex items-center gap-3">
<img :src="vntIcon" alt="" class="h-10 w-10 shrink-0" />
<div>
<h1 class="text-lg font-bold text-slate-900 dark:text-white">访问 VNT 控制台</h1>
<p class="mt-0.5 text-xs text-slate-400">请输入桌面端 Web 访问设置中的令牌</p>
</div>
</div>
<label class="mb-2 block text-sm font-medium text-slate-700 dark:text-slate-200" for="access-token">访问令牌</label>
<input id="access-token" v-model="token" class="input font-mono" type="password" autocomplete="current-password" autofocus placeholder="粘贴访问令牌" />
<button class="btn-primary mt-5 w-full" type="submit" :disabled="!token.trim()">进入控制台</button>
<p class="mt-5 text-center text-xs leading-5 text-slate-400">令牌只保存在当前浏览器中可随时在桌面端重新生成</p>
</form>
</main>
</template>
+60
View File
@@ -0,0 +1,60 @@
<script setup>
import { useRoute } from "vue-router";
import { useAppStore } from "../stores/app";
import { visibleNavItems } from "../navigation";
import vntIcon from "../assets/vnt-icon.png";
defineEmits(["navigate"]);
const route = useRoute();
const app = useAppStore();
const items = visibleNavItems();
</script>
<template>
<div class="flex h-full min-h-0 flex-col bg-white dark:bg-slate-900">
<div class="flex h-16 shrink-0 items-center gap-3 border-b border-slate-200 px-5 dark:border-slate-800">
<img :src="vntIcon" alt="" class="h-8 w-8 shrink-0" />
<div>
<div class="text-sm font-bold tracking-wide text-slate-900 dark:text-white">VNT</div>
<div class="text-[9px] font-semibold tracking-[0.18em] text-slate-400">CONTROL CENTER</div>
</div>
</div>
<div class="mx-3 mt-4 flex items-center gap-3 rounded-xl border border-slate-200 bg-slate-50 px-3 py-3 dark:border-slate-700 dark:bg-slate-800/60">
<span
class="h-2.5 w-2.5 shrink-0 rounded-full"
:class="app.runningCount > 0 ? 'bg-green-500' : app.startingCount > 0 ? 'animate-pulse bg-amber-400' : 'bg-slate-300 dark:bg-slate-600'"
></span>
<div class="min-w-0">
<div class="text-[9px] font-semibold tracking-wider text-slate-400">虚拟网络</div>
<div class="mt-0.5 truncate text-xs font-medium text-slate-700 dark:text-slate-200">{{ app.headerStatusText }}</div>
</div>
</div>
<nav class="mt-4 flex min-h-0 flex-1 flex-col gap-1 overflow-y-auto px-3" aria-label="主导航">
<router-link
v-for="item in items"
:key="item.to"
:to="item.to"
class="flex min-h-10 items-center gap-3 rounded-lg px-3 text-sm font-medium transition-colors"
:class="route.path === item.to
? 'bg-indigo-50 text-indigo-700 dark:bg-indigo-500/10 dark:text-indigo-300'
: 'text-slate-500 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white'"
@click="$emit('navigate')"
>
<svg class="h-[18px] w-[18px] shrink-0 fill-none stroke-current" viewBox="0 0 24 24">
<path :d="item.icon" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.7" />
</svg>
<span>{{ item.label }}</span>
</router-link>
</nav>
<div class="mx-3 mt-3 shrink-0 border-t border-slate-200 px-1 py-4 dark:border-slate-800">
<div class="flex items-center gap-2 text-xs text-slate-400">
<span class="h-1.5 w-1.5 rounded-full" :class="app.version ? 'bg-green-500' : 'bg-amber-400'"></span>
<span>{{ app.version ? "本地服务正常" : "正在连接服务…" }}</span>
<span class="ml-auto font-mono text-[10px]">v{{ app.version || "2.0" }}</span>
</div>
</div>
</div>
</template>
+7
View File
@@ -2,6 +2,13 @@ import { createApp } from "vue";
import { createPinia } from "pinia";
import App from "./App.vue";
import router from "./router";
import vntIcon from "./assets/vnt-icon.png";
import "./style.css";
const favicon = document.querySelector('link[rel~="icon"]') || document.createElement("link");
favicon.rel = "icon";
favicon.type = "image/png";
favicon.href = vntIcon;
document.head.appendChild(favicon);
createApp(App).use(createPinia()).use(router).mount("#app");
+42
View File
@@ -0,0 +1,42 @@
export const navItems = [
{
to: "/",
label: "网络总览",
shortLabel: "总览",
icon: "M4 4h6v6H4V4Zm10 0h6v6h-6V4ZM4 14h6v6H4v-6Zm10 0h6v6h-6v-6Z",
},
{
to: "/instances",
label: "运行实例",
shortLabel: "实例",
icon: "M4 18V8m5 10V4m6 14v-7m5 7V6",
},
{
to: "/peers",
label: "在线设备",
shortLabel: "设备",
icon: "M15 19a6 6 0 0 0-12 0m18 0a5 5 0 0 0-5-5m-7-3a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm8 0a3 3 0 1 0 0-6",
},
{
to: "/routes",
label: "路由表",
shortLabel: "路由",
icon: "M6 8a2 2 0 1 0 0-4 2 2 0 0 0 0 4Zm12 12a2 2 0 1 0 0-4 2 2 0 0 0 0 4ZM8 6h6a4 4 0 0 1 4 4v2m-3-3 3 3 3-3M16 18h-6a4 4 0 0 1-4-4v-2",
},
{
to: "/config",
label: "组网配置",
shortLabel: "配置",
icon: "M12 15.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Zm7.4-.5a1.7 1.7 0 0 0 .3 1.9l.1.1-2.8 2.8-.1-.1a1.7 1.7 0 0 0-1.9-.3A1.7 1.7 0 0 0 14 21v.2h-4V21a1.7 1.7 0 0 0-1-1.6 1.7 1.7 0 0 0-1.9.3l-.1.1L4.2 17l.1-.1a1.7 1.7 0 0 0 .3-1.9A1.7 1.7 0 0 0 3 14h-.2v-4H3a1.7 1.7 0 0 0 1.6-1 1.7 1.7 0 0 0-.3-1.9L4.2 7 7 4.2l.1.1A1.7 1.7 0 0 0 9 4.6 1.7 1.7 0 0 0 10 3v-.2h4V3a1.7 1.7 0 0 0 1 1.6 1.7 1.7 0 0 0 1.9-.3l.1-.1L19.8 7l-.1.1a1.7 1.7 0 0 0-.3 1.9 1.7 1.7 0 0 0 1.6 1h.2v4H21a1.7 1.7 0 0 0-1.6 1Z",
},
{
to: "/web-access",
label: "Web 访问",
shortLabel: "Web",
desktopOnly: true,
icon: "M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Zm0 0c2.2-2.5 3.3-5.5 3.3-9S14.2 5.5 12 3m0 18c-2.2-2.5-3.3-5.5-3.3-9S9.8 5.5 12 3M3.5 9h17m-17 6h17",
},
];
export const visibleNavItems = () =>
navItems.filter((item) => !item.desktopOnly || globalThis.__VNT_DESKTOP__);
+2
View File
@@ -4,6 +4,7 @@ import InstancesView from "../views/InstancesView.vue";
import ConfigView from "../views/ConfigView.vue";
import PeersView from "../views/PeersView.vue";
import RoutesView from "../views/RoutesView.vue";
import WebAccessView from "../views/WebAccessView.vue";
const routes = [
{ path: "/", component: DashboardView },
@@ -13,6 +14,7 @@ const routes = [
{ path: "/config", component: ConfigView },
{ path: "/peers", component: PeersView },
{ path: "/routes", component: RoutesView },
{ path: "/web-access", component: WebAccessView },
];
export default createRouter({
+2
View File
@@ -12,6 +12,7 @@ import {
} from "../api";
import { useUiStore } from "./ui";
import { useStartLogStore } from "./startLog";
import { getAccessToken, isDesktop } from "../auth";
export const useAppStore = defineStore("app", () => {
const ui = useUiStore();
@@ -217,6 +218,7 @@ export const useAppStore = defineStore("app", () => {
startLog.bindApp({ fetchInstances, instanceList, configList });
const init = async () => {
if (!isDesktop && !getAccessToken()) return;
document.addEventListener("visibilitychange", visibilityHandler);
fetchVersion();
await fetchInstances();
+38 -3
View File
@@ -1,4 +1,5 @@
@import "tailwindcss";
@source "./**/*.{vue,js}";
@custom-variant dark (&:where(.dark, .dark *));
@@ -12,6 +13,13 @@ body {
@apply bg-slate-50 text-slate-700 antialiased dark:bg-slate-950 dark:text-slate-200;
}
html,
body,
#app {
height: 100%;
overflow: hidden;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
@@ -123,6 +131,27 @@ body {
transform: translateY(-6px);
}
/* 移动端侧栏抽屉 */
.drawer-enter-active,
.drawer-leave-active {
transition: opacity 0.2s ease;
}
.drawer-enter-active .drawer-panel,
.drawer-leave-active .drawer-panel {
transition: transform 0.2s ease;
}
.drawer-enter-from,
.drawer-leave-to {
opacity: 0;
}
.drawer-enter-from .drawer-panel,
.drawer-leave-to .drawer-panel {
transform: translateX(-100%);
}
@utility btn {
@apply inline-flex items-center justify-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium
transition-all duration-150 active:scale-95 cursor-pointer
@@ -232,9 +261,15 @@ body {
@apply text-2xl font-bold text-slate-900 dark:text-white;
}
.page-subtitle {
@apply mt-1 text-sm text-slate-500 dark:text-slate-400;
}
.page-subtitle {
@apply mt-1 text-sm text-slate-500 dark:text-slate-400;
}
/* 页面名称由统一外壳标题栏展示,视图只保留自身操作按钮。 */
.page-title,
.page-subtitle {
display: none;
}
/* 弱文本 */
.muted {
+20 -1
View File
@@ -111,7 +111,7 @@ onMounted(() => app.fetchConfigList());
</p>
</div>
</div>
<div class="mt-4 flex justify-end opacity-0 transition-opacity group-hover:opacity-100">
<div class="config-card-actions mt-4 flex justify-end transition-opacity">
<button
class="mr-4 text-sm text-indigo-600 hover:text-indigo-500 dark:text-indigo-400"
@click.stop="openEditor(cfg.file_name)"
@@ -133,3 +133,22 @@ onMounted(() => app.fetchConfigList());
/>
</div>
</template>
<style scoped>
/* 触屏和窄屏设备没有可靠的 hover,操作按钮必须直接可见。 */
.config-card-actions {
opacity: 1;
}
/* 只有宽屏且确实支持精细悬停的设备才使用移入显示。 */
@media (min-width: 640px) and (hover: hover) and (pointer: fine) {
.config-card-actions {
opacity: 0;
}
.group:hover .config-card-actions,
.group:focus-within .config-card-actions {
opacity: 1;
}
}
</style>
+211
View File
@@ -0,0 +1,211 @@
<script setup>
import { onMounted, reactive, ref } from "vue";
const bridge = globalThis.__VNT_WEB_ACCESS__;
const draft = reactive({ enabled: false, port: 19099, global: false, token: "" });
const status = ref(null);
const loading = ref(true);
const saving = ref(false);
const notice = ref("");
const error = ref("");
const sync = (value) => {
status.value = value;
Object.assign(draft, {
enabled: value.enabled,
port: value.port,
global: value.global,
token: value.token,
});
};
const load = async () => {
loading.value = true;
error.value = "";
try {
if (!bridge) throw new Error("Web 访问设置仅在桌面客户端中提供");
sync(await bridge.status());
} catch (err) {
error.value = err.message || String(err);
} finally {
loading.value = false;
}
};
const update = async (changes, successMessage) => {
saving.value = true;
error.value = "";
notice.value = "";
try {
sync(await bridge.update({
...draft,
...changes,
port: Number(changes.port ?? draft.port),
}));
notice.value = successMessage;
} catch (err) {
const message = err.message || String(err);
try {
sync(await bridge.status());
} catch {
//
}
error.value = message;
} finally {
saving.value = false;
}
};
const toggleService = async () => {
const enabled = !draft.enabled;
await update(
{ enabled },
enabled ? "Web 服务已启动" : "Web 服务已关闭",
);
};
const regenerate = async () => {
const token = await bridge.generateToken();
await update(
{ token },
draft.enabled ? "新令牌已生效,Web 服务已重新加载" : "新令牌已生成",
);
};
const saveNetworkSettings = async () => {
if (draft.enabled) return;
await update({}, "监听设置已自动保存");
};
const copyToken = async () => {
await navigator.clipboard.writeText(draft.token);
notice.value = "访问令牌已复制";
};
const copyUrl = async () => {
await navigator.clipboard.writeText(status.value.url);
notice.value = "访问地址已复制";
};
const openBrowser = async () => {
await bridge.openUrl(status.value.url);
};
onMounted(load);
</script>
<template>
<div class="mx-auto max-w-4xl space-y-5">
<div class="page-title">
<div>
<h2>Web 访问</h2>
<p>从浏览器访问当前 VNT 进程API 请求由持久访问令牌保护</p>
</div>
</div>
<div v-if="loading" class="card text-sm text-slate-400">正在读取 Web 服务状态</div>
<div v-else-if="!bridge" class="card border-red-200 text-sm text-red-600 dark:border-red-900 dark:text-red-300">{{ error }}</div>
<template v-else>
<section class="card space-y-6">
<div class="flex items-start justify-between gap-5">
<div>
<h3 class="text-sm font-semibold text-slate-900 dark:text-white">启用 Web 服务</h3>
<p class="mt-1 text-xs leading-5 text-slate-400">服务内置于桌面客户端不会启动单独的 vnt2_web 进程</p>
</div>
<button
type="button"
role="switch"
aria-label="启用 Web 服务"
:aria-checked="draft.enabled"
:disabled="saving"
class="web-switch transition-colors disabled:opacity-50"
:class="draft.enabled ? 'bg-indigo-600 dark:bg-indigo-500' : 'bg-slate-300 dark:bg-slate-600'"
@click="toggleService"
>
<span class="web-switch-knob bg-white shadow-sm" :class="{ 'web-switch-knob-on': draft.enabled }"></span>
</button>
</div>
<div class="grid gap-5 sm:grid-cols-2">
<label class="block">
<span class="mb-2 block text-sm font-medium text-slate-700 dark:text-slate-200">监听端口</span>
<input v-model.number="draft.port" class="input font-mono" type="number" min="1" max="65535" :disabled="saving || draft.enabled" @change="saveNetworkSettings" />
</label>
<label class="block">
<span class="mb-2 block text-sm font-medium text-slate-700 dark:text-slate-200">监听范围</span>
<select v-model="draft.global" class="input" :disabled="saving || draft.enabled" @change="saveNetworkSettings">
<option :value="false">仅本机推荐</option>
<option :value="true">局域网内所有设备</option>
</select>
</label>
</div>
<p class="-mt-3 text-xs text-slate-400">端口和监听范围会自动保存需要修改时请先关闭 Web 服务</p>
<div>
<div class="mb-2 flex items-center justify-between gap-3">
<span class="text-sm font-medium text-slate-700 dark:text-slate-200">访问令牌</span>
<button class="text-xs font-medium text-indigo-600 hover:text-indigo-500 disabled:opacity-50 dark:text-indigo-400" type="button" :disabled="saving" @click="regenerate">更换令牌</button>
</div>
<div class="flex items-center gap-2">
<code class="min-w-0 flex-1 truncate rounded-lg border border-slate-200 bg-slate-50 px-3 py-2.5 text-xs text-slate-600 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-300">{{ draft.token }}</code>
<button class="btn-ghost shrink-0" type="button" :disabled="saving" @click="copyToken">复制令牌</button>
</div>
<p class="mt-2 text-xs text-slate-400">更换令牌后已登录的浏览器需要使用新令牌重新鉴权</p>
</div>
<div v-if="notice || error" class="border-t border-slate-200 pt-5 dark:border-slate-800">
<span v-if="notice" class="text-xs text-green-600 dark:text-green-400">{{ notice }}</span>
<span v-if="error" class="text-xs text-red-600 dark:text-red-400">{{ error }}</span>
</div>
</section>
<section class="card">
<div class="flex items-start gap-3">
<span class="mt-1 h-2.5 w-2.5 shrink-0 rounded-full" :class="status?.running ? 'bg-green-500' : 'bg-slate-300 dark:bg-slate-600'"></span>
<div class="min-w-0 flex-1">
<div class="text-sm font-semibold text-slate-900 dark:text-white">{{ status?.running ? "运行中" : "未运行" }}</div>
<div class="mt-1 text-xs text-slate-400">监听地址{{ status?.listenAddress }}</div>
<div v-if="status?.running" class="mt-4 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2.5 font-mono text-xs text-slate-600 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-300">
<span class="block truncate">{{ status.url }}</span>
</div>
<div v-if="status?.running" class="mt-3 flex flex-wrap gap-2">
<button class="btn-primary btn-sm" type="button" @click="openBrowser">打开浏览器</button>
<button class="btn-ghost btn-sm" type="button" @click="copyUrl">复制访问地址</button>
</div>
</div>
</div>
</section>
</template>
</div>
</template>
<style scoped>
.web-switch {
position: relative;
display: inline-flex;
flex: 0 0 44px;
align-items: center;
width: 44px;
min-width: 44px;
height: 24px;
min-height: 24px;
padding: 2px;
border: 0;
border-radius: 9999px;
cursor: pointer;
}
.web-switch-knob {
display: block;
width: 20px;
min-width: 20px;
height: 20px;
border-radius: 9999px;
transform: translateX(0);
transition: transform 160ms ease;
}
.web-switch-knob-on {
transform: translateX(20px);
}
</style>