From d6a79316a60da0340abc3be95e3145d15bc15e09 Mon Sep 17 00:00:00 2001 From: wonfen Date: Fri, 25 Apr 2025 17:17:34 +0800 Subject: [PATCH] feat: toggle next auto-update time on subscription card click and show update result feedback --- UPDATELOG.md | 1 + src-tauri/src/cmd/profile.rs | 43 ++++- src-tauri/src/core/mod.rs | 1 + src-tauri/src/core/timer.rs | 199 ++++++++++++++++++++---- src-tauri/src/feat/profile.rs | 14 +- src-tauri/src/lib.rs | 1 + src/components/profile/profile-item.tsx | 161 +++++++++++++++++-- src/locales/en.json | 5 + src/locales/zh.json | 5 + src/services/cmds.ts | 4 + 10 files changed, 391 insertions(+), 43 deletions(-) diff --git a/UPDATELOG.md b/UPDATELOG.md index 0faa9b6a..f6263a23 100644 --- a/UPDATELOG.md +++ b/UPDATELOG.md @@ -28,6 +28,7 @@ - 系统代理守卫可检查意外设置变更并恢复 - 定时自动订阅更新也能自动回退使用代理 - 订阅请求超时机制,防止订阅更新的时候卡死 + - 订阅卡片点击时间可切换下次自动更新时间,自动更新触发后页面有明确的成功与否提示 #### 优化了: - 系统代理 Bypass 设置 diff --git a/src-tauri/src/cmd/profile.rs b/src-tauri/src/cmd/profile.rs index 2fb1f860..e6f25910 100644 --- a/src-tauri/src/cmd/profile.rs +++ b/src-tauri/src/cmd/profile.rs @@ -1,11 +1,12 @@ use super::CmdResult; use crate::{ config::{Config, IProfiles, PrfItem, PrfOption}, - core::{handle, tray::Tray, CoreManager}, + core::{handle, timer::Timer, tray::Tray, CoreManager}, feat, logging, ret_err, utils::{dirs, help, logging::Type}, wrap_err, }; +use tauri::Emitter; /// 获取配置文件列表 #[tauri::command] @@ -45,7 +46,7 @@ pub async fn create_profile(item: PrfItem, file_data: Option) -> CmdResu /// 更新配置文件 #[tauri::command] pub async fn update_profile(index: String, option: Option) -> CmdResult { - wrap_err!(feat::update_profile(index, option).await) + wrap_err!(feat::update_profile(index, option, Some(true)).await) } /// 删除配置文件 @@ -211,7 +212,35 @@ pub async fn patch_profiles_config_by_profile_index( /// 修改某个profile item的 #[tauri::command] pub fn patch_profile(index: String, profile: PrfItem) -> CmdResult { - wrap_err!(Config::profiles().data().patch_item(index, profile))?; + // 保存修改前检查是否有更新 update_interval + let update_interval_changed = + if let Ok(old_profile) = Config::profiles().latest().get_item(&index) { + let old_interval = old_profile.option.as_ref().and_then(|o| o.update_interval); + let new_interval = profile.option.as_ref().and_then(|o| o.update_interval); + old_interval != new_interval + } else { + false + }; + + // 保存修改 + wrap_err!(Config::profiles().data().patch_item(index.clone(), profile))?; + + // 如果更新间隔变更,异步刷新定时器 + if update_interval_changed { + let index_clone = index.clone(); + crate::process::AsyncHandler::spawn(move || async move { + logging!(info, Type::Timer, "定时器更新间隔已变更,正在刷新定时器..."); + if let Err(e) = crate::core::Timer::global().refresh() { + logging!(error, Type::Timer, "刷新定时器失败: {}", e); + } else { + // 刷新成功后发送自定义事件,不触发配置重载 + if let Some(window) = crate::core::handle::Handle::global().get_window() { + let _ = window.emit("verge://timer-updated", index_clone); + } + } + }); + } + Ok(()) } @@ -242,3 +271,11 @@ pub fn read_profile_file(index: String) -> CmdResult { let data = wrap_err!(item.read_file())?; Ok(data) } + +/// 获取下一次更新时间 +#[tauri::command] +pub fn get_next_update_time(uid: String) -> CmdResult> { + let timer = Timer::global(); + let next_time = timer.get_next_update_time(&uid); + Ok(next_time) +} diff --git a/src-tauri/src/core/mod.rs b/src-tauri/src/core/mod.rs index 7fade5ce..793472a3 100644 --- a/src-tauri/src/core/mod.rs +++ b/src-tauri/src/core/mod.rs @@ -10,3 +10,4 @@ pub mod tray; pub mod win_uwp; pub use self::core::*; +pub use self::timer::Timer; diff --git a/src-tauri/src/core/timer.rs b/src-tauri/src/core/timer.rs index 0f0df384..092a1fd4 100644 --- a/src-tauri/src/core/timer.rs +++ b/src-tauri/src/core/timer.rs @@ -1,6 +1,4 @@ -use crate::{ - config::Config, core::CoreManager, feat, logging, logging_error, utils::logging::Type, -}; +use crate::{config::Config, feat, logging, logging_error, utils::logging::Type}; use anyhow::{Context, Result}; use delay_timer::prelude::{DelayTimer, DelayTimerBuilder, TaskBuilder}; use once_cell::sync::OnceCell; @@ -71,6 +69,25 @@ impl Timer { return Err(e); } + let timer_map = self.timer_map.read(); + logging!( + info, + Type::Timer, + "已注册的定时任务数量: {}", + timer_map.len() + ); + + for (uid, task) in timer_map.iter() { + logging!( + info, + Type::Timer, + "注册了定时任务 - uid={}, interval={}min, task_id={}", + uid, + task.interval_minutes, + task.task_id + ); + } + let cur_timestamp = chrono::Local::now().timestamp(); // Collect profiles that need immediate update @@ -83,6 +100,7 @@ impl Timer { let uid = item.uid.as_ref()?; if interval > 0 && cur_timestamp - updated >= interval * 60 { + logging!(info, Type::Timer, "需要立即更新的配置: uid={}", uid); Some(uid.clone()) } else { None @@ -95,12 +113,18 @@ impl Timer { // Advance tasks outside of locks to minimize lock contention if !profiles_to_update.is_empty() { + logging!( + info, + Type::Timer, + "需要立即更新的配置数量: {}", + profiles_to_update.len() + ); let timer_map = self.timer_map.read(); let delay_timer = self.delay_timer.write(); for uid in profiles_to_update { if let Some(task) = timer_map.get(&uid) { - logging!(info, Type::Timer, "Advancing task for uid: {}", uid); + logging!(info, Type::Timer, "立即执行任务: uid={}", uid); if let Err(e) = delay_timer.advance_task(task.task_id) { logging!(warn, Type::Timer, "Failed to advance task {}: {}", uid, e); } @@ -210,6 +234,13 @@ impl Timer { if let Some(option) = item.option.as_ref() { if let (Some(interval), Some(uid)) = (option.update_interval, &item.uid) { if interval > 0 { + logging!( + debug, + Type::Timer, + "找到定时更新配置: uid={}, interval={}min", + uid, + interval + ); new_map.insert(uid.clone(), interval); } } @@ -217,6 +248,12 @@ impl Timer { } } + logging!( + debug, + Type::Timer, + "生成的定时更新配置数量: {}", + new_map.len() + ); new_map } @@ -227,20 +264,36 @@ impl Timer { // Read lock for comparing current state let timer_map = self.timer_map.read(); + logging!( + debug, + Type::Timer, + "当前 timer_map 大小: {}", + timer_map.len() + ); // Find tasks to modify or delete for (uid, task) in timer_map.iter() { match new_map.get(uid) { Some(&interval) if interval != task.interval_minutes => { // Task exists but interval changed + logging!( + debug, + Type::Timer, + "定时任务间隔变更: uid={}, 旧={}, 新={}", + uid, + task.interval_minutes, + interval + ); diff_map.insert(uid.clone(), DiffFlag::Mod(task.task_id, interval)); } None => { // Task no longer needed + logging!(debug, Type::Timer, "定时任务已删除: uid={}", uid); diff_map.insert(uid.clone(), DiffFlag::Del(task.task_id)); } _ => { // Task exists with same interval, no change needed + logging!(debug, Type::Timer, "定时任务保持不变: uid={}", uid); } } } @@ -250,6 +303,13 @@ impl Timer { for (uid, &interval) in new_map.iter() { if !timer_map.contains_key(uid) { + logging!( + debug, + Type::Timer, + "新增定时任务: uid={}, interval={}min", + uid, + interval + ); diff_map.insert(uid.clone(), DiffFlag::Add(next_id, interval)); next_id += 1; } @@ -260,6 +320,7 @@ impl Timer { *self.timer_count.lock() = next_id; } + logging!(debug, Type::Timer, "定时任务变更数量: {}", diff_map.len()); diff_map } @@ -300,42 +361,126 @@ impl Timer { Ok(()) } + /// Get next update time for a profile + pub fn get_next_update_time(&self, uid: &str) -> Option { + logging!(info, Type::Timer, "获取下次更新时间,uid={}", uid); + + let timer_map = self.timer_map.read(); + let task = match timer_map.get(uid) { + Some(t) => t, + None => { + logging!(warn, Type::Timer, "找不到对应的定时任务,uid={}", uid); + return None; + } + }; + + // Get the profile updated timestamp + let profiles_config = Config::profiles(); + let profiles = profiles_config.latest(); + let items = match profiles.get_items() { + Some(i) => i, + None => { + logging!(warn, Type::Timer, "获取配置列表失败"); + return None; + } + }; + + // 修复类型比较,使用字符串值比较而不是引用比较 + let profile = match items + .iter() + .find(|item| item.uid.as_ref().map(|u| u.as_str()) == Some(uid)) + { + Some(p) => p, + None => { + logging!(warn, Type::Timer, "找不到对应的配置,uid={}", uid); + return None; + } + }; + + let updated = profile.updated.unwrap_or(0) as i64; + + // Calculate next update time + if updated > 0 && task.interval_minutes > 0 { + let next_time = updated + (task.interval_minutes as i64 * 60); + logging!( + info, + Type::Timer, + "计算得到下次更新时间: {}, uid={}", + next_time, + uid + ); + Some(next_time) + } else { + logging!( + warn, + Type::Timer, + "更新时间或间隔无效,updated={}, interval={}", + updated, + task.interval_minutes + ); + None + } + } + + /// Emit update events for frontend notification + fn emit_update_event(_uid: &str, _is_start: bool) { + // 当feature="verge-dev"或"default"时才启用此功能 + #[cfg(any(feature = "verge-dev", feature = "default"))] + { + use serde_json::json; + use tauri::Emitter; + + let event_name = if _is_start { + "profile-update-started" + } else { + "profile-update-completed" + }; + + if let Some(window) = super::handle::Handle::global().get_window() { + let _ = window.emit(event_name, json!({ "uid": _uid })); + } + } + } + /// Async task with better error handling and logging async fn async_task(uid: String) { let task_start = std::time::Instant::now(); logging!(info, Type::Timer, "Running timer task for profile: {}", uid); - // Update profile - let profile_result = feat::update_profile(uid.clone(), None).await; + // Emit start event + Self::emit_update_event(&uid, true); + + // 检查是否是当前激活的配置文件 + let is_current = Config::profiles().latest().current.as_ref() == Some(&uid); + logging!( + info, + Type::Timer, + "配置 {} 是否为当前激活配置: {}", + uid, + is_current + ); + + // Update profile - 由update_profile函数自动处理是否需要刷新UI + let profile_result = feat::update_profile(uid.clone(), None, Some(is_current)).await; match profile_result { Ok(_) => { - // Update configuration - match CoreManager::global().update_config().await { - Ok(_) => { - let duration = task_start.elapsed().as_millis(); - logging!( - info, - Type::Timer, - "Timer task completed successfully for uid: {} (took {}ms)", - uid, - duration - ); - } - Err(e) => { - logging_error!( - Type::Timer, - "Failed to refresh config after profile update for uid {}: {}", - uid, - e - ); - } - } + let duration = task_start.elapsed().as_millis(); + logging!( + info, + Type::Timer, + "Timer task completed successfully for uid: {} (took {}ms)", + uid, + duration + ); } Err(e) => { logging_error!(Type::Timer, "Failed to update profile uid {}: {}", uid, e); } } + + // Emit completed event + Self::emit_update_event(&uid, false); } } diff --git a/src-tauri/src/feat/profile.rs b/src-tauri/src/feat/profile.rs index 0c2d01b5..f2f34197 100644 --- a/src-tauri/src/feat/profile.rs +++ b/src-tauri/src/feat/profile.rs @@ -23,8 +23,14 @@ pub fn toggle_proxy_profile(profile_index: String) { /// Update a profile /// If updating current profile, activate it -pub async fn update_profile(uid: String, option: Option) -> Result<()> { +/// auto_refresh: 是否自动更新配置和刷新前端 +pub async fn update_profile( + uid: String, + option: Option, + auto_refresh: Option, +) -> Result<()> { println!("[订阅更新] 开始更新订阅 {}", uid); + let auto_refresh = auto_refresh.unwrap_or(true); // 默认为true,保持兼容性 let url_opt = { let profiles = Config::profiles(); @@ -63,7 +69,7 @@ pub async fn update_profile(uid: String, option: Option) -> Result<() let is_current = Some(uid.clone()) == profiles.get_current(); println!("[订阅更新] 是否为当前使用的订阅: {}", is_current); - is_current + is_current && auto_refresh } Err(err) => { // 首次更新失败,尝试使用Clash代理 @@ -105,7 +111,7 @@ pub async fn update_profile(uid: String, option: Option) -> Result<() let is_current = Some(uid.clone()) == profiles.get_current(); println!("[订阅更新] 是否为当前使用的订阅: {}", is_current); - is_current + is_current && auto_refresh } Err(retry_err) => { println!("[订阅更新] 使用Clash代理更新仍然失败: {}", retry_err); @@ -119,7 +125,7 @@ pub async fn update_profile(uid: String, option: Option) -> Result<() } } } - None => true, + None => auto_refresh, }; if should_update { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 85a04ae2..46910c6b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -213,6 +213,7 @@ pub fn run() { cmd::delete_profile, cmd::read_profile_file, cmd::save_profile_file, + cmd::get_next_update_time, // script validation cmd::script_validate_notice, cmd::validate_script_file, diff --git a/src/components/profile/profile-item.tsx b/src/components/profile/profile-item.tsx index d9258ec9..671cfab4 100644 --- a/src/components/profile/profile-item.tsx +++ b/src/components/profile/profile-item.tsx @@ -22,6 +22,7 @@ import { readProfileFile, updateProfile, saveProfileFile, + getNextUpdateTime, } from "@/services/cmds"; import { Notice } from "@/components/base"; import { GroupsEditorViewer } from "@/components/profile/groups-editor-viewer"; @@ -65,9 +66,107 @@ export const ProfileItem = (props: Props) => { const [position, setPosition] = useState({ left: 0, top: 0 }); const loadingCache = useLoadingCache(); const setLoadingCache = useSetLoadingCache(); + + // 新增状态:是否显示下次更新时间 + const [showNextUpdate, setShowNextUpdate] = useState(false); + const [nextUpdateTime, setNextUpdateTime] = useState(""); const { uid, name = "Profile", extra, updated = 0, option } = itemData; + // 获取下次更新时间的函数 + const fetchNextUpdateTime = useLockFn(async (forceRefresh = false) => { + if (itemData.option?.update_interval && itemData.option.update_interval > 0) { + try { + console.log(`尝试获取配置 ${itemData.uid} 的下次更新时间`); + + // 如果需要强制刷新,先触发Timer.refresh() + if (forceRefresh) { + // 这里可以通过一个新的API来触发刷新,但目前我们依赖patch_profile中的刷新 + console.log(`强制刷新定时器任务`); + } + + const nextUpdate = await getNextUpdateTime(itemData.uid); + console.log(`获取到下次更新时间结果:`, nextUpdate); + + if (nextUpdate) { + const nextUpdateDate = dayjs(nextUpdate * 1000); + const now = dayjs(); + + // 如果已经过期,显示"更新失败" + if (nextUpdateDate.isBefore(now)) { + setNextUpdateTime(t("Last Update failed")); + } else { + // 否则显示剩余时间 + const diffMinutes = nextUpdateDate.diff(now, 'minute'); + + if (diffMinutes < 60) { + if (diffMinutes <= 0) { + setNextUpdateTime(`${t("Next Up")} <1m`); + } else { + setNextUpdateTime(`${t("Next Up")} ${diffMinutes}m`); + } + } else { + const hours = Math.floor(diffMinutes / 60); + const mins = diffMinutes % 60; + setNextUpdateTime(`${t("Next Up")} ${hours}h ${mins}m`); + } + } + } else { + console.log(`返回的下次更新时间为空`); + setNextUpdateTime(t("No schedule")); + } + } catch (err) { + console.error(`获取下次更新时间出错:`, err); + setNextUpdateTime(t("Unknown")); + } + } else { + console.log(`该配置未设置更新间隔或间隔为0`); + setNextUpdateTime(t("Auto update disabled")); + } + }); + + // 切换显示模式的函数 + const toggleUpdateTimeDisplay = (e: React.MouseEvent) => { + e.stopPropagation(); + + if (!showNextUpdate) { + fetchNextUpdateTime(); + } + + setShowNextUpdate(!showNextUpdate); + }; + + // 当组件加载或更新间隔变化时更新下次更新时间 + useEffect(() => { + if (showNextUpdate) { + fetchNextUpdateTime(); + } + }, [showNextUpdate, itemData.option?.update_interval, updated]); + + // 订阅定时器更新事件 + useEffect(() => { + // 处理定时器更新事件 - 这个事件专门用于通知定时器变更 + const handleTimerUpdate = (event: any) => { + const updatedUid = event.payload as string; + + // 只有当更新的是当前配置时才刷新显示 + if (updatedUid === itemData.uid && showNextUpdate) { + console.log(`收到定时器更新事件: uid=${updatedUid}`); + setTimeout(() => { + fetchNextUpdateTime(true); + }, 1000); + } + }; + + // 只注册定时器更新事件监听 + window.addEventListener('verge://timer-updated', handleTimerUpdate as EventListener); + + return () => { + // 清理事件监听 + window.removeEventListener('verge://timer-updated', handleTimerUpdate as EventListener); + }; + }, [showNextUpdate, itemData.uid]); + // local file mode // remote file mode // remote file mode @@ -303,6 +402,35 @@ export const ProfileItem = (props: Props) => { justifyContent: "space-between", }; + // 监听自动更新事件 + useEffect(() => { + const handleUpdateStarted = (event: CustomEvent) => { + if (event.detail.uid === itemData.uid) { + setLoadingCache((cache) => ({ ...cache, [itemData.uid]: true })); + } + }; + + const handleUpdateCompleted = (event: CustomEvent) => { + if (event.detail.uid === itemData.uid) { + setLoadingCache((cache) => ({ ...cache, [itemData.uid]: false })); + // 更新完成后刷新显示 + if (showNextUpdate) { + fetchNextUpdateTime(); + } + } + }; + + // 注册事件监听 + window.addEventListener('profile-update-started', handleUpdateStarted as EventListener); + window.addEventListener('profile-update-completed', handleUpdateCompleted as EventListener); + + return () => { + // 清理事件监听 + window.removeEventListener('profile-update-started', handleUpdateStarted as EventListener); + window.removeEventListener('profile-update-completed', handleUpdateCompleted as EventListener); + }; + }, [itemData.uid, showNextUpdate]); + return ( { ) )} {hasUrl && ( - - {updated > 0 ? dayjs(updated * 1000).fromNow() : ""} - + + + {showNextUpdate + ? nextUpdateTime + : (updated > 0 ? dayjs(updated * 1000).fromNow() : "")} + + )} } diff --git a/src/locales/en.json b/src/locales/en.json index ddc89207..3eeac24b 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -563,6 +563,11 @@ "Administrator + Service Mode": "Admin + Service Mode", "Last Check Update": "Last Check Update", "Click to import subscription": "Click to import subscription", + "Last Update failed": "Last Update failed", + "Next Up": "Next Up", + "No schedule": "No schedule", + "Unknown": "Unknown", + "Auto update disabled": "Auto update disabled", "Update subscription successfully": "Update subscription successfully", "Update failed, retrying with Clash proxy...": "Update failed, retrying with Clash proxy...", "Update with Clash proxy successfully": "Update with Clash proxy successfully", diff --git a/src/locales/zh.json b/src/locales/zh.json index 1833d0a4..bb421310 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -563,6 +563,11 @@ "Administrator + Service Mode": "管理员 + 服务模式", "Last Check Update": "最后检查更新", "Click to import subscription": "点击导入订阅", + "Last Update failed": "上次更新失败", + "Next Up": "下次更新", + "No schedule": "没有计划", + "Unknown": "未知", + "Auto update disabled": "自动更新已禁用", "Update subscription successfully": "订阅更新成功", "Update failed, retrying with Clash proxy...": "订阅更新失败,尝试使用 Clash 代理更新", "Update with Clash proxy successfully": "使用 Clash 代理更新成功", diff --git a/src/services/cmds.ts b/src/services/cmds.ts index 58770736..96e7522f 100644 --- a/src/services/cmds.ts +++ b/src/services/cmds.ts @@ -359,3 +359,7 @@ export const isAdmin = async () => { return false; } }; + +export async function getNextUpdateTime(uid: string) { + return invoke("get_next_update_time", { uid }); +}