feat: 全面重构前端UI及完善后端功能
前端重构: - 重构Layout为左侧导航+顶栏的现代管理后台布局 - 重构设备管理页面(Devices.vue):左侧分组面板+右侧设备列表 - 重构设备详情(DeviceDetail.vue):集成硬件资产/软件资产/变更记录标签页 - 移除独立资产管理页面,功能合并至设备详情 - 重构Dashboard/登录/设置/告警/水印/上网管控等页面样式 - 新增全局CSS变量和统一样式系统 - 添加分组管理UI:新建/重命名/删除分组,移动设备到分组 后端完善: - 新增分组CRUD API(groups.rs):创建/重命名/删除分组,设备分组移动 - 客户端硬件采集:完善GPU/主板/序列号/磁盘信息采集(Windows PowerShell) - 客户端软件采集:通过Windows注册表读取已安装软件列表 - 新增SoftwareAssetReport消息类型(0x09)及处理链路 - 数据库新增upsert_software方法处理软件资产存储 - 服务端推送软件资产配置给新注册设备 - 修复密码修改功能,添加旧密码验证
This commit is contained in:
@@ -1,20 +1,17 @@
|
||||
use csm_protocol::{Frame, MessageType, HardwareAsset};
|
||||
use csm_protocol::{Frame, MessageType, HardwareAsset, SoftwareAsset};
|
||||
use std::time::Duration;
|
||||
use tokio::sync::mpsc::Sender;
|
||||
use tracing::{info, error};
|
||||
use sysinfo::System;
|
||||
|
||||
pub async fn start_collecting(tx: Sender<Frame>, device_uid: String) {
|
||||
let interval = Duration::from_secs(86400); // Once per day
|
||||
let interval = Duration::from_secs(86400);
|
||||
|
||||
// Initial collection on startup
|
||||
if let Err(e) = collect_and_send(&tx, &device_uid).await {
|
||||
error!("Initial asset collection failed: {}", e);
|
||||
}
|
||||
|
||||
loop {
|
||||
tokio::time::sleep(interval).await;
|
||||
|
||||
if let Err(e) = collect_and_send(&tx, &device_uid).await {
|
||||
error!("Asset collection failed: {}", e);
|
||||
}
|
||||
@@ -22,33 +19,163 @@ pub async fn start_collecting(tx: Sender<Frame>, device_uid: String) {
|
||||
}
|
||||
|
||||
async fn collect_and_send(tx: &Sender<Frame>, device_uid: &str) -> anyhow::Result<()> {
|
||||
// Collect & send hardware
|
||||
let hardware = collect_hardware(device_uid)?;
|
||||
let frame = Frame::new_json(MessageType::AssetReport, &hardware)?;
|
||||
tx.send(frame).await.map_err(|e| anyhow::anyhow!("Channel send failed: {}", e))?;
|
||||
info!("Asset report sent for {}", device_uid);
|
||||
info!("Hardware asset report sent for {}", device_uid);
|
||||
|
||||
// Collect & send software list
|
||||
let software_list = collect_software(device_uid);
|
||||
let sw_count = software_list.len();
|
||||
for sw in &software_list {
|
||||
let frame = Frame::new_json(MessageType::SoftwareAssetReport, sw)?;
|
||||
tx.send(frame).await.map_err(|e| anyhow::anyhow!("Channel send failed: {}", e))?;
|
||||
}
|
||||
info!("Software asset reports sent ({} items) for {}", sw_count, device_uid);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn collect_hardware(device_uid: &str) -> anyhow::Result<HardwareAsset> {
|
||||
let mut sys = System::new_all();
|
||||
let mut sys = sysinfo::System::new_all();
|
||||
sys.refresh_all();
|
||||
|
||||
// CPU
|
||||
let cpu_model = sys.cpus().first()
|
||||
.map(|c| c.brand().to_string())
|
||||
.unwrap_or_else(|| "Unknown".to_string());
|
||||
|
||||
let cpu_cores = sys.cpus().len() as u32;
|
||||
let memory_total_mb = sys.total_memory() / 1024 / 1024; // bytes to MB (sysinfo 0.30)
|
||||
|
||||
// Memory
|
||||
let memory_total_mb = sys.total_memory() / 1024 / 1024;
|
||||
|
||||
// Disk — pick the largest non-removable disk
|
||||
let disks = sysinfo::Disks::new_with_refreshed_list();
|
||||
let (disk_model, disk_total_mb) = disks.iter()
|
||||
.max_by_key(|d| d.total_space())
|
||||
.map(|d| {
|
||||
let name = d.name().to_string_lossy().to_string();
|
||||
let total = d.total_space() / 1024 / 1024;
|
||||
(if name.is_empty() { "Unknown".to_string() } else { name }, total)
|
||||
})
|
||||
.unwrap_or_else(|| ("Unknown".to_string(), 0));
|
||||
|
||||
// GPU, motherboard, serial — Windows-specific via PowerShell
|
||||
let (gpu_model, motherboard, serial_number) = collect_system_details();
|
||||
|
||||
Ok(HardwareAsset {
|
||||
device_uid: device_uid.to_string(),
|
||||
cpu_model,
|
||||
cpu_cores,
|
||||
memory_total_mb: memory_total_mb as u64,
|
||||
disk_model: "Unknown".to_string(),
|
||||
disk_total_mb: 0,
|
||||
gpu_model: None,
|
||||
motherboard: None,
|
||||
serial_number: None,
|
||||
memory_total_mb,
|
||||
disk_model,
|
||||
disk_total_mb,
|
||||
gpu_model,
|
||||
motherboard,
|
||||
serial_number,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn collect_system_details() -> (Option<String>, Option<String>, Option<String>) {
|
||||
let gpu = powershell_first("Get-CimInstance Win32_VideoController | Select-Object -ExpandProperty Caption");
|
||||
let mb_manufacturer = powershell_first("Get-CimInstance Win32_BaseBoard | Select-Object -ExpandProperty Manufacturer");
|
||||
let mb_product = powershell_first("Get-CimInstance Win32_BaseBoard | Select-Object -ExpandProperty Product");
|
||||
let motherboard = match (mb_manufacturer, mb_product) {
|
||||
(Some(m), Some(p)) => Some(format!("{} {}", m, p)),
|
||||
(single @ Some(_), _) => single,
|
||||
(_, single @ Some(_)) => single,
|
||||
_ => None,
|
||||
};
|
||||
let serial_number = powershell_first("Get-CimInstance Win32_BIOS | Select-Object -ExpandProperty SerialNumber");
|
||||
|
||||
(gpu, motherboard, serial_number)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn collect_system_details() -> (Option<String>, Option<String>, Option<String>) {
|
||||
(None, None, None)
|
||||
}
|
||||
|
||||
/// Run a PowerShell command and return the first non-empty line of output.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn powershell_first(command: &str) -> Option<String> {
|
||||
use std::process::Command;
|
||||
let output = Command::new("powershell")
|
||||
.args(["-NoProfile", "-NonInteractive", "-Command",
|
||||
&format!("[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; {}", command)])
|
||||
.output()
|
||||
.ok()?;
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
stdout.lines()
|
||||
.map(|l| l.trim().to_string())
|
||||
.find(|l| !l.is_empty())
|
||||
}
|
||||
|
||||
/// Collect installed software from Windows Registry via PowerShell.
|
||||
fn collect_software(device_uid: &str) -> Vec<SoftwareAsset> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
collect_windows_software(device_uid)
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let _ = device_uid;
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn collect_windows_software(device_uid: &str) -> Vec<SoftwareAsset> {
|
||||
use std::process::Command;
|
||||
|
||||
let ps_cmd = r#"
|
||||
$paths = @(
|
||||
"HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*",
|
||||
"HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*",
|
||||
"HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*"
|
||||
)
|
||||
Get-ItemProperty $paths -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.DisplayName -and $_.SystemComponent -ne 1 -and $null -eq $_.ParentDisplayName } |
|
||||
Select-Object DisplayName, DisplayVersion, Publisher, InstallDate, InstallLocation |
|
||||
ConvertTo-Json -Compress
|
||||
"#;
|
||||
|
||||
let output = match Command::new("powershell")
|
||||
.args(["-NoProfile", "-NonInteractive", "-Command", ps_cmd])
|
||||
.output()
|
||||
{
|
||||
Ok(o) => o,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if stdout.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// Single item -> object, multiple -> array
|
||||
let items: Vec<serde_json::Value> = if stdout.starts_with('[') {
|
||||
serde_json::from_str(&stdout).unwrap_or_default()
|
||||
} else {
|
||||
serde_json::from_str::<serde_json::Value>(&stdout)
|
||||
.map(|v| vec![v])
|
||||
.unwrap_or_default()
|
||||
};
|
||||
|
||||
items.iter().filter_map(|item| {
|
||||
let name = item.get("DisplayName")?.as_str()?.to_string();
|
||||
if name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(SoftwareAsset {
|
||||
device_uid: device_uid.to_string(),
|
||||
name,
|
||||
version: item.get("DisplayVersion").and_then(|v| v.as_str()).map(String::from),
|
||||
publisher: item.get("Publisher").and_then(|v| v.as_str()).map(String::from),
|
||||
install_date: item.get("InstallDate").and_then(|v| v.as_str()).map(String::from),
|
||||
install_path: item.get("InstallLocation").and_then(|v| v.as_str()).map(String::from),
|
||||
})
|
||||
}).collect()
|
||||
}
|
||||
|
||||
@@ -14,34 +14,49 @@ mod usb_audit;
|
||||
mod popup_blocker;
|
||||
mod software_blocker;
|
||||
mod web_filter;
|
||||
#[cfg(target_os = "windows")]
|
||||
mod service;
|
||||
|
||||
/// Shared shutdown flag
|
||||
static SHUTDOWN: AtomicBool = AtomicBool::new(false);
|
||||
/// Shared shutdown flag — checked by the main reconnect loop and set by
|
||||
/// Ctrl+C (console mode) or SERVICE_CONTROL_STOP (service mode).
|
||||
pub static SHUTDOWN: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Client configuration
|
||||
struct ClientState {
|
||||
device_uid: String,
|
||||
server_addr: String,
|
||||
config: ClientConfig,
|
||||
device_secret: Option<String>,
|
||||
registration_token: String,
|
||||
/// Whether to use TLS when connecting to the server
|
||||
use_tls: bool,
|
||||
pub struct ClientState {
|
||||
pub device_uid: String,
|
||||
pub server_addr: String,
|
||||
pub config: ClientConfig,
|
||||
pub device_secret: Option<String>,
|
||||
pub registration_token: String,
|
||||
pub use_tls: bool,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
fn main() -> Result<()> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
|
||||
// ---- Service management commands (Windows only) ----
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
if args.len() > 1 {
|
||||
match args[1].as_str() {
|
||||
"--install" => return service::install(),
|
||||
"--uninstall" => return service::uninstall(),
|
||||
"--service" => return service::run_service_mode(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Console (development) mode ----
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter("csm_client=info")
|
||||
.init();
|
||||
|
||||
info!("CSM Client starting...");
|
||||
info!("CSM Client starting (console mode)...");
|
||||
|
||||
// Load or generate device identity
|
||||
let device_uid = load_or_create_device_uid()?;
|
||||
info!("Device UID: {}", device_uid);
|
||||
|
||||
// Load server address
|
||||
let server_addr = std::env::var("CSM_SERVER")
|
||||
.unwrap_or_else(|_| "127.0.0.1:9999".to_string());
|
||||
|
||||
@@ -54,17 +69,14 @@ async fn main() -> Result<()> {
|
||||
use_tls: std::env::var("CSM_USE_TLS").as_deref() == Ok("true"),
|
||||
};
|
||||
|
||||
// TODO: Register as Windows Service on Windows
|
||||
// For development, run directly
|
||||
|
||||
// Main event loop
|
||||
run(state).await
|
||||
let runtime = tokio::runtime::Runtime::new()?;
|
||||
runtime.block_on(run(state))
|
||||
}
|
||||
|
||||
async fn run(state: ClientState) -> Result<()> {
|
||||
pub async fn run(state: ClientState) -> Result<()> {
|
||||
let (data_tx, mut data_rx) = tokio::sync::mpsc::channel::<Frame>(1024);
|
||||
|
||||
// Spawn Ctrl+C handler
|
||||
// Spawn Ctrl+C handler (harmless in service mode — no console to receive it)
|
||||
tokio::spawn(async {
|
||||
tokio::signal::ctrl_c().await.ok();
|
||||
info!("Received Ctrl+C, initiating graceful shutdown...");
|
||||
@@ -153,8 +165,6 @@ async fn run(state: ClientState) -> Result<()> {
|
||||
match network::connect_and_run(&state, &mut data_rx, &plugins).await {
|
||||
Ok(()) => {
|
||||
warn!("Disconnected from server, reconnecting...");
|
||||
// Use a short fixed delay for clean disconnects (server-initiated),
|
||||
// but don't reset to zero to prevent connection storms
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -177,8 +187,6 @@ async fn run(state: ClientState) -> Result<()> {
|
||||
}
|
||||
|
||||
fn load_or_create_device_uid() -> Result<String> {
|
||||
// In production, store in Windows Credential Store or local config
|
||||
// For now, use a simple file
|
||||
let uid_file = "device_uid.txt";
|
||||
if std::path::Path::new(uid_file).exists() {
|
||||
let uid = std::fs::read_to_string(uid_file)?;
|
||||
|
||||
174
crates/client/src/service.rs
Normal file
174
crates/client/src/service.rs
Normal file
@@ -0,0 +1,174 @@
|
||||
use std::ffi::OsString;
|
||||
use std::time::Duration;
|
||||
use tracing::{info, error};
|
||||
use windows_service::define_windows_service;
|
||||
use windows_service::service::{
|
||||
ServiceAccess, ServiceControl, ServiceErrorControl, ServiceExitCode,
|
||||
ServiceInfo, ServiceStartType, ServiceState, ServiceStatus, ServiceType,
|
||||
};
|
||||
use windows_service::service_control_handler::{self, ServiceControlHandlerResult};
|
||||
use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
|
||||
|
||||
const SERVICE_NAME: &str = "CSMClient";
|
||||
const SERVICE_DISPLAY_NAME: &str = "CSM Client Service";
|
||||
const SERVICE_DESCRIPTION: &str = "Client Security Management - endpoint monitoring and policy enforcement";
|
||||
|
||||
/// Install the CSM client as a Windows service (auto-start, LocalSystem).
|
||||
pub fn install() -> anyhow::Result<()> {
|
||||
let exe_path = std::env::current_exe()?;
|
||||
|
||||
let manager = ServiceManager::local_computer(
|
||||
None::<&str>,
|
||||
ServiceManagerAccess::CONNECT | ServiceManagerAccess::CREATE_SERVICE,
|
||||
)?;
|
||||
|
||||
let service_info = ServiceInfo {
|
||||
name: OsString::from(SERVICE_NAME),
|
||||
display_name: OsString::from(SERVICE_DISPLAY_NAME),
|
||||
service_type: ServiceType::OWN_PROCESS,
|
||||
start_type: ServiceStartType::AutoStart,
|
||||
error_control: ServiceErrorControl::Normal,
|
||||
executable_path: exe_path,
|
||||
launch_arguments: vec![OsString::from("--service")],
|
||||
dependencies: vec![],
|
||||
account_name: None,
|
||||
account_password: None,
|
||||
};
|
||||
|
||||
let service = manager.create_service(&service_info, ServiceAccess::CHANGE_CONFIG)?;
|
||||
service.set_description(SERVICE_DESCRIPTION)?;
|
||||
|
||||
println!("Service '{}' installed successfully.", SERVICE_NAME);
|
||||
println!("Use 'sc start {}' or restart to launch.", SERVICE_NAME);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Uninstall the CSM client Windows service.
|
||||
pub fn uninstall() -> anyhow::Result<()> {
|
||||
let manager = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)?;
|
||||
let service = manager.open_service(
|
||||
SERVICE_NAME,
|
||||
ServiceAccess::QUERY_STATUS | ServiceAccess::STOP | ServiceAccess::DELETE,
|
||||
)?;
|
||||
|
||||
let status = service.query_status()?;
|
||||
if status.current_state == ServiceState::Running {
|
||||
println!("Stopping service '{}'...", SERVICE_NAME);
|
||||
service.stop()?;
|
||||
for _ in 0..30 {
|
||||
std::thread::sleep(Duration::from_secs(1));
|
||||
if service.query_status()?.current_state != ServiceState::Running {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
service.delete()?;
|
||||
println!("Service '{}' uninstalled successfully.", SERVICE_NAME);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Entry point for Windows Service mode.
|
||||
pub fn run_service_mode() -> anyhow::Result<()> {
|
||||
// Log to file when running as a service (no console available)
|
||||
let log_file = std::fs::File::create("csm_client_service.log")?;
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter("csm_client=info")
|
||||
.with_writer(std::sync::Mutex::new(log_file))
|
||||
.with_ansi(false)
|
||||
.init();
|
||||
|
||||
info!("CSM Client service mode starting...");
|
||||
|
||||
windows_service::service_dispatcher::start(SERVICE_NAME, ffi_service_main)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
define_windows_service!(ffi_service_main, service_main);
|
||||
|
||||
fn service_main(_arguments: Vec<OsString>) {
|
||||
if let Err(e) = run_service_inner() {
|
||||
error!("Service exited with error: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
fn run_service_inner() -> anyhow::Result<()> {
|
||||
let status_handle = service_control_handler::register(SERVICE_NAME, |event| {
|
||||
match event {
|
||||
ServiceControl::Stop | ServiceControl::Preshutdown => {
|
||||
crate::SHUTDOWN.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
ServiceControlHandlerResult::NoError
|
||||
}
|
||||
ServiceControl::Interrogate => ServiceControlHandlerResult::NoError,
|
||||
_ => ServiceControlHandlerResult::NotImplemented,
|
||||
}
|
||||
})?;
|
||||
|
||||
// Report StartPending
|
||||
status_handle.set_service_status(ServiceStatus {
|
||||
service_type: ServiceType::OWN_PROCESS,
|
||||
current_state: ServiceState::StartPending,
|
||||
controls_accepted: windows_service::service::ServiceControlAccept::STOP,
|
||||
exit_code: ServiceExitCode::Win32(0),
|
||||
checkpoint: 0,
|
||||
wait_hint: Duration::from_secs(10),
|
||||
process_id: None,
|
||||
})?;
|
||||
|
||||
// Build ClientState
|
||||
let device_uid = crate::load_or_create_device_uid()?;
|
||||
info!("Device UID: {}", device_uid);
|
||||
|
||||
let state = crate::ClientState {
|
||||
device_uid,
|
||||
server_addr: std::env::var("CSM_SERVER").unwrap_or_else(|_| "127.0.0.1:9999".to_string()),
|
||||
config: csm_protocol::ClientConfig::default(),
|
||||
device_secret: crate::load_device_secret(),
|
||||
registration_token: std::env::var("CSM_REGISTRATION_TOKEN").unwrap_or_default(),
|
||||
use_tls: std::env::var("CSM_USE_TLS").as_deref() == Ok("true"),
|
||||
};
|
||||
|
||||
// Report Running
|
||||
status_handle.set_service_status(ServiceStatus {
|
||||
service_type: ServiceType::OWN_PROCESS,
|
||||
current_state: ServiceState::Running,
|
||||
controls_accepted: windows_service::service::ServiceControlAccept::STOP,
|
||||
exit_code: ServiceExitCode::Win32(0),
|
||||
checkpoint: 0,
|
||||
wait_hint: Duration::default(),
|
||||
process_id: None,
|
||||
})?;
|
||||
info!("CSM Client service is running");
|
||||
|
||||
// Run the same async loop as console mode.
|
||||
// The loop checks SHUTDOWN, so the control handler will cause a graceful exit.
|
||||
let runtime = tokio::runtime::Runtime::new()?;
|
||||
let result = runtime.block_on(crate::run(state));
|
||||
|
||||
// Report StopPending
|
||||
status_handle.set_service_status(ServiceStatus {
|
||||
service_type: ServiceType::OWN_PROCESS,
|
||||
current_state: ServiceState::StopPending,
|
||||
controls_accepted: windows_service::service::ServiceControlAccept::empty(),
|
||||
exit_code: ServiceExitCode::Win32(0),
|
||||
checkpoint: 0,
|
||||
wait_hint: Duration::from_secs(10),
|
||||
process_id: None,
|
||||
})?;
|
||||
|
||||
drop(runtime);
|
||||
|
||||
// Report Stopped
|
||||
status_handle.set_service_status(ServiceStatus {
|
||||
service_type: ServiceType::OWN_PROCESS,
|
||||
current_state: ServiceState::Stopped,
|
||||
controls_accepted: windows_service::service::ServiceControlAccept::empty(),
|
||||
exit_code: ServiceExitCode::Win32(0),
|
||||
checkpoint: 0,
|
||||
wait_hint: Duration::default(),
|
||||
process_id: None,
|
||||
})?;
|
||||
|
||||
info!("CSM Client service stopped");
|
||||
result
|
||||
}
|
||||
@@ -64,7 +64,7 @@ fn apply_hosts_rules(rules: &[WebFilterRule]) -> io::Result<()> {
|
||||
|
||||
// Build new block with block rules
|
||||
let block_rules: Vec<&WebFilterRule> = rules.iter()
|
||||
.filter(|r| r.rule_type == "block")
|
||||
.filter(|r| r.rule_type == "blacklist")
|
||||
.filter(|r| is_valid_hosts_entry(&r.pattern))
|
||||
.collect();
|
||||
|
||||
@@ -75,8 +75,11 @@ fn apply_hosts_rules(rules: &[WebFilterRule]) -> io::Result<()> {
|
||||
|
||||
let mut new_block = format!("{}\n", HOSTS_MARKER_START);
|
||||
for rule in &block_rules {
|
||||
// Redirect blocked domains to 127.0.0.1
|
||||
new_block.push_str(&format!("127.0.0.1 {}\n", rule.pattern));
|
||||
// Expand wildcard patterns into concrete host entries
|
||||
// Hosts file does NOT support wildcards like *.example.com
|
||||
for host in expand_wildcard(&rule.pattern) {
|
||||
new_block.push_str(&format!("127.0.0.1 {}\n", host));
|
||||
}
|
||||
}
|
||||
new_block.push_str(HOSTS_MARKER_END);
|
||||
new_block.push('\n');
|
||||
@@ -167,3 +170,14 @@ fn is_valid_hosts_entry(pattern: &str) -> bool {
|
||||
// Must look like a hostname (alphanumeric, dots, hyphens, underscores, asterisks)
|
||||
pattern.chars().all(|c| c.is_alphanumeric() || c == '.' || c == '-' || c == '_' || c == '*')
|
||||
}
|
||||
|
||||
/// Expand a wildcard pattern into concrete host entries for the hosts file.
|
||||
/// `*.example.com` → `["example.com", "www.example.com"]`
|
||||
/// `example.com` → `["example.com"]`
|
||||
fn expand_wildcard(pattern: &str) -> Vec<String> {
|
||||
if let Some(domain) = pattern.strip_prefix("*.") {
|
||||
vec![domain.to_string(), format!("www.{}", domain)]
|
||||
} else {
|
||||
vec![pattern.to_string()]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ pub enum MessageType {
|
||||
AssetChange = 0x05,
|
||||
UsbEvent = 0x06,
|
||||
AlertAck = 0x07,
|
||||
SoftwareAssetReport = 0x09,
|
||||
|
||||
// Server → Client (Core)
|
||||
RegisterResponse = 0x08,
|
||||
@@ -73,6 +74,7 @@ impl TryFrom<u8> for MessageType {
|
||||
0x06 => Ok(Self::UsbEvent),
|
||||
0x07 => Ok(Self::AlertAck),
|
||||
0x08 => Ok(Self::RegisterResponse),
|
||||
0x09 => Ok(Self::SoftwareAssetReport),
|
||||
0x10 => Ok(Self::PolicyUpdate),
|
||||
0x11 => Ok(Self::ConfigUpdate),
|
||||
0x12 => Ok(Self::TaskExecute),
|
||||
|
||||
@@ -109,17 +109,20 @@ pub async fn list_software(
|
||||
|
||||
pub async fn list_changes(
|
||||
State(state): State<AppState>,
|
||||
Query(page): Query<Pagination>,
|
||||
Query(params): Query<AssetListParams>,
|
||||
) -> Json<ApiResponse<serde_json::Value>> {
|
||||
let offset = page.offset();
|
||||
let limit = page.limit();
|
||||
let limit = params.page_size.unwrap_or(20).min(100);
|
||||
let offset = params.page.unwrap_or(1).saturating_sub(1) * limit;
|
||||
let device_uid = params.device_uid.as_deref().filter(|s| !s.is_empty()).map(String::from);
|
||||
|
||||
let rows = sqlx::query(
|
||||
"SELECT id, device_uid, change_type, change_detail, detected_at
|
||||
FROM asset_changes ORDER BY detected_at DESC LIMIT ? OFFSET ?"
|
||||
FROM asset_changes WHERE 1=1
|
||||
AND (? IS NULL OR device_uid = ?)
|
||||
ORDER BY detected_at DESC LIMIT ? OFFSET ?"
|
||||
)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.bind(&device_uid).bind(&device_uid)
|
||||
.bind(limit).bind(offset)
|
||||
.fetch_all(&state.db)
|
||||
.await;
|
||||
|
||||
@@ -134,7 +137,7 @@ pub async fn list_changes(
|
||||
})).collect();
|
||||
Json(ApiResponse::ok(serde_json::json!({
|
||||
"changes": items,
|
||||
"page": page.page.unwrap_or(1),
|
||||
"page": params.page.unwrap_or(1),
|
||||
"page_size": limit,
|
||||
})))
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use axum::{extract::State, Json, http::StatusCode, extract::Request, middleware::Next, response::Response};
|
||||
use axum::{extract::State, Json, http::StatusCode, extract::Request, middleware::Next, response::Response, Extension};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use jsonwebtoken::{encode, decode, Header, EncodingKey, DecodingKey, Validation};
|
||||
use std::sync::Arc;
|
||||
@@ -45,6 +45,12 @@ pub struct RefreshRequest {
|
||||
pub refresh_token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ChangePasswordRequest {
|
||||
pub old_password: String,
|
||||
pub new_password: String,
|
||||
}
|
||||
|
||||
/// In-memory rate limiter for login attempts
|
||||
#[derive(Clone, Default)]
|
||||
pub struct LoginRateLimiter {
|
||||
@@ -293,3 +299,46 @@ pub async fn require_admin(
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub async fn change_password(
|
||||
State(state): State<AppState>,
|
||||
claims: axum::Extension<Claims>,
|
||||
Json(req): Json<ChangePasswordRequest>,
|
||||
) -> Result<(StatusCode, Json<ApiResponse<()>>), StatusCode> {
|
||||
if req.new_password.len() < 6 {
|
||||
return Ok((StatusCode::BAD_REQUEST, Json(ApiResponse::error("新密码至少6位"))));
|
||||
}
|
||||
|
||||
// Verify old password
|
||||
let hash: String = sqlx::query_scalar::<_, String>(
|
||||
"SELECT password FROM users WHERE id = ?"
|
||||
)
|
||||
.bind(claims.sub)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
if !bcrypt::verify(&req.old_password, &hash).unwrap_or(false) {
|
||||
return Ok((StatusCode::BAD_REQUEST, Json(ApiResponse::error("当前密码错误"))));
|
||||
}
|
||||
|
||||
// Update password
|
||||
let new_hash = bcrypt::hash(&req.new_password, 12).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
sqlx::query("UPDATE users SET password = ? WHERE id = ?")
|
||||
.bind(&new_hash)
|
||||
.bind(claims.sub)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
// Audit log
|
||||
let _ = sqlx::query(
|
||||
"INSERT INTO admin_audit_log (user_id, action, detail) VALUES (?, 'change_password', ?)"
|
||||
)
|
||||
.bind(claims.sub)
|
||||
.bind(format!("User {} changed password", claims.username))
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
|
||||
Ok((StatusCode::OK, Json(ApiResponse::ok(()))))
|
||||
}
|
||||
|
||||
139
crates/server/src/api/groups.rs
Normal file
139
crates/server/src/api/groups.rs
Normal file
@@ -0,0 +1,139 @@
|
||||
use axum::{extract::{State, Path, Json}, http::StatusCode};
|
||||
use serde::Deserialize;
|
||||
use sqlx::Row;
|
||||
use crate::AppState;
|
||||
use super::ApiResponse;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateGroupRequest {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RenameGroupRequest {
|
||||
pub new_name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct MoveDeviceRequest {
|
||||
pub group_name: String,
|
||||
}
|
||||
|
||||
/// List all groups with device counts
|
||||
pub async fn list_groups(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<(StatusCode, Json<ApiResponse<serde_json::Value>>), StatusCode> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT COALESCE(NULLIF(group_name, ''), 'default') as grp, COUNT(*) as cnt \
|
||||
FROM devices GROUP BY grp ORDER BY cnt DESC"
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let groups: Vec<serde_json::Value> = rows.iter().map(|r| serde_json::json!({
|
||||
"name": r.get::<String, _>("grp"),
|
||||
"count": r.get::<i64, _>("cnt"),
|
||||
})).collect();
|
||||
|
||||
Ok((StatusCode::OK, Json(ApiResponse::ok(serde_json::json!({
|
||||
"groups": groups,
|
||||
})))))
|
||||
}
|
||||
|
||||
/// Create a new group (validates name doesn't exist)
|
||||
pub async fn create_group(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<CreateGroupRequest>,
|
||||
) -> Result<(StatusCode, Json<ApiResponse<()>>), StatusCode> {
|
||||
let name = req.name.trim().to_string();
|
||||
if name.is_empty() || name.len() > 50 {
|
||||
return Ok((StatusCode::BAD_REQUEST, Json(ApiResponse::error("分组名称无效"))));
|
||||
}
|
||||
|
||||
// Check if group already exists
|
||||
let exists: bool = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) > 0 FROM devices WHERE group_name = ?"
|
||||
)
|
||||
.bind(&name)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
|
||||
if exists {
|
||||
return Ok((StatusCode::CONFLICT, Json(ApiResponse::error("分组已存在"))));
|
||||
}
|
||||
|
||||
// Groups are virtual — just return success. They materialize when devices are assigned.
|
||||
Ok((StatusCode::OK, Json(ApiResponse::ok(()))))
|
||||
}
|
||||
|
||||
/// Rename a group (moves all devices in old group to new name)
|
||||
pub async fn rename_group(
|
||||
State(state): State<AppState>,
|
||||
Path(old_name): Path<String>,
|
||||
Json(req): Json<RenameGroupRequest>,
|
||||
) -> Result<(StatusCode, Json<ApiResponse<()>>), StatusCode> {
|
||||
let new_name = req.new_name.trim().to_string();
|
||||
if new_name.is_empty() || new_name.len() > 50 {
|
||||
return Ok((StatusCode::BAD_REQUEST, Json(ApiResponse::error("分组名称无效"))));
|
||||
}
|
||||
|
||||
let result = sqlx::query(
|
||||
"UPDATE devices SET group_name = ? WHERE group_name = ?"
|
||||
)
|
||||
.bind(&new_name)
|
||||
.bind(&old_name)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Ok((StatusCode::NOT_FOUND, Json(ApiResponse::error("分组不存在或没有设备"))));
|
||||
}
|
||||
|
||||
Ok((StatusCode::OK, Json(ApiResponse::ok(()))))
|
||||
}
|
||||
|
||||
/// Delete a group (moves all devices to default)
|
||||
pub async fn delete_group(
|
||||
State(state): State<AppState>,
|
||||
Path(name): Path<String>,
|
||||
) -> Result<(StatusCode, Json<ApiResponse<()>>), StatusCode> {
|
||||
let _result = sqlx::query(
|
||||
"UPDATE devices SET group_name = 'default' WHERE group_name = ?"
|
||||
)
|
||||
.bind(&name)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
Ok((StatusCode::OK, Json(ApiResponse::ok(()))))
|
||||
}
|
||||
|
||||
/// Move a device to a group
|
||||
pub async fn move_device(
|
||||
State(state): State<AppState>,
|
||||
Path(uid): Path<String>,
|
||||
Json(req): Json<MoveDeviceRequest>,
|
||||
) -> Result<(StatusCode, Json<ApiResponse<()>>), StatusCode> {
|
||||
let group_name = req.group_name.trim().to_string();
|
||||
if group_name.is_empty() || group_name.len() > 50 {
|
||||
return Ok((StatusCode::BAD_REQUEST, Json(ApiResponse::error("分组名称无效"))));
|
||||
}
|
||||
|
||||
let result = sqlx::query(
|
||||
"UPDATE devices SET group_name = ? WHERE device_uid = ?"
|
||||
)
|
||||
.bind(&group_name)
|
||||
.bind(&uid)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Ok((StatusCode::NOT_FOUND, Json(ApiResponse::error("设备不存在"))));
|
||||
}
|
||||
|
||||
Ok((StatusCode::OK, Json(ApiResponse::ok(()))))
|
||||
}
|
||||
@@ -8,6 +8,7 @@ pub mod assets;
|
||||
pub mod usb;
|
||||
pub mod alerts;
|
||||
pub mod plugins;
|
||||
pub mod groups;
|
||||
|
||||
pub fn routes(state: AppState) -> Router<AppState> {
|
||||
let public = Router::new()
|
||||
@@ -18,6 +19,8 @@ pub fn routes(state: AppState) -> Router<AppState> {
|
||||
|
||||
// Read-only routes (any authenticated user)
|
||||
let read_routes = Router::new()
|
||||
// Auth
|
||||
.route("/api/auth/change-password", put(auth::change_password))
|
||||
// Devices
|
||||
.route("/api/devices", get(devices::list))
|
||||
.route("/api/devices/:uid", get(devices::get_detail))
|
||||
@@ -27,6 +30,8 @@ pub fn routes(state: AppState) -> Router<AppState> {
|
||||
.route("/api/assets/hardware", get(assets::list_hardware))
|
||||
.route("/api/assets/software", get(assets::list_software))
|
||||
.route("/api/assets/changes", get(assets::list_changes))
|
||||
// Groups (read)
|
||||
.route("/api/groups", get(groups::list_groups))
|
||||
// USB (read)
|
||||
.route("/api/usb/events", get(usb::list_events))
|
||||
.route("/api/usb/policies", get(usb::list_policies))
|
||||
@@ -41,6 +46,10 @@ pub fn routes(state: AppState) -> Router<AppState> {
|
||||
let write_routes = Router::new()
|
||||
// Devices
|
||||
.route("/api/devices/:uid", delete(devices::remove))
|
||||
// Groups (write)
|
||||
.route("/api/groups", post(groups::create_group))
|
||||
.route("/api/groups/:name", put(groups::rename_group).delete(groups::delete_group))
|
||||
.route("/api/devices/:uid/group", put(groups::move_device))
|
||||
// USB (write)
|
||||
.route("/api/usb/policies", post(usb::create_policy))
|
||||
.route("/api/usb/policies/:id", put(usb::update_policy).delete(usb::delete_policy))
|
||||
|
||||
@@ -108,7 +108,7 @@ fn default_audit_log_days() -> u32 { 365 }
|
||||
pub fn default_config() -> AppConfig {
|
||||
AppConfig {
|
||||
server: ServerConfig {
|
||||
http_addr: "0.0.0.0:8080".into(),
|
||||
http_addr: "0.0.0.0:9998".into(),
|
||||
tcp_addr: "0.0.0.0:9999".into(),
|
||||
cors_origins: vec![],
|
||||
tls: None,
|
||||
|
||||
@@ -115,4 +115,24 @@ impl DeviceRepo {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn upsert_software(pool: &SqlitePool, asset: &csm_protocol::SoftwareAsset) -> Result<()> {
|
||||
// Use INSERT OR REPLACE to handle the UNIQUE(device_uid, name, version) constraint
|
||||
// where version can be NULL (treated as distinct by SQLite)
|
||||
let version = asset.version.as_deref().unwrap_or("");
|
||||
sqlx::query(
|
||||
"INSERT OR REPLACE INTO software_assets (device_uid, name, version, publisher, install_date, install_path, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))"
|
||||
)
|
||||
.bind(&asset.device_uid)
|
||||
.bind(&asset.name)
|
||||
.bind(if version.is_empty() { None } else { Some(version) })
|
||||
.bind(&asset.publisher)
|
||||
.bind(&asset.install_date)
|
||||
.bind(&asset.install_path)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -574,6 +574,17 @@ async fn process_frame(
|
||||
crate::db::DeviceRepo::upsert_hardware(&state.db, &asset).await?;
|
||||
}
|
||||
|
||||
MessageType::SoftwareAssetReport => {
|
||||
let sw: csm_protocol::SoftwareAsset = frame.decode_payload()
|
||||
.map_err(|e| anyhow::anyhow!("Invalid software asset report: {}", e))?;
|
||||
|
||||
if !verify_device_uid(device_uid, "SoftwareAssetReport", &sw.device_uid) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
crate::db::DeviceRepo::upsert_software(&state.db, &sw).await?;
|
||||
}
|
||||
|
||||
MessageType::UsageReport => {
|
||||
let report: csm_protocol::UsageDailyReport = frame.decode_payload()
|
||||
.map_err(|e| anyhow::anyhow!("Invalid usage report: {}", e))?;
|
||||
|
||||
5
web/components.d.ts
vendored
5
web/components.d.ts
vendored
@@ -10,13 +10,11 @@ declare module 'vue' {
|
||||
ElAside: typeof import('element-plus/es')['ElAside']
|
||||
ElBadge: typeof import('element-plus/es')['ElBadge']
|
||||
ElButton: typeof import('element-plus/es')['ElButton']
|
||||
ElCard: typeof import('element-plus/es')['ElCard']
|
||||
ElCol: typeof import('element-plus/es')['ElCol']
|
||||
ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
|
||||
ElContainer: typeof import('element-plus/es')['ElContainer']
|
||||
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
|
||||
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
|
||||
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||
ElDivider: typeof import('element-plus/es')['ElDivider']
|
||||
ElDropdown: typeof import('element-plus/es')['ElDropdown']
|
||||
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
|
||||
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
|
||||
@@ -44,6 +42,7 @@ declare module 'vue' {
|
||||
ElTabPane: typeof import('element-plus/es')['ElTabPane']
|
||||
ElTabs: typeof import('element-plus/es')['ElTabs']
|
||||
ElTag: typeof import('element-plus/es')['ElTag']
|
||||
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
}
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body, #app { height: 100%; }
|
||||
</style>
|
||||
|
||||
205
web/src/assets/styles/global.css
Normal file
205
web/src/assets/styles/global.css
Normal file
@@ -0,0 +1,205 @@
|
||||
/* ========================================
|
||||
CSM Global Styles
|
||||
======================================== */
|
||||
@import './variables.css';
|
||||
|
||||
/* ---- Reset & Base ---- */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 14px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
html, body, #app {
|
||||
height: 100%;
|
||||
font-family: var(--csm-font-family);
|
||||
color: var(--csm-text-primary);
|
||||
background: var(--csm-bg-page);
|
||||
}
|
||||
|
||||
/* ---- Scrollbar ---- */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #d1d5db;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #9ca3af;
|
||||
}
|
||||
|
||||
/* ---- Card Styles ---- */
|
||||
.csm-card {
|
||||
background: var(--csm-bg-card);
|
||||
border-radius: var(--csm-border-radius-lg);
|
||||
border: 1px solid var(--csm-border-color);
|
||||
}
|
||||
|
||||
.csm-card-header {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: var(--csm-text-primary);
|
||||
padding: 14px 20px;
|
||||
border-bottom: 1px solid var(--csm-border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.csm-card-body {
|
||||
padding: var(--csm-card-padding);
|
||||
}
|
||||
|
||||
/* ---- Stat Cards ---- */
|
||||
.stat-card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--csm-bg-card);
|
||||
border-radius: var(--csm-border-radius);
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
border: 1px solid var(--csm-border-color);
|
||||
transition: box-shadow var(--csm-transition-fast);
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
box-shadow: var(--csm-shadow-md);
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stat-icon.online { background: #16a34a; }
|
||||
.stat-icon.offline { background: #64748b; }
|
||||
.stat-icon.warning { background: #d97706; }
|
||||
.stat-icon.usb { background: #2563eb; }
|
||||
|
||||
.stat-value {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
color: var(--csm-text-primary);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
color: var(--csm-text-secondary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ---- Page Container ---- */
|
||||
.page-container {
|
||||
padding: var(--csm-page-padding);
|
||||
}
|
||||
|
||||
/* ---- Toolbar ---- */
|
||||
.page-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.page-toolbar-right {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* ---- Table ---- */
|
||||
.el-table th.el-table__cell {
|
||||
font-weight: 600;
|
||||
color: var(--csm-text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ---- Element Plus Overrides ---- */
|
||||
.el-card {
|
||||
border-radius: var(--csm-border-radius-lg) !important;
|
||||
border-color: var(--csm-border-color) !important;
|
||||
}
|
||||
|
||||
.el-button--primary {
|
||||
background: var(--csm-primary);
|
||||
border-color: var(--csm-primary);
|
||||
}
|
||||
|
||||
.el-button--primary:hover {
|
||||
background: var(--csm-primary-light);
|
||||
border-color: var(--csm-primary-light);
|
||||
}
|
||||
|
||||
.el-tag {
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.el-dialog {
|
||||
border-radius: var(--csm-border-radius-xl) !important;
|
||||
}
|
||||
|
||||
.el-dialog__header {
|
||||
border-bottom: 1px solid var(--csm-border-color);
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.el-dialog__footer {
|
||||
border-top: 1px solid var(--csm-border-color);
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
/* ---- Page Transition ---- */
|
||||
.page-enter-active,
|
||||
.page-leave-active {
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.page-enter-from,
|
||||
.page-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* ---- Responsive ---- */
|
||||
@media (max-width: 1200px) {
|
||||
.stat-card-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.stat-card-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
:root {
|
||||
--csm-page-padding: 16px;
|
||||
}
|
||||
}
|
||||
78
web/src/assets/styles/variables.css
Normal file
78
web/src/assets/styles/variables.css
Normal file
@@ -0,0 +1,78 @@
|
||||
/* ========================================
|
||||
CSM Design System - CSS Variables
|
||||
Enterprise Terminal Management System
|
||||
======================================== */
|
||||
|
||||
:root {
|
||||
/* ---- Brand Colors ---- */
|
||||
--csm-primary: #1d4ed8;
|
||||
--csm-primary-light: #3b6de0;
|
||||
--csm-primary-dark: #1e40af;
|
||||
--csm-primary-bg: rgba(29, 78, 216, 0.06);
|
||||
|
||||
/* ---- Sidebar ---- */
|
||||
--csm-sidebar-bg: #1e293b;
|
||||
--csm-sidebar-hover: #263548;
|
||||
--csm-sidebar-active: rgba(29, 78, 216, 0.15);
|
||||
--csm-sidebar-text: #94a3b8;
|
||||
--csm-sidebar-text-active: #e2e8f0;
|
||||
--csm-sidebar-width: 240px;
|
||||
|
||||
/* ---- Surface & Background ---- */
|
||||
--csm-bg-page: #f5f6fa;
|
||||
--csm-bg-card: #ffffff;
|
||||
--csm-bg-header: #ffffff;
|
||||
|
||||
/* ---- Text Colors ---- */
|
||||
--csm-text-primary: #1e293b;
|
||||
--csm-text-secondary: #64748b;
|
||||
--csm-text-tertiary: #94a3b8;
|
||||
--csm-text-inverse: #ffffff;
|
||||
|
||||
/* ---- Status Colors ---- */
|
||||
--csm-success: #16a34a;
|
||||
--csm-success-bg: rgba(22, 163, 74, 0.06);
|
||||
--csm-warning: #d97706;
|
||||
--csm-warning-bg: rgba(217, 119, 6, 0.06);
|
||||
--csm-danger: #dc2626;
|
||||
--csm-danger-bg: rgba(220, 38, 38, 0.06);
|
||||
--csm-info: #2563eb;
|
||||
--csm-info-bg: rgba(37, 99, 235, 0.06);
|
||||
|
||||
/* ---- Border ---- */
|
||||
--csm-border-color: #e5e7eb;
|
||||
--csm-border-radius: 8px;
|
||||
--csm-border-radius-lg: 12px;
|
||||
--csm-border-radius-xl: 16px;
|
||||
|
||||
/* ---- Shadow ---- */
|
||||
--csm-shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.04);
|
||||
--csm-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.06), 0 1px 2px -1px rgba(0, 0, 0, 0.06);
|
||||
--csm-shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.06), 0 2px 4px -2px rgba(0, 0, 0, 0.06);
|
||||
|
||||
/* ---- Spacing ---- */
|
||||
--csm-page-padding: 24px;
|
||||
--csm-card-padding: 20px;
|
||||
|
||||
/* ---- Transitions ---- */
|
||||
--csm-transition-fast: 150ms ease;
|
||||
--csm-transition: 250ms ease;
|
||||
|
||||
/* ---- Typography ---- */
|
||||
--csm-font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
--csm-font-mono: 'Menlo', 'Consolas', monospace;
|
||||
}
|
||||
|
||||
/* Element Plus Theme Overrides */
|
||||
:root {
|
||||
--el-color-primary: var(--csm-primary);
|
||||
--el-color-primary-light-3: var(--csm-primary-light);
|
||||
--el-color-primary-dark-2: var(--csm-primary-dark);
|
||||
--el-color-success: var(--csm-success);
|
||||
--el-color-warning: var(--csm-warning);
|
||||
--el-color-danger: var(--csm-danger);
|
||||
--el-color-info: #64748b;
|
||||
--el-border-radius-base: 6px;
|
||||
--el-font-family: var(--csm-font-family);
|
||||
--el-bg-color-page: var(--csm-bg-page);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import 'element-plus/dist/index.css'
|
||||
import './assets/styles/global.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ const router = createRouter({
|
||||
{ path: 'dashboard', name: 'Dashboard', component: () => import('../views/Dashboard.vue') },
|
||||
{ path: 'devices', name: 'Devices', component: () => import('../views/Devices.vue') },
|
||||
{ path: 'devices/:uid', name: 'DeviceDetail', component: () => import('../views/DeviceDetail.vue') },
|
||||
{ path: 'assets', name: 'Assets', component: () => import('../views/Assets.vue') },
|
||||
{ path: 'usb', name: 'UsbPolicy', component: () => import('../views/UsbPolicy.vue') },
|
||||
{ path: 'alerts', name: 'Alerts', component: () => import('../views/Alerts.vue') },
|
||||
{ path: 'settings', name: 'Settings', component: () => import('../views/Settings.vue') },
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div class="alerts-page">
|
||||
<el-tabs v-model="activeTab">
|
||||
<div class="page-container">
|
||||
<el-tabs v-model="activeTab" class="page-tabs">
|
||||
<el-tab-pane label="告警记录" name="records">
|
||||
<div class="toolbar">
|
||||
<div class="page-toolbar">
|
||||
<el-select v-model="severityFilter" placeholder="严重程度" clearable style="width: 140px" @change="fetchRecords">
|
||||
<el-option label="Critical" value="critical" />
|
||||
<el-option label="High" value="high" />
|
||||
@@ -14,63 +14,81 @@
|
||||
<el-option label="已处理" value="true" />
|
||||
</el-select>
|
||||
</div>
|
||||
<el-table :data="records" v-loading="recLoading" stripe size="small">
|
||||
<el-table-column label="严重程度" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="severityTag(row.severity)" size="small">{{ row.severity }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="alert_type" label="告警类型" width="130" />
|
||||
<el-table-column prop="detail" label="详情" min-width="250" show-overflow-tooltip />
|
||||
<el-table-column prop="device_uid" label="终端" width="150" show-overflow-tooltip />
|
||||
<el-table-column prop="triggered_at" label="触发时间" width="170" />
|
||||
<el-table-column label="状态" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.handled ? 'success' : 'warning'" size="small">
|
||||
{{ row.handled ? '已处理' : '待处理' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="100" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="!row.handled" link type="primary" size="small" @click="handleRecord(row.id)">处理</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="csm-card">
|
||||
<el-table :data="records" v-loading="recLoading" style="width:100%">
|
||||
<el-table-column label="严重程度" width="110">
|
||||
<template #default="{ row }">
|
||||
<div style="display:flex;align-items:center;gap:6px">
|
||||
<span class="severity-dot" :class="row.severity"></span>
|
||||
<el-tag :type="severityTag(row.severity)" size="small" effect="light">{{ row.severity }}</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="alert_type" label="告警类型" width="130" />
|
||||
<el-table-column prop="detail" label="详情" min-width="250" show-overflow-tooltip />
|
||||
<el-table-column prop="device_uid" label="终端" width="150" show-overflow-tooltip />
|
||||
<el-table-column prop="triggered_at" label="触发时间" width="170">
|
||||
<template #default="{ row }"><span class="secondary-text">{{ row.triggered_at }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.handled ? 'success' : 'warning'" size="small" effect="light">
|
||||
{{ row.handled ? '已处理' : '待处理' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="!row.handled" link type="primary" size="small" @click="handleRecord(row.id)">处理</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="告警规则" name="rules">
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="showRuleDialog()">新建规则</el-button>
|
||||
<div class="page-toolbar">
|
||||
<el-button type="primary" @click="showRuleDialog()">
|
||||
<el-icon><Plus /></el-icon>新建规则
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="csm-card">
|
||||
<el-table :data="rules" v-loading="ruleLoading" style="width:100%">
|
||||
<el-table-column prop="name" label="规则名称" min-width="180">
|
||||
<template #default="{ row }"><span style="font-weight:500">{{ row.name }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="rule_type" label="规则类型" width="140" />
|
||||
<el-table-column prop="severity" label="严重程度" width="110">
|
||||
<template #default="{ row }">
|
||||
<div style="display:flex;align-items:center;gap:6px">
|
||||
<span class="severity-dot" :class="row.severity"></span>
|
||||
<el-tag :type="severityTag(row.severity)" size="small" effect="light">{{ row.severity }}</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="condition" label="条件" min-width="200" show-overflow-tooltip>
|
||||
<template #default="{ row }"><span class="mono-text">{{ row.condition }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="enabled" label="启用" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-switch :model-value="row.enabled" :active-value="1" :inactive-value="0" @change="toggleRule(row)" size="small" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="140" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="showRuleDialog(row)">编辑</el-button>
|
||||
<el-button link type="danger" size="small" @click="deleteRule(row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<el-table :data="rules" v-loading="ruleLoading" stripe size="small">
|
||||
<el-table-column prop="name" label="规则名称" width="180" />
|
||||
<el-table-column prop="rule_type" label="规则类型" width="140" />
|
||||
<el-table-column prop="severity" label="严重程度" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="severityTag(row.severity)" size="small">{{ row.severity }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="condition" label="条件" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="enabled" label="启用" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-switch :model-value="row.enabled" @change="toggleRule(row)" size="small" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="140" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="showRuleDialog(row)">编辑</el-button>
|
||||
<el-button link type="danger" size="small" @click="deleteRule(row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<el-dialog v-model="ruleDialogVisible" :title="editingRule ? '编辑规则' : '新建规则'" width="500px">
|
||||
<el-dialog v-model="ruleDialogVisible" :title="editingRule ? '编辑规则' : '新建规则'" width="520px" destroy-on-close>
|
||||
<el-form :model="ruleForm" label-width="100px">
|
||||
<el-form-item label="规则名称">
|
||||
<el-input v-model="ruleForm.name" />
|
||||
<el-input v-model="ruleForm.name" placeholder="输入规则名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="规则类型">
|
||||
<el-select v-model="ruleForm.rule_type" style="width: 100%">
|
||||
@@ -109,11 +127,11 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
const activeTab = ref('records')
|
||||
|
||||
// Records
|
||||
const records = ref<any[]>([])
|
||||
const recLoading = ref(false)
|
||||
const severityFilter = ref('')
|
||||
@@ -138,7 +156,6 @@ async function handleRecord(id: number) {
|
||||
} catch (e: any) { ElMessage.error(e.message || '操作失败') }
|
||||
}
|
||||
|
||||
// Rules
|
||||
const rules = ref<any[]>([])
|
||||
const ruleLoading = ref(false)
|
||||
const ruleDialogVisible = ref(false)
|
||||
@@ -211,6 +228,33 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.alerts-page { padding: 20px; }
|
||||
.toolbar { display: flex; gap: 12px; margin-bottom: 16px; }
|
||||
.severity-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.severity-dot.critical { background: #ef4444; box-shadow: 0 0 6px rgba(239, 68, 68, 0.4); }
|
||||
.severity-dot.high { background: #f59e0b; }
|
||||
.severity-dot.medium { background: #2563eb; }
|
||||
.severity-dot.low { background: #94a3b8; }
|
||||
|
||||
.mono-text {
|
||||
font-family: var(--csm-font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--csm-text-secondary);
|
||||
}
|
||||
|
||||
.secondary-text {
|
||||
font-size: 12px;
|
||||
color: var(--csm-text-tertiary);
|
||||
}
|
||||
|
||||
.page-tabs :deep(.el-tabs__header) {
|
||||
margin: 0 0 20px 0;
|
||||
padding: 0 24px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid var(--csm-border-color);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
<template>
|
||||
<div class="assets-page">
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane label="硬件资产" name="hardware">
|
||||
<div class="toolbar">
|
||||
<el-input v-model="hwSearch" placeholder="搜索CPU/GPU型号" style="width: 300px" clearable @input="fetchHardware" />
|
||||
</div>
|
||||
<el-table :data="hardware" v-loading="hwLoading" stripe size="small">
|
||||
<el-table-column prop="device_uid" label="终端UID" width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="cpu_model" label="CPU型号" min-width="180" />
|
||||
<el-table-column prop="cpu_cores" label="核心数" width="80" />
|
||||
<el-table-column label="内存" width="100">
|
||||
<template #default="{ row }">{{ formatMB(row.memory_total_mb) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="gpu_model" label="GPU" min-width="150" />
|
||||
<el-table-column prop="reported_at" label="上报时间" width="170" />
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="软件资产" name="software">
|
||||
<div class="toolbar">
|
||||
<el-input v-model="swSearch" placeholder="搜索软件名称/发行商" style="width: 300px" clearable @input="fetchSoftware" />
|
||||
</div>
|
||||
<el-table :data="software" v-loading="swLoading" stripe size="small">
|
||||
<el-table-column prop="name" label="软件名称" min-width="200" />
|
||||
<el-table-column prop="version" label="版本" width="120" />
|
||||
<el-table-column prop="publisher" label="发行商" min-width="150" />
|
||||
<el-table-column prop="install_date" label="安装日期" width="120" />
|
||||
<el-table-column prop="device_uid" label="终端UID" width="160" show-overflow-tooltip />
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="变更记录" name="changes">
|
||||
<el-table :data="changes" v-loading="chLoading" stripe size="small">
|
||||
<el-table-column prop="device_uid" label="终端UID" width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="change_type" label="变更类型" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="changeTag(row.change_type)" size="small">{{ row.change_type }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="change_detail" label="详情" min-width="300" show-overflow-tooltip />
|
||||
<el-table-column prop="detected_at" label="检测时间" width="170" />
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
const activeTab = ref('hardware')
|
||||
|
||||
// Hardware
|
||||
const hardware = ref<any[]>([])
|
||||
const hwLoading = ref(false)
|
||||
const hwSearch = ref('')
|
||||
|
||||
async function fetchHardware() {
|
||||
hwLoading.value = true
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
if (hwSearch.value) params.set('search', hwSearch.value)
|
||||
const data = await api.get<any>(`/api/assets/hardware?${params}`)
|
||||
hardware.value = data.hardware || []
|
||||
} catch { /* api.ts handles 401 */ } finally { hwLoading.value = false }
|
||||
}
|
||||
|
||||
// Software
|
||||
const software = ref<any[]>([])
|
||||
const swLoading = ref(false)
|
||||
const swSearch = ref('')
|
||||
|
||||
async function fetchSoftware() {
|
||||
swLoading.value = true
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
if (swSearch.value) params.set('search', swSearch.value)
|
||||
const data = await api.get<any>(`/api/assets/software?${params}`)
|
||||
software.value = data.software || []
|
||||
} catch { /* api.ts handles 401 */ } finally { swLoading.value = false }
|
||||
}
|
||||
|
||||
// Changes
|
||||
const changes = ref<any[]>([])
|
||||
const chLoading = ref(false)
|
||||
|
||||
async function fetchChanges() {
|
||||
chLoading.value = true
|
||||
try {
|
||||
const data = await api.get<any>('/api/assets/changes')
|
||||
changes.value = data.changes || []
|
||||
} catch { /* api.ts handles 401 */ } finally { chLoading.value = false }
|
||||
}
|
||||
|
||||
function formatMB(mb: number) {
|
||||
if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GB`
|
||||
return `${mb} MB`
|
||||
}
|
||||
|
||||
function changeTag(type: string) {
|
||||
const map: Record<string, string> = { hardware: 'warning', software_added: 'success', software_removed: 'danger' }
|
||||
return map[type] || 'info'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchHardware()
|
||||
fetchSoftware()
|
||||
fetchChanges()
|
||||
})
|
||||
|
||||
watch(activeTab, () => {
|
||||
if (activeTab.value === 'hardware') fetchHardware()
|
||||
else if (activeTab.value === 'software') fetchSoftware()
|
||||
else fetchChanges()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.assets-page { padding: 20px; }
|
||||
.toolbar { display: flex; gap: 12px; margin-bottom: 16px; }
|
||||
</style>
|
||||
@@ -1,114 +1,137 @@
|
||||
<template>
|
||||
<div class="dashboard">
|
||||
<el-row :gutter="20" class="stat-cards">
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover" class="stat-card">
|
||||
<div class="stat-icon online"><el-icon :size="28"><Monitor /></el-icon></div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ stats.online }}</div>
|
||||
<div class="stat-label">在线终端</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover" class="stat-card">
|
||||
<div class="stat-icon offline"><el-icon :size="28"><Platform /></el-icon></div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ stats.offline }}</div>
|
||||
<div class="stat-label">离线终端</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover" class="stat-card">
|
||||
<div class="stat-icon warning"><el-icon :size="28"><Bell /></el-icon></div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ stats.alerts }}</div>
|
||||
<div class="stat-label">待处理告警</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover" class="stat-card">
|
||||
<div class="stat-icon usb"><el-icon :size="28"><Connection /></el-icon></div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ stats.usbEvents }}</div>
|
||||
<div class="stat-label">USB事件(24h)</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<div class="page-container">
|
||||
<!-- Stat cards -->
|
||||
<div class="stat-card-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon online">
|
||||
<el-icon :size="26"><Monitor /></el-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ stats.online }}</div>
|
||||
<div class="stat-label">在线终端</div>
|
||||
</div>
|
||||
<div class="stat-trend" v-if="stats.online > 0">
|
||||
<el-icon color="#10b981"><Top /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon offline">
|
||||
<el-icon :size="26"><Platform /></el-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ stats.offline }}</div>
|
||||
<div class="stat-label">离线终端</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon warning">
|
||||
<el-icon :size="26"><Bell /></el-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ stats.alerts }}</div>
|
||||
<div class="stat-label">待处理告警</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon usb">
|
||||
<el-icon :size="26"><Connection /></el-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ stats.usbEvents }}</div>
|
||||
<div class="stat-label">USB事件(24h)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts row -->
|
||||
<el-row :gutter="20" style="margin-top: 20px">
|
||||
<el-col :span="16">
|
||||
<el-card shadow="hover">
|
||||
<template #header>
|
||||
<span class="card-title">终端状态总览</span>
|
||||
</template>
|
||||
<div ref="cpuChartRef" style="height: 320px"></div>
|
||||
</el-card>
|
||||
<div class="csm-card">
|
||||
<div class="csm-card-header">
|
||||
<span>终端状态总览</span>
|
||||
<el-tag size="small" type="info" effect="plain">TOP 10</el-tag>
|
||||
</div>
|
||||
<div class="csm-card-body">
|
||||
<div ref="cpuChartRef" style="height: 320px"></div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-card shadow="hover">
|
||||
<template #header>
|
||||
<span class="card-title">最近告警</span>
|
||||
</template>
|
||||
<div class="alert-list">
|
||||
<div v-for="alert in recentAlerts" :key="alert.id" class="alert-item">
|
||||
<el-tag :type="severityTag(alert.severity)" size="small">{{ alert.severity }}</el-tag>
|
||||
<span class="alert-detail">{{ alert.detail }}</span>
|
||||
<span class="alert-time">{{ alert.triggered_at }}</span>
|
||||
</div>
|
||||
<el-empty v-if="recentAlerts.length === 0" description="暂无告警" :image-size="60" />
|
||||
<div class="csm-card" style="height: 100%">
|
||||
<div class="csm-card-header">
|
||||
<span>最近告警</span>
|
||||
<el-button link type="primary" size="small" @click="$router.push('/alerts')">查看全部</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
<div class="csm-card-body alert-list-container">
|
||||
<div class="alert-list">
|
||||
<div v-for="alert in recentAlerts" :key="alert.id" class="alert-item">
|
||||
<div class="alert-severity" :class="alert.severity"></div>
|
||||
<div class="alert-content">
|
||||
<div class="alert-detail">{{ alert.detail }}</div>
|
||||
<div class="alert-time">{{ formatTime(alert.triggered_at) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-if="recentAlerts.length === 0" description="暂无告警" :image-size="64" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- Bottom row -->
|
||||
<el-row :gutter="20" style="margin-top: 20px">
|
||||
<el-col :span="12">
|
||||
<el-card shadow="hover">
|
||||
<template #header>
|
||||
<span class="card-title">最近USB事件</span>
|
||||
</template>
|
||||
<el-table :data="recentUsbEvents" size="small" max-height="240">
|
||||
<el-table-column prop="device_name" label="设备" width="120" />
|
||||
<el-table-column label="类型" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.event_type === 'Inserted' ? 'success' : row.event_type === 'Blocked' ? 'danger' : 'info'" size="small">
|
||||
{{ eventTypeLabel(row.event_type) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="device_uid" label="终端" show-overflow-tooltip />
|
||||
<el-table-column prop="event_time" label="时间" width="160" />
|
||||
</el-table>
|
||||
</el-card>
|
||||
<div class="csm-card">
|
||||
<div class="csm-card-header">
|
||||
<span>最近USB事件</span>
|
||||
</div>
|
||||
<div class="csm-card-body">
|
||||
<el-table :data="recentUsbEvents" size="small" max-height="260" :show-header="true">
|
||||
<el-table-column prop="device_name" label="设备" width="120" />
|
||||
<el-table-column label="类型" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag
|
||||
:type="row.event_type === 'Inserted' ? 'success' : row.event_type === 'Blocked' ? 'danger' : 'info'"
|
||||
size="small" effect="light"
|
||||
>
|
||||
{{ eventTypeLabel(row.event_type) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="device_uid" label="终端" show-overflow-tooltip />
|
||||
<el-table-column prop="event_time" label="时间" width="160" />
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-card shadow="hover">
|
||||
<template #header>
|
||||
<span class="card-title">Top 5 高负载终端</span>
|
||||
</template>
|
||||
<el-table :data="topDevices" size="small" max-height="240">
|
||||
<el-table-column prop="hostname" label="主机名" />
|
||||
<el-table-column label="CPU" width="140">
|
||||
<template #default="{ row }">
|
||||
<el-progress :percentage="Math.round(row.cpu_usage)" :stroke-width="6" :color="progressColor(row.cpu_usage)" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="内存" width="140">
|
||||
<template #default="{ row }">
|
||||
<el-progress :percentage="Math.round(row.memory_usage)" :stroke-width="6" :color="progressColor(row.memory_usage)" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'online' ? 'success' : 'info'" size="small">{{ row.status }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
<div class="csm-card">
|
||||
<div class="csm-card-header">
|
||||
<span>Top 5 高负载终端</span>
|
||||
</div>
|
||||
<div class="csm-card-body">
|
||||
<el-table :data="topDevices" size="small" max-height="260">
|
||||
<el-table-column prop="hostname" label="主机名" min-width="120" />
|
||||
<el-table-column label="CPU" width="140">
|
||||
<template #default="{ row }">
|
||||
<el-progress :percentage="Math.round(row.cpu_usage)" :stroke-width="6" :color="progressColor(row.cpu_usage)" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="内存" width="140">
|
||||
<template #default="{ row }">
|
||||
<el-progress :percentage="Math.round(row.memory_usage)" :stroke-width="6" :color="progressColor(row.memory_usage)" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'online' ? 'success' : 'info'" size="small" effect="light">
|
||||
{{ row.status === 'online' ? '在线' : '离线' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
@@ -116,7 +139,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { Monitor, Platform, Bell, Connection } from '@element-plus/icons-vue'
|
||||
import { Monitor, Platform, Bell, Connection, Top } from '@element-plus/icons-vue'
|
||||
import * as echarts from 'echarts'
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
@@ -165,14 +188,60 @@ function initChart() {
|
||||
if (!cpuChartRef.value) return
|
||||
chart = echarts.init(cpuChartRef.value)
|
||||
chart.setOption({
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: ['CPU%', '内存%'] },
|
||||
grid: { left: 50, right: 20, bottom: 30, top: 40 },
|
||||
xAxis: { type: 'category', data: [] },
|
||||
yAxis: { type: 'value', max: 100, axisLabel: { formatter: '{value}%' } },
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
backgroundColor: '#fff',
|
||||
borderColor: '#e2e8f0',
|
||||
borderWidth: 1,
|
||||
textStyle: { color: '#1e293b', fontSize: 13 },
|
||||
axisPointer: { type: 'shadow' },
|
||||
},
|
||||
legend: {
|
||||
data: ['CPU%', '内存%'],
|
||||
top: 0,
|
||||
right: 0,
|
||||
textStyle: { color: '#64748b', fontSize: 12 },
|
||||
itemWidth: 12,
|
||||
itemHeight: 8,
|
||||
itemGap: 16,
|
||||
},
|
||||
grid: { left: 48, right: 16, bottom: 24, top: 36 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: [],
|
||||
axisLine: { lineStyle: { color: '#e2e8f0' } },
|
||||
axisLabel: { color: '#64748b', fontSize: 11, rotate: 30 },
|
||||
axisTick: { show: false },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
max: 100,
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
splitLine: { lineStyle: { color: '#f1f5f9', type: 'dashed' } },
|
||||
axisLabel: { color: '#94a3b8', fontSize: 11, formatter: '{value}%' },
|
||||
},
|
||||
series: [
|
||||
{ name: 'CPU%', type: 'bar', data: [], itemStyle: { color: '#409EFF' } },
|
||||
{ name: '内存%', type: 'bar', data: [], itemStyle: { color: '#67C23A' } },
|
||||
{
|
||||
name: 'CPU%',
|
||||
type: 'bar',
|
||||
data: [],
|
||||
barWidth: '30%',
|
||||
itemStyle: {
|
||||
borderRadius: [4, 4, 0, 0],
|
||||
color: '#1d4ed8',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: '内存%',
|
||||
type: 'bar',
|
||||
data: [],
|
||||
barWidth: '30%',
|
||||
itemStyle: {
|
||||
borderRadius: [4, 4, 0, 0],
|
||||
color: '#16a34a',
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
@@ -192,9 +261,18 @@ function updateChart(devices: any[]) {
|
||||
})
|
||||
}
|
||||
|
||||
function severityTag(severity: string) {
|
||||
const map: Record<string, string> = { critical: 'danger', high: 'warning', medium: '', low: 'info' }
|
||||
return map[severity] || 'info'
|
||||
function formatTime(timeStr: string): string {
|
||||
if (!timeStr) return ''
|
||||
const date = new Date(timeStr)
|
||||
if (isNaN(date.getTime())) return timeStr
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffMin = Math.floor(diffMs / 60000)
|
||||
if (diffMin < 1) return '刚刚'
|
||||
if (diffMin < 60) return `${diffMin}分钟前`
|
||||
const diffHr = Math.floor(diffMin / 60)
|
||||
if (diffHr < 24) return `${diffHr}小时前`
|
||||
return timeStr.slice(5, 16)
|
||||
}
|
||||
|
||||
function eventTypeLabel(type: string) {
|
||||
@@ -203,9 +281,9 @@ function eventTypeLabel(type: string) {
|
||||
}
|
||||
|
||||
function progressColor(value: number) {
|
||||
if (value > 90) return '#F56C6C'
|
||||
if (value > 70) return '#E6A23C'
|
||||
return '#67C23A'
|
||||
if (value > 90) return '#dc2626'
|
||||
if (value > 70) return '#d97706'
|
||||
return '#16a34a'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
@@ -224,41 +302,76 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dashboard { padding: 20px; }
|
||||
|
||||
.stat-cards .stat-card {
|
||||
/* Card header with flex layout */
|
||||
.csm-card-header {
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
color: var(--csm-text-primary);
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--csm-border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.stat-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 12px;
|
||||
|
||||
.csm-card-body {
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
/* Alert list */
|
||||
.alert-list-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 16px;
|
||||
color: #fff;
|
||||
flex-direction: column;
|
||||
}
|
||||
.stat-icon.online { background: linear-gradient(135deg, #67C23A, #409EFF); }
|
||||
.stat-icon.offline { background: linear-gradient(135deg, #909399, #606266); }
|
||||
.stat-icon.warning { background: linear-gradient(135deg, #E6A23C, #F56C6C); }
|
||||
.stat-icon.usb { background: linear-gradient(135deg, #409EFF, #7C3AED); }
|
||||
|
||||
.stat-value { font-size: 28px; font-weight: 700; color: #303133; }
|
||||
.stat-label { font-size: 13px; color: #909399; margin-top: 4px; }
|
||||
.alert-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
max-height: 336px;
|
||||
}
|
||||
|
||||
.card-title { font-weight: 600; font-size: 15px; }
|
||||
|
||||
.alert-list { max-height: 320px; overflow-y: auto; }
|
||||
.alert-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.alert-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.alert-severity {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-top: 5px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.alert-severity.critical { background: #ef4444; box-shadow: 0 0 6px rgba(239, 68, 68, 0.4); }
|
||||
.alert-severity.high { background: #f59e0b; }
|
||||
.alert-severity.medium { background: #2563eb; }
|
||||
.alert-severity.low { background: #94a3b8; }
|
||||
|
||||
.alert-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.alert-detail {
|
||||
font-size: 13px;
|
||||
color: var(--csm-text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.alert-time {
|
||||
font-size: 11px;
|
||||
color: var(--csm-text-tertiary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.alert-detail { flex: 1; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.alert-time { font-size: 12px; color: #C0C4CC; white-space: nowrap; }
|
||||
</style>
|
||||
|
||||
@@ -1,92 +1,177 @@
|
||||
<template>
|
||||
<div class="device-detail" v-loading="loading">
|
||||
<el-page-header @back="$router.back()" :title="'返回'">
|
||||
<template #content>
|
||||
<span>{{ device?.hostname || deviceUid }}</span>
|
||||
<el-tag v-if="device" :type="device.status === 'online' ? 'success' : 'info'" size="small" style="margin-left: 8px">
|
||||
{{ device.status === 'online' ? '在线' : '离线' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-page-header>
|
||||
<div class="page-container" v-loading="loading">
|
||||
<!-- Header -->
|
||||
<div class="detail-header">
|
||||
<el-page-header @back="$router.back()" title="返回列表">
|
||||
<template #content>
|
||||
<div class="header-device">
|
||||
<div class="device-avatar-lg" :class="device?.status">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="width:24px;height:24px">
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" />
|
||||
<line x1="8" y1="21" x2="16" y2="21" />
|
||||
<line x1="12" y1="17" x2="12" y2="21" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="header-name">{{ device?.hostname || deviceUid }}</div>
|
||||
<div class="header-meta">
|
||||
<el-tag v-if="device" :type="device.status === 'online' ? 'success' : 'info'" size="small" effect="light">
|
||||
{{ device.status === 'online' ? '在线' : '离线' }}
|
||||
</el-tag>
|
||||
<span class="meta-text">{{ device?.ip_address }}</span>
|
||||
<el-tag v-if="device?.group_name" size="small" effect="plain" round>{{ device.group_name }}</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-page-header>
|
||||
</div>
|
||||
|
||||
<el-row :gutter="20" style="margin-top: 20px">
|
||||
<el-col :span="8">
|
||||
<el-card shadow="hover">
|
||||
<template #header><span class="card-title">基本信息</span></template>
|
||||
<el-descriptions :column="1" size="small" border>
|
||||
<el-descriptions-item label="设备UID">{{ device?.device_uid }}</el-descriptions-item>
|
||||
<el-descriptions-item label="主机名">{{ device?.hostname }}</el-descriptions-item>
|
||||
<el-descriptions-item label="IP地址">{{ device?.ip_address }}</el-descriptions-item>
|
||||
<el-descriptions-item label="MAC地址">{{ device?.mac_address || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="操作系统">{{ device?.os_version || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="客户端版本">{{ device?.client_version || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="分组">{{ device?.group_name || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="注册时间">{{ device?.registered_at || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="最后心跳">{{ device?.last_heartbeat || '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<!-- Tabs -->
|
||||
<el-tabs v-model="activeTab" class="detail-tabs">
|
||||
<!-- Tab 1: 概览 -->
|
||||
<el-tab-pane label="概览" name="overview">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="8">
|
||||
<div class="csm-card">
|
||||
<div class="card-section-title">基本信息</div>
|
||||
<div class="info-list">
|
||||
<div class="info-row" v-for="item in basicInfo" :key="item.label">
|
||||
<span class="info-label">{{ item.label }}</span>
|
||||
<span class="info-value" :title="item.value">{{ item.value || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
|
||||
<el-col :span="16">
|
||||
<el-card shadow="hover" style="margin-bottom: 20px">
|
||||
<template #header><span class="card-title">实时状态</span></template>
|
||||
<el-row :gutter="20" v-if="status">
|
||||
<el-col :span="6">
|
||||
<div class="metric">
|
||||
<div class="metric-label">CPU</div>
|
||||
<el-progress type="dashboard" :percentage="Math.round(status.cpu_usage)" :width="100"
|
||||
:color="progressColor(status.cpu_usage)" />
|
||||
<el-col :span="16">
|
||||
<div class="csm-card" style="margin-bottom: 16px">
|
||||
<div class="card-section-title">实时状态</div>
|
||||
<div class="metrics-grid" v-if="status">
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">CPU</div>
|
||||
<el-progress type="dashboard" :percentage="Math.round(status.cpu_usage)" :width="90"
|
||||
:color="progressColor(status.cpu_usage)" :stroke-width="6" />
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">内存</div>
|
||||
<el-progress type="dashboard" :percentage="Math.round(status.memory_usage)" :width="90"
|
||||
:color="progressColor(status.memory_usage)" :stroke-width="6" />
|
||||
<div class="metric-sub">{{ formatMB(status.memory_total_mb) }}</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">磁盘</div>
|
||||
<el-progress type="dashboard" :percentage="Math.round(status.disk_usage)" :width="90"
|
||||
:color="progressColor(status.disk_usage)" :stroke-width="6" />
|
||||
<div class="metric-sub">{{ formatMB(status.disk_total_mb) }}</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">进程</div>
|
||||
<div class="metric-big-number">{{ status.running_procs }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="metric">
|
||||
<div class="metric-label">内存</div>
|
||||
<el-progress type="dashboard" :percentage="Math.round(status.memory_usage)" :width="100"
|
||||
:color="progressColor(status.memory_usage)" />
|
||||
<div class="metric-sub">{{ formatMB(status.memory_total_mb) }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="metric">
|
||||
<div class="metric-label">磁盘</div>
|
||||
<el-progress type="dashboard" :percentage="Math.round(status.disk_usage)" :width="100"
|
||||
:color="progressColor(status.disk_usage)" />
|
||||
<div class="metric-sub">{{ formatMB(status.disk_total_mb) }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="metric">
|
||||
<div class="metric-label">进程</div>
|
||||
<div class="metric-value">{{ status.running_procs }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-empty v-else description="暂无状态数据" :image-size="60" />
|
||||
</el-card>
|
||||
<el-empty v-else description="暂无状态数据" :image-size="64" />
|
||||
</div>
|
||||
|
||||
<el-card shadow="hover">
|
||||
<template #header><span class="card-title">Top 进程</span></template>
|
||||
<el-table :data="status?.top_processes || []" size="small" max-height="200">
|
||||
<el-table-column prop="name" label="进程名" />
|
||||
<el-table-column prop="pid" label="PID" width="80" />
|
||||
<el-table-column label="CPU" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-progress :percentage="Math.min(Math.round(row.cpu_usage), 100)" :stroke-width="6" :color="progressColor(row.cpu_usage)" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<div class="csm-card">
|
||||
<div class="card-section-title">Top 进程</div>
|
||||
<el-table :data="status?.top_processes || []" size="small" max-height="200" style="width:100%">
|
||||
<el-table-column prop="name" label="进程名" />
|
||||
<el-table-column prop="pid" label="PID" width="80">
|
||||
<template #default="{ row }"><span class="mono-text">{{ row.pid }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="CPU" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-progress :percentage="Math.min(Math.round(row.cpu_usage), 100)" :stroke-width="6" :color="progressColor(row.cpu_usage)" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="内存" width="100">
|
||||
<template #default="{ row }"><span class="mono-text">{{ formatMB(row.memory_mb) }}</span></template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Tab 2: 硬件资产 -->
|
||||
<el-tab-pane label="硬件资产" name="hardware">
|
||||
<div class="csm-card">
|
||||
<el-table :data="hardware" v-loading="hwLoading" style="width:100%">
|
||||
<el-table-column prop="cpu_model" label="CPU型号" min-width="200" />
|
||||
<el-table-column prop="cpu_cores" label="核心数" width="80" />
|
||||
<el-table-column label="内存" width="100">
|
||||
<template #default="{ row }">{{ formatMB(row.memory_mb) }}</template>
|
||||
<template #default="{ row }"><span class="mono-text">{{ formatMB(row.memory_total_mb) }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="gpu_model" label="GPU" min-width="150">
|
||||
<template #default="{ row }"><span>{{ row.gpu_model || '-' }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="motherboard" label="主板" min-width="140">
|
||||
<template #default="{ row }"><span>{{ row.motherboard || '-' }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="disk_model" label="磁盘" min-width="140">
|
||||
<template #default="{ row }"><span>{{ row.disk_model || '-' }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="磁盘容量" width="100">
|
||||
<template #default="{ row }"><span class="mono-text">{{ formatMB(row.disk_total_mb) }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="serial_number" label="序列号" width="160">
|
||||
<template #default="{ row }"><span class="mono-text">{{ row.serial_number || '-' }}</span></template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Tab 3: 软件资产 -->
|
||||
<el-tab-pane label="软件资产" name="software">
|
||||
<div class="toolbar-inline">
|
||||
<el-input v-model="swSearch" placeholder="搜索软件名称 / 发行商" style="width: 260px" clearable :prefix-icon="Search" @input="fetchSoftware" />
|
||||
</div>
|
||||
<div class="csm-card">
|
||||
<el-table :data="software" v-loading="swLoading" style="width:100%">
|
||||
<el-table-column prop="name" label="软件名称" min-width="200">
|
||||
<template #default="{ row }"><span style="font-weight:500">{{ row.name }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="version" label="版本" width="120">
|
||||
<template #default="{ row }"><span class="mono-text">{{ row.version || '-' }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="publisher" label="发行商" min-width="150">
|
||||
<template #default="{ row }"><span>{{ row.publisher || '-' }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="install_date" label="安装日期" width="120">
|
||||
<template #default="{ row }"><span class="secondary-text">{{ row.install_date || '-' }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="install_path" label="安装路径" min-width="250" show-overflow-tooltip>
|
||||
<template #default="{ row }"><span class="mono-text secondary-text">{{ row.install_path || '-' }}</span></template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Tab 4: 变更记录 -->
|
||||
<el-tab-pane label="变更记录" name="changes">
|
||||
<div class="csm-card">
|
||||
<el-table :data="changes" v-loading="chLoading" style="width:100%">
|
||||
<el-table-column prop="change_type" label="变更类型" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="changeTag(row.change_type)" size="small" effect="light">{{ changeLabel(row.change_type) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="change_detail" label="详情" min-width="400" show-overflow-tooltip />
|
||||
<el-table-column prop="detected_at" label="检测时间" width="170">
|
||||
<template #default="{ row }"><span class="secondary-text">{{ row.detected_at }}</span></template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { Search } from '@element-plus/icons-vue'
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -95,18 +180,94 @@ const deviceUid = route.params.uid as string
|
||||
const loading = ref(true)
|
||||
const device = ref<any>(null)
|
||||
const status = ref<any>(null)
|
||||
const activeTab = ref('overview')
|
||||
|
||||
// --- Overview data ---
|
||||
const basicInfo = computed(() => {
|
||||
if (!device.value) return []
|
||||
return [
|
||||
{ label: '设备UID', value: device.value.device_uid },
|
||||
{ label: '主机名', value: device.value.hostname },
|
||||
{ label: 'IP地址', value: device.value.ip_address },
|
||||
{ label: 'MAC地址', value: device.value.mac_address },
|
||||
{ label: '操作系统', value: device.value.os_version },
|
||||
{ label: '客户端版本', value: device.value.client_version },
|
||||
{ label: '分组', value: device.value.group_name },
|
||||
{ label: '注册时间', value: device.value.registered_at },
|
||||
{ label: '最后心跳', value: device.value.last_heartbeat },
|
||||
]
|
||||
})
|
||||
|
||||
// --- Hardware data ---
|
||||
const hardware = ref<any[]>([])
|
||||
const hwLoading = ref(false)
|
||||
|
||||
async function fetchHardware() {
|
||||
hwLoading.value = true
|
||||
try {
|
||||
const data = await api.get<any>(`/api/assets/hardware?device_uid=${deviceUid}`)
|
||||
hardware.value = data.hardware || []
|
||||
} catch { /* ignore */ } finally { hwLoading.value = false }
|
||||
}
|
||||
|
||||
// --- Software data ---
|
||||
const software = ref<any[]>([])
|
||||
const swLoading = ref(false)
|
||||
const swSearch = ref('')
|
||||
|
||||
async function fetchSoftware() {
|
||||
swLoading.value = true
|
||||
try {
|
||||
const params = new URLSearchParams({ device_uid: deviceUid })
|
||||
if (swSearch.value) params.set('search', swSearch.value)
|
||||
const data = await api.get<any>(`/api/assets/software?${params}`)
|
||||
software.value = data.software || []
|
||||
} catch { /* ignore */ } finally { swLoading.value = false }
|
||||
}
|
||||
|
||||
// --- Changes data ---
|
||||
const changes = ref<any[]>([])
|
||||
const chLoading = ref(false)
|
||||
|
||||
async function fetchChanges() {
|
||||
chLoading.value = true
|
||||
try {
|
||||
const data = await api.get<any>(`/api/assets/changes?device_uid=${deviceUid}`)
|
||||
changes.value = data.changes || []
|
||||
} catch { /* ignore */ } finally { chLoading.value = false }
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
function progressColor(value: number) {
|
||||
if (value > 90) return '#F56C6C'
|
||||
if (value > 70) return '#E6A23C'
|
||||
return '#67C23A'
|
||||
if (value > 90) return '#dc2626'
|
||||
if (value > 70) return '#d97706'
|
||||
return '#16a34a'
|
||||
}
|
||||
|
||||
function formatMB(mb: number) {
|
||||
if (!mb) return '-'
|
||||
if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GB`
|
||||
return `${mb} MB`
|
||||
}
|
||||
|
||||
function changeTag(type: string) {
|
||||
const map: Record<string, string> = { hardware: 'warning', software_added: 'success', software_removed: 'danger' }
|
||||
return map[type] || 'info'
|
||||
}
|
||||
|
||||
function changeLabel(type: string) {
|
||||
const map: Record<string, string> = { hardware: '硬件变更', software_added: '软件安装', software_removed: '软件卸载' }
|
||||
return map[type] || type
|
||||
}
|
||||
|
||||
// --- Tab switching ---
|
||||
watch(activeTab, (tab) => {
|
||||
if (tab === 'hardware' && hardware.value.length === 0) fetchHardware()
|
||||
else if (tab === 'software' && software.value.length === 0) fetchSoftware()
|
||||
else if (tab === 'changes' && changes.value.length === 0) fetchChanges()
|
||||
})
|
||||
|
||||
// --- Init ---
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [devData, statData] = await Promise.all([
|
||||
@@ -122,10 +283,164 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.device-detail { padding: 20px; }
|
||||
.card-title { font-weight: 600; font-size: 15px; }
|
||||
.metric { text-align: center; padding: 10px 0; }
|
||||
.metric-label { font-size: 13px; color: #909399; margin-bottom: 8px; }
|
||||
.metric-value { font-size: 32px; font-weight: 700; color: #303133; margin-top: 16px; }
|
||||
.metric-sub { font-size: 12px; color: #909399; margin-top: 4px; }
|
||||
.detail-header {
|
||||
background: #fff;
|
||||
padding: 16px 24px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--csm-border-color);
|
||||
}
|
||||
|
||||
.header-device {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.header-name {
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
color: var(--csm-text-primary);
|
||||
}
|
||||
|
||||
.header-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.meta-text {
|
||||
font-size: 12px;
|
||||
color: var(--csm-text-tertiary);
|
||||
font-family: var(--csm-font-mono);
|
||||
}
|
||||
|
||||
.device-avatar-lg {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 10px;
|
||||
background: #f1f5f9;
|
||||
color: #94a3b8;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.device-avatar-lg.online {
|
||||
background: #f0fdf4;
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.device-avatar-lg.offline {
|
||||
background: #f1f5f9;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
.detail-tabs {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.detail-tabs :deep(.el-tabs__header) {
|
||||
margin: 0 0 16px 0;
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--csm-border-color);
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.detail-tabs :deep(.el-tabs__item) {
|
||||
height: 46px;
|
||||
line-height: 46px;
|
||||
}
|
||||
|
||||
.toolbar-inline {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.card-section-title {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: var(--csm-text-primary);
|
||||
padding: 14px 20px;
|
||||
border-bottom: 1px solid var(--csm-border-color);
|
||||
}
|
||||
|
||||
.info-list {
|
||||
padding: 8px 20px 16px;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid #f8fafc;
|
||||
}
|
||||
|
||||
.info-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: 13px;
|
||||
color: var(--csm-text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--csm-text-primary);
|
||||
text-align: right;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 60%;
|
||||
}
|
||||
|
||||
.metrics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
text-align: center;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
font-size: 13px;
|
||||
color: var(--csm-text-secondary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.metric-big-number {
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
color: var(--csm-text-primary);
|
||||
margin-top: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.metric-sub {
|
||||
font-size: 12px;
|
||||
color: var(--csm-text-tertiary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.mono-text {
|
||||
font-family: var(--csm-font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--csm-text-secondary);
|
||||
}
|
||||
|
||||
.secondary-text {
|
||||
font-size: 12px;
|
||||
color: var(--csm-text-tertiary);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,108 +1,846 @@
|
||||
<template>
|
||||
<div class="devices-page">
|
||||
<div class="toolbar">
|
||||
<el-input v-model="search" placeholder="搜索主机名/IP" style="width: 300px" clearable @input="handleSearch" />
|
||||
<el-select v-model="statusFilter" placeholder="状态" clearable style="width: 120px" @change="handleSearch">
|
||||
<el-option label="在线" value="online" />
|
||||
<el-option label="离线" value="offline" />
|
||||
</el-select>
|
||||
<el-select v-model="groupFilter" placeholder="分组" clearable style="width: 150px" @change="handleSearch">
|
||||
<el-option label="默认组" value="default" />
|
||||
</el-select>
|
||||
<div class="device-page">
|
||||
<!-- Left: Group Panel -->
|
||||
<div class="group-panel">
|
||||
<div class="group-header">
|
||||
<span>设备分组</span>
|
||||
<el-icon class="group-add-btn" :size="16" @click="showCreateGroup"><Plus /></el-icon>
|
||||
</div>
|
||||
<div class="group-list">
|
||||
<div
|
||||
class="group-item"
|
||||
:class="{ active: activeGroup === '' }"
|
||||
@click="selectGroup('')"
|
||||
>
|
||||
<el-icon :size="16"><Monitor /></el-icon>
|
||||
<span class="group-name">全部设备</span>
|
||||
<span class="group-count">{{ totalCount }}</span>
|
||||
</div>
|
||||
|
||||
<div class="group-section-title">
|
||||
<span>组织分组</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="g in groups"
|
||||
:key="g.name"
|
||||
class="group-item"
|
||||
:class="{ active: activeGroup === g.name }"
|
||||
@click="selectGroup(g.name)"
|
||||
@contextmenu.prevent="showGroupContext($event, g)"
|
||||
>
|
||||
<el-icon :size="16"><FolderOpened /></el-icon>
|
||||
<span class="group-name">{{ g.name }}</span>
|
||||
<span class="group-count">{{ g.count }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="ungroupedCount > 0"
|
||||
class="group-item"
|
||||
:class="{ active: activeGroup === '__ungrouped__' }"
|
||||
@click="selectGroup('__ungrouped__')"
|
||||
>
|
||||
<el-icon :size="16"><Folder /></el-icon>
|
||||
<span class="group-name">未分组</span>
|
||||
<span class="group-count">{{ ungroupedCount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Group context menu -->
|
||||
<teleport to="body">
|
||||
<div
|
||||
v-if="contextMenu.visible"
|
||||
class="group-context-menu"
|
||||
:style="{ left: contextMenu.x + 'px', top: contextMenu.y + 'px' }"
|
||||
>
|
||||
<div class="context-item" @click="renameGroup">重命名</div>
|
||||
<div class="context-item danger" @click="deleteGroup">删除分组</div>
|
||||
</div>
|
||||
</teleport>
|
||||
</div>
|
||||
|
||||
<el-table :data="deviceStore.devices" v-loading="deviceStore.loading" stripe @row-click="handleRowClick">
|
||||
<el-table-column prop="hostname" label="主机名" min-width="150" />
|
||||
<el-table-column prop="ip_address" label="IP地址" width="150" />
|
||||
<el-table-column prop="group_name" label="分组" width="120" />
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'online' ? 'success' : 'info'" size="small">
|
||||
{{ row.status === 'online' ? '在线' : '离线' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="CPU" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-progress :percentage="row.cpu_usage ?? 0" :stroke-width="6" :color="getProgressColor(row.cpu_usage)" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="内存" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-progress :percentage="row.memory_usage ?? 0" :stroke-width="6" :color="getProgressColor(row.memory_usage)" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="last_heartbeat" label="最后心跳" width="180" />
|
||||
<el-table-column label="操作" width="100" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="danger" link size="small" @click.stop="handleDelete(row)">移除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<!-- Right: Main Content -->
|
||||
<div class="device-main">
|
||||
<!-- Stats Bar -->
|
||||
<div class="stats-bar">
|
||||
<div class="stat-item">
|
||||
<span class="stat-value">{{ totalCount }}</span>
|
||||
<span class="stat-label">设备总数</span>
|
||||
</div>
|
||||
<div class="stat-divider"></div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-value online">{{ onlineCount }}</span>
|
||||
<span class="stat-label">在线</span>
|
||||
</div>
|
||||
<div class="stat-divider"></div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-value offline">{{ offlineCount }}</span>
|
||||
<span class="stat-label">离线</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-pagination
|
||||
style="margin-top: 20px; justify-content: flex-end"
|
||||
:total="deviceStore.total"
|
||||
:page-size="20"
|
||||
layout="total, prev, pager, next"
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
<!-- Toolbar -->
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-left">
|
||||
<el-input
|
||||
v-model="search"
|
||||
placeholder="搜索主机名 / IP地址"
|
||||
style="width: 260px"
|
||||
clearable
|
||||
:prefix-icon="Search"
|
||||
@input="handleSearch"
|
||||
/>
|
||||
<el-select v-model="statusFilter" placeholder="状态" clearable style="width: 110px" @change="handleSearch">
|
||||
<el-option label="在线" value="online" />
|
||||
<el-option label="离线" value="offline" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<el-button v-if="selectedDevices.length > 0" type="danger" plain size="default" @click="batchRemove">
|
||||
<el-icon><Delete /></el-icon>批量移除 ({{ selectedDevices.length }})
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Device Table -->
|
||||
<div class="table-card">
|
||||
<el-table
|
||||
ref="tableRef"
|
||||
:data="deviceStore.devices"
|
||||
v-loading="deviceStore.loading"
|
||||
@row-click="handleRowClick"
|
||||
@selection-change="handleSelectionChange"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-table-column type="selection" width="44" />
|
||||
<el-table-column label="设备" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<div class="device-cell">
|
||||
<div class="device-avatar" :class="row.status">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="width:20px;height:20px">
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" />
|
||||
<line x1="8" y1="21" x2="16" y2="21" />
|
||||
<line x1="12" y1="17" x2="12" y2="21" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="device-info">
|
||||
<div class="device-name">{{ row.hostname }}</div>
|
||||
<div class="device-ip">{{ row.ip_address }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<div class="status-cell">
|
||||
<span class="status-indicator" :class="row.status"></span>
|
||||
<span :class="row.status">{{ row.status === 'online' ? '在线' : '离线' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="group_name" label="分组" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" effect="plain" round>{{ row.group_name || '默认' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="CPU" width="100">
|
||||
<template #default="{ row }">
|
||||
<div class="usage-cell">
|
||||
<el-progress
|
||||
:percentage="Math.round(row.cpu_usage ?? 0)"
|
||||
:stroke-width="4"
|
||||
:show-text="false"
|
||||
:color="getProgressColor(row.cpu_usage)"
|
||||
style="flex:1"
|
||||
/>
|
||||
<span class="usage-text">{{ Math.round(row.cpu_usage ?? 0) }}%</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="内存" width="100">
|
||||
<template #default="{ row }">
|
||||
<div class="usage-cell">
|
||||
<el-progress
|
||||
:percentage="Math.round(row.memory_usage ?? 0)"
|
||||
:stroke-width="4"
|
||||
:show-text="false"
|
||||
:color="getProgressColor(row.memory_usage)"
|
||||
style="flex:1"
|
||||
/>
|
||||
<span class="usage-text">{{ Math.round(row.memory_usage ?? 0) }}%</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="os_version" label="系统" width="140">
|
||||
<template #default="{ row }">
|
||||
<span class="os-text">{{ row.os_version || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="last_heartbeat" label="最后活跃" width="160">
|
||||
<template #default="{ row }">
|
||||
<span class="time-text">{{ formatTime(row.last_heartbeat) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="" width="60" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-dropdown trigger="click" @command="(cmd: string) => handleAction(cmd, row)">
|
||||
<el-icon class="action-trigger"><MoreFilled /></el-icon>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="detail">查看详情</el-dropdown-item>
|
||||
<el-dropdown-item command="move">移动到分组</el-dropdown-item>
|
||||
<el-dropdown-item command="remove" divided>
|
||||
<span style="color:#dc2626">移除设备</span>
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="pagination-bar">
|
||||
<el-pagination
|
||||
:total="deviceStore.total"
|
||||
:page-size="20"
|
||||
layout="total, prev, pager, next"
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Move Device Dialog -->
|
||||
<el-dialog v-model="moveDialog.visible" title="移动到分组" width="400px" :close-on-click-modal="false">
|
||||
<p style="margin-bottom:12px;color:var(--csm-text-secondary);font-size:13px">
|
||||
设备:<strong>{{ moveDialog.device?.hostname }}</strong>
|
||||
</p>
|
||||
<el-select v-model="moveDialog.target" placeholder="选择目标分组" style="width:100%">
|
||||
<el-option
|
||||
v-for="g in groups"
|
||||
:key="g.name"
|
||||
:label="g.name"
|
||||
:value="g.name"
|
||||
/>
|
||||
<el-option label="+ 新建分组" value="__new__" />
|
||||
</el-select>
|
||||
<el-input
|
||||
v-if="moveDialog.target === '__new__'"
|
||||
v-model="newGroupName"
|
||||
placeholder="新分组名称"
|
||||
style="width:100%;margin-top:10px"
|
||||
/>
|
||||
<template #footer>
|
||||
<el-button @click="moveDialog.visible = false">取消</el-button>
|
||||
<el-button type="primary" :disabled="!moveDialog.target || moveDialog.target === '__new__' && !newGroupName" @click="handleMoveSubmit">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import axios from 'axios'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
Search, Monitor, FolderOpened, Folder, Delete, MoreFilled, Plus
|
||||
} from '@element-plus/icons-vue'
|
||||
import { useDeviceStore } from '../stores/devices'
|
||||
import type { Device } from '../stores/devices'
|
||||
|
||||
const apiBase = axios.create({
|
||||
baseURL: '/api',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
apiBase.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||
return config
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
const deviceStore = useDeviceStore()
|
||||
|
||||
const search = ref('')
|
||||
const statusFilter = ref('')
|
||||
const groupFilter = ref('')
|
||||
const activeGroup = ref('')
|
||||
const currentPage = ref(1)
|
||||
const selectedDevices = ref<Device[]>([])
|
||||
// Fetch all devices for group stats (unfiltered)
|
||||
const allDevices = ref<Device[]>([])
|
||||
|
||||
// Group context menu state
|
||||
const contextMenu = ref<{ visible: boolean; x: number; y: number; group: { name: string; count: number } | null }>({
|
||||
visible: false, x: 0, y: 0, group: null,
|
||||
})
|
||||
|
||||
function showGroupContext(e: MouseEvent, g: { name: string; count: number }) {
|
||||
contextMenu.value = { visible: true, x: e.clientX, y: e.clientY, group: g }
|
||||
}
|
||||
|
||||
function hideContextMenu() {
|
||||
contextMenu.value = { ...contextMenu.value, visible: false }
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
deviceStore.fetchDevices()
|
||||
document.addEventListener('click', hideContextMenu)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', hideContextMenu)
|
||||
})
|
||||
const totalCount = computed(() => allDevices.value.length)
|
||||
const onlineCount = computed(() => allDevices.value.filter(d => d.status === 'online').length)
|
||||
const offlineCount = computed(() => allDevices.value.filter(d => d.status === 'offline').length)
|
||||
|
||||
const groups = computed(() => {
|
||||
const map = new Map<string, number>()
|
||||
allDevices.value.forEach(d => {
|
||||
const name = d.group_name || '默认'
|
||||
map.set(name, (map.get(name) || 0) + 1)
|
||||
})
|
||||
return Array.from(map.entries())
|
||||
.map(([name, count]) => ({ name, count }))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
})
|
||||
|
||||
const ungroupedCount = computed(() => allDevices.value.filter(d => !d.group_name).length)
|
||||
|
||||
onMounted(async () => {
|
||||
await deviceStore.fetchDevices()
|
||||
allDevices.value = [...deviceStore.devices]
|
||||
})
|
||||
|
||||
watch(() => deviceStore.devices, (val) => {
|
||||
allDevices.value = [...val]
|
||||
})
|
||||
|
||||
function selectGroup(group: string) {
|
||||
activeGroup.value = group
|
||||
currentPage.value = 1
|
||||
doSearch()
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
currentPage.value = 1
|
||||
doSearch()
|
||||
}
|
||||
|
||||
function doSearch() {
|
||||
const groupVal = activeGroup.value === '__ungrouped__' ? '' : activeGroup.value
|
||||
deviceStore.fetchDevices({
|
||||
search: search.value,
|
||||
status: statusFilter.value,
|
||||
group: groupFilter.value,
|
||||
group: groupVal,
|
||||
page: '1',
|
||||
})
|
||||
}
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
currentPage.value = page
|
||||
deviceStore.fetchDevices({ page: String(page) })
|
||||
const groupVal = activeGroup.value === '__ungrouped__' ? '' : activeGroup.value
|
||||
deviceStore.fetchDevices({
|
||||
search: search.value,
|
||||
status: statusFilter.value,
|
||||
group: groupVal,
|
||||
page: String(page),
|
||||
})
|
||||
}
|
||||
|
||||
function handleRowClick(row: Device) {
|
||||
router.push(`/devices/${row.device_uid}`)
|
||||
}
|
||||
|
||||
function handleSelectionChange(rows: Device[]) {
|
||||
selectedDevices.value = rows
|
||||
}
|
||||
|
||||
function handleAction(cmd: string, row: Device) {
|
||||
if (cmd === 'detail') {
|
||||
router.push(`/devices/${row.device_uid}`)
|
||||
} else if (cmd === 'remove') {
|
||||
handleDelete(row)
|
||||
} else if (cmd === 'move') {
|
||||
showMoveDeviceDialog(row)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(row: Device) {
|
||||
await ElMessageBox.confirm(`确定移除设备 ${row.hostname}?`, '确认', { type: 'warning' })
|
||||
await deviceStore.removeDevice(row.device_uid)
|
||||
ElMessage.success('设备已移除')
|
||||
}
|
||||
|
||||
async function batchRemove() {
|
||||
const count = selectedDevices.value.length
|
||||
await ElMessageBox.confirm(`确定批量移除 ${count} 台设备?`, '确认', { type: 'warning' })
|
||||
for (const d of selectedDevices.value) {
|
||||
await deviceStore.removeDevice(d.device_uid)
|
||||
}
|
||||
ElMessage.success(`已移除 ${count} 台设备`)
|
||||
selectedDevices.value = []
|
||||
}
|
||||
|
||||
function getProgressColor(value?: number): string {
|
||||
if (!value) return '#67C23A'
|
||||
if (value > 90) return '#F56C6C'
|
||||
if (value > 70) return '#E6A23C'
|
||||
return '#67C23A'
|
||||
if (!value) return '#16a34a'
|
||||
if (value > 90) return '#dc2626'
|
||||
if (value > 70) return '#d97706'
|
||||
return '#16a34a'
|
||||
}
|
||||
|
||||
function formatTime(t: string | null): string {
|
||||
if (!t) return '-'
|
||||
const d = new Date(t)
|
||||
if (isNaN(d.getTime())) return t
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - d.getTime()
|
||||
const diffMin = Math.floor(diffMs / 60000)
|
||||
if (diffMin < 1) return '刚刚'
|
||||
if (diffMin < 60) return `${diffMin}分钟前`
|
||||
const diffHour = Math.floor(diffMin / 60)
|
||||
if (diffHour < 24) return `${diffHour}小时前`
|
||||
const diffDay = Math.floor(diffHour / 24)
|
||||
if (diffDay < 7) return `${diffDay}天前`
|
||||
return t.slice(0, 16).replace('T', ' ')
|
||||
}
|
||||
|
||||
// ---- Group Management ----
|
||||
|
||||
async function showCreateGroup() {
|
||||
const { value: name } = await ElMessageBox.prompt('请输入分组名称', '新建分组', {
|
||||
confirmButtonText: '创建',
|
||||
cancelButtonText: '取消',
|
||||
inputPattern: /^.{1,50}$/,
|
||||
inputErrorMessage: '名称长度为1-50个字符',
|
||||
})
|
||||
if (!name) return
|
||||
const { data } = await apiBase.post('/groups', { name: name.trim() })
|
||||
if (data.success) {
|
||||
ElMessage.success('分组创建成功')
|
||||
doSearch() // refresh device list to reflect new group
|
||||
} else {
|
||||
ElMessage.error(data.error || '创建失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function renameGroup() {
|
||||
const g = contextMenu.value.group
|
||||
if (!g) return
|
||||
hideContextMenu()
|
||||
const { value: newName } = await ElMessageBox.prompt(`重命名分组 "${g.name}"`, '重命名', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
inputValue: g.name,
|
||||
inputPattern: /^.{1,50}$/,
|
||||
inputErrorMessage: '名称长度为1-50个字符',
|
||||
})
|
||||
if (!newName || newName.trim() === g.name) return
|
||||
const { data } = await apiBase.put(`/groups/${encodeURIComponent(g.name)}`, { new_name: newName.trim() })
|
||||
if (data.success) {
|
||||
ElMessage.success('重命名成功')
|
||||
if (activeGroup.value === g.name) activeGroup.value = newName.trim()
|
||||
doSearch()
|
||||
} else {
|
||||
ElMessage.error(data.error || '重命名失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteGroup() {
|
||||
const g = contextMenu.value.group
|
||||
if (!g) return
|
||||
hideContextMenu()
|
||||
await ElMessageBox.confirm(
|
||||
`删除分组 "${g.name}" 后,组内 ${g.count} 台设备将移至"默认"分组。继续?`,
|
||||
'删除分组',
|
||||
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' }
|
||||
)
|
||||
const { data } = await apiBase.delete(`/groups/${encodeURIComponent(g.name)}`)
|
||||
if (data.success) {
|
||||
ElMessage.success('分组已删除')
|
||||
if (activeGroup.value === g.name) activeGroup.value = ''
|
||||
doSearch()
|
||||
} else {
|
||||
ElMessage.error(data.error || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
// Move device dialog
|
||||
const moveDialog = ref({ visible: false, device: null as Device | null, target: '' })
|
||||
|
||||
function showMoveDeviceDialog(row: Device) {
|
||||
moveDialog.value = { visible: true, device: row, target: '' }
|
||||
}
|
||||
|
||||
const newGroupName = ref('')
|
||||
|
||||
async function handleMoveSubmit() {
|
||||
const d = moveDialog.value.device
|
||||
if (!d) return
|
||||
|
||||
let target = moveDialog.value.target
|
||||
if (target === '__new__') {
|
||||
target = newGroupName.value.trim()
|
||||
if (!target) return
|
||||
// Create group first
|
||||
const { data: createData } = await apiBase.post('/groups', { name: target })
|
||||
if (!createData.success) {
|
||||
ElMessage.error(createData.error || '创建分组失败')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const { data } = await apiBase.put(`/devices/${d.device_uid}/group`, { group_name: target })
|
||||
if (data.success) {
|
||||
ElMessage.success('已移动到分组')
|
||||
moveDialog.value = { visible: false, device: null, target: '' }
|
||||
newGroupName.value = ''
|
||||
doSearch()
|
||||
} else {
|
||||
ElMessage.error(data.error || '移动失败')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.devices-page { padding: 20px; }
|
||||
.toolbar { display: flex; gap: 12px; margin-bottom: 20px; }
|
||||
.device-page {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ---- Left: Group Panel ---- */
|
||||
.group-panel {
|
||||
width: 220px;
|
||||
min-width: 220px;
|
||||
background: #fff;
|
||||
border-right: 1px solid var(--csm-border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.group-header {
|
||||
padding: 16px 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--csm-text-primary);
|
||||
border-bottom: 1px solid var(--csm-border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.group-add-btn {
|
||||
cursor: pointer;
|
||||
color: var(--csm-text-tertiary);
|
||||
transition: color 0.15s;
|
||||
border-radius: 4px;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.group-add-btn:hover {
|
||||
color: var(--csm-primary);
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.group-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.group-section-title {
|
||||
padding: 12px 20px 6px;
|
||||
font-size: 11px;
|
||||
color: var(--csm-text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.group-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 9px 20px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
color: var(--csm-text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.group-item:hover {
|
||||
background: var(--csm-bg-page);
|
||||
color: var(--csm-text-primary);
|
||||
}
|
||||
|
||||
.group-item.active {
|
||||
background: #eff6ff;
|
||||
color: var(--csm-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.group-item .el-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.group-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.group-count {
|
||||
font-size: 11px;
|
||||
color: var(--csm-text-tertiary);
|
||||
min-width: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.group-item.active .group-count {
|
||||
color: var(--csm-primary);
|
||||
}
|
||||
|
||||
/* ---- Right: Main Content ---- */
|
||||
.device-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: 20px 24px;
|
||||
}
|
||||
|
||||
/* Stats Bar */
|
||||
.stats-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
padding: 16px 24px;
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--csm-border-color);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--csm-text-primary);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.stat-value.online { color: #16a34a; }
|
||||
.stat-value.offline { color: #94a3b8; }
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: var(--csm-text-tertiary);
|
||||
}
|
||||
|
||||
.stat-divider {
|
||||
width: 1px;
|
||||
height: 28px;
|
||||
background: var(--csm-border-color);
|
||||
}
|
||||
|
||||
/* Toolbar */
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.toolbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Table Card */
|
||||
.table-card {
|
||||
flex: 1;
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--csm-border-color);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.table-card :deep(.el-table) {
|
||||
--el-table-border-color: var(--csm-border-color);
|
||||
--el-table-header-bg-color: #fafbfc;
|
||||
}
|
||||
|
||||
.table-card :deep(.el-table__row) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.table-card :deep(.el-table__row:hover > td) {
|
||||
background: #f8fafc !important;
|
||||
}
|
||||
|
||||
/* Device Cell */
|
||||
.device-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.device-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
background: #f1f5f9;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.device-avatar.online {
|
||||
background: #f0fdf4;
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.device-avatar.offline {
|
||||
background: #f1f5f9;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.device-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.device-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--csm-text-primary);
|
||||
}
|
||||
|
||||
.device-ip {
|
||||
font-size: 11px;
|
||||
font-family: var(--csm-font-mono);
|
||||
color: var(--csm-text-tertiary);
|
||||
}
|
||||
|
||||
/* Status Cell */
|
||||
.status-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.status-indicator.online {
|
||||
background: #16a34a;
|
||||
box-shadow: 0 0 4px rgba(22, 163, 74, 0.4);
|
||||
}
|
||||
|
||||
.status-indicator.offline {
|
||||
background: #cbd5e1;
|
||||
}
|
||||
|
||||
.status-cell .online { color: #16a34a; }
|
||||
.status-cell .offline { color: #94a3b8; }
|
||||
|
||||
/* Usage Cell */
|
||||
.usage-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.usage-text {
|
||||
font-size: 11px;
|
||||
font-family: var(--csm-font-mono);
|
||||
color: var(--csm-text-tertiary);
|
||||
min-width: 30px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.os-text {
|
||||
font-size: 12px;
|
||||
color: var(--csm-text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.time-text {
|
||||
font-size: 12px;
|
||||
color: var(--csm-text-tertiary);
|
||||
}
|
||||
|
||||
.action-trigger {
|
||||
cursor: pointer;
|
||||
color: var(--csm-text-tertiary);
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.action-trigger:hover {
|
||||
color: var(--csm-text-primary);
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
.pagination-bar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
/* Group Context Menu */
|
||||
.group-context-menu {
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.12);
|
||||
border: 1px solid var(--csm-border-color);
|
||||
padding: 4px 0;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.context-item {
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
color: var(--csm-text-primary);
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.context-item:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.context-item.danger {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.context-item.danger:hover {
|
||||
background: #fef2f2;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,36 +1,44 @@
|
||||
<template>
|
||||
<el-container class="app-container">
|
||||
<el-aside width="220px" class="sidebar">
|
||||
<div class="logo">
|
||||
<h2>CSM</h2>
|
||||
<span>终端管理系统</span>
|
||||
<!-- Sidebar -->
|
||||
<el-aside :width="sidebarCollapsed ? '64px' : '240px'" class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<div class="logo-icon" v-show="!sidebarCollapsed">
|
||||
<svg viewBox="0 0 32 32" fill="none"><rect width="32" height="32" rx="8" fill="rgba(255,255,255,0.1)"/><path d="M16 5L27 11V21L16 27L5 21V11L16 5Z" stroke="rgba(255,255,255,0.6)" stroke-width="1.5" fill="rgba(255,255,255,0.05)"/><circle cx="16" cy="16" r="4" fill="rgba(255,255,255,0.7)"/></svg>
|
||||
</div>
|
||||
<h2 v-show="!sidebarCollapsed">CSM</h2>
|
||||
<div class="collapse-btn" @click="sidebarCollapsed = !sidebarCollapsed">
|
||||
<el-icon :size="18"><component :is="sidebarCollapsed ? Expand : Fold" /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-menu
|
||||
:default-active="currentRoute"
|
||||
:collapse="sidebarCollapsed"
|
||||
:collapse-transition="false"
|
||||
router
|
||||
background-color="#1d1e2c"
|
||||
text-color="#a0a3bd"
|
||||
active-text-color="#409eff"
|
||||
background-color="transparent"
|
||||
text-color="var(--csm-sidebar-text)"
|
||||
active-text-color="var(--csm-sidebar-text-active)"
|
||||
>
|
||||
<el-menu-item index="/dashboard">
|
||||
<el-icon><Monitor /></el-icon>
|
||||
<span>仪表盘</span>
|
||||
<template #title><span>仪表盘</span></template>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/devices">
|
||||
<el-icon><Platform /></el-icon>
|
||||
<span>设备管理</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/assets">
|
||||
<el-icon><Box /></el-icon>
|
||||
<span>资产管理</span>
|
||||
<template #title><span>设备管理</span></template>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/usb">
|
||||
<el-icon><Connection /></el-icon>
|
||||
<span>U盘管控</span>
|
||||
<template #title><span>U盘管控</span></template>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/alerts">
|
||||
<el-icon><Bell /></el-icon>
|
||||
<span>告警中心</span>
|
||||
<template #title>
|
||||
<span>告警中心</span>
|
||||
<el-badge v-if="unreadAlerts > 0" :value="unreadAlerts" :max="99" class="menu-badge" />
|
||||
</template>
|
||||
</el-menu-item>
|
||||
|
||||
<el-sub-menu index="plugins">
|
||||
@@ -38,45 +46,81 @@
|
||||
<el-icon><Grid /></el-icon>
|
||||
<span>安全插件</span>
|
||||
</template>
|
||||
<el-menu-item index="/plugins/web-filter">上网拦截</el-menu-item>
|
||||
<el-menu-item index="/plugins/usage-timer">时长记录</el-menu-item>
|
||||
<el-menu-item index="/plugins/software-blocker">软件管控</el-menu-item>
|
||||
<el-menu-item index="/plugins/popup-blocker">弹窗拦截</el-menu-item>
|
||||
<el-menu-item index="/plugins/usb-file-audit">U盘审计</el-menu-item>
|
||||
<el-menu-item index="/plugins/watermark">水印管理</el-menu-item>
|
||||
<el-menu-item index="/plugins/web-filter">
|
||||
<template #title><span>上网拦截</span></template>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/plugins/usage-timer">
|
||||
<template #title><span>时长记录</span></template>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/plugins/software-blocker">
|
||||
<template #title><span>软件管控</span></template>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/plugins/popup-blocker">
|
||||
<template #title><span>弹窗拦截</span></template>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/plugins/usb-file-audit">
|
||||
<template #title><span>U盘审计</span></template>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/plugins/watermark">
|
||||
<template #title><span>水印管理</span></template>
|
||||
</el-menu-item>
|
||||
</el-sub-menu>
|
||||
|
||||
<el-menu-item index="/settings">
|
||||
<el-icon><Setting /></el-icon>
|
||||
<span>系统设置</span>
|
||||
<template #title><span>系统设置</span></template>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
|
||||
<div class="sidebar-footer" v-show="!sidebarCollapsed">
|
||||
<span class="version">v0.1.0</span>
|
||||
</div>
|
||||
</el-aside>
|
||||
|
||||
<!-- Main content -->
|
||||
<el-container>
|
||||
<el-header class="app-header">
|
||||
<el-header class="app-header" height="60px">
|
||||
<div class="header-left">
|
||||
<span class="page-title">{{ pageTitle }}</span>
|
||||
<div class="breadcrumb">
|
||||
<span class="breadcrumb-home" @click="$router.push('/dashboard')">首页</span>
|
||||
<span class="breadcrumb-sep" v-if="pageTitle !== '仪表盘'">/</span>
|
||||
<span class="breadcrumb-current" v-if="pageTitle !== '仪表盘'">{{ pageTitle }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<el-badge :value="unreadAlerts" :hidden="unreadAlerts === 0">
|
||||
<el-icon :size="20"><Bell /></el-icon>
|
||||
</el-badge>
|
||||
<el-tooltip content="告警中心" placement="bottom">
|
||||
<div class="header-action" @click="$router.push('/alerts')">
|
||||
<el-badge :value="unreadAlerts" :hidden="unreadAlerts === 0" :max="99">
|
||||
<el-icon :size="20"><Bell /></el-icon>
|
||||
</el-badge>
|
||||
</div>
|
||||
</el-tooltip>
|
||||
<el-dropdown>
|
||||
<span class="user-info">
|
||||
{{ username }} <el-icon><ArrowDown /></el-icon>
|
||||
</span>
|
||||
<div class="user-info">
|
||||
<div class="user-avatar">{{ username.charAt(0).toUpperCase() }}</div>
|
||||
<span class="user-name">{{ username }}</span>
|
||||
<el-icon class="user-arrow"><ArrowDown /></el-icon>
|
||||
</div>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item @click="handleLogout">退出登录</el-dropdown-item>
|
||||
<el-dropdown-item @click="$router.push('/settings')">
|
||||
<el-icon><Setting /></el-icon>系统设置
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item divided @click="handleLogout">
|
||||
<el-icon><SwitchButton /></el-icon>退出登录
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</el-header>
|
||||
|
||||
<el-main>
|
||||
<router-view />
|
||||
<el-main class="app-main">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="page" mode="out-in">
|
||||
<component :is="Component" />
|
||||
</transition>
|
||||
</router-view>
|
||||
</el-main>
|
||||
</el-container>
|
||||
</el-container>
|
||||
@@ -86,13 +130,15 @@
|
||||
import { computed, ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
Monitor, Platform, Box, Connection, Bell, Setting, ArrowDown, Grid
|
||||
Monitor, Platform, Connection, Bell, Setting,
|
||||
ArrowDown, Grid, Expand, Fold, SwitchButton
|
||||
} from '@element-plus/icons-vue'
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const sidebarCollapsed = ref(false)
|
||||
const currentRoute = computed(() => route.path)
|
||||
const unreadAlerts = ref(0)
|
||||
const username = ref('')
|
||||
@@ -120,7 +166,6 @@ async function fetchUnreadAlerts() {
|
||||
const pageTitles: Record<string, string> = {
|
||||
'/dashboard': '仪表盘',
|
||||
'/devices': '设备管理',
|
||||
'/assets': '资产管理',
|
||||
'/usb': 'U盘管控',
|
||||
'/alerts': '告警中心',
|
||||
'/settings': '系统设置',
|
||||
@@ -147,43 +192,233 @@ function handleLogout() {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app-container { height: 100vh; }
|
||||
.app-container {
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ---- Sidebar ---- */
|
||||
.sidebar {
|
||||
background-color: #1d1e2c;
|
||||
background: var(--csm-sidebar-bg);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
border-right: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: width var(--csm-transition);
|
||||
}
|
||||
|
||||
.logo {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
.sidebar::-webkit-scrollbar {
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 20px 16px 16px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
.logo-icon svg {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-header h2 {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
border-bottom: 1px solid #2d2e3e;
|
||||
letter-spacing: -0.02em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.logo h2 { font-size: 24px; margin-bottom: 4px; }
|
||||
.logo span { font-size: 12px; color: #a0a3bd; }
|
||||
|
||||
.collapse-btn {
|
||||
margin-left: auto;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
color: var(--csm-sidebar-text);
|
||||
cursor: pointer;
|
||||
transition: all var(--csm-transition-fast);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.collapse-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Menu overrides */
|
||||
.sidebar :deep(.el-menu) {
|
||||
border-right: none;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.sidebar :deep(.el-menu-item),
|
||||
.sidebar :deep(.el-sub-menu__title) {
|
||||
height: 42px;
|
||||
line-height: 42px;
|
||||
border-radius: 8px;
|
||||
margin: 2px 0;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
transition: all var(--csm-transition-fast);
|
||||
}
|
||||
|
||||
.sidebar :deep(.el-menu-item:hover),
|
||||
.sidebar :deep(.el-sub-menu__title:hover) {
|
||||
background: var(--csm-sidebar-hover) !important;
|
||||
}
|
||||
|
||||
.sidebar :deep(.el-menu-item.is-active) {
|
||||
background: var(--csm-sidebar-active) !important;
|
||||
color: var(--csm-sidebar-text-active) !important;
|
||||
}
|
||||
|
||||
.sidebar :deep(.el-sub-menu .el-menu-item) {
|
||||
padding-left: 48px !important;
|
||||
height: 38px;
|
||||
line-height: 38px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.sidebar :deep(.el-menu--collapse .el-menu-item),
|
||||
.sidebar :deep(.el-menu--collapse .el-sub-menu__title) {
|
||||
padding: 0 !important;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.menu-badge {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.menu-badge :deep(.el-badge__content) {
|
||||
font-size: 10px;
|
||||
height: 16px;
|
||||
line-height: 16px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
margin-top: auto;
|
||||
padding: 16px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sidebar-footer .version {
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
/* ---- Header ---- */
|
||||
.app-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
background: #fff;
|
||||
background: var(--csm-bg-header);
|
||||
border-bottom: 1px solid var(--csm-border-color);
|
||||
padding: 0 24px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.page-title { font-size: 18px; font-weight: 600; }
|
||||
.breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.breadcrumb-home {
|
||||
color: var(--csm-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: color var(--csm-transition-fast);
|
||||
}
|
||||
|
||||
.breadcrumb-home:hover {
|
||||
color: var(--csm-primary);
|
||||
}
|
||||
|
||||
.breadcrumb-sep {
|
||||
color: var(--csm-text-tertiary);
|
||||
}
|
||||
|
||||
.breadcrumb-current {
|
||||
color: var(--csm-text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.header-action {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
color: var(--csm-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all var(--csm-transition-fast);
|
||||
}
|
||||
|
||||
.header-action:hover {
|
||||
background: var(--csm-bg-page);
|
||||
color: var(--csm-text-primary);
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
color: #606266;
|
||||
padding: 4px 8px;
|
||||
border-radius: 8px;
|
||||
transition: background var(--csm-transition-fast);
|
||||
}
|
||||
|
||||
.user-info:hover {
|
||||
background: var(--csm-bg-page);
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 8px;
|
||||
background: var(--csm-primary);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--csm-text-primary);
|
||||
}
|
||||
|
||||
.user-arrow {
|
||||
color: var(--csm-text-tertiary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ---- Main ---- */
|
||||
.app-main {
|
||||
background: var(--csm-bg-page);
|
||||
padding: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,43 +1,57 @@
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<div class="login-card">
|
||||
<div class="login-header">
|
||||
<h2>CSM</h2>
|
||||
<p>终端管理系统</p>
|
||||
<!-- Left branding -->
|
||||
<div class="login-brand">
|
||||
<div class="brand-content">
|
||||
<div class="brand-logo">
|
||||
<h1>CSM</h1>
|
||||
</div>
|
||||
<p class="brand-tagline">企业终端管理系统</p>
|
||||
<div class="brand-features">
|
||||
<div class="feature-item">
|
||||
<el-icon :size="16"><Monitor /></el-icon>
|
||||
<span>终端设备统一管控</span>
|
||||
</div>
|
||||
<div class="feature-item">
|
||||
<el-icon :size="16"><Connection /></el-icon>
|
||||
<span>USB 策略实时下发</span>
|
||||
</div>
|
||||
<div class="feature-item">
|
||||
<el-icon :size="16"><Stamp /></el-icon>
|
||||
<span>安全水印防泄密</span>
|
||||
</div>
|
||||
<div class="feature-item">
|
||||
<el-icon :size="16"><Grid /></el-icon>
|
||||
<span>上网行为审计追踪</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="brand-footer">
|
||||
<span>CSM Terminal Management v0.1</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right form -->
|
||||
<div class="login-form-panel">
|
||||
<div class="login-form-wrapper">
|
||||
<div class="login-form-header">
|
||||
<h2>欢迎回来</h2>
|
||||
<p>请登录您的管理账号</p>
|
||||
</div>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" @submit.prevent="handleLogin" class="login-form">
|
||||
<el-form-item prop="username">
|
||||
<el-input v-model="form.username" placeholder="用户名" :prefix-icon="User" size="large" @keyup.enter="handleLogin" />
|
||||
</el-form-item>
|
||||
<el-form-item prop="password">
|
||||
<el-input v-model="form.password" type="password" placeholder="密码" :prefix-icon="Lock" size="large" show-password @keyup.enter="handleLogin" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" size="large" :loading="loading" class="login-btn" @click="handleLogin">
|
||||
{{ loading ? '登录中...' : '登 录' }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" @submit.prevent="handleLogin">
|
||||
<el-form-item prop="username">
|
||||
<el-input
|
||||
v-model="form.username"
|
||||
placeholder="用户名"
|
||||
:prefix-icon="User"
|
||||
size="large"
|
||||
@keyup.enter="handleLogin"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item prop="password">
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
placeholder="密码"
|
||||
:prefix-icon="Lock"
|
||||
size="large"
|
||||
show-password
|
||||
@keyup.enter="handleLogin"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
:loading="loading"
|
||||
style="width: 100%"
|
||||
@click="handleLogin"
|
||||
>
|
||||
登录
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -46,17 +60,14 @@
|
||||
import { ref, reactive } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { User, Lock } from '@element-plus/icons-vue'
|
||||
import { User, Lock, Monitor, Connection, Stamp, Grid } from '@element-plus/icons-vue'
|
||||
import { api, ApiError } from '../lib/api'
|
||||
|
||||
const router = useRouter()
|
||||
const formRef = ref()
|
||||
const loading = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
})
|
||||
const form = reactive({ username: '', password: '' })
|
||||
|
||||
const rules = {
|
||||
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||
@@ -88,32 +99,111 @@ async function handleLogin() {
|
||||
.login-container {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Left branding */
|
||||
.login-brand {
|
||||
flex: 1;
|
||||
background: #1e293b;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 80px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.brand-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.brand-logo h1 {
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
letter-spacing: -0.02em;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.brand-tagline {
|
||||
font-size: 16px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
.brand-features {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.feature-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.feature-item .el-icon {
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.brand-footer {
|
||||
position: absolute;
|
||||
bottom: 32px;
|
||||
color: rgba(255, 255, 255, 0.2);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Right form */
|
||||
.login-form-panel {
|
||||
width: 460px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #1d1e2c 0%, #2d3a4a 100%);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 400px;
|
||||
padding: 40px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
|
||||
padding: 60px;
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
.login-form-wrapper {
|
||||
width: 100%;
|
||||
max-width: 340px;
|
||||
}
|
||||
|
||||
.login-header h2 {
|
||||
font-size: 32px;
|
||||
color: #303133;
|
||||
margin-bottom: 8px;
|
||||
.login-form-header {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.login-header p {
|
||||
.login-form-header h2 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--csm-text-primary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.login-form-header p {
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
color: var(--csm-text-secondary);
|
||||
}
|
||||
|
||||
.login-form .el-form-item {
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
width: 100%;
|
||||
height: 42px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 900px) {
|
||||
.login-brand { display: none; }
|
||||
.login-form-panel { width: 100%; background: var(--csm-bg-page); }
|
||||
.login-form-wrapper { background: #fff; padding: 40px; border-radius: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,55 +1,82 @@
|
||||
<template>
|
||||
<div class="settings-page">
|
||||
<div class="page-container">
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-card shadow="hover">
|
||||
<template #header><span class="card-title">系统信息</span></template>
|
||||
<el-descriptions :column="1" border size="small">
|
||||
<el-descriptions-item label="系统版本">v{{ version }}</el-descriptions-item>
|
||||
<el-descriptions-item label="数据库">{{ dbInfo }}</el-descriptions-item>
|
||||
<el-descriptions-item label="在线终端">{{ health.connected_clients }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
<div class="csm-card">
|
||||
<div class="csm-card-header">系统信息</div>
|
||||
<div class="csm-card-body">
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<span class="info-label">系统版本</span>
|
||||
<span class="info-value">v{{ version }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">数据库</span>
|
||||
<span class="info-value">{{ dbInfo }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">在线终端</span>
|
||||
<span class="info-value">{{ health.connected_clients }} 台</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card shadow="hover" style="margin-top: 20px">
|
||||
<template #header><span class="card-title">修改密码</span></template>
|
||||
<el-form :model="pwdForm" label-width="100px" size="small">
|
||||
<el-form-item label="当前密码">
|
||||
<el-input v-model="pwdForm.oldPassword" type="password" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item label="新密码">
|
||||
<el-input v-model="pwdForm.newPassword" type="password" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item label="确认密码">
|
||||
<el-input v-model="pwdForm.confirmPassword" type="password" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="changePassword">修改密码</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
<div class="csm-card" style="margin-top: 20px">
|
||||
<div class="csm-card-header">修改密码</div>
|
||||
<div class="csm-card-body">
|
||||
<el-form :model="pwdForm" label-width="100px" size="default">
|
||||
<el-form-item label="当前密码">
|
||||
<el-input v-model="pwdForm.oldPassword" type="password" show-password placeholder="输入当前密码" />
|
||||
</el-form-item>
|
||||
<el-form-item label="新密码">
|
||||
<el-input v-model="pwdForm.newPassword" type="password" show-password placeholder="输入新密码" />
|
||||
</el-form-item>
|
||||
<el-form-item label="确认密码">
|
||||
<el-input v-model="pwdForm.confirmPassword" type="password" show-password placeholder="再次输入新密码" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="pwdLoading" @click="changePassword">修改密码</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
|
||||
<el-col :span="12">
|
||||
<el-card shadow="hover">
|
||||
<template #header><span class="card-title">数据维护</span></template>
|
||||
<el-form label-width="100px" size="small">
|
||||
<el-form-item label="历史数据">
|
||||
<el-button @click="showRetentionInfo">查看保留策略</el-button>
|
||||
</el-form-item>
|
||||
<el-form-item label="数据库">
|
||||
<el-button type="warning" @click="manualCleanup">手动清理</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
<div class="csm-card">
|
||||
<div class="csm-card-header">数据维护</div>
|
||||
<div class="csm-card-body">
|
||||
<div class="maintenance-item">
|
||||
<div>
|
||||
<div style="font-weight:500">历史数据保留</div>
|
||||
<div style="font-size:12px;color:var(--csm-text-tertiary);margin-top:4px">配置数据保留策略,自动清理过期数据</div>
|
||||
</div>
|
||||
<el-button @click="showRetentionInfo">查看策略</el-button>
|
||||
</div>
|
||||
<el-divider style="margin:12px 0" />
|
||||
<div class="maintenance-item">
|
||||
<div>
|
||||
<div style="font-weight:500">手动清理</div>
|
||||
<div style="font-size:12px;color:var(--csm-text-tertiary);margin-top:4px">立即清理过期的历史数据</div>
|
||||
</div>
|
||||
<el-button type="warning" plain @click="manualCleanup">执行清理</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card shadow="hover" style="margin-top: 20px">
|
||||
<template #header><span class="card-title">当前用户</span></template>
|
||||
<el-descriptions :column="1" border size="small">
|
||||
<el-descriptions-item label="用户名">{{ user.username }}</el-descriptions-item>
|
||||
<el-descriptions-item label="角色">{{ user.role }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
<div class="csm-card" style="margin-top: 20px">
|
||||
<div class="csm-card-header">当前用户</div>
|
||||
<div class="csm-card-body">
|
||||
<div class="user-card">
|
||||
<div class="user-avatar-large">{{ user.username.charAt(0).toUpperCase() }}</div>
|
||||
<div class="user-detail">
|
||||
<div style="font-weight:600;font-size:16px">{{ user.username }}</div>
|
||||
<el-tag size="small" type="warning" effect="light" style="margin-top:4px">{{ user.role }}</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
@@ -66,9 +93,9 @@ const health = reactive({ connected_clients: 0, db_size_bytes: 0 })
|
||||
const user = reactive({ username: 'admin', role: 'admin' })
|
||||
|
||||
const pwdForm = reactive({ oldPassword: '', newPassword: '', confirmPassword: '' })
|
||||
const pwdLoading = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
// Decode username from JWT token
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
@@ -83,21 +110,39 @@ onMounted(() => {
|
||||
if (data.version) version.value = data.version
|
||||
health.connected_clients = data.connected_clients || 0
|
||||
const bytes = data.db_size_bytes || 0
|
||||
dbInfo.value = `SQLite (WAL mode) - ${(bytes / 1024 / 1024).toFixed(2)} MB`
|
||||
dbInfo.value = `SQLite (WAL) - ${(bytes / 1024 / 1024).toFixed(2)} MB`
|
||||
})
|
||||
.catch(() => { /* ignore */ })
|
||||
})
|
||||
|
||||
function changePassword() {
|
||||
async function changePassword() {
|
||||
if (!pwdForm.oldPassword) {
|
||||
ElMessage.error('请输入当前密码')
|
||||
return
|
||||
}
|
||||
if (pwdForm.newPassword.length < 6) {
|
||||
ElMessage.error('新密码至少6位')
|
||||
return
|
||||
}
|
||||
if (pwdForm.newPassword !== pwdForm.confirmPassword) {
|
||||
ElMessage.error('两次输入的密码不一致')
|
||||
return
|
||||
}
|
||||
if (pwdForm.newPassword.length < 6) {
|
||||
ElMessage.error('密码至少6位')
|
||||
return
|
||||
pwdLoading.value = true
|
||||
try {
|
||||
await api.put('/api/auth/change-password', {
|
||||
old_password: pwdForm.oldPassword,
|
||||
new_password: pwdForm.newPassword,
|
||||
})
|
||||
ElMessage.success('密码修改成功')
|
||||
pwdForm.oldPassword = ''
|
||||
pwdForm.newPassword = ''
|
||||
pwdForm.confirmPassword = ''
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e.message || '密码修改失败')
|
||||
} finally {
|
||||
pwdLoading.value = false
|
||||
}
|
||||
ElMessage.success('密码修改功能待实现')
|
||||
}
|
||||
|
||||
function showRetentionInfo() {
|
||||
@@ -110,6 +155,69 @@ function manualCleanup() {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.settings-page { padding: 20px; }
|
||||
.card-title { font-weight: 600; font-size: 15px; }
|
||||
.csm-card-header {
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
color: var(--csm-text-primary);
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--csm-border-color);
|
||||
}
|
||||
|
||||
.csm-card-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: 13px;
|
||||
color: var(--csm-text-secondary);
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--csm-text-primary);
|
||||
}
|
||||
|
||||
.maintenance-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.user-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.user-avatar-large {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
background: var(--csm-primary);
|
||||
color: #fff;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.user-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,63 +1,91 @@
|
||||
<template>
|
||||
<div class="usb-page">
|
||||
<el-tabs v-model="activeTab">
|
||||
<div class="page-container">
|
||||
<el-tabs v-model="activeTab" class="page-tabs">
|
||||
<el-tab-pane label="策略管理" name="policies">
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="showPolicyDialog()">新建策略</el-button>
|
||||
<div class="page-toolbar">
|
||||
<el-button type="primary" @click="showPolicyDialog()">
|
||||
<el-icon><Plus /></el-icon>新建策略
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="csm-card">
|
||||
<el-table :data="policies" v-loading="loading" style="width:100%">
|
||||
<el-table-column prop="name" label="策略名称" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<span style="font-weight:500">{{ row.name }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="policy_type" label="策略类型" width="130">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="policyTypeTag(row.policy_type)" size="small" effect="light">
|
||||
{{ policyTypeLabel(row.policy_type) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="target_group" label="目标分组" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" effect="plain">{{ row.target_group || '全部' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="enabled" label="启用" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-switch :model-value="row.enabled" :active-value="1" :inactive-value="0" @change="togglePolicy(row)" size="small" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" label="创建时间" width="170">
|
||||
<template #default="{ row }">
|
||||
<span class="secondary-text">{{ row.created_at }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="140" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="showPolicyDialog(row)">编辑</el-button>
|
||||
<el-button link type="danger" size="small" @click="deletePolicy(row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<el-table :data="policies" v-loading="loading" stripe size="small">
|
||||
<el-table-column prop="name" label="策略名称" width="180" />
|
||||
<el-table-column prop="policy_type" label="策略类型" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="policyTypeTag(row.policy_type)" size="small">{{ policyTypeLabel(row.policy_type) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="target_group" label="目标分组" width="120" />
|
||||
<el-table-column prop="enabled" label="启用" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-switch :model-value="row.enabled" @change="togglePolicy(row)" size="small" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" label="创建时间" width="170" />
|
||||
<el-table-column label="操作" width="140" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="showPolicyDialog(row)">编辑</el-button>
|
||||
<el-button link type="danger" size="small" @click="deletePolicy(row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="事件日志" name="events">
|
||||
<div class="toolbar">
|
||||
<div class="page-toolbar">
|
||||
<el-select v-model="eventFilter" placeholder="事件类型" clearable style="width: 150px" @change="fetchEvents">
|
||||
<el-option label="插入" value="Inserted" />
|
||||
<el-option label="拔出" value="Removed" />
|
||||
<el-option label="拦截" value="Blocked" />
|
||||
</el-select>
|
||||
</div>
|
||||
<el-table :data="events" v-loading="evLoading" stripe size="small">
|
||||
<el-table-column prop="device_name" label="USB设备" width="150" />
|
||||
<el-table-column label="事件类型" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.event_type === 'Inserted' ? 'success' : row.event_type === 'Blocked' ? 'danger' : 'info'" size="small">
|
||||
{{ eventTypeLabel(row.event_type) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="vendor_id" label="VID" width="100" />
|
||||
<el-table-column prop="product_id" label="PID" width="100" />
|
||||
<el-table-column prop="serial_number" label="序列号" width="160" />
|
||||
<el-table-column prop="device_uid" label="终端UID" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="event_time" label="时间" width="170" />
|
||||
</el-table>
|
||||
<div class="csm-card">
|
||||
<el-table :data="events" v-loading="evLoading" style="width:100%">
|
||||
<el-table-column prop="device_name" label="USB设备" width="150" />
|
||||
<el-table-column label="事件类型" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.event_type === 'Inserted' ? 'success' : row.event_type === 'Blocked' ? 'danger' : 'info'" size="small" effect="light">
|
||||
{{ eventTypeLabel(row.event_type) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="vendor_id" label="VID" width="100">
|
||||
<template #default="{ row }"><span class="mono-text">{{ row.vendor_id }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="product_id" label="PID" width="100">
|
||||
<template #default="{ row }"><span class="mono-text">{{ row.product_id }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="serial_number" label="序列号" width="160">
|
||||
<template #default="{ row }"><span class="mono-text">{{ row.serial_number || '-' }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="device_uid" label="终端UID" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="event_time" label="时间" width="170">
|
||||
<template #default="{ row }"><span class="secondary-text">{{ row.event_time }}</span></template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<el-dialog v-model="policyDialogVisible" :title="editingPolicy ? '编辑策略' : '新建策略'" width="500px">
|
||||
<el-dialog v-model="policyDialogVisible" :title="editingPolicy ? '编辑策略' : '新建策略'" width="520px" destroy-on-close>
|
||||
<el-form :model="policyForm" label-width="100px">
|
||||
<el-form-item label="策略名称">
|
||||
<el-input v-model="policyForm.name" />
|
||||
<el-input v-model="policyForm.name" placeholder="输入策略名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="策略类型">
|
||||
<el-select v-model="policyForm.policy_type" style="width: 100%">
|
||||
@@ -84,11 +112,11 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
const activeTab = ref('policies')
|
||||
|
||||
// Policies
|
||||
const policies = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
const policyDialogVisible = ref(false)
|
||||
@@ -160,7 +188,6 @@ function policyTypeLabel(type: string) {
|
||||
return map[type] || type
|
||||
}
|
||||
|
||||
// Events
|
||||
const events = ref<any[]>([])
|
||||
const evLoading = ref(false)
|
||||
const eventFilter = ref('')
|
||||
@@ -187,6 +214,21 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.usb-page { padding: 20px; }
|
||||
.toolbar { display: flex; gap: 12px; margin-bottom: 16px; }
|
||||
.mono-text {
|
||||
font-family: var(--csm-font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--csm-text-secondary);
|
||||
}
|
||||
|
||||
.secondary-text {
|
||||
font-size: 12px;
|
||||
color: var(--csm-text-tertiary);
|
||||
}
|
||||
|
||||
.page-tabs :deep(.el-tabs__header) {
|
||||
margin: 0 0 20px 0;
|
||||
padding: 0 24px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid var(--csm-border-color);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,73 +1,126 @@
|
||||
<template>
|
||||
<div class="plugin-page">
|
||||
<el-card shadow="hover">
|
||||
<template #header>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<span class="card-title">水印配置</span>
|
||||
<el-button type="primary" size="small" @click="showDialog()">新建配置</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="configs" v-loading="loading" stripe size="small">
|
||||
<el-table-column prop="target_type" label="应用范围" width="100" />
|
||||
<el-table-column prop="target_id" label="目标" width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="content" label="水印内容" min-width="250" show-overflow-tooltip />
|
||||
<el-table-column prop="font_size" label="字号" width="70" />
|
||||
<el-table-column label="透明度" width="100">
|
||||
<template #default="{ row }">{{ (row.opacity * 100).toFixed(0) }}%</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="color" label="颜色" width="80" />
|
||||
<el-table-column prop="angle" label="角度" width="70" />
|
||||
<el-table-column prop="enabled" label="启用" width="70">
|
||||
<template #default="{ row }"><el-tag :type="row.enabled?'success':'info'" size="small">{{ row.enabled?'是':'否' }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="showDialog(row)">编辑</el-button>
|
||||
<el-button link type="danger" size="small" @click="remove(row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
<div class="page-container">
|
||||
<div class="csm-card">
|
||||
<div class="csm-card-header">
|
||||
<span>水印配置</span>
|
||||
<el-button type="primary" size="small" @click="showDialog()">
|
||||
<el-icon><Plus /></el-icon>新建配置
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="csm-card-body">
|
||||
<el-table :data="configs" v-loading="loading" style="width:100%">
|
||||
<el-table-column prop="target_type" label="应用范围" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" effect="plain">{{ { global: '全局', group: '分组', device: '设备' }[row.target_type as string] || row.target_type }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="target_id" label="目标" width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="content" label="水印内容" min-width="250" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span style="font-weight:500">{{ row.content }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="font_size" label="字号" width="70">
|
||||
<template #default="{ row }"><span class="mono-text">{{ row.font_size }}px</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="透明度" width="80">
|
||||
<template #default="{ row }"><span class="mono-text">{{ (row.opacity * 100).toFixed(0) }}%</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="color" label="颜色" width="80">
|
||||
<template #default="{ row }">
|
||||
<div style="display:flex;align-items:center;gap:6px">
|
||||
<span class="color-swatch" :style="{ background: row.color }"></span>
|
||||
<span class="mono-text">{{ row.color }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="angle" label="角度" width="60">
|
||||
<template #default="{ row }"><span class="mono-text">{{ row.angle }}°</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="enabled" label="启用" width="70">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.enabled ? 'success' : 'info'" size="small" effect="light">{{ row.enabled ? '启用' : '停用' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="showDialog(row)">编辑</el-button>
|
||||
<el-button link type="danger" size="small" @click="remove(row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card shadow="hover" style="margin-top:20px">
|
||||
<template #header><span class="card-title">水印预览</span></template>
|
||||
<div class="preview-area">
|
||||
<div class="watermark-overlay" :style="watermarkStyle">
|
||||
<span v-for="i in 12" :key="i" class="wm-text">{{ previewContent }}</span>
|
||||
</div>
|
||||
<div class="preview-content">
|
||||
<p style="color:#606266;font-size:14px">此区域模拟用户桌面效果</p>
|
||||
<p style="color:#909399;font-size:12px">水印内容会以设定角度和透明度覆盖整个屏幕</p>
|
||||
<!-- Preview card -->
|
||||
<div class="csm-card" style="margin-top:20px">
|
||||
<div class="csm-card-header">水印预览</div>
|
||||
<div class="csm-card-body">
|
||||
<div class="preview-area">
|
||||
<div class="watermark-overlay" :style="watermarkStyle">
|
||||
<span v-for="i in 12" :key="i" class="wm-text">{{ previewContent }}</span>
|
||||
</div>
|
||||
<div class="preview-content">
|
||||
<p class="preview-hint">此区域模拟用户桌面效果</p>
|
||||
<p class="preview-sub">水印内容会以设定角度和透明度覆盖整个屏幕</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="visible" :title="editing?'编辑配置':'新建配置'" width="520px">
|
||||
<el-dialog v-model="visible" :title="editing ? '编辑配置' : '新建配置'" width="520px" destroy-on-close>
|
||||
<el-form :model="form" label-width="80px">
|
||||
<el-form-item label="应用范围">
|
||||
<el-select v-model="form.target_type"><el-option label="全局" value="global" /><el-option label="分组" value="group" /><el-option label="指定设备" value="device" /></el-select>
|
||||
<el-select v-model="form.target_type" style="width:100%">
|
||||
<el-option label="全局" value="global" />
|
||||
<el-option label="分组" value="group" />
|
||||
<el-option label="指定设备" value="device" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="目标ID" v-if="form.target_type !== 'global'">
|
||||
<el-input v-model="form.target_id" placeholder="输入分组名或设备UID" />
|
||||
</el-form-item>
|
||||
<el-form-item label="水印内容">
|
||||
<el-input v-model="form.content" type="textarea" :rows="2" placeholder="支持变量: {company} {username} {hostname} {date} {time}" />
|
||||
</el-form-item>
|
||||
<el-form-item label="字号">
|
||||
<el-input-number v-model="form.font_size" :min="8" :max="48" />
|
||||
</el-form-item>
|
||||
<el-form-item label="透明度">
|
||||
<el-slider v-model="form.opacity" :min="5" :max="50" :step="1" :format-tooltip="(v: number) => `${v}%`" />
|
||||
</el-form-item>
|
||||
<el-form-item label="颜色">
|
||||
<el-color-picker v-model="form.color" />
|
||||
</el-form-item>
|
||||
<el-form-item label="角度">
|
||||
<el-input-number v-model="form.angle" :min="-90" :max="90" />
|
||||
</el-form-item>
|
||||
<el-form-item label="启用">
|
||||
<el-switch v-model="form.enabled" />
|
||||
</el-form-item>
|
||||
<el-form-item label="目标ID" v-if="form.target_type!=='global'"><el-input v-model="form.target_id" /></el-form-item>
|
||||
<el-form-item label="水印内容"><el-input v-model="form.content" type="textarea" :rows="2" placeholder="支持变量: {company} {username} {hostname} {date} {time}" /></el-form-item>
|
||||
<el-form-item label="字号"><el-input-number v-model="form.font_size" :min="8" :max="48" /></el-form-item>
|
||||
<el-form-item label="透明度"><el-slider v-model="form.opacity" :min="5" :max="50" :step="1" :format-tooltip="(v:number)=>`${v}%`" /></el-form-item>
|
||||
<el-form-item label="颜色"><el-color-picker v-model="form.color" /></el-form-item>
|
||||
<el-form-item label="角度"><el-input-number v-model="form.angle" :min="-90" :max="90" /></el-form-item>
|
||||
<el-form-item label="启用"><el-switch v-model="form.enabled" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer><el-button @click="visible=false">取消</el-button><el-button type="primary" @click="save">保存</el-button></template>
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
<el-button type="primary" @click="save">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
const auth = () => ({ headers: { Authorization: `Bearer ${localStorage.getItem('token')}`, 'Content-Type': 'application/json' } })
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
const configs = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
const visible = ref(false)
|
||||
const editing = ref<any>(null)
|
||||
const form = reactive({ target_type: 'global', target_id: '', content: '{company} | {username} | {date}', font_size: 14, opacity: 15, color: '#808080', angle: -30, enabled: true })
|
||||
const form = reactive({
|
||||
target_type: 'global', target_id: '', content: '{company} | {username} | {date}',
|
||||
font_size: 14, opacity: 15, color: '#808080', angle: -30, enabled: true,
|
||||
})
|
||||
|
||||
const previewContent = computed(() => form.content
|
||||
.replace('{company}', 'CSM Corp')
|
||||
@@ -85,28 +138,132 @@ const watermarkStyle = computed(() => ({
|
||||
|
||||
async function fetchConfigs() {
|
||||
loading.value = true
|
||||
try { const r = await fetch('/api/plugins/watermark/config', auth()).then(r => r.json()); if (r.success) configs.value = r.data.configs || [] } finally { loading.value = false }
|
||||
try {
|
||||
const data = await api.get<any>('/api/plugins/watermark/config')
|
||||
configs.value = data.configs || []
|
||||
} catch { /* api.ts handles 401 */ } finally { loading.value = false }
|
||||
}
|
||||
|
||||
function showDialog(row?: any) {
|
||||
if (row) { editing.value = row; Object.assign(form, { target_type: row.target_type, target_id: row.target_id || '', content: row.content, font_size: row.font_size, opacity: Math.round(row.opacity * 100), color: row.color, angle: row.angle, enabled: row.enabled }) }
|
||||
else { editing.value = null; Object.assign(form, { target_type: 'global', target_id: '', content: '{company} | {username} | {date}', font_size: 14, opacity: 15, color: '#808080', angle: -30, enabled: true }) }
|
||||
if (row) {
|
||||
editing.value = row
|
||||
Object.assign(form, {
|
||||
target_type: row.target_type, target_id: row.target_id || '',
|
||||
content: row.content, font_size: row.font_size,
|
||||
opacity: Math.round(row.opacity * 100), color: row.color,
|
||||
angle: row.angle, enabled: row.enabled,
|
||||
})
|
||||
} else {
|
||||
editing.value = null
|
||||
Object.assign(form, {
|
||||
target_type: 'global', target_id: '', content: '{company} | {username} | {date}',
|
||||
font_size: 14, opacity: 15, color: '#808080', angle: -30, enabled: true,
|
||||
})
|
||||
}
|
||||
visible.value = true
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const body = { ...form, opacity: form.opacity / 100 }
|
||||
const url = editing.value ? `/api/plugins/watermark/config/${editing.value.id}` : '/api/plugins/watermark/config'
|
||||
const method = editing.value ? 'PUT' : 'POST'
|
||||
const r = await fetch(url, { method, ...auth(), body: JSON.stringify(body) }).then(r => r.json())
|
||||
if (r.success) { ElMessage.success('已保存'); visible.value = false; fetchConfigs() } else { ElMessage.error(r.error) }
|
||||
try {
|
||||
const body = { ...form, opacity: form.opacity / 100 }
|
||||
if (editing.value) {
|
||||
await api.put(`/api/plugins/watermark/config/${editing.value.id}`, body)
|
||||
ElMessage.success('配置已更新')
|
||||
} else {
|
||||
await api.post('/api/plugins/watermark/config', body)
|
||||
ElMessage.success('配置已创建')
|
||||
}
|
||||
visible.value = false
|
||||
fetchConfigs()
|
||||
} catch (e: any) { ElMessage.error(e.message || '保存失败') }
|
||||
}
|
||||
async function remove(id: number) { await ElMessageBox.confirm('确定删除?', '确认', { type: 'warning' }); await fetch(`/api/plugins/watermark/config/${id}`, { method: 'DELETE', ...auth() }); ElMessage.success('已删除'); fetchConfigs() }
|
||||
|
||||
async function remove(id: number) {
|
||||
await ElMessageBox.confirm('确定删除该配置?', '确认', { type: 'warning' })
|
||||
try {
|
||||
await api.delete(`/api/plugins/watermark/config/${id}`)
|
||||
ElMessage.success('配置已删除')
|
||||
fetchConfigs()
|
||||
} catch (e: any) { ElMessage.error(e.message || '删除失败') }
|
||||
}
|
||||
|
||||
onMounted(() => fetchConfigs())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.plugin-page{padding:20px}
|
||||
.card-title{font-weight:600;font-size:15px}
|
||||
.preview-area{position:relative;height:200px;border:1px solid #e4e7ed;border-radius:8px;overflow:hidden;background:#f5f7fa}
|
||||
.watermark-overlay{position:absolute;inset:0;display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:80px;pointer-events:none}
|
||||
.wm-text{white-space:nowrap;user-select:none}
|
||||
.preview-content{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center}
|
||||
.csm-card-header {
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
color: var(--csm-text-primary);
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--csm-border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.csm-card-body {
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.color-swatch {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
display: inline-block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mono-text {
|
||||
font-family: var(--csm-font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--csm-text-secondary);
|
||||
}
|
||||
|
||||
.preview-area {
|
||||
position: relative;
|
||||
height: 220px;
|
||||
border: 1px dashed var(--csm-border-color);
|
||||
border-radius: var(--csm-border-radius);
|
||||
overflow: hidden;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.watermark-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 80px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.wm-text {
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.preview-content {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.preview-hint {
|
||||
color: var(--csm-text-secondary);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.preview-sub {
|
||||
color: var(--csm-text-tertiary);
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,61 +1,98 @@
|
||||
<template>
|
||||
<div class="plugin-page">
|
||||
<el-tabs v-model="activeTab">
|
||||
<div class="page-container">
|
||||
<el-tabs v-model="activeTab" class="page-tabs">
|
||||
<el-tab-pane label="过滤规则" name="rules">
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="showRuleDialog()">新建规则</el-button>
|
||||
<div class="page-toolbar">
|
||||
<el-button type="primary" @click="showRuleDialog()">
|
||||
<el-icon><Plus /></el-icon>新建规则
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="csm-card">
|
||||
<el-table :data="rules" v-loading="loading" style="width:100%">
|
||||
<el-table-column prop="rule_type" label="类型" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.rule_type === 'blacklist' ? 'danger' : row.rule_type === 'whitelist' ? 'success' : 'info'" size="small" effect="light">
|
||||
{{ ruleTypeLabel(row.rule_type) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="pattern" label="匹配模式" min-width="200">
|
||||
<template #default="{ row }"><span class="mono-text">{{ row.pattern }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="target_type" label="应用范围" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" effect="plain">{{ { global: '全局', group: '分组', device: '设备' }[row.target_type as string] || row.target_type }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="enabled" label="启用" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.enabled ? 'success' : 'info'" size="small" effect="light">{{ row.enabled ? '启用' : '停用' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" label="创建时间" width="170">
|
||||
<template #default="{ row }"><span class="secondary-text">{{ row.created_at }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="showRuleDialog(row)">编辑</el-button>
|
||||
<el-button link type="danger" size="small" @click="deleteRule(row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<el-table :data="rules" v-loading="loading" stripe size="small">
|
||||
<el-table-column prop="rule_type" label="类型" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.rule_type==='blacklist'?'danger':row.rule_type==='whitelist'?'success':'info'" size="small">
|
||||
{{ ruleTypeLabel(row.rule_type) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="pattern" label="匹配模式" min-width="200" />
|
||||
<el-table-column prop="target_type" label="应用范围" width="100" />
|
||||
<el-table-column prop="enabled" label="启用" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.enabled?'success':'info'" size="small">{{ row.enabled?'是':'否' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" label="创建时间" width="170" />
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="showRuleDialog(row)">编辑</el-button>
|
||||
<el-button link type="danger" size="small" @click="deleteRule(row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="访问日志" name="log">
|
||||
<el-table :data="accessLog" v-loading="logLoading" stripe size="small">
|
||||
<el-table-column prop="device_uid" label="终端" width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="url" label="URL" min-width="300" show-overflow-tooltip />
|
||||
<el-table-column label="动作" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.action==='blocked'?'danger':'success'" size="small">{{ row.action==='blocked'?'拦截':'放行' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="timestamp" label="时间" width="170" />
|
||||
</el-table>
|
||||
<div class="csm-card">
|
||||
<el-table :data="accessLog" v-loading="logLoading" style="width:100%">
|
||||
<el-table-column prop="device_uid" label="终端" width="160" show-overflow-tooltip>
|
||||
<template #default="{ row }"><span class="mono-text">{{ row.device_uid }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="url" label="URL" min-width="300" show-overflow-tooltip>
|
||||
<template #default="{ row }"><span class="mono-text">{{ row.url }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="动作" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.action === 'blocked' ? 'danger' : 'success'" size="small" effect="light">
|
||||
{{ row.action === 'blocked' ? '拦截' : '放行' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="timestamp" label="时间" width="170">
|
||||
<template #default="{ row }"><span class="secondary-text">{{ row.timestamp }}</span></template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="editing?'编辑规则':'新建规则'" width="480px">
|
||||
<el-dialog v-model="dialogVisible" :title="editing ? '编辑规则' : '新建规则'" width="480px" destroy-on-close>
|
||||
<el-form :model="form" label-width="80px">
|
||||
<el-form-item label="规则类型">
|
||||
<el-select v-model="form.rule_type"><el-option label="黑名单" value="blacklist" /><el-option label="白名单" value="whitelist" /><el-option label="分类" value="category" /></el-select>
|
||||
<el-select v-model="form.rule_type" style="width:100%">
|
||||
<el-option label="黑名单" value="blacklist" />
|
||||
<el-option label="白名单" value="whitelist" />
|
||||
<el-option label="分类" value="category" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="匹配模式">
|
||||
<el-input v-model="form.pattern" placeholder="*.example.com" />
|
||||
</el-form-item>
|
||||
<el-form-item label="匹配模式"><el-input v-model="form.pattern" placeholder="*.example.com" /></el-form-item>
|
||||
<el-form-item label="应用范围">
|
||||
<el-select v-model="form.target_type"><el-option label="全局" value="global" /><el-option label="分组" value="group" /><el-option label="指定设备" value="device" /></el-select>
|
||||
<el-select v-model="form.target_type" style="width:100%">
|
||||
<el-option label="全局" value="global" />
|
||||
<el-option label="分组" value="group" />
|
||||
<el-option label="指定设备" value="device" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="启用">
|
||||
<el-switch v-model="form.enabled" />
|
||||
</el-form-item>
|
||||
<el-form-item label="启用"><el-switch v-model="form.enabled" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer><el-button @click="dialogVisible=false">取消</el-button><el-button type="primary" @click="saveRule">保存</el-button></template>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveRule">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
@@ -63,8 +100,10 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
const activeTab = ref('rules')
|
||||
const auth = () => ({ headers: { Authorization: `Bearer ${localStorage.getItem('token')}` } })
|
||||
const rules = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
const accessLog = ref<any[]>([])
|
||||
@@ -73,33 +112,79 @@ const dialogVisible = ref(false)
|
||||
const editing = ref<any>(null)
|
||||
const form = reactive({ rule_type: 'blacklist', pattern: '', target_type: 'global', target_id: '', enabled: true })
|
||||
|
||||
function ruleTypeLabel(t: string) { return { blacklist: '黑名单', whitelist: '白名单', category: '分类' }[t] || t }
|
||||
function ruleTypeLabel(t: string) {
|
||||
return { blacklist: '黑名单', whitelist: '白名单', category: '分类' }[t] || t
|
||||
}
|
||||
|
||||
async function fetchRules() {
|
||||
loading.value = true
|
||||
try { const r = await fetch('/api/plugins/web-filter/rules', auth()).then(r=>r.json()); if(r.success) rules.value = r.data.rules||[] } finally { loading.value = false }
|
||||
try {
|
||||
const data = await api.get<any>('/api/plugins/web-filter/rules')
|
||||
rules.value = data.rules || []
|
||||
} catch { /* api.ts handles 401 */ } finally { loading.value = false }
|
||||
}
|
||||
|
||||
async function fetchLog() {
|
||||
logLoading.value = true
|
||||
try { const r = await fetch('/api/plugins/web-filter/log', auth()).then(r=>r.json()); if(r.success) accessLog.value = r.data.log||[] } finally { logLoading.value = false }
|
||||
try {
|
||||
const data = await api.get<any>('/api/plugins/web-filter/log')
|
||||
accessLog.value = data.log || []
|
||||
} catch { /* api.ts handles 401 */ } finally { logLoading.value = false }
|
||||
}
|
||||
|
||||
function showRuleDialog(row?: any) {
|
||||
if(row){ editing.value=row; Object.assign(form,{rule_type:row.rule_type,pattern:row.pattern,target_type:row.target_type,target_id:row.target_id||'',enabled:row.enabled}) }
|
||||
else{ editing.value=null; Object.assign(form,{rule_type:'blacklist',pattern:'',target_type:'global',target_id:'',enabled:true}) }
|
||||
dialogVisible.value=true
|
||||
if (row) {
|
||||
editing.value = row
|
||||
Object.assign(form, { rule_type: row.rule_type, pattern: row.pattern, target_type: row.target_type, target_id: row.target_id || '', enabled: row.enabled })
|
||||
} else {
|
||||
editing.value = null
|
||||
Object.assign(form, { rule_type: 'blacklist', pattern: '', target_type: 'global', target_id: '', enabled: true })
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function saveRule() {
|
||||
const url = editing.value ? `/api/plugins/web-filter/rules/${editing.value.id}` : '/api/plugins/web-filter/rules'
|
||||
const method = editing.value ? 'PUT' : 'POST'
|
||||
const res = await fetch(url,{method,...auth(),headers:{...auth().headers,'Content-Type':'application/json'},body:JSON.stringify(form)}).then(r=>r.json())
|
||||
if(res.success){ElMessage.success('已保存');dialogVisible.value=false;fetchRules()}else{ElMessage.error(res.error)}
|
||||
try {
|
||||
if (editing.value) {
|
||||
await api.put(`/api/plugins/web-filter/rules/${editing.value.id}`, form)
|
||||
ElMessage.success('规则已更新')
|
||||
} else {
|
||||
await api.post('/api/plugins/web-filter/rules', form)
|
||||
ElMessage.success('规则已创建')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
fetchRules()
|
||||
} catch (e: any) { ElMessage.error(e.message || '保存失败') }
|
||||
}
|
||||
|
||||
async function deleteRule(id: number) {
|
||||
await ElMessageBox.confirm('确定删除?','确认',{type:'warning'})
|
||||
await fetch(`/api/plugins/web-filter/rules/${id}`,{method:'DELETE',...auth()})
|
||||
ElMessage.success('已删除'); fetchRules()
|
||||
await ElMessageBox.confirm('确定删除该规则?', '确认', { type: 'warning' })
|
||||
try {
|
||||
await api.delete(`/api/plugins/web-filter/rules/${id}`)
|
||||
ElMessage.success('规则已删除')
|
||||
fetchRules()
|
||||
} catch (e: any) { ElMessage.error(e.message || '删除失败') }
|
||||
}
|
||||
onMounted(()=>{fetchRules();fetchLog()})
|
||||
|
||||
onMounted(() => { fetchRules(); fetchLog() })
|
||||
</script>
|
||||
|
||||
<style scoped>.plugin-page{padding:20px}.toolbar{display:flex;gap:12px;margin-bottom:16px}</style>
|
||||
<style scoped>
|
||||
.mono-text {
|
||||
font-family: var(--csm-font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--csm-text-secondary);
|
||||
}
|
||||
|
||||
.secondary-text {
|
||||
font-size: 12px;
|
||||
color: var(--csm-text-tertiary);
|
||||
}
|
||||
|
||||
.page-tabs :deep(.el-tabs__header) {
|
||||
margin: 0 0 20px 0;
|
||||
padding: 0 24px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid var(--csm-border-color);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -28,18 +28,18 @@ export default defineConfig({
|
||||
],
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
port: 9997,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
target: 'http://localhost:9998',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/ws': {
|
||||
target: 'ws://localhost:8080',
|
||||
target: 'ws://localhost:9998',
|
||||
ws: true,
|
||||
},
|
||||
'/health': {
|
||||
target: 'http://localhost:8080',
|
||||
target: 'http://localhost:9998',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user