mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
chore: bump version to 0.39.0
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -26,3 +26,4 @@ __pycache__/
|
||||
# Kiro
|
||||
.kiro/
|
||||
.history
|
||||
docs/prd/
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "proxycast",
|
||||
"private": true,
|
||||
"version": "0.38.0",
|
||||
"version": "0.39.0",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
Generated
+1
-1
@@ -3917,7 +3917,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast"
|
||||
version = "0.38.0"
|
||||
version = "0.39.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "proxycast"
|
||||
version = "0.38.0"
|
||||
version = "0.39.0"
|
||||
description = "AI API Proxy Desktop App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "https://schemas.tauri.app/config/2/capability",
|
||||
"identifier": "default",
|
||||
"description": "Default capabilities for ProxyCast",
|
||||
"windows": ["main", "screenshot-chat"],
|
||||
"windows": ["main", "screenshot-chat", "update-notification"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:webview:default",
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"default":{"identifier":"default","description":"Default capabilities for ProxyCast","local":true,"windows":["main","screenshot-chat"],"permissions":["core:default","core:webview:default","core:webview:allow-webview-close","core:webview:allow-webview-position","core:webview:allow-webview-size","core:webview:allow-set-webview-position","core:webview:allow-set-webview-size","core:window:default","core:window:allow-close","core:window:allow-show","core:window:allow-hide","core:window:allow-set-focus","core:window:allow-center","core:window:allow-start-dragging","shell:allow-open","shell:allow-spawn","shell:allow-execute","shell:allow-kill","shell:allow-stdin-write","dialog:default","global-shortcut:default","global-shortcut:allow-is-registered","global-shortcut:allow-register","global-shortcut:allow-unregister",{"identifier":"shell:allow-execute","allow":[{"args":true,"name":"binaries/aster-server","sidecar":true}]},{"identifier":"shell:allow-spawn","allow":[{"args":true,"name":"binaries/aster-server","sidecar":true}]}]}}
|
||||
{"default":{"identifier":"default","description":"Default capabilities for ProxyCast","local":true,"windows":["main","screenshot-chat","update-notification"],"permissions":["core:default","core:webview:default","core:webview:allow-webview-close","core:webview:allow-webview-position","core:webview:allow-webview-size","core:webview:allow-set-webview-position","core:webview:allow-set-webview-size","core:window:default","core:window:allow-close","core:window:allow-show","core:window:allow-hide","core:window:allow-set-focus","core:window:allow-center","core:window:allow-start-dragging","shell:allow-open","shell:allow-spawn","shell:allow-execute","shell:allow-kill","shell:allow-stdin-write","dialog:default","global-shortcut:default","global-shortcut:allow-is-registered","global-shortcut:allow-register","global-shortcut:allow-unregister",{"identifier":"shell:allow-execute","allow":[{"args":true,"name":"binaries/aster-server","sidecar":true}]},{"identifier":"shell:allow-spawn","allow":[{"args":true,"name":"binaries/aster-server","sidecar":true}]}]}}
|
||||
@@ -37,6 +37,7 @@ use crate::services::api_key_provider_service::ApiKeyProviderService;
|
||||
use crate::services::provider_pool_service::ProviderPoolService;
|
||||
use crate::services::skill_service::SkillService;
|
||||
use crate::services::token_cache_service::TokenCacheService;
|
||||
use crate::services::update_check_service::UpdateCheckServiceState;
|
||||
use crate::telemetry;
|
||||
|
||||
use super::types::{AppState, LogState, TokenCacheServiceState};
|
||||
@@ -146,6 +147,7 @@ pub struct AppStates {
|
||||
pub global_config_manager: GlobalConfigManagerState,
|
||||
pub terminal_manager: TerminalManagerState,
|
||||
pub webview_manager: WebviewManagerWrapper,
|
||||
pub update_check_service: UpdateCheckServiceState,
|
||||
// 用于 setup hook 的共享实例
|
||||
pub shared_stats: Arc<parking_lot::RwLock<telemetry::StatsAggregator>>,
|
||||
pub shared_tokens: Arc<parking_lot::RwLock<telemetry::TokenTracker>>,
|
||||
@@ -233,6 +235,9 @@ pub fn init_states(config: &Config) -> Result<AppStates, String> {
|
||||
let webview_manager_state =
|
||||
WebviewManagerWrapper(Arc::new(RwLock::new(WebviewManagerState::new())));
|
||||
|
||||
// 初始化更新检查服务
|
||||
let update_check_service_state = UpdateCheckServiceState::new();
|
||||
|
||||
// 初始化全局配置管理器
|
||||
let config_path = ConfigManager::default_config_path();
|
||||
let global_config_manager = GlobalConfigManager::new(config.clone(), config_path);
|
||||
@@ -277,6 +282,7 @@ pub fn init_states(config: &Config) -> Result<AppStates, String> {
|
||||
global_config_manager: global_config_manager_state,
|
||||
terminal_manager: terminal_manager_state,
|
||||
webview_manager: webview_manager_state,
|
||||
update_check_service: update_check_service_state,
|
||||
shared_stats,
|
||||
shared_tokens,
|
||||
shared_logger,
|
||||
|
||||
@@ -75,6 +75,7 @@ pub fn run() {
|
||||
global_config_manager: global_config_manager_state,
|
||||
terminal_manager: terminal_manager_state,
|
||||
webview_manager: webview_manager_state,
|
||||
update_check_service: update_check_service_state,
|
||||
shared_stats,
|
||||
shared_tokens,
|
||||
shared_logger,
|
||||
@@ -93,6 +94,7 @@ pub fn run() {
|
||||
let shared_logger_clone = shared_logger.clone();
|
||||
let flow_monitor_clone = flow_monitor.clone();
|
||||
let flow_interceptor_clone = flow_interceptor.clone();
|
||||
let update_check_service_clone = update_check_service_state.0.clone();
|
||||
|
||||
let mut builder = tauri::Builder::default()
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
@@ -154,6 +156,7 @@ pub fn run() {
|
||||
.manage(global_config_manager_state)
|
||||
.manage(terminal_manager_state)
|
||||
.manage(webview_manager_state)
|
||||
.manage(update_check_service_state)
|
||||
.on_window_event(move |window, event| {
|
||||
// 处理窗口关闭事件
|
||||
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||
@@ -563,6 +566,18 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 启动后台更新检查任务
|
||||
let app_handle_for_update = app.handle().clone();
|
||||
let update_service_for_task = update_check_service_clone.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
crate::commands::update_cmd::start_background_update_check(
|
||||
app_handle_for_update,
|
||||
update_service_for_task,
|
||||
).await;
|
||||
});
|
||||
tracing::info!("[启动] 后台更新检查任务已启动");
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
@@ -1153,6 +1168,14 @@ pub fn run() {
|
||||
commands::screenshot_cmd::send_screenshot_chat,
|
||||
commands::screenshot_cmd::close_screenshot_chat_window,
|
||||
commands::screenshot_cmd::read_image_as_base64,
|
||||
// Update Check commands
|
||||
commands::update_cmd::check_update,
|
||||
commands::update_cmd::get_update_check_settings,
|
||||
commands::update_cmd::set_update_check_settings,
|
||||
commands::update_cmd::skip_update_version,
|
||||
commands::update_cmd::update_last_check_timestamp,
|
||||
commands::update_cmd::close_update_window,
|
||||
commands::update_cmd::test_update_window,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -30,6 +30,7 @@ pub mod switch_cmd;
|
||||
pub mod telemetry_cmd;
|
||||
pub mod terminal_cmd;
|
||||
pub mod tray_cmd;
|
||||
pub mod update_cmd;
|
||||
pub mod usage_cmd;
|
||||
pub mod websocket_cmd;
|
||||
pub mod webview_cmd;
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
//! 更新检查命令模块
|
||||
//!
|
||||
//! 提供自动更新检查相关的 Tauri 命令
|
||||
//!
|
||||
//! input: 前端调用请求
|
||||
//! output: 更新信息、配置操作结果
|
||||
//! pos: commands 层,被前端调用
|
||||
|
||||
use crate::app::AppState;
|
||||
use crate::config;
|
||||
use crate::services::update_check_service::{
|
||||
UpdateCheckService, UpdateCheckServiceState, UpdateInfo,
|
||||
};
|
||||
use crate::services::update_window;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use tauri::{AppHandle, Manager, State};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
/// 更新检查配置(前端可见)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UpdateCheckSettings {
|
||||
pub enabled: bool,
|
||||
pub check_interval_hours: u32,
|
||||
pub show_notification: bool,
|
||||
pub last_check_timestamp: u64,
|
||||
pub skipped_version: Option<String>,
|
||||
}
|
||||
|
||||
/// 手动检查更新
|
||||
#[tauri::command]
|
||||
pub async fn check_update(
|
||||
update_service: State<'_, UpdateCheckServiceState>,
|
||||
) -> Result<UpdateInfo, String> {
|
||||
let service = update_service.0.read().await;
|
||||
Ok(service.check_for_updates().await)
|
||||
}
|
||||
|
||||
/// 获取更新检查配置
|
||||
#[tauri::command]
|
||||
pub async fn get_update_check_settings(
|
||||
app_state: State<'_, AppState>,
|
||||
) -> Result<UpdateCheckSettings, String> {
|
||||
let state = app_state.read().await;
|
||||
let update_config = &state.config.experimental.update_check;
|
||||
|
||||
Ok(UpdateCheckSettings {
|
||||
enabled: update_config.enabled,
|
||||
check_interval_hours: update_config.check_interval_hours,
|
||||
show_notification: update_config.show_notification,
|
||||
last_check_timestamp: update_config.last_check_timestamp,
|
||||
skipped_version: update_config.skipped_version.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// 更新检查配置
|
||||
#[tauri::command]
|
||||
pub async fn set_update_check_settings(
|
||||
app_state: State<'_, AppState>,
|
||||
settings: UpdateCheckSettings,
|
||||
) -> Result<(), String> {
|
||||
let mut state = app_state.write().await;
|
||||
let update_config = &mut state.config.experimental.update_check;
|
||||
|
||||
update_config.enabled = settings.enabled;
|
||||
update_config.check_interval_hours = settings.check_interval_hours;
|
||||
update_config.show_notification = settings.show_notification;
|
||||
update_config.skipped_version = settings.skipped_version;
|
||||
|
||||
config::save_config(&state.config).map_err(|e| format!("保存配置失败: {}", e))
|
||||
}
|
||||
|
||||
/// 跳过指定版本
|
||||
#[tauri::command]
|
||||
pub async fn skip_update_version(
|
||||
app_handle: AppHandle,
|
||||
app_state: State<'_, AppState>,
|
||||
version: String,
|
||||
) -> Result<(), String> {
|
||||
let mut state = app_state.write().await;
|
||||
state.config.experimental.update_check.skipped_version = Some(version);
|
||||
|
||||
config::save_config(&state.config).map_err(|e| format!("保存配置失败: {}", e))?;
|
||||
|
||||
// 关闭更新窗口
|
||||
let _ = update_window::close_update_window(&app_handle);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 关闭更新提醒窗口
|
||||
#[tauri::command]
|
||||
pub fn close_update_window(app_handle: AppHandle) -> Result<(), String> {
|
||||
update_window::close_update_window(&app_handle).map_err(|e| format!("关闭更新窗口失败: {}", e))
|
||||
}
|
||||
|
||||
/// 测试更新提醒窗口(仅开发环境使用)
|
||||
#[tauri::command]
|
||||
pub fn test_update_window(app_handle: AppHandle) -> Result<(), String> {
|
||||
let current_version = env!("CARGO_PKG_VERSION");
|
||||
let test_info = UpdateInfo {
|
||||
current_version: current_version.to_string(),
|
||||
latest_version: Some("0.99.0".to_string()),
|
||||
has_update: true,
|
||||
download_url: Some(format!(
|
||||
"https://github.com/aiclientproxy/proxycast/releases/tag/v0.99.0"
|
||||
)),
|
||||
release_notes_url: None,
|
||||
checked_at: std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs(),
|
||||
error: None,
|
||||
};
|
||||
|
||||
update_window::open_update_window(&app_handle, &test_info)
|
||||
.map_err(|e| format!("打开更新窗口失败: {}", e))
|
||||
}
|
||||
|
||||
/// 更新上次检查时间
|
||||
#[tauri::command]
|
||||
pub async fn update_last_check_timestamp(app_state: State<'_, AppState>) -> Result<u64, String> {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
|
||||
let mut state = app_state.write().await;
|
||||
state.config.experimental.update_check.last_check_timestamp = now;
|
||||
|
||||
config::save_config(&state.config).map_err(|e| format!("保存配置失败: {}", e))?;
|
||||
|
||||
Ok(now)
|
||||
}
|
||||
|
||||
/// 启动后台更新检查任务
|
||||
///
|
||||
/// 在应用启动时调用,根据配置定期检查更新
|
||||
pub async fn start_background_update_check(
|
||||
app_handle: tauri::AppHandle,
|
||||
update_service: Arc<RwLock<UpdateCheckService>>,
|
||||
) {
|
||||
let app_handle_clone = app_handle.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
// 延迟 30 秒后开始第一次检查,避免影响启动性能
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(30)).await;
|
||||
|
||||
loop {
|
||||
// 获取当前配置
|
||||
let (enabled, interval_hours, show_notification, last_check, skipped_version) = {
|
||||
if let Some(app_state) = app_handle_clone.try_state::<AppState>() {
|
||||
let state = app_state.read().await;
|
||||
let update_config = &state.config.experimental.update_check;
|
||||
(
|
||||
update_config.enabled,
|
||||
update_config.check_interval_hours,
|
||||
update_config.show_notification,
|
||||
update_config.last_check_timestamp,
|
||||
update_config.skipped_version.clone(),
|
||||
)
|
||||
} else {
|
||||
(true, 24, true, 0, None)
|
||||
}
|
||||
};
|
||||
|
||||
if !enabled {
|
||||
// 如果禁用了自动检查,每小时检查一次配置是否变化
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(3600)).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 检查是否需要执行更新检查
|
||||
let service = update_service.read().await;
|
||||
let last_result = service.get_state().await.last_result;
|
||||
let latest_version = last_result
|
||||
.as_ref()
|
||||
.and_then(|r| r.latest_version.as_deref());
|
||||
|
||||
if UpdateCheckService::should_check(
|
||||
last_check,
|
||||
interval_hours,
|
||||
skipped_version.as_deref(),
|
||||
latest_version,
|
||||
) {
|
||||
drop(service);
|
||||
|
||||
// 执行更新检查
|
||||
let service = update_service.read().await;
|
||||
let result = service.check_for_updates().await;
|
||||
|
||||
tracing::info!(
|
||||
"[更新检查] 当前版本: {}, 最新版本: {:?}, 有更新: {}",
|
||||
result.current_version,
|
||||
result.latest_version,
|
||||
result.has_update
|
||||
);
|
||||
|
||||
// 更新检查时间
|
||||
if let Some(app_state) = app_handle_clone.try_state::<AppState>() {
|
||||
let mut state = app_state.write().await;
|
||||
state.config.experimental.update_check.last_check_timestamp = result.checked_at;
|
||||
let _ = config::save_config(&state.config);
|
||||
}
|
||||
|
||||
// 如果有更新且启用了通知,打开独立的更新提醒窗口
|
||||
if result.has_update && show_notification {
|
||||
// 检查是否跳过了此版本
|
||||
let should_notify = result.latest_version.as_ref().map_or(true, |latest| {
|
||||
skipped_version
|
||||
.as_ref()
|
||||
.map_or(true, |skipped| skipped != latest)
|
||||
});
|
||||
|
||||
if should_notify {
|
||||
// 打开独立的更新提醒窗口
|
||||
if let Err(e) =
|
||||
update_window::open_update_window(&app_handle_clone, &result)
|
||||
{
|
||||
tracing::error!("[更新检查] 打开更新窗口失败: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 每小时检查一次是否需要执行更新检查
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(3600)).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -404,6 +404,52 @@ fn default_screenshot_shortcut() -> String {
|
||||
"CommandOrControl+Alt+Q".to_string()
|
||||
}
|
||||
|
||||
/// 自动更新检查配置
|
||||
///
|
||||
/// 配置自动检查更新的行为,符合 macOS/Windows 平台规范
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct UpdateCheckConfig {
|
||||
/// 是否启用自动检查更新
|
||||
#[serde(default = "default_update_check_enabled")]
|
||||
pub enabled: bool,
|
||||
/// 检查间隔(小时),默认 24 小时
|
||||
#[serde(default = "default_check_interval_hours")]
|
||||
pub check_interval_hours: u32,
|
||||
/// 是否显示系统通知
|
||||
#[serde(default = "default_show_notification")]
|
||||
pub show_notification: bool,
|
||||
/// 上次检查时间(Unix 时间戳,秒)
|
||||
#[serde(default)]
|
||||
pub last_check_timestamp: u64,
|
||||
/// 已跳过的版本(用户选择"跳过此版本")
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub skipped_version: Option<String>,
|
||||
}
|
||||
|
||||
fn default_update_check_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_check_interval_hours() -> u32 {
|
||||
24
|
||||
}
|
||||
|
||||
fn default_show_notification() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl Default for UpdateCheckConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: default_update_check_enabled(),
|
||||
check_interval_hours: default_check_interval_hours(),
|
||||
show_notification: default_show_notification(),
|
||||
last_check_timestamp: 0,
|
||||
skipped_version: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ScreenshotChatConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -421,6 +467,9 @@ pub struct ExperimentalFeatures {
|
||||
/// 截图对话功能配置
|
||||
#[serde(default)]
|
||||
pub screenshot_chat: ScreenshotChatConfig,
|
||||
/// 自动更新检查配置
|
||||
#[serde(default)]
|
||||
pub update_check: UpdateCheckConfig,
|
||||
}
|
||||
|
||||
impl NativeAgentConfig {
|
||||
@@ -1522,6 +1571,7 @@ mod unit_tests {
|
||||
enabled: true,
|
||||
shortcut: "CommandOrControl+Alt+X".to_string(),
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let yaml = serde_yaml::to_string(&config).unwrap();
|
||||
|
||||
@@ -119,6 +119,7 @@ mod tests {
|
||||
enabled: true,
|
||||
shortcut: "CommandOrControl+Shift+S".to_string(),
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
assert!(is_screenshot_chat_enabled(&config));
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@
|
||||
- `kiro_event_service.rs` - Kiro 事件服务
|
||||
- `machine_id_service.rs` - 机器 ID 服务
|
||||
- `model_registry_service.rs` - 模型注册表服务
|
||||
- `update_check_service.rs` - 自动更新检查服务(每日检查、系统通知)
|
||||
- `update_window.rs` - 更新提醒独立窗口管理
|
||||
|
||||
## 更新提醒
|
||||
|
||||
|
||||
@@ -14,4 +14,6 @@ pub mod skill_service;
|
||||
pub mod switch;
|
||||
pub mod sysinfo_service;
|
||||
pub mod token_cache_service;
|
||||
pub mod update_check_service;
|
||||
pub mod update_window;
|
||||
pub mod usage_service;
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
//! 自动更新检查服务
|
||||
//!
|
||||
//! 提供每日自动检查更新功能,支持:
|
||||
//! - 定时检查(可配置间隔)
|
||||
//! - 系统原生通知(macOS/Windows)
|
||||
//! - 用户可控(启用/禁用、跳过版本)
|
||||
//!
|
||||
//! ## 平台适配
|
||||
//! - macOS: 使用 NSUserNotification / UNUserNotificationCenter
|
||||
//! - Windows: 使用 Windows Toast Notification
|
||||
//!
|
||||
//! ## 使用示例
|
||||
//! ```rust,ignore
|
||||
//! let service = UpdateCheckService::new();
|
||||
//! service.start_background_check(app_handle).await;
|
||||
//! ```
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
/// 更新检查结果
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UpdateInfo {
|
||||
/// 当前版本
|
||||
pub current_version: String,
|
||||
/// 最新版本
|
||||
pub latest_version: Option<String>,
|
||||
/// 是否有更新
|
||||
pub has_update: bool,
|
||||
/// 下载链接
|
||||
pub download_url: Option<String>,
|
||||
/// 发布说明链接
|
||||
pub release_notes_url: Option<String>,
|
||||
/// 检查时间(Unix 时间戳)
|
||||
pub checked_at: u64,
|
||||
/// 错误信息
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// 更新检查服务状态
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UpdateCheckState {
|
||||
/// 是否正在检查
|
||||
pub is_checking: bool,
|
||||
/// 最后一次检查结果
|
||||
pub last_result: Option<UpdateInfo>,
|
||||
/// 下次检查时间(Unix 时间戳)
|
||||
pub next_check_at: Option<u64>,
|
||||
}
|
||||
|
||||
impl Default for UpdateCheckState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
is_checking: false,
|
||||
last_result: None,
|
||||
next_check_at: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新检查服务
|
||||
pub struct UpdateCheckService {
|
||||
state: Arc<RwLock<UpdateCheckState>>,
|
||||
github_api_url: String,
|
||||
}
|
||||
|
||||
impl UpdateCheckService {
|
||||
const CURRENT_VERSION: &'static str = env!("CARGO_PKG_VERSION");
|
||||
const DEFAULT_GITHUB_API_URL: &'static str =
|
||||
"https://api.github.com/repos/aiclientproxy/proxycast/releases/latest";
|
||||
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
state: Arc::new(RwLock::new(UpdateCheckState::default())),
|
||||
github_api_url: Self::DEFAULT_GITHUB_API_URL.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取当前状态
|
||||
pub async fn get_state(&self) -> UpdateCheckState {
|
||||
self.state.read().await.clone()
|
||||
}
|
||||
|
||||
/// 检查是否需要执行更新检查
|
||||
pub fn should_check(
|
||||
last_check_timestamp: u64,
|
||||
check_interval_hours: u32,
|
||||
skipped_version: Option<&str>,
|
||||
latest_version: Option<&str>,
|
||||
) -> bool {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
|
||||
let interval_secs = (check_interval_hours as u64) * 3600;
|
||||
|
||||
// 检查时间间隔
|
||||
if now < last_check_timestamp + interval_secs {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查是否跳过了当前最新版本
|
||||
if let (Some(skipped), Some(latest)) = (skipped_version, latest_version) {
|
||||
if skipped == latest {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
/// 执行更新检查
|
||||
pub async fn check_for_updates(&self) -> UpdateInfo {
|
||||
{
|
||||
let mut state = self.state.write().await;
|
||||
state.is_checking = true;
|
||||
}
|
||||
|
||||
let result = self.do_check().await;
|
||||
|
||||
{
|
||||
let mut state = self.state.write().await;
|
||||
state.is_checking = false;
|
||||
state.last_result = Some(result.clone());
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
async fn do_check(&self) -> UpdateInfo {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build();
|
||||
|
||||
let client = match client {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
return UpdateInfo {
|
||||
current_version: Self::CURRENT_VERSION.to_string(),
|
||||
latest_version: None,
|
||||
has_update: false,
|
||||
download_url: None,
|
||||
release_notes_url: None,
|
||||
checked_at: now,
|
||||
error: Some(format!("创建 HTTP 客户端失败: {}", e)),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
match client
|
||||
.get(&self.github_api_url)
|
||||
.header("User-Agent", "ProxyCast")
|
||||
.header("Accept", "application/vnd.github.v3+json")
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
if response.status().is_success() {
|
||||
match response.json::<serde_json::Value>().await {
|
||||
Ok(data) => {
|
||||
let latest_version = data["tag_name"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.trim_start_matches('v')
|
||||
.to_string();
|
||||
|
||||
let download_url = data["html_url"].as_str().map(|s| s.to_string());
|
||||
let release_notes_url = download_url.clone();
|
||||
|
||||
let has_update =
|
||||
Self::version_compare(Self::CURRENT_VERSION, &latest_version);
|
||||
|
||||
UpdateInfo {
|
||||
current_version: Self::CURRENT_VERSION.to_string(),
|
||||
latest_version: Some(latest_version),
|
||||
has_update,
|
||||
download_url,
|
||||
release_notes_url,
|
||||
checked_at: now,
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
Err(e) => UpdateInfo {
|
||||
current_version: Self::CURRENT_VERSION.to_string(),
|
||||
latest_version: None,
|
||||
has_update: false,
|
||||
download_url: None,
|
||||
release_notes_url: None,
|
||||
checked_at: now,
|
||||
error: Some(format!("解析响应失败: {}", e)),
|
||||
},
|
||||
}
|
||||
} else {
|
||||
UpdateInfo {
|
||||
current_version: Self::CURRENT_VERSION.to_string(),
|
||||
latest_version: None,
|
||||
has_update: false,
|
||||
download_url: None,
|
||||
release_notes_url: None,
|
||||
checked_at: now,
|
||||
error: Some(format!("GitHub API 请求失败: {}", response.status())),
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => UpdateInfo {
|
||||
current_version: Self::CURRENT_VERSION.to_string(),
|
||||
latest_version: None,
|
||||
has_update: false,
|
||||
download_url: None,
|
||||
release_notes_url: None,
|
||||
checked_at: now,
|
||||
error: Some(format!("网络请求失败: {}", e)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// 版本比较:返回 true 如果 latest > current
|
||||
fn version_compare(current: &str, latest: &str) -> bool {
|
||||
let current = current.trim_start_matches('v');
|
||||
let latest = latest.trim_start_matches('v');
|
||||
|
||||
let current_parts: Vec<u32> = current.split('.').filter_map(|s| s.parse().ok()).collect();
|
||||
let latest_parts: Vec<u32> = latest.split('.').filter_map(|s| s.parse().ok()).collect();
|
||||
|
||||
let max_len = current_parts.len().max(latest_parts.len());
|
||||
|
||||
for i in 0..max_len {
|
||||
let current_part = current_parts.get(i).unwrap_or(&0);
|
||||
let latest_part = latest_parts.get(i).unwrap_or(&0);
|
||||
|
||||
if latest_part > current_part {
|
||||
return true;
|
||||
} else if latest_part < current_part {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for UpdateCheckService {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// 发送系统通知(跨平台)
|
||||
///
|
||||
/// 使用 Tauri 的通知 API 发送原生系统通知
|
||||
#[cfg(feature = "notification")]
|
||||
pub async fn send_update_notification(
|
||||
app_handle: &tauri::AppHandle,
|
||||
update_info: &UpdateInfo,
|
||||
) -> Result<(), String> {
|
||||
use tauri::api::notification::Notification;
|
||||
|
||||
if !update_info.has_update {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let latest = update_info.latest_version.as_deref().unwrap_or("未知版本");
|
||||
|
||||
Notification::new(&app_handle.config().tauri.bundle.identifier)
|
||||
.title("ProxyCast 有新版本可用")
|
||||
.body(&format!(
|
||||
"新版本 {} 已发布,当前版本 {}",
|
||||
latest, update_info.current_version
|
||||
))
|
||||
.show()
|
||||
.map_err(|e| format!("发送通知失败: {}", e))
|
||||
}
|
||||
|
||||
/// 更新检查服务状态包装器(用于 Tauri 状态管理)
|
||||
pub struct UpdateCheckServiceState(pub Arc<RwLock<UpdateCheckService>>);
|
||||
|
||||
impl UpdateCheckServiceState {
|
||||
pub fn new() -> Self {
|
||||
Self(Arc::new(RwLock::new(UpdateCheckService::new())))
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for UpdateCheckServiceState {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_version_compare() {
|
||||
assert!(UpdateCheckService::version_compare("0.14.0", "0.14.1"));
|
||||
assert!(UpdateCheckService::version_compare("0.14.0", "0.15.0"));
|
||||
assert!(UpdateCheckService::version_compare("0.14.0", "1.0.0"));
|
||||
assert!(UpdateCheckService::version_compare("0.38.0", "0.39.0"));
|
||||
assert!(!UpdateCheckService::version_compare("0.14.1", "0.14.0"));
|
||||
assert!(!UpdateCheckService::version_compare("0.14.0", "0.14.0"));
|
||||
assert!(!UpdateCheckService::version_compare("1.0.0", "0.14.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_check() {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
|
||||
// 从未检查过,应该检查
|
||||
assert!(UpdateCheckService::should_check(0, 24, None, None));
|
||||
|
||||
// 刚检查过,不应该检查
|
||||
assert!(!UpdateCheckService::should_check(now, 24, None, None));
|
||||
|
||||
// 超过间隔,应该检查
|
||||
let old_timestamp = now - 25 * 3600;
|
||||
assert!(UpdateCheckService::should_check(
|
||||
old_timestamp,
|
||||
24,
|
||||
None,
|
||||
None
|
||||
));
|
||||
|
||||
// 跳过了当前最新版本,不应该检查
|
||||
assert!(!UpdateCheckService::should_check(
|
||||
0,
|
||||
24,
|
||||
Some("1.0.0"),
|
||||
Some("1.0.0")
|
||||
));
|
||||
|
||||
// 跳过了旧版本,应该检查
|
||||
assert!(UpdateCheckService::should_check(
|
||||
0,
|
||||
24,
|
||||
Some("0.9.0"),
|
||||
Some("1.0.0")
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
//! 更新提醒窗口管理
|
||||
//!
|
||||
//! 提供独立的更新提醒悬浮窗口,与主应用窗口分离显示。
|
||||
//! 参考 screenshot/window.rs 的实现模式。
|
||||
//!
|
||||
//! input: UpdateInfo(更新信息)
|
||||
//! output: 独立的更新提醒窗口
|
||||
//! pos: services 层,被 update_cmd 调用
|
||||
|
||||
use mouse_position::mouse_position::Mouse;
|
||||
use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder};
|
||||
use tracing::{debug, info};
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
use cocoa::appkit::{NSColor, NSWindow};
|
||||
#[cfg(target_os = "macos")]
|
||||
use cocoa::base::{id, nil};
|
||||
|
||||
use super::update_check_service::UpdateInfo;
|
||||
|
||||
/// 窗口错误类型
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum UpdateWindowError {
|
||||
#[error("窗口创建失败: {0}")]
|
||||
CreateFailed(String),
|
||||
#[error("窗口操作失败: {0}")]
|
||||
OperationFailed(String),
|
||||
}
|
||||
|
||||
/// 更新提醒窗口标签
|
||||
const UPDATE_WINDOW_LABEL: &str = "update-notification";
|
||||
|
||||
/// 窗口尺寸
|
||||
const WINDOW_WIDTH: f64 = 340.0;
|
||||
const WINDOW_HEIGHT: f64 = 280.0;
|
||||
|
||||
/// 距离屏幕顶部的边距
|
||||
const TOP_MARGIN: f64 = 80.0;
|
||||
|
||||
/// 获取鼠标所在的显示器
|
||||
fn get_monitor_at_cursor(app: &AppHandle) -> Option<tauri::Monitor> {
|
||||
let (cursor_x, cursor_y) = match Mouse::get_mouse_position() {
|
||||
Mouse::Position { x, y } => {
|
||||
debug!("鼠标位置: ({}, {})", x, y);
|
||||
(x as f64, y as f64)
|
||||
}
|
||||
Mouse::Error => {
|
||||
debug!("无法获取鼠标位置");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let monitors = match app.available_monitors() {
|
||||
Ok(monitors) => monitors,
|
||||
Err(e) => {
|
||||
debug!("无法获取显示器列表: {}", e);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
for monitor in monitors {
|
||||
let pos = monitor.position();
|
||||
let size = monitor.size();
|
||||
|
||||
let left = pos.x as f64;
|
||||
let top = pos.y as f64;
|
||||
let right = left + size.width as f64;
|
||||
let bottom = top + size.height as f64;
|
||||
|
||||
if cursor_x >= left && cursor_x < right && cursor_y >= top && cursor_y < bottom {
|
||||
debug!(
|
||||
"鼠标在显示器: {:?}, 位置: ({}, {}), 尺寸: {}x{}",
|
||||
monitor.name(),
|
||||
pos.x,
|
||||
pos.y,
|
||||
size.width,
|
||||
size.height
|
||||
);
|
||||
return Some(monitor);
|
||||
}
|
||||
}
|
||||
|
||||
debug!("未找到鼠标所在的显示器");
|
||||
None
|
||||
}
|
||||
|
||||
/// 计算窗口位置(鼠标所在屏幕的顶部居中)
|
||||
fn calculate_window_position(app: &AppHandle) -> (f64, f64) {
|
||||
// 优先获取鼠标所在的显示器,否则使用主显示器
|
||||
let monitor = get_monitor_at_cursor(app).or_else(|| app.primary_monitor().ok().flatten());
|
||||
|
||||
if let Some(monitor) = monitor {
|
||||
let screen_pos = monitor.position();
|
||||
let screen_size = monitor.size();
|
||||
let scale_factor = monitor.scale_factor();
|
||||
|
||||
// 物理像素转换为逻辑像素
|
||||
let screen_width = screen_size.width as f64 / scale_factor;
|
||||
let screen_x = screen_pos.x as f64 / scale_factor;
|
||||
let screen_y = screen_pos.y as f64 / scale_factor;
|
||||
|
||||
// 顶部居中定位
|
||||
let x = screen_x + (screen_width - WINDOW_WIDTH) / 2.0;
|
||||
let y = screen_y + TOP_MARGIN;
|
||||
|
||||
debug!(
|
||||
"更新窗口位置: 屏幕({}, {}), 尺寸: {}x?, 窗口位置: ({}, {})",
|
||||
screen_x, screen_y, screen_width, x, y
|
||||
);
|
||||
return (x, y);
|
||||
}
|
||||
|
||||
// 默认位置
|
||||
debug!("无法获取显示器信息,使用默认位置");
|
||||
(800.0, 400.0)
|
||||
}
|
||||
|
||||
/// 打开更新提醒窗口
|
||||
///
|
||||
/// 如果窗口已存在,则更新内容并显示;否则创建新窗口
|
||||
pub fn open_update_window(
|
||||
app: &AppHandle,
|
||||
update_info: &UpdateInfo,
|
||||
) -> Result<(), UpdateWindowError> {
|
||||
info!("打开更新提醒窗口");
|
||||
|
||||
// 构建窗口 URL,包含更新信息参数
|
||||
let url = format!(
|
||||
"/update-notification?current={}&latest={}&download_url={}",
|
||||
urlencoding::encode(&update_info.current_version),
|
||||
urlencoding::encode(update_info.latest_version.as_deref().unwrap_or("")),
|
||||
urlencoding::encode(update_info.download_url.as_deref().unwrap_or(""))
|
||||
);
|
||||
|
||||
debug!("更新窗口 URL: {}", url);
|
||||
|
||||
// 检查窗口是否已存在
|
||||
if let Some(window) = app.get_webview_window(UPDATE_WINDOW_LABEL) {
|
||||
info!("更新窗口已存在,导航到新 URL 并显示");
|
||||
|
||||
let (x, y) = calculate_window_position(app);
|
||||
use tauri::LogicalPosition;
|
||||
let _ = window.set_position(LogicalPosition::new(x, y));
|
||||
|
||||
// 使用 JavaScript 导航到新的 URL
|
||||
let js = format!("window.location.href = '{}';", url);
|
||||
window
|
||||
.eval(&js)
|
||||
.map_err(|e| UpdateWindowError::OperationFailed(format!("导航失败: {}", e)))?;
|
||||
|
||||
window
|
||||
.show()
|
||||
.map_err(|e| UpdateWindowError::OperationFailed(format!("显示窗口失败: {}", e)))?;
|
||||
|
||||
window
|
||||
.set_focus()
|
||||
.map_err(|e| UpdateWindowError::OperationFailed(format!("聚焦窗口失败: {}", e)))?;
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 窗口不存在,动态创建
|
||||
info!("动态创建更新提醒窗口");
|
||||
|
||||
let (x, y) = calculate_window_position(app);
|
||||
|
||||
let window = WebviewWindowBuilder::new(app, UPDATE_WINDOW_LABEL, WebviewUrl::App(url.into()))
|
||||
.inner_size(WINDOW_WIDTH, WINDOW_HEIGHT)
|
||||
.position(x, y)
|
||||
.decorations(false)
|
||||
.always_on_top(true)
|
||||
.skip_taskbar(true)
|
||||
.visible(true)
|
||||
.focused(true)
|
||||
.transparent(true)
|
||||
.resizable(false)
|
||||
.build()
|
||||
.map_err(|e| UpdateWindowError::CreateFailed(format!("{}", e)))?;
|
||||
|
||||
// macOS: 设置窗口背景透明
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
use objc::{msg_send, sel, sel_impl};
|
||||
if let Ok(ns_win) = window.ns_window() {
|
||||
unsafe {
|
||||
let ns_window = ns_win as id;
|
||||
let clear_color = NSColor::clearColor(nil);
|
||||
ns_window.setBackgroundColor_(clear_color);
|
||||
let _: () = msg_send![ns_window, setOpaque: false];
|
||||
let _: () = msg_send![ns_window, setHasShadow: false];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!("更新提醒窗口创建成功: {}", UPDATE_WINDOW_LABEL);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 关闭更新提醒窗口
|
||||
pub fn close_update_window(app: &AppHandle) -> Result<(), UpdateWindowError> {
|
||||
info!("关闭更新提醒窗口");
|
||||
|
||||
if let Some(window) = app.get_webview_window(UPDATE_WINDOW_LABEL) {
|
||||
window
|
||||
.close()
|
||||
.map_err(|e| UpdateWindowError::OperationFailed(format!("关闭窗口失败: {}", e)))?;
|
||||
info!("更新提醒窗口已关闭");
|
||||
} else {
|
||||
debug!("更新提醒窗口不存在,无需关闭");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 检查更新窗口是否打开
|
||||
pub fn is_update_window_open(app: &AppHandle) -> bool {
|
||||
app.get_webview_window(UPDATE_WINDOW_LABEL)
|
||||
.map(|w| w.is_visible().unwrap_or(false))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_window_label() {
|
||||
assert_eq!(UPDATE_WINDOW_LABEL, "update-notification");
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "ProxyCast",
|
||||
"version": "0.38.0",
|
||||
"version": "0.39.0",
|
||||
"identifier": "com.proxycast.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
@@ -5,12 +5,14 @@
|
||||
|
||||
import App from "./App";
|
||||
import { ScreenshotChatPage } from "./pages/screenshot-chat";
|
||||
import { UpdateNotificationPage } from "./pages/update-notification";
|
||||
import { Toaster } from "./components/ui/sonner";
|
||||
|
||||
/**
|
||||
* 根据 URL 路径渲染对应的组件
|
||||
*
|
||||
* - /screenshot-chat: 截图对话悬浮窗口(独立 Tauri 窗口)
|
||||
* - /update-notification: 更新提醒悬浮窗口(独立 Tauri 窗口)
|
||||
* - 其他: 主应用
|
||||
*/
|
||||
export function RootRouter() {
|
||||
@@ -21,6 +23,11 @@ export function RootRouter() {
|
||||
return <ScreenshotChatPage />;
|
||||
}
|
||||
|
||||
// 更新提醒悬浮窗口路由
|
||||
if (pathname === "/update-notification") {
|
||||
return <UpdateNotificationPage />;
|
||||
}
|
||||
|
||||
// 默认渲染主应用
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
ExperimentalFeatures,
|
||||
} from "@/hooks/useTauri";
|
||||
import { ShortcutSettings } from "@/components/screenshot-chat/ShortcutSettings";
|
||||
import { UpdateCheckSettings } from "./UpdateNotification";
|
||||
|
||||
// ============================================================
|
||||
// 组件
|
||||
@@ -257,6 +258,11 @@ export function ExperimentalSettings() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 自动更新检查设置 */}
|
||||
<div className="rounded-lg border p-4">
|
||||
<UpdateCheckSettings />
|
||||
</div>
|
||||
|
||||
{/* 更多实验功能占位 */}
|
||||
<div className="rounded-lg border border-dashed p-4 text-center">
|
||||
<p className="text-sm text-muted-foreground">更多实验功能即将推出...</p>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# settings
|
||||
|
||||
<!-- 一旦我所属的文件夹有所变化,请更新我 -->
|
||||
|
||||
## 架构说明
|
||||
|
||||
设置页面组件集合,提供应用配置界面。采用分组设计,每个设置类别独立为一个组件。
|
||||
|
||||
## 文件索引
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `index.ts` | 模块导出入口 |
|
||||
| `SettingsPage.tsx` | 设置页面主容器,管理标签页切换 |
|
||||
| `GeneralSettings.tsx` | 通用设置(主题、语言等) |
|
||||
| `ProxySettings.tsx` | 代理服务器设置 |
|
||||
| `DirectorySettings.tsx` | 目录路径设置 |
|
||||
| `TlsSettings.tsx` | TLS/SSL 证书设置 |
|
||||
| `QuotaSettings.tsx` | 配额管理设置 |
|
||||
| `ConnectionsSettings.tsx` | 连接管理设置 |
|
||||
| `RemoteManagementSettings.tsx` | 远程管理设置 |
|
||||
| `DeveloperSettings.tsx` | 开发者选项设置 |
|
||||
| `ExperimentalSettings.tsx` | 实验室功能设置(截图对话、自动更新检查等) |
|
||||
| `ExtensionsSettings.tsx` | 扩展管理设置 |
|
||||
| `AboutSection.tsx` | 关于页面(版本信息、更新检查) |
|
||||
| `LanguageSelector.tsx` | 语言选择器组件 |
|
||||
| `UpdateNotification.tsx` | 更新提醒弹窗和更新检查设置组件 |
|
||||
| `GeneralSettings.test.ts` | 通用设置测试文件 |
|
||||
|
||||
## 更新提醒
|
||||
|
||||
任何文件变更后,请更新此文档和相关的上级文档。
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* @file UpdateNotification.tsx
|
||||
* @description 更新检查设置组件
|
||||
*
|
||||
* 用于在设置页面中配置自动更新检查行为。
|
||||
* 更新提醒弹窗已移至独立窗口 (src/pages/update-notification.tsx)。
|
||||
*
|
||||
* input: 用户配置操作
|
||||
* output: 更新检查设置 UI
|
||||
* pos: components/settings 层
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { Bug } from "lucide-react";
|
||||
|
||||
interface UpdateCheckConfig {
|
||||
enabled: boolean;
|
||||
check_interval_hours: number;
|
||||
show_notification: boolean;
|
||||
last_check_timestamp: number;
|
||||
skipped_version: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新检查设置组件
|
||||
* 用于在设置页面中配置自动更新检查行为
|
||||
*/
|
||||
export function UpdateCheckSettings() {
|
||||
const [settings, setSettings] = useState<UpdateCheckConfig>({
|
||||
enabled: true,
|
||||
check_interval_hours: 24,
|
||||
show_notification: true,
|
||||
last_check_timestamp: 0,
|
||||
skipped_version: null,
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
const result = await invoke<UpdateCheckConfig>(
|
||||
"get_update_check_settings",
|
||||
);
|
||||
setSettings(result);
|
||||
} catch (error) {
|
||||
console.error("加载更新检查设置失败:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveSettings = async (newSettings: UpdateCheckConfig) => {
|
||||
try {
|
||||
await invoke("set_update_check_settings", { settings: newSettings });
|
||||
setSettings(newSettings);
|
||||
} catch (error) {
|
||||
console.error("保存更新检查设置失败:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleEnabled = () => {
|
||||
saveSettings({ ...settings, enabled: !settings.enabled });
|
||||
};
|
||||
|
||||
const handleToggleNotification = () => {
|
||||
saveSettings({
|
||||
...settings,
|
||||
show_notification: !settings.show_notification,
|
||||
});
|
||||
};
|
||||
|
||||
const handleIntervalChange = (hours: number) => {
|
||||
saveSettings({ ...settings, check_interval_hours: hours });
|
||||
};
|
||||
|
||||
const handleClearSkipped = () => {
|
||||
saveSettings({ ...settings, skipped_version: null });
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-4 rounded-lg border animate-pulse">
|
||||
<div className="h-4 bg-muted rounded w-1/3 mb-2"></div>
|
||||
<div className="h-3 bg-muted rounded w-2/3"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">自动更新检查</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* 启用自动检查 */}
|
||||
<div className="flex items-center justify-between p-3 rounded-lg border">
|
||||
<div>
|
||||
<div className="text-sm font-medium">自动检查更新</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
定期检查是否有新版本可用
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleToggleEnabled}
|
||||
className={`relative w-11 h-6 rounded-full transition-colors ${
|
||||
settings.enabled ? "bg-blue-600" : "bg-muted"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-white shadow transition-transform ${
|
||||
settings.enabled ? "translate-x-5" : ""
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 显示通知 */}
|
||||
<div className="flex items-center justify-between p-3 rounded-lg border">
|
||||
<div>
|
||||
<div className="text-sm font-medium">显示更新通知</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
发现新版本时显示弹窗提醒
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleToggleNotification}
|
||||
disabled={!settings.enabled}
|
||||
className={`relative w-11 h-6 rounded-full transition-colors disabled:opacity-50 ${
|
||||
settings.show_notification ? "bg-blue-600" : "bg-muted"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-white shadow transition-transform ${
|
||||
settings.show_notification ? "translate-x-5" : ""
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 检查间隔 */}
|
||||
<div className="p-3 rounded-lg border">
|
||||
<div className="text-sm font-medium mb-2">检查间隔</div>
|
||||
<div className="flex gap-2">
|
||||
{[12, 24, 48, 168].map((hours) => (
|
||||
<button
|
||||
key={hours}
|
||||
onClick={() => handleIntervalChange(hours)}
|
||||
disabled={!settings.enabled}
|
||||
className={`px-3 py-1.5 rounded-md text-xs transition-colors disabled:opacity-50 ${
|
||||
settings.check_interval_hours === hours
|
||||
? "bg-blue-600 text-white"
|
||||
: "bg-muted hover:bg-muted/80"
|
||||
}`}
|
||||
>
|
||||
{hours === 168 ? "每周" : `${hours}小时`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 已跳过的版本 */}
|
||||
{settings.skipped_version && (
|
||||
<div className="flex items-center justify-between p-3 rounded-lg border bg-muted/30">
|
||||
<div>
|
||||
<div className="text-sm">已跳过版本</div>
|
||||
<div className="text-xs text-muted-foreground font-mono">
|
||||
{settings.skipped_version}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClearSkipped}
|
||||
className="px-3 py-1 rounded-md text-xs bg-muted hover:bg-muted/80 transition-colors"
|
||||
>
|
||||
清除
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 上次检查时间 */}
|
||||
{settings.last_check_timestamp > 0 && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
上次检查:{" "}
|
||||
{new Date(settings.last_check_timestamp * 1000).toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 测试按钮(仅开发环境) */}
|
||||
{import.meta.env.DEV && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await invoke("test_update_window");
|
||||
} catch (error) {
|
||||
console.error("测试更新弹窗失败:", error);
|
||||
}
|
||||
}}
|
||||
className="flex items-center justify-center gap-1.5 w-full px-3 py-1.5 rounded-lg border border-dashed border-orange-400 text-orange-600 text-xs hover:bg-orange-50 dark:hover:bg-orange-900/20 transition-colors"
|
||||
>
|
||||
<Bug className="h-3.5 w-3.5" />
|
||||
测试更新弹窗
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 保持向后兼容的导出(已废弃,返回 null)
|
||||
export function UpdateNotification() {
|
||||
return null;
|
||||
}
|
||||
@@ -11,6 +11,8 @@
|
||||
|
||||
- `FlowMonitorPage.tsx` - LLM 流量监控页面
|
||||
- `screenshot-chat.tsx` - 截图对话悬浮窗口页面(独立 Tauri 窗口)
|
||||
- `update-notification.tsx` - 更新提醒悬浮窗口页面(独立 Tauri 窗口)
|
||||
- `update-notification.css` - 更新提醒窗口样式
|
||||
- `index.ts` - 页面导出入口
|
||||
|
||||
## 更新提醒
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* @file update-notification.css
|
||||
* @description 更新提醒独立窗口样式
|
||||
*
|
||||
* 独立窗口的样式,支持透明背景和圆角卡片设计。
|
||||
*/
|
||||
|
||||
.update-container {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
padding: 16px;
|
||||
box-sizing: border-box;
|
||||
/* 允许拖动窗口 */
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
.update-card {
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border-radius: 16px;
|
||||
box-shadow:
|
||||
0 8px 32px rgba(0, 0, 0, 0.12),
|
||||
0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
overflow: hidden;
|
||||
/* 卡片内容不可拖动 */
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
/* 深色模式 */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.update-card {
|
||||
background: rgba(30, 30, 30, 0.95);
|
||||
box-shadow:
|
||||
0 8px 32px rgba(0, 0, 0, 0.4),
|
||||
0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.update-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 16px 12px;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(59, 130, 246, 0.1),
|
||||
rgba(147, 51, 234, 0.1)
|
||||
);
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.update-header:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.update-header {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(59, 130, 246, 0.15),
|
||||
rgba(147, 51, 234, 0.15)
|
||||
);
|
||||
border-bottom-color: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
}
|
||||
|
||||
.update-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.update-title {
|
||||
color: #f0f0f0;
|
||||
}
|
||||
}
|
||||
|
||||
.update-logo {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.update-close-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 6px;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.update-close-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
color: #333;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.update-close-btn {
|
||||
color: #999;
|
||||
}
|
||||
.update-close-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.update-content {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.update-version-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.update-label {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.update-label {
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.update-value {
|
||||
font-size: 13px;
|
||||
font-family:
|
||||
ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, monospace;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.update-value {
|
||||
color: #f0f0f0;
|
||||
}
|
||||
}
|
||||
|
||||
.update-value.update-new {
|
||||
color: #16a34a;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.update-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.update-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 10px 16px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.update-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.update-btn-primary {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.update-btn-primary:hover:not(:disabled) {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.update-btn-secondary {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: 1px solid rgba(0, 0, 0, 0.12);
|
||||
color: #333;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.update-btn-secondary:hover:not(:disabled) {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.update-btn-secondary {
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
color: #e0e0e0;
|
||||
}
|
||||
.update-btn-secondary:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
}
|
||||
|
||||
.update-btn-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.update-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 8px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.update-link:hover {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.update-link {
|
||||
color: #999;
|
||||
}
|
||||
.update-link:hover {
|
||||
color: #60a5fa;
|
||||
}
|
||||
}
|
||||
|
||||
/* 动画 */
|
||||
@keyframes bounce {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-bounce {
|
||||
animation: bounce 0.6s ease infinite;
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* @file update-notification.tsx
|
||||
* @description 更新提醒独立窗口页面
|
||||
*
|
||||
* 独立于主应用的更新提醒悬浮窗口,显示版本信息和更新操作。
|
||||
* 参考 screenshot-chat.tsx 的实现模式。
|
||||
*
|
||||
* input: URL 参数(current, latest, download_url)
|
||||
* output: 更新提醒 UI
|
||||
* pos: pages 层,独立 Tauri 窗口
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback, type MouseEvent } from "react";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { open as shellOpen } from "@tauri-apps/plugin-shell";
|
||||
import { Download, X, Clock, SkipForward, ExternalLink } from "lucide-react";
|
||||
import "./update-notification.css";
|
||||
|
||||
interface UpdateParams {
|
||||
currentVersion: string;
|
||||
latestVersion: string;
|
||||
downloadUrl: string;
|
||||
}
|
||||
|
||||
function getUpdateParamsFromUrl(): UpdateParams {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return {
|
||||
currentVersion: params.get("current") || "",
|
||||
latestVersion: params.get("latest") || "",
|
||||
downloadUrl: params.get("download_url") || "",
|
||||
};
|
||||
}
|
||||
|
||||
export function UpdateNotificationPage() {
|
||||
const [params, setParams] = useState<UpdateParams>({
|
||||
currentVersion: "",
|
||||
latestVersion: "",
|
||||
downloadUrl: "",
|
||||
});
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setParams(getUpdateParamsFromUrl());
|
||||
}, []);
|
||||
|
||||
// 关闭窗口
|
||||
const handleClose = useCallback(async () => {
|
||||
try {
|
||||
await invoke("close_update_window");
|
||||
} catch (err) {
|
||||
console.error("关闭窗口失败:", err);
|
||||
// 备用方案:直接关闭
|
||||
await getCurrentWindow().close();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ESC 关闭窗口
|
||||
useEffect(() => {
|
||||
const handleKeyDown = async (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
await handleClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [handleClose]);
|
||||
|
||||
// 开始拖动窗口
|
||||
const handleStartDrag = useCallback(async (e: MouseEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
try {
|
||||
await getCurrentWindow().startDragging();
|
||||
} catch (err) {
|
||||
console.error("拖动窗口失败:", err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 立即更新
|
||||
const handleDownload = async () => {
|
||||
setDownloading(true);
|
||||
try {
|
||||
await invoke("download_update");
|
||||
// download_update 成功后会自动关闭窗口并启动安装程序
|
||||
} catch (error) {
|
||||
console.error("下载更新失败:", error);
|
||||
// 如果下载失败,尝试打开浏览器
|
||||
if (params.downloadUrl) {
|
||||
try {
|
||||
await shellOpen(params.downloadUrl);
|
||||
await handleClose();
|
||||
} catch {
|
||||
window.open(params.downloadUrl, "_blank");
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 稍后提醒
|
||||
const handleLater = async () => {
|
||||
await handleClose();
|
||||
};
|
||||
|
||||
// 跳过此版本
|
||||
const handleSkipVersion = async () => {
|
||||
if (params.latestVersion) {
|
||||
try {
|
||||
await invoke("skip_update_version", {
|
||||
version: params.latestVersion,
|
||||
});
|
||||
await handleClose();
|
||||
} catch (error) {
|
||||
console.error("跳过版本失败:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 在浏览器中打开
|
||||
const handleOpenInBrowser = async () => {
|
||||
if (params.downloadUrl) {
|
||||
try {
|
||||
await shellOpen(params.downloadUrl);
|
||||
} catch (error) {
|
||||
console.error("打开浏览器失败:", error);
|
||||
// 备用方案
|
||||
window.open(params.downloadUrl, "_blank");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="update-container">
|
||||
<div className="update-card">
|
||||
{/* 头部 - 可拖动区域 */}
|
||||
<div className="update-header" onMouseDown={handleStartDrag}>
|
||||
<div className="update-title">
|
||||
<img src="/logo.png" alt="ProxyCast" className="update-logo" />
|
||||
<span>发现新版本</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="update-close-btn"
|
||||
title="关闭 (ESC)"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 内容 */}
|
||||
<div className="update-content">
|
||||
<div className="update-version-row">
|
||||
<span className="update-label">当前版本</span>
|
||||
<span className="update-value">{params.currentVersion}</span>
|
||||
</div>
|
||||
<div className="update-version-row">
|
||||
<span className="update-label">最新版本</span>
|
||||
<span className="update-value update-new">
|
||||
{params.latestVersion}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="update-actions">
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
disabled={downloading}
|
||||
className="update-btn update-btn-primary"
|
||||
>
|
||||
<Download
|
||||
size={16}
|
||||
className={downloading ? "animate-bounce" : ""}
|
||||
/>
|
||||
{downloading ? "下载中..." : "立即更新"}
|
||||
</button>
|
||||
|
||||
<div className="update-btn-row">
|
||||
<button
|
||||
onClick={handleLater}
|
||||
className="update-btn update-btn-secondary"
|
||||
>
|
||||
<Clock size={14} />
|
||||
稍后提醒
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSkipVersion}
|
||||
className="update-btn update-btn-secondary"
|
||||
>
|
||||
<SkipForward size={14} />
|
||||
跳过此版本
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{params.downloadUrl && (
|
||||
<button onClick={handleOpenInBrowser} className="update-link">
|
||||
<ExternalLink size={12} />
|
||||
在浏览器中查看
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default UpdateNotificationPage;
|
||||
Reference in New Issue
Block a user