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:
iven
2026-04-06 13:09:43 +08:00
parent fd6fb5cca0
commit e99ea53eba
30 changed files with 3493 additions and 856 deletions

View File

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

View File

@@ -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(()))))
}

View 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(()))))
}

View File

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

View File

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

View File

@@ -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(())
}
}

View File

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