feat: toggle next auto-update time on subscription card click and show update result feedback

This commit is contained in:
wonfen 2025-04-25 17:17:34 +08:00
parent bd3231bfa8
commit d6a79316a6
10 changed files with 391 additions and 43 deletions

View File

@ -28,6 +28,7 @@
- 系统代理守卫可检查意外设置变更并恢复
- 定时自动订阅更新也能自动回退使用代理
- 订阅请求超时机制,防止订阅更新的时候卡死
- 订阅卡片点击时间可切换下次自动更新时间,自动更新触发后页面有明确的成功与否提示
#### 优化了:
- 系统代理 Bypass 设置

View File

@ -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<String>) -> CmdResu
/// 更新配置文件
#[tauri::command]
pub async fn update_profile(index: String, option: Option<PrfOption>) -> 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<String> {
let data = wrap_err!(item.read_file())?;
Ok(data)
}
/// 获取下一次更新时间
#[tauri::command]
pub fn get_next_update_time(uid: String) -> CmdResult<Option<i64>> {
let timer = Timer::global();
let next_time = timer.get_next_update_time(&uid);
Ok(next_time)
}

View File

@ -10,3 +10,4 @@ pub mod tray;
pub mod win_uwp;
pub use self::core::*;
pub use self::timer::Timer;

View File

@ -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,18 +361,109 @@ impl Timer {
Ok(())
}
/// Get next update time for a profile
pub fn get_next_update_time(&self, uid: &str) -> Option<i64> {
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!(
@ -322,20 +474,13 @@ impl Timer {
duration
);
}
Err(e) => {
logging_error!(
Type::Timer,
"Failed to refresh config after profile update for uid {}: {}",
uid,
e
);
}
}
}
Err(e) => {
logging_error!(Type::Timer, "Failed to update profile uid {}: {}", uid, e);
}
}
// Emit completed event
Self::emit_update_event(&uid, false);
}
}

View File

@ -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<PrfOption>) -> Result<()> {
/// auto_refresh: 是否自动更新配置和刷新前端
pub async fn update_profile(
uid: String,
option: Option<PrfOption>,
auto_refresh: Option<bool>,
) -> 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<PrfOption>) -> 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<PrfOption>) -> 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<PrfOption>) -> Result<()
}
}
}
None => true,
None => auto_refresh,
};
if should_update {

View File

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

View File

@ -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";
@ -66,8 +67,106 @@ export const ProfileItem = (props: Props) => {
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 (
<Box
sx={{
@ -413,15 +541,30 @@ export const ProfileItem = (props: Props) => {
)
)}
{hasUrl && (
<Box sx={{ display: "flex", justifyContent: "flex-end", ml: "auto" }}>
<Typography
noWrap
flex="1 0 auto"
component="span"
fontSize={14}
textAlign="right"
title={`${t("Update Time")}: ${parseExpire(updated)}`}
title={showNextUpdate ? t("Click to show last update time") : `${t("Update Time")}: ${parseExpire(updated)}\n${t("Click to show next update")}`}
sx={{
cursor: "pointer",
display: "inline-block",
borderBottom: "1px dashed transparent",
transition: "all 0.2s",
"&:hover": {
borderBottomColor: "primary.main",
color: "primary.main",
}
}}
onClick={toggleUpdateTimeDisplay}
>
{updated > 0 ? dayjs(updated * 1000).fromNow() : ""}
{showNextUpdate
? nextUpdateTime
: (updated > 0 ? dayjs(updated * 1000).fromNow() : "")}
</Typography>
</Box>
)}
</>
}

View File

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

View File

@ -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 代理更新成功",

View File

@ -359,3 +359,7 @@ export const isAdmin = async () => {
return false;
}
};
export async function getNextUpdateTime(uid: string) {
return invoke<number | null>("get_next_update_time", { uid });
}