diff --git a/src/config/store.rs b/src/config/store.rs
index ce7a1b5b..2a69ade0 100644
--- a/src/config/store.rs
+++ b/src/config/store.rs
@@ -120,6 +120,26 @@ impl ConfigStore {
.execute(pool)
.await?;
+ sqlx::query(
+ r#"
+ CREATE TABLE IF NOT EXISTS wol_history (
+ mac_address TEXT PRIMARY KEY,
+ updated_at INTEGER NOT NULL
+ )
+ "#,
+ )
+ .execute(pool)
+ .await?;
+
+ sqlx::query(
+ r#"
+ CREATE INDEX IF NOT EXISTS idx_wol_history_updated_at
+ ON wol_history(updated_at DESC)
+ "#,
+ )
+ .execute(pool)
+ .await?;
+
Ok(())
}
diff --git a/src/web/handlers/mod.rs b/src/web/handlers/mod.rs
index 47e19fce..10ed79bc 100644
--- a/src/web/handlers/mod.rs
+++ b/src/web/handlers/mod.rs
@@ -2660,6 +2660,10 @@ pub async fn msd_drive_mkdir(
use crate::atx::{AtxState, PowerStatus};
+const WOL_HISTORY_MAX_ENTRIES: i64 = 50;
+const WOL_HISTORY_DEFAULT_LIMIT: usize = 5;
+const WOL_HISTORY_MAX_LIMIT: usize = 50;
+
/// ATX state response
#[derive(Serialize)]
pub struct AtxStateResponse {
@@ -2765,11 +2769,78 @@ pub struct WolRequest {
pub mac_address: String,
}
+#[derive(Debug, Deserialize, Default)]
+pub struct WolHistoryQuery {
+ /// Maximum history entries to return
+ pub limit: Option,
+}
+
+#[derive(Debug, Serialize)]
+pub struct WolHistoryEntry {
+ pub mac_address: String,
+ pub updated_at: i64,
+}
+
+#[derive(Debug, Serialize)]
+pub struct WolHistoryResponse {
+ pub history: Vec,
+}
+
+fn normalize_wol_mac_address(mac_address: &str) -> String {
+ let normalized = mac_address.trim().to_uppercase().replace('-', ":");
+
+ if normalized.len() == 12 && normalized.chars().all(|c| c.is_ascii_hexdigit()) {
+ let mut mac_with_separator = String::with_capacity(17);
+ for (index, chunk) in normalized.as_bytes().chunks(2).enumerate() {
+ if index > 0 {
+ mac_with_separator.push(':');
+ }
+ mac_with_separator.push(chunk[0] as char);
+ mac_with_separator.push(chunk[1] as char);
+ }
+ mac_with_separator
+ } else {
+ normalized
+ }
+}
+
+async fn record_wol_history(state: &Arc, mac_address: &str) -> Result<()> {
+ sqlx::query(
+ r#"
+ INSERT INTO wol_history (mac_address, updated_at)
+ VALUES (?1, CAST(strftime('%s', 'now') AS INTEGER))
+ ON CONFLICT(mac_address) DO UPDATE SET
+ updated_at = excluded.updated_at
+ "#,
+ )
+ .bind(mac_address)
+ .execute(state.config.pool())
+ .await?;
+
+ sqlx::query(
+ r#"
+ DELETE FROM wol_history
+ WHERE mac_address NOT IN (
+ SELECT mac_address FROM wol_history
+ ORDER BY updated_at DESC
+ LIMIT ?1
+ )
+ "#,
+ )
+ .bind(WOL_HISTORY_MAX_ENTRIES)
+ .execute(state.config.pool())
+ .await?;
+
+ Ok(())
+}
+
/// Send Wake-on-LAN magic packet
pub async fn atx_wol(
State(state): State>,
Json(req): Json,
) -> Result> {
+ let mac_address = normalize_wol_mac_address(&req.mac_address);
+
// Get WOL interface from config
let config = state.config.get();
let interface = if config.atx.wol_interface.is_empty() {
@@ -2779,14 +2850,51 @@ pub async fn atx_wol(
};
// Send WOL packet
- crate::atx::send_wol(&req.mac_address, interface)?;
+ crate::atx::send_wol(&mac_address, interface)?;
+
+ if let Err(error) = record_wol_history(&state, &mac_address).await {
+ warn!("Failed to persist WOL history: {}", error);
+ }
Ok(Json(LoginResponse {
success: true,
- message: Some(format!("WOL packet sent to {}", req.mac_address)),
+ message: Some(format!("WOL packet sent to {}", mac_address)),
}))
}
+/// Get WOL history
+pub async fn atx_wol_history(
+ State(state): State>,
+ Query(query): Query,
+) -> Result> {
+ let limit = query
+ .limit
+ .unwrap_or(WOL_HISTORY_DEFAULT_LIMIT)
+ .clamp(1, WOL_HISTORY_MAX_LIMIT);
+
+ let rows: Vec<(String, i64)> = sqlx::query_as(
+ r#"
+ SELECT mac_address, updated_at
+ FROM wol_history
+ ORDER BY updated_at DESC
+ LIMIT ?1
+ "#,
+ )
+ .bind(limit as i64)
+ .fetch_all(state.config.pool())
+ .await?;
+
+ let history = rows
+ .into_iter()
+ .map(|(mac_address, updated_at)| WolHistoryEntry {
+ mac_address,
+ updated_at,
+ })
+ .collect();
+
+ Ok(Json(WolHistoryResponse { history }))
+}
+
// ============================================================================
// Audio Control
// ============================================================================
diff --git a/src/web/routes.rs b/src/web/routes.rs
index 02e7bfa9..b67c93d3 100644
--- a/src/web/routes.rs
+++ b/src/web/routes.rs
@@ -166,6 +166,7 @@ pub fn create_router(state: Arc) -> Router {
.route("/atx/status", get(handlers::atx_status))
.route("/atx/power", post(handlers::atx_power))
.route("/atx/wol", post(handlers::atx_wol))
+ .route("/atx/wol/history", get(handlers::atx_wol_history))
// Device discovery endpoints
.route("/devices/atx", get(handlers::devices::list_atx_devices))
// Extension management endpoints
diff --git a/web/src/api/config.ts b/web/src/api/config.ts
index d48a5e8c..6198fb08 100644
--- a/web/src/api/config.ts
+++ b/web/src/api/config.ts
@@ -136,6 +136,15 @@ export const msdConfigApi = {
// ===== ATX 配置 API =====
import type { AtxDevices } from '@/types/generated'
+export interface WolHistoryEntry {
+ mac_address: string
+ updated_at: number
+}
+
+export interface WolHistoryResponse {
+ history: WolHistoryEntry[]
+}
+
export const atxConfigApi = {
/**
* 获取 ATX 配置
@@ -166,6 +175,13 @@ export const atxConfigApi = {
method: 'POST',
body: JSON.stringify({ mac_address: macAddress }),
}),
+
+ /**
+ * 获取 WOL 历史记录(服务端持久化)
+ * @param limit 返回条数(1-50)
+ */
+ getWolHistory: (limit = 5) =>
+ request(`/atx/wol/history?limit=${Math.max(1, Math.min(50, limit))}`),
}
// ===== Audio 配置 API =====
diff --git a/web/src/components/AtxPopover.vue b/web/src/components/AtxPopover.vue
index 3d70fe78..2583cbc6 100644
--- a/web/src/components/AtxPopover.vue
+++ b/web/src/components/AtxPopover.vue
@@ -1,5 +1,5 @@
@@ -227,6 +236,10 @@ if (savedHistory) {
+
+ {{ t('common.loading') }}
+
+