feat: 实现仪表盘页面及系统信息采集功能
新增系统信息采集后端服务,引入 sysinfo 依赖获取 CPU、内存、主机名、系统版本、架构和 IP 地址等数据;新增系统相关 API 接口和 TypeScript 类型定义;重构仪表盘页面,整合服务器/隧道数量统计、系统信息展示、资源占用监控和 Frpc 运行日志功能;新增中英文国际化文案支持,完善前端交互逻辑。
This commit is contained in:
@@ -68,3 +68,19 @@ Layout布局content区域的磨砂玻璃卡片圆角样式我要改动一下:
|
|||||||
- 隧道信息在Rust端使用SQLite存储,Rust已经加入相关的sqlx依赖等,后端操作返回前端务必使用ApiResponse统一返回格式,前端也需要做好对应的类型匹配
|
- 隧道信息在Rust端使用SQLite存储,Rust已经加入相关的sqlx依赖等,后端操作返回前端务必使用ApiResponse统一返回格式,前端也需要做好对应的类型匹配
|
||||||
|
|
||||||
开发过程中使用到的组件尽可能全部使用Antdv库里面的,另外我引入的有tailwindcss原子化组件、auto-import和components依赖可以简化引入,开发之前尽可能先阅读一下package.json,看一下有哪些依赖可以简化代码开发
|
开发过程中使用到的组件尽可能全部使用Antdv库里面的,另外我引入的有tailwindcss原子化组件、auto-import和components依赖可以简化引入,开发之前尽可能先阅读一下package.json,看一下有哪些依赖可以简化代码开发
|
||||||
|
|
||||||
|
#### 3.3 仪表盘页面开发
|
||||||
|
|
||||||
|
按照我给的图片布局开发仪表盘页面
|
||||||
|
|
||||||
|
- 三个卡片分别展示服务器数量、隧道数量、CPU/内存/运行状态,每个卡片都需要圆角磨砂玻璃效果
|
||||||
|
- 三个卡片下方是快速操作,可以选择已下载的Frpc版本,右侧按钮一键启动或停止,Frpc下载功能暂未实现,先用模拟数据展示
|
||||||
|
- 快速操作下方是Frpc运行实时日志展示,也先使用模拟数据展示
|
||||||
|
|
||||||
|
开发过程中使用到的组件尽可能全部使用Antdv库里面的,另外我引入的有tailwindcss原子化组件、auto-import和components依赖可以简化引入,开发之前尽可能先阅读一下package.json,看一下有哪些依赖可以简化代码开发
|
||||||
|
|
||||||
|
##### 3.3.1 仪表盘页面调整
|
||||||
|
|
||||||
|
- 三个卡片当中的服务器数量和隧道数量合并到一个卡片里面,这一个卡片里面上方展示服务器数量,下方展示隧道数量
|
||||||
|
- 原本的隧道数量卡片改为展示当前运行系统主机名、系统架构、IP地址等,通过Rust端sysinfo获取
|
||||||
|
- 其余卡片保持不动
|
||||||
Generated
+46
@@ -1254,6 +1254,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
|
"sysinfo",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-helper",
|
"tauri-helper",
|
||||||
@@ -2504,6 +2505,15 @@ version = "1.0.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ntapi"
|
||||||
|
version = "0.4.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae"
|
||||||
|
dependencies = [
|
||||||
|
"winapi",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-conv"
|
name = "num-conv"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
@@ -2670,6 +2680,16 @@ dependencies = [
|
|||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-io-kit"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2-io-surface"
|
name = "objc2-io-surface"
|
||||||
version = "0.3.2"
|
version = "0.3.2"
|
||||||
@@ -2681,6 +2701,17 @@ dependencies = [
|
|||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-open-directory"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bb82bed227edf5201dfedf072bba4015a33d3d4a98519837295a90f0a23f676d"
|
||||||
|
dependencies = [
|
||||||
|
"objc2",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
"objc2-foundation",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2-osa-kit"
|
name = "objc2-osa-kit"
|
||||||
version = "0.3.2"
|
version = "0.3.2"
|
||||||
@@ -4210,6 +4241,21 @@ dependencies = [
|
|||||||
"syn 3.0.9",
|
"syn 3.0.9",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sysinfo"
|
||||||
|
version = "0.39.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d2071df9448915b71c4fe6d25deaf1c22f12bd234f01540b77312bb8e41361e6"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"memchr",
|
||||||
|
"ntapi",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
"objc2-io-kit",
|
||||||
|
"objc2-open-directory",
|
||||||
|
"windows",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "system-configuration"
|
name = "system-configuration"
|
||||||
version = "0.7.0"
|
version = "0.7.0"
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ sqlx = { version = "0.9.0", features = [
|
|||||||
"chrono",
|
"chrono",
|
||||||
] }
|
] }
|
||||||
chrono = { version = "0.4.45", features = ["serde"] }
|
chrono = { version = "0.4.45", features = ["serde"] }
|
||||||
|
sysinfo = "0.39"
|
||||||
tauri-plugin-dialog = "2.7.2"
|
tauri-plugin-dialog = "2.7.2"
|
||||||
tauri-plugin-updater = "2.10.1"
|
tauri-plugin-updater = "2.10.1"
|
||||||
tauri-plugin-process = "2.3.1"
|
tauri-plugin-process = "2.3.1"
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
pub mod tunnel;
|
pub mod tunnel;
|
||||||
pub mod server;
|
pub mod server;
|
||||||
|
pub mod system;
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
use std::net::IpAddr;
|
||||||
|
|
||||||
|
use sysinfo::{Networks, System};
|
||||||
|
use tauri::State;
|
||||||
|
use tauri_helper::auto_collect_command;
|
||||||
|
|
||||||
|
use crate::models::{ApiResponse, SystemDetails, SystemInfo};
|
||||||
|
use crate::services::system::SystemStats;
|
||||||
|
|
||||||
|
/// 获取系统资源占用(CPU / 内存百分比)
|
||||||
|
#[tauri::command]
|
||||||
|
#[auto_collect_command]
|
||||||
|
pub async fn get_system_info(
|
||||||
|
state: State<'_, SystemStats>,
|
||||||
|
) -> Result<ApiResponse<SystemInfo>, String> {
|
||||||
|
let (cpu, memory) = state.snapshot();
|
||||||
|
Ok(ApiResponse::success(SystemInfo { cpu, memory }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取系统信息(主机名、系统版本、架构、IP 地址)
|
||||||
|
#[tauri::command]
|
||||||
|
#[auto_collect_command]
|
||||||
|
pub async fn get_system_details() -> Result<ApiResponse<SystemDetails>, String> {
|
||||||
|
Ok(ApiResponse::success(collect_details()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_details() -> SystemDetails {
|
||||||
|
let hostname = System::host_name().unwrap_or_else(|| "-".to_string());
|
||||||
|
let os = System::long_os_version()
|
||||||
|
.or_else(System::os_version)
|
||||||
|
.or_else(System::name)
|
||||||
|
.unwrap_or_else(|| "-".to_string());
|
||||||
|
let arch = System::cpu_arch();
|
||||||
|
|
||||||
|
// 采集本机 IPv4 地址(过滤回环地址),并去重
|
||||||
|
let networks = Networks::new_with_refreshed_list();
|
||||||
|
let mut ips: Vec<String> = Vec::new();
|
||||||
|
for data in networks.list().values() {
|
||||||
|
for ip_net in data.ip_networks() {
|
||||||
|
if let IpAddr::V4(ipv4) = ip_net.addr {
|
||||||
|
if !ipv4.is_loopback() {
|
||||||
|
ips.push(ipv4.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ips.sort();
|
||||||
|
ips.dedup();
|
||||||
|
|
||||||
|
SystemDetails {
|
||||||
|
hostname,
|
||||||
|
os,
|
||||||
|
arch,
|
||||||
|
ips,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn details_are_collected() {
|
||||||
|
let d = collect_details();
|
||||||
|
println!(
|
||||||
|
"hostname={}, os={}, arch={}, ips={:?}",
|
||||||
|
d.hostname, d.os, d.arch, d.ips
|
||||||
|
);
|
||||||
|
assert!(!d.hostname.is_empty());
|
||||||
|
assert!(!d.arch.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,11 +8,13 @@ use crate::commands::server::{
|
|||||||
create_server, delete_server, get_server_list, set_server_enabled, set_server_pinned,
|
create_server, delete_server, get_server_list, set_server_enabled, set_server_pinned,
|
||||||
update_server,
|
update_server,
|
||||||
};
|
};
|
||||||
|
use crate::commands::system::{get_system_details, get_system_info};
|
||||||
use crate::commands::tunnel::{
|
use crate::commands::tunnel::{
|
||||||
create_tunnel, delete_tunnel, get_tunnel_list, set_tunnel_enabled, set_tunnel_pinned,
|
create_tunnel, delete_tunnel, get_tunnel_list, set_tunnel_enabled, set_tunnel_pinned,
|
||||||
update_tunnel,
|
update_tunnel,
|
||||||
};
|
};
|
||||||
use crate::services::db::init_pool;
|
use crate::services::db::init_pool;
|
||||||
|
use crate::services::system::SystemStats;
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
@@ -30,6 +32,11 @@ pub fn run() {
|
|||||||
});
|
});
|
||||||
app.manage(pool);
|
app.manage(pool);
|
||||||
|
|
||||||
|
// 系统资源占用(CPU / 内存)后台采集
|
||||||
|
let stats = SystemStats::default();
|
||||||
|
stats.start();
|
||||||
|
app.manage(stats);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.plugin(tauri_plugin_store::Builder::default().build())
|
.plugin(tauri_plugin_store::Builder::default().build())
|
||||||
|
|||||||
+23
-2
@@ -157,8 +157,7 @@ pub struct Tunnel {
|
|||||||
|
|
||||||
/// 新增 / 编辑隧道的提交数据
|
/// 新增 / 编辑隧道的提交数据
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct TunnelPayload {
|
pub struct TunnelPayload { /// 编辑时携带,新增时为空
|
||||||
/// 编辑时携带,新增时为空
|
|
||||||
pub id: Option<i64>,
|
pub id: Option<i64>,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
#[serde(rename = "type")]
|
#[serde(rename = "type")]
|
||||||
@@ -187,3 +186,25 @@ pub struct TunnelPayload {
|
|||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 系统资源占用(百分比,0 - 100)
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct SystemInfo {
|
||||||
|
/// CPU 占用百分比
|
||||||
|
pub cpu: u32,
|
||||||
|
/// 内存占用百分比
|
||||||
|
pub memory: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 系统信息(主机名、系统版本、架构、IP 地址)
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct SystemDetails {
|
||||||
|
/// 主机名
|
||||||
|
pub hostname: String,
|
||||||
|
/// 操作系统版本
|
||||||
|
pub os: String,
|
||||||
|
/// 系统架构
|
||||||
|
pub arch: String,
|
||||||
|
/// 本机 IPv4 地址列表(不含回环地址)
|
||||||
|
pub ips: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
pub mod db;
|
pub mod db;
|
||||||
|
pub mod system;
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicU32, Ordering};
|
||||||
|
use std::thread;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use sysinfo::{CpuRefreshKind, MemoryRefreshKind, RefreshKind, System};
|
||||||
|
|
||||||
|
/// 系统资源占用(CPU / 内存百分比)。
|
||||||
|
///
|
||||||
|
/// 通过后台线程定时刷新并缓存,命令调用时直接读取缓存值,
|
||||||
|
/// 避免每次请求都阻塞等待 CPU 采样间隔。
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
pub struct SystemStats {
|
||||||
|
cpu: Arc<AtomicU32>,
|
||||||
|
memory: Arc<AtomicU32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SystemStats {
|
||||||
|
/// 启动后台刷新线程(每秒刷新一次 CPU / 内存占用)
|
||||||
|
pub fn start(&self) {
|
||||||
|
let cpu = self.cpu.clone();
|
||||||
|
let memory = self.memory.clone();
|
||||||
|
|
||||||
|
thread::spawn(move || {
|
||||||
|
// 仅刷新 CPU 使用率与内存,避免枚举进程导致首次采样过慢
|
||||||
|
let mut sys = System::new_with_specifics(
|
||||||
|
RefreshKind::nothing()
|
||||||
|
.with_cpu(CpuRefreshKind::nothing().with_cpu_usage())
|
||||||
|
.with_memory(MemoryRefreshKind::nothing().with_ram()),
|
||||||
|
);
|
||||||
|
// 首次刷新后间隔一小段时间再采样,保证 CPU 使用率准确
|
||||||
|
sys.refresh_cpu_usage();
|
||||||
|
thread::sleep(Duration::from_millis(500));
|
||||||
|
|
||||||
|
loop {
|
||||||
|
sys.refresh_cpu_usage();
|
||||||
|
sys.refresh_memory();
|
||||||
|
|
||||||
|
cpu.store(sys.global_cpu_usage().round() as u32, Ordering::Relaxed);
|
||||||
|
memory.store(
|
||||||
|
usage_percent(sys.used_memory(), sys.total_memory()),
|
||||||
|
Ordering::Relaxed,
|
||||||
|
);
|
||||||
|
|
||||||
|
thread::sleep(Duration::from_secs(1));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 读取当前缓存的占用百分比
|
||||||
|
pub fn snapshot(&self) -> (u32, u32) {
|
||||||
|
(
|
||||||
|
self.cpu.load(Ordering::Relaxed),
|
||||||
|
self.memory.load(Ordering::Relaxed),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 计算百分比(0 - 100)
|
||||||
|
fn usage_percent(used: u64, total: u64) -> u32 {
|
||||||
|
if total == 0 {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
((used as f64 / total as f64) * 100.0).round() as u32
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn snapshot_returns_real_percentages() {
|
||||||
|
let stats = SystemStats::default();
|
||||||
|
stats.start();
|
||||||
|
thread::sleep(Duration::from_millis(1500));
|
||||||
|
let (cpu, memory) = stats.snapshot();
|
||||||
|
println!("real cpu = {cpu}%, memory = {memory}%");
|
||||||
|
assert!(cpu <= 100, "cpu out of range: {cpu}");
|
||||||
|
assert!(memory <= 100, "memory out of range: {memory}");
|
||||||
|
assert!(memory > 0, "memory should be greater than 0");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { invoke } from "@tauri-apps/api/core"
|
||||||
|
import type { ApiResponse } from "@/types/api"
|
||||||
|
import type { SystemDetails, SystemInfo } from "@/types/system"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调用后端命令并统一解包 ApiResponse。
|
||||||
|
*
|
||||||
|
* 后端系统相关命令均返回 `ApiResponse<T>`,业务失败时
|
||||||
|
* `success === false`,此处统一抛出异常,交由调用方处理。
|
||||||
|
*/
|
||||||
|
async function call<T>(command: string, args?: Record<string, unknown>): Promise<T> {
|
||||||
|
const res = await invoke<ApiResponse<T>>(command, args)
|
||||||
|
if (!res?.success) {
|
||||||
|
throw new Error(res?.msg || "操作失败")
|
||||||
|
}
|
||||||
|
return res.data as T
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取系统资源占用(CPU / 内存百分比) */
|
||||||
|
export function getSystemInfo() {
|
||||||
|
return call<SystemInfo>("get_system_info")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取系统信息(主机名、系统版本、架构、IP 地址) */
|
||||||
|
export function getSystemDetails() {
|
||||||
|
return call<SystemDetails>("get_system_details")
|
||||||
|
}
|
||||||
@@ -1,16 +1,11 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core"
|
import { getSystemInfo } from "@/api/system"
|
||||||
import { isTauri } from "@/utils/window"
|
import { isTauri } from "@/utils/window"
|
||||||
|
|
||||||
interface SystemInfo {
|
|
||||||
cpu: number
|
|
||||||
memory: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 系统资源占用(CPU / 内存 百分比)。
|
* 系统资源占用(CPU / 内存 百分比)。
|
||||||
*
|
*
|
||||||
* 优先调用后端命令 `get_system_info`(返回 { cpu, memory }),
|
* 调用后端命令 `get_system_info`(返回 `ApiResponse<{ cpu, memory }>`),
|
||||||
* 后端尚未实现时回退到模拟数据,保证界面可用。
|
* 在非 Tauri 环境或后端不可用时回退到模拟数据,保证界面可用。
|
||||||
*/
|
*/
|
||||||
export function useSystemStats(interval = 2000) {
|
export function useSystemStats(interval = 2000) {
|
||||||
const cpu = ref(0)
|
const cpu = ref(0)
|
||||||
@@ -31,15 +26,13 @@ export function useSystemStats(interval = 2000) {
|
|||||||
async function refresh() {
|
async function refresh() {
|
||||||
if (isTauri()) {
|
if (isTauri()) {
|
||||||
try {
|
try {
|
||||||
const res = await invoke<SystemInfo>("get_system_info")
|
const info = await getSystemInfo()
|
||||||
if (res) {
|
cpu.value = Math.round(info.cpu)
|
||||||
cpu.value = Math.round(res.cpu)
|
memory.value = Math.round(info.memory)
|
||||||
memory.value = Math.round(res.memory)
|
|
||||||
isMock.value = false
|
isMock.value = false
|
||||||
return
|
return
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// 后端命令未实现,回退到模拟数据
|
// 后端命令不可用,回退到模拟数据
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
mock()
|
mock()
|
||||||
|
|||||||
@@ -33,6 +33,24 @@
|
|||||||
"statusError": "Error",
|
"statusError": "Error",
|
||||||
"statusStopped": "Stopped"
|
"statusStopped": "Stopped"
|
||||||
},
|
},
|
||||||
|
"dashboard": {
|
||||||
|
"serverCount": "Servers",
|
||||||
|
"tunnelCount": "Tunnels",
|
||||||
|
"hostname": "Hostname",
|
||||||
|
"os": "OS",
|
||||||
|
"arch": "Architecture",
|
||||||
|
"ip": "IP address",
|
||||||
|
"frpcVersion": "Frpc version",
|
||||||
|
"start": "One-click start",
|
||||||
|
"stop": "Stop",
|
||||||
|
"logs": "Live logs",
|
||||||
|
"clear": "Clear",
|
||||||
|
"mock": "Mock data",
|
||||||
|
"logEmpty": "No logs yet. Live logs will appear here once Frpc starts.",
|
||||||
|
"logReady": "Frpilot is ready. Waiting for Frpc to start.",
|
||||||
|
"logStarted": "Frpc {version} started",
|
||||||
|
"logStopped": "Frpc stopped"
|
||||||
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "Settings",
|
"title": "Settings",
|
||||||
"placeholder": "The settings page is under construction.",
|
"placeholder": "The settings page is under construction.",
|
||||||
|
|||||||
@@ -33,6 +33,24 @@
|
|||||||
"statusError": "运行异常",
|
"statusError": "运行异常",
|
||||||
"statusStopped": "未启动"
|
"statusStopped": "未启动"
|
||||||
},
|
},
|
||||||
|
"dashboard": {
|
||||||
|
"serverCount": "服务器数量",
|
||||||
|
"tunnelCount": "隧道数量",
|
||||||
|
"hostname": "主机名",
|
||||||
|
"os": "系统",
|
||||||
|
"arch": "系统架构",
|
||||||
|
"ip": "IP 地址",
|
||||||
|
"frpcVersion": "Frpc 版本",
|
||||||
|
"start": "一键启动",
|
||||||
|
"stop": "停止",
|
||||||
|
"logs": "实时日志",
|
||||||
|
"clear": "清空",
|
||||||
|
"mock": "模拟数据",
|
||||||
|
"logEmpty": "暂无日志,启动 Frpc 后将在此显示实时日志",
|
||||||
|
"logReady": "Frpilot 已就绪,等待启动 Frpc",
|
||||||
|
"logStarted": "已启动 Frpc {version}",
|
||||||
|
"logStopped": "已停止 Frpc"
|
||||||
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "设置",
|
"title": "设置",
|
||||||
"placeholder": "设置页面开发中,敬请期待。",
|
"placeholder": "设置页面开发中,敬请期待。",
|
||||||
|
|||||||
Vendored
+1
@@ -36,6 +36,7 @@ declare module 'vue' {
|
|||||||
ASelect: typeof import('ant-design-vue/es')['Select']
|
ASelect: typeof import('ant-design-vue/es')['Select']
|
||||||
ASpace: typeof import('ant-design-vue/es')['Space']
|
ASpace: typeof import('ant-design-vue/es')['Space']
|
||||||
ASpin: typeof import('ant-design-vue/es')['Spin']
|
ASpin: typeof import('ant-design-vue/es')['Spin']
|
||||||
|
AStatistic: typeof import('ant-design-vue/es')['Statistic']
|
||||||
ASwitch: typeof import('ant-design-vue/es')['Switch']
|
ASwitch: typeof import('ant-design-vue/es')['Switch']
|
||||||
ATag: typeof import('ant-design-vue/es')['Tag']
|
ATag: typeof import('ant-design-vue/es')['Tag']
|
||||||
ATextarea: typeof import('ant-design-vue/es')['Textarea']
|
ATextarea: typeof import('ant-design-vue/es')['Textarea']
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* 系统相关的类型定义,字段与 Rust 端 `models::SystemInfo` / `models::SystemDetails` 一一对应。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 系统资源占用(百分比) */
|
||||||
|
export interface SystemInfo {
|
||||||
|
/** CPU 占用百分比 */
|
||||||
|
cpu: number
|
||||||
|
/** 内存占用百分比 */
|
||||||
|
memory: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 系统信息(主机名、系统版本、架构、IP 地址) */
|
||||||
|
export interface SystemDetails {
|
||||||
|
/** 主机名 */
|
||||||
|
hostname: string
|
||||||
|
/** 操作系统版本 */
|
||||||
|
os: string
|
||||||
|
/** 系统架构 */
|
||||||
|
arch: string
|
||||||
|
/** 本机 IPv4 地址列表(不含回环地址) */
|
||||||
|
ips: string[]
|
||||||
|
}
|
||||||
+313
-6
@@ -1,19 +1,326 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex h-full flex-col gap-3">
|
<div class="flex h-full flex-col gap-3">
|
||||||
<!-- 顶部操作栏(独立磨砂卡片) -->
|
<!-- 顶部统计卡片 -->
|
||||||
<div class="glass-panel glass-panel-flat flex shrink-0 items-center justify-between gap-3 px-5 py-3">
|
<div class="grid shrink-0 grid-cols-1 gap-3 sm:grid-cols-3">
|
||||||
<h2 class="text-lg font-semibold">{{ t("menu.dashboard") }}</h2>
|
<!-- 服务器 / 隧道数量 -->
|
||||||
|
<div class="glass-panel flex flex-col justify-center gap-2 px-5 py-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span
|
||||||
|
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-indigo-500/10 text-base text-indigo-500"
|
||||||
|
>
|
||||||
|
<CloudServerOutlined />
|
||||||
|
</span>
|
||||||
|
<a-statistic
|
||||||
|
:title="t('dashboard.serverCount')"
|
||||||
|
:value="serverCount"
|
||||||
|
:value-style="statValueStyle"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span
|
||||||
|
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-sky-500/10 text-base text-sky-500"
|
||||||
|
>
|
||||||
|
<ApiOutlined />
|
||||||
|
</span>
|
||||||
|
<a-statistic
|
||||||
|
:title="t('dashboard.tunnelCount')"
|
||||||
|
:value="tunnelCount"
|
||||||
|
:value-style="statValueStyle"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 内容区(独立磨砂卡片) -->
|
<!-- 系统信息 -->
|
||||||
<div class="glass-panel glass-panel-flat min-h-0 flex-1 overflow-y-auto p-5">
|
<div class="glass-panel flex items-center gap-4 px-5 py-4">
|
||||||
<a-result status="info" :sub-title="t('common.developing')" />
|
<span
|
||||||
|
class="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl bg-emerald-500/10 text-xl text-emerald-500"
|
||||||
|
>
|
||||||
|
<DesktopOutlined />
|
||||||
|
</span>
|
||||||
|
<div class="flex min-w-0 flex-1 flex-col gap-1.5 text-xs">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="shrink-0 opacity-70">{{ t("dashboard.hostname") }}</span>
|
||||||
|
<span class="truncate font-medium" :title="details?.hostname">
|
||||||
|
{{ details?.hostname || "-" }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="shrink-0 opacity-70">{{ t("dashboard.os") }}</span>
|
||||||
|
<span class="truncate font-medium" :title="details?.os">{{ details?.os || "-" }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="shrink-0 opacity-70">{{ t("dashboard.arch") }}</span>
|
||||||
|
<span class="truncate font-medium">{{ details?.arch || "-" }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="shrink-0 opacity-70">{{ t("dashboard.ip") }}</span>
|
||||||
|
<span class="truncate font-medium" :title="ipFull">{{ ipText }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CPU / 内存 / 运行状态 -->
|
||||||
|
<div class="glass-panel flex flex-col justify-center gap-2 px-5 py-4">
|
||||||
|
<div class="flex items-center justify-between text-xs">
|
||||||
|
<span class="opacity-70">{{ t("system.cpu") }}</span>
|
||||||
|
<span class="font-medium tabular-nums">{{ cpu }}%</span>
|
||||||
|
</div>
|
||||||
|
<a-progress :percent="cpu" :show-info="false" :stroke-color="barColor(cpu)" size="small" />
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between text-xs">
|
||||||
|
<span class="opacity-70">{{ t("system.memory") }}</span>
|
||||||
|
<span class="font-medium tabular-nums">{{ memory }}%</span>
|
||||||
|
</div>
|
||||||
|
<a-progress :percent="memory" :show-info="false" :stroke-color="barColor(memory)" size="small" />
|
||||||
|
|
||||||
|
<div class="mt-1 flex items-center gap-2 text-xs">
|
||||||
|
<span class="h-2 w-2 shrink-0 rounded-full" :class="statusDotClass" />
|
||||||
|
<span class="opacity-70">{{ t("system.status") }}</span>
|
||||||
|
<span class="font-medium">{{ statusText }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 快速操作 -->
|
||||||
|
<div
|
||||||
|
class="glass-panel glass-panel-flat flex shrink-0 flex-wrap items-center justify-between gap-3 px-5 py-3"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="shrink-0 text-sm opacity-70">{{ t("dashboard.frpcVersion") }}</span>
|
||||||
|
<a-select v-model:value="selectedVersion" :options="versionOptions" class="w-56" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-space>
|
||||||
|
<a-button
|
||||||
|
type="primary"
|
||||||
|
:icon="h(PlayCircleOutlined)"
|
||||||
|
:disabled="isRunning"
|
||||||
|
@click="start"
|
||||||
|
>
|
||||||
|
{{ t("dashboard.start") }}
|
||||||
|
</a-button>
|
||||||
|
<a-button danger :icon="h(PoweroffOutlined)" :disabled="!isRunning" @click="stop">
|
||||||
|
{{ t("dashboard.stop") }}
|
||||||
|
</a-button>
|
||||||
|
</a-space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 实时日志 -->
|
||||||
|
<div class="glass-panel glass-panel-flat flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||||
|
<div
|
||||||
|
class="flex shrink-0 items-center justify-between gap-3 border-b border-black/5 px-5 py-3 dark:border-white/10"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<h3 class="text-sm font-semibold">{{ t("dashboard.logs") }}</h3>
|
||||||
|
<a-tag class="!me-0">{{ t("dashboard.mock") }}</a-tag>
|
||||||
|
<a-tag :color="statusTagColor" class="!me-0">{{ statusText }}</a-tag>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-tooltip :title="t('dashboard.clear')">
|
||||||
|
<a-button size="small" :icon="h(ClearOutlined)" @click="clearLogs" />
|
||||||
|
</a-tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref="logBox"
|
||||||
|
class="min-h-0 flex-1 overflow-y-auto px-5 py-3 font-mono text-xs leading-relaxed"
|
||||||
|
>
|
||||||
|
<p v-if="!logs.length" class="opacity-50">{{ t("dashboard.logEmpty") }}</p>
|
||||||
|
<div v-for="(line, index) in logs" :key="index" class="flex gap-2">
|
||||||
|
<span class="shrink-0 opacity-50">{{ line.time }}</span>
|
||||||
|
<span class="shrink-0" :class="levelClass(line.level)">[{{ line.level }}]</span>
|
||||||
|
<span class="break-all">{{ line.message }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { h } from "vue"
|
||||||
|
import dayjs from "dayjs"
|
||||||
|
import {
|
||||||
|
ApiOutlined,
|
||||||
|
ClearOutlined,
|
||||||
|
CloudServerOutlined,
|
||||||
|
DesktopOutlined,
|
||||||
|
PlayCircleOutlined,
|
||||||
|
PoweroffOutlined,
|
||||||
|
} from "@ant-design/icons-vue"
|
||||||
import { useI18n } from "vue-i18n"
|
import { useI18n } from "vue-i18n"
|
||||||
|
import { getServerList } from "@/api/server"
|
||||||
|
import { getSystemDetails } from "@/api/system"
|
||||||
|
import { getTunnelList } from "@/api/tunnel"
|
||||||
|
import { useAppStore } from "@/stores/app"
|
||||||
|
import { useSystemStats } from "@/composables/useSystemStats"
|
||||||
|
import type { SystemDetails } from "@/types/system"
|
||||||
|
import { isTauri } from "@/utils/window"
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const appStore = useAppStore()
|
||||||
|
const { cpu, memory } = useSystemStats()
|
||||||
|
|
||||||
|
/** 服务器 / 隧道数量(来自后端,未连接 Tauri 时保持 0) */
|
||||||
|
const serverCount = ref(0)
|
||||||
|
const tunnelCount = ref(0)
|
||||||
|
|
||||||
|
/** 系统信息(主机名 / 系统 / 架构 / IP) */
|
||||||
|
const details = ref<SystemDetails | null>(null)
|
||||||
|
|
||||||
|
/** 卡片内展示的主 IP(多个时追加数量,完整列表在 tooltip 中显示) */
|
||||||
|
const ipText = computed(() => {
|
||||||
|
const ips = details.value?.ips ?? []
|
||||||
|
if (!ips.length) return "-"
|
||||||
|
return ips.length > 1 ? `${ips[0]} +${ips.length - 1}` : ips[0]
|
||||||
|
})
|
||||||
|
const ipFull = computed(() => (details.value?.ips?.length ? details.value.ips.join(", ") : "-"))
|
||||||
|
|
||||||
|
/** 合并卡片中两个统计数值的样式(更紧凑) */
|
||||||
|
const statValueStyle = { fontSize: "20px", fontWeight: 600, lineHeight: "1.2" }
|
||||||
|
|
||||||
|
/** 模拟:已下载的 Frpc 版本列表(下载功能尚未实现) */
|
||||||
|
const versionOptions = [
|
||||||
|
{ label: "v0.61.1 · windows_amd64", value: "v0.61.1" },
|
||||||
|
{ label: "v0.60.0 · windows_amd64", value: "v0.60.0" },
|
||||||
|
{ label: "v0.58.2 · windows_amd64", value: "v0.58.2" },
|
||||||
|
]
|
||||||
|
const selectedVersion = ref(versionOptions[0].value)
|
||||||
|
|
||||||
|
const isRunning = computed(() => appStore.runStatus === "running")
|
||||||
|
|
||||||
|
type LogLevel = "INFO" | "WARN" | "ERROR" | "DEBUG"
|
||||||
|
|
||||||
|
interface LogLine {
|
||||||
|
time: string
|
||||||
|
level: LogLevel
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const logs = ref<LogLine[]>([])
|
||||||
|
const logBox = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
/** 追加一行日志并自动滚动到底部(最多保留 500 行) */
|
||||||
|
function pushLog(level: LogLevel, message: string) {
|
||||||
|
logs.value.push({ time: dayjs().format("HH:mm:ss"), level, message })
|
||||||
|
if (logs.value.length > 500) {
|
||||||
|
logs.value.splice(0, logs.value.length - 500)
|
||||||
|
}
|
||||||
|
nextTick(() => {
|
||||||
|
const el = logBox.value
|
||||||
|
if (el) el.scrollTop = el.scrollHeight
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearLogs() {
|
||||||
|
logs.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
if (isRunning.value) return
|
||||||
|
appStore.runStatus = "running"
|
||||||
|
pushLog("INFO", t("dashboard.logStarted", { version: selectedVersion.value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function stop() {
|
||||||
|
if (!isRunning.value) return
|
||||||
|
appStore.runStatus = "stopped"
|
||||||
|
pushLog("WARN", t("dashboard.logStopped"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 模拟实时日志:运行中定时追加 */
|
||||||
|
const MOCK_LOGS: { level: LogLevel; message: string }[] = [
|
||||||
|
{ level: "INFO", message: "login to server success" },
|
||||||
|
{ level: "INFO", message: "start proxy success" },
|
||||||
|
{ level: "DEBUG", message: "heartbeat sent to server" },
|
||||||
|
{ level: "INFO", message: "new connection established" },
|
||||||
|
{ level: "WARN", message: "connection timeout, retrying..." },
|
||||||
|
{ level: "DEBUG", message: "connection closed by remote" },
|
||||||
|
]
|
||||||
|
|
||||||
|
useIntervalFn(() => {
|
||||||
|
if (!isRunning.value) return
|
||||||
|
const item = MOCK_LOGS[Math.floor(Math.random() * MOCK_LOGS.length)]
|
||||||
|
pushLog(item.level, item.message)
|
||||||
|
}, 1500)
|
||||||
|
|
||||||
|
/** 拉取服务器 / 隧道数量 */
|
||||||
|
async function loadCounts() {
|
||||||
|
if (!isTauri()) return
|
||||||
|
try {
|
||||||
|
const [servers, tunnels] = await Promise.all([getServerList(), getTunnelList()])
|
||||||
|
serverCount.value = servers?.length ?? 0
|
||||||
|
tunnelCount.value = tunnels?.length ?? 0
|
||||||
|
} catch {
|
||||||
|
// 读取失败时保持 0,不影响仪表盘其它区域展示
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 拉取系统信息(主机名 / 系统 / 架构 / IP) */
|
||||||
|
async function loadSystemDetails() {
|
||||||
|
if (!isTauri()) return
|
||||||
|
try {
|
||||||
|
details.value = await getSystemDetails()
|
||||||
|
} catch {
|
||||||
|
// 读取失败时保持占位符
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
pushLog("INFO", t("dashboard.logReady"))
|
||||||
|
loadCounts()
|
||||||
|
loadSystemDetails()
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 进度条颜色:低=蓝,中=橙,高=红 */
|
||||||
|
function barColor(value: number) {
|
||||||
|
if (value >= 85) return "#ef4444"
|
||||||
|
if (value >= 60) return "#f59e0b"
|
||||||
|
return "#6366f1"
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusDotClass = computed(() => {
|
||||||
|
switch (appStore.runStatus) {
|
||||||
|
case "running":
|
||||||
|
return "bg-green-500 shadow-[0_0_6px_#22c55e]"
|
||||||
|
case "error":
|
||||||
|
return "bg-red-500 shadow-[0_0_6px_#ef4444]"
|
||||||
|
default:
|
||||||
|
return "bg-yellow-400"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const statusText = computed(() => {
|
||||||
|
switch (appStore.runStatus) {
|
||||||
|
case "running":
|
||||||
|
return t("system.statusRunning")
|
||||||
|
case "error":
|
||||||
|
return t("system.statusError")
|
||||||
|
default:
|
||||||
|
return t("system.statusStopped")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const statusTagColor = computed(() => {
|
||||||
|
switch (appStore.runStatus) {
|
||||||
|
case "running":
|
||||||
|
return "success"
|
||||||
|
case "error":
|
||||||
|
return "error"
|
||||||
|
default:
|
||||||
|
return "default"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function levelClass(level: LogLevel) {
|
||||||
|
switch (level) {
|
||||||
|
case "WARN":
|
||||||
|
return "text-amber-500"
|
||||||
|
case "ERROR":
|
||||||
|
return "text-red-500"
|
||||||
|
case "DEBUG":
|
||||||
|
return "text-slate-400"
|
||||||
|
default:
|
||||||
|
return "text-indigo-500"
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user