feat(updater): 优化更新体验与 UI

- ui: UpdateBadge 使用 Tailwind 内置过渡,支持点击打开设置,保留图标动画

- updater: 新增 UpdateContext 首启延迟检查,忽略版本键名命名空间化(含旧键迁移),并发保护

- settings: 去除版本硬编码回退;检测到更新时复用 updateHandle 下载并安装,并新增常显“更新日志”入口

- a11y: 更新徽标支持键盘触达(Enter/Space)

- refactor: 移除未使用的 runUpdateFlow 导出

- chore: 类型检查通过,整体行为与权限边界未改变
This commit is contained in:
Jason
2025-09-10 19:46:38 +08:00
parent bf7e13d4e9
commit af8b9289fe
8 changed files with 321 additions and 68 deletions

2
src-tauri/Cargo.lock generated
View File

@@ -559,7 +559,7 @@ dependencies = [
[[package]] [[package]]
name = "cc-switch" name = "cc-switch"
version = "3.1.1" version = "3.1.2"
dependencies = [ dependencies = [
"dirs 5.0.1", "dirs 5.0.1",
"log", "log",

View File

@@ -7,6 +7,7 @@ import EditProviderModal from "./components/EditProviderModal";
import { ConfirmDialog } from "./components/ConfirmDialog"; import { ConfirmDialog } from "./components/ConfirmDialog";
import { AppSwitcher } from "./components/AppSwitcher"; import { AppSwitcher } from "./components/AppSwitcher";
import SettingsModal from "./components/SettingsModal"; import SettingsModal from "./components/SettingsModal";
import { UpdateBadge } from "./components/UpdateBadge";
import { Plus, Settings, Moon, Sun } from "lucide-react"; import { Plus, Settings, Moon, Sun } from "lucide-react";
import { buttonStyles } from "./lib/styles"; import { buttonStyles } from "./lib/styles";
import { useDarkMode } from "./hooks/useDarkMode"; import { useDarkMode } from "./hooks/useDarkMode";
@@ -220,13 +221,16 @@ function App() {
> >
{isDarkMode ? <Sun size={18} /> : <Moon size={18} />} {isDarkMode ? <Sun size={18} /> : <Moon size={18} />}
</button> </button>
<button <div className="flex items-center gap-2">
onClick={() => setIsSettingsOpen(true)} <button
className={buttonStyles.icon} onClick={() => setIsSettingsOpen(true)}
title="设置" className={buttonStyles.icon}
> title="设置"
<Settings size={18} /> >
</button> <Settings size={18} />
</button>
<UpdateBadge onClick={() => setIsSettingsOpen(true)} />
</div>
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">

View File

@@ -419,14 +419,12 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
// 初始时从配置中同步 API Key编辑模式 // 初始时从配置中同步 API Key编辑模式
useEffect(() => { useEffect(() => {
if (initialData) { if (!initialData) return;
const parsedKey = getApiKeyFromConfig( const parsedKey = getApiKeyFromConfig(
JSON.stringify(initialData.settingsConfig), JSON.stringify(initialData.settingsConfig),
); );
if (parsedKey) setApiKey(parsedKey); if (parsedKey) setApiKey(parsedKey);
} }, [initialData]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// 支持按下 ESC 关闭弹窗 // 支持按下 ESC 关闭弹窗
useEffect(() => { useEffect(() => {

View File

@@ -1,8 +1,9 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { X, Info, RefreshCw, FolderOpen } from "lucide-react"; import { X, Info, RefreshCw, FolderOpen, Download, ExternalLink } from "lucide-react";
import { getVersion } from "@tauri-apps/api/app"; import { getVersion } from "@tauri-apps/api/app";
import "../lib/tauri-api"; import "../lib/tauri-api";
import { runUpdateFlow } from "../lib/updater"; import { relaunchApp } from "../lib/updater";
import { useUpdate } from "../contexts/UpdateContext";
import type { Settings } from "../types"; import type { Settings } from "../types";
interface SettingsModalProps { interface SettingsModalProps {
@@ -16,6 +17,8 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
const [configPath, setConfigPath] = useState<string>(""); const [configPath, setConfigPath] = useState<string>("");
const [version, setVersion] = useState<string>(""); const [version, setVersion] = useState<string>("");
const [isCheckingUpdate, setIsCheckingUpdate] = useState(false); const [isCheckingUpdate, setIsCheckingUpdate] = useState(false);
const [isDownloading, setIsDownloading] = useState(false);
const { hasUpdate, updateInfo, updateHandle, checkUpdate, resetDismiss } = useUpdate();
useEffect(() => { useEffect(() => {
loadSettings(); loadSettings();
@@ -29,7 +32,8 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
setVersion(appVersion); setVersion(appVersion);
} catch (error) { } catch (error) {
console.error("获取版本信息失败:", error); console.error("获取版本信息失败:", error);
setVersion("3.1.1"); // 降级使用默认版本 // 失败时不硬编码版本号,显示为未知
setVersion("未知");
} }
}; };
@@ -65,15 +69,32 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
}; };
const handleCheckUpdate = async () => { const handleCheckUpdate = async () => {
setIsCheckingUpdate(true); if (hasUpdate && updateHandle) {
try { // 已检测到更新:直接复用 updateHandle 下载并安装,避免重复检查
// 优先使用 Tauri Updater 流程;失败时回退到打开 Releases 页面 setIsDownloading(true);
await runUpdateFlow({ timeout: 30000 }); try {
} catch (error) { resetDismiss();
console.error("检查更新失败,回退到 Releases 页面:", error); await updateHandle.downloadAndInstall();
await window.api.checkForUpdates(); await relaunchApp();
} finally { } catch (error) {
setIsCheckingUpdate(false); console.error("更新失败:", error);
// 更新失败时回退到打开 Releases 页面
await window.api.checkForUpdates();
} finally {
setIsDownloading(false);
}
} else {
// 尚未检测到更新:先检查
setIsCheckingUpdate(true);
try {
await checkUpdate();
// 检查后若有更新,让用户再次点击执行
} catch (error) {
console.error("检查更新失败,回退到 Releases 页面:", error);
await window.api.checkForUpdates();
} finally {
setIsCheckingUpdate(false);
}
} }
}; };
@@ -85,6 +106,23 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
} }
}; };
const handleOpenReleaseNotes = async () => {
try {
const targetVersion = updateInfo?.availableVersion || version;
// 如果未知或为空,回退到 releases 首页
if (!targetVersion || targetVersion === "未知") {
await window.api.openExternal("https://github.com/farion1231/cc-switch/releases");
return;
}
const tag = targetVersion.startsWith("v") ? targetVersion : `v${targetVersion}`;
await window.api.openExternal(
`https://github.com/farion1231/cc-switch/releases/tag/${tag}`,
);
} catch (error) {
console.error("打开更新日志失败:", error);
}
};
return ( return (
<div className="fixed inset-0 bg-black/50 dark:bg-black/70 flex items-center justify-center z-50"> <div className="fixed inset-0 bg-black/50 dark:bg-black/70 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-2xl w-[500px] overflow-hidden"> <div className="bg-white dark:bg-gray-900 rounded-xl shadow-2xl w-[500px] overflow-hidden">
@@ -168,24 +206,48 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
</p> </p>
</div> </div>
</div> </div>
<button <div className="flex items-center gap-2">
onClick={handleCheckUpdate} <button
disabled={isCheckingUpdate} onClick={handleOpenReleaseNotes}
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-all ${ className="px-2 py-1 text-xs font-medium text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300 rounded-lg hover:bg-blue-500/10 transition-colors"
isCheckingUpdate title={hasUpdate ? "查看该版本更新日志" : "查看当前版本更新日志"}
? "bg-white dark:bg-gray-700 text-gray-400 dark:text-gray-500" >
: "bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 text-blue-500 dark:text-blue-400" <span className="inline-flex items-center gap-1">
}`} <ExternalLink size={12} />
>
{isCheckingUpdate ? (
<span className="flex items-center gap-1">
<RefreshCw size={12} className="animate-spin" />
...
</span> </span>
) : ( </button>
"检查更新" <button
)} onClick={handleCheckUpdate}
</button> disabled={isCheckingUpdate || isDownloading}
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-all ${
isCheckingUpdate || isDownloading
? "bg-white dark:bg-gray-700 text-gray-400 dark:text-gray-500"
: hasUpdate
? "bg-gradient-to-r from-blue-500 to-purple-500 hover:from-blue-600 hover:to-purple-600 text-white"
: "bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 text-blue-500 dark:text-blue-400"
}`}
>
{isDownloading ? (
<span className="flex items-center gap-1">
<Download size={12} className="animate-pulse" />
...
</span>
) : isCheckingUpdate ? (
<span className="flex items-center gap-1">
<RefreshCw size={12} className="animate-spin" />
...
</span>
) : hasUpdate ? (
<span className="flex items-center gap-1">
<Download size={12} />
{updateInfo?.availableVersion}
</span>
) : (
"检查更新"
)}
</button>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,59 @@
import { X, Sparkles } from "lucide-react";
import { useUpdate } from "../contexts/UpdateContext";
interface UpdateBadgeProps {
className?: string;
onClick?: () => void; // 点击徽标的回调(例如打开设置)
}
export function UpdateBadge({ className = "", onClick }: UpdateBadgeProps) {
const { hasUpdate, updateInfo, isDismissed, dismissUpdate } = useUpdate();
// 如果没有更新或已关闭,不显示
if (!hasUpdate || isDismissed || !updateInfo) {
return null;
}
return (
<div
className={`
flex items-center gap-2 px-3 py-1.5
bg-gradient-to-r from-blue-500/20 to-purple-500/20
border border-blue-500/30
rounded-full text-xs
transition-all duration-200
${onClick ? "cursor-pointer hover:border-blue-400/50" : ""}
${className}
`}
role={onClick ? "button" : undefined}
tabIndex={onClick ? 0 : -1}
onClick={onClick}
onKeyDown={(e) => {
if (!onClick) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onClick();
}
}}
>
<Sparkles className="w-3 h-3 text-blue-400 animate-pulse" />
<span className="text-gray-200 font-medium">
{updateInfo.availableVersion}
</span>
<button
onClick={(e) => {
e.stopPropagation();
dismissUpdate();
}}
className="
-mr-1 p-0.5 rounded-full
hover:bg-white/10 transition-colors
focus:outline-none focus:ring-2 focus:ring-blue-500/50
"
aria-label="关闭更新提醒"
>
<X className="w-3 h-3 text-gray-400 hover:text-gray-200" />
</button>
</div>
);
}

View File

@@ -0,0 +1,147 @@
import React, { createContext, useContext, useState, useEffect, useCallback, useRef } from "react";
import type { UpdateInfo, UpdateHandle } from "../lib/updater";
import { checkForUpdate } from "../lib/updater";
interface UpdateContextValue {
// 更新状态
hasUpdate: boolean;
updateInfo: UpdateInfo | null;
updateHandle: UpdateHandle | null;
isChecking: boolean;
error: string | null;
// 提示状态
isDismissed: boolean;
dismissUpdate: () => void;
// 操作方法
checkUpdate: () => Promise<void>;
resetDismiss: () => void;
}
const UpdateContext = createContext<UpdateContextValue | undefined>(undefined);
export function UpdateProvider({ children }: { children: React.ReactNode }) {
const DISMISSED_VERSION_KEY = "ccswitch:update:dismissedVersion";
const LEGACY_DISMISSED_KEY = "dismissedUpdateVersion"; // 兼容旧键
const [hasUpdate, setHasUpdate] = useState(false);
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null);
const [updateHandle, setUpdateHandle] = useState<UpdateHandle | null>(null);
const [isChecking, setIsChecking] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isDismissed, setIsDismissed] = useState(false);
// 从 localStorage 读取已关闭的版本
useEffect(() => {
const current = updateInfo?.availableVersion;
if (!current) return;
// 读取新键;若不存在,尝试迁移旧键
let dismissedVersion = localStorage.getItem(DISMISSED_VERSION_KEY);
if (!dismissedVersion) {
const legacy = localStorage.getItem(LEGACY_DISMISSED_KEY);
if (legacy) {
localStorage.setItem(DISMISSED_VERSION_KEY, legacy);
localStorage.removeItem(LEGACY_DISMISSED_KEY);
dismissedVersion = legacy;
}
}
setIsDismissed(dismissedVersion === current);
}, [updateInfo?.availableVersion]);
const isCheckingRef = useRef(false);
const checkUpdate = useCallback(async () => {
if (isCheckingRef.current) return;
isCheckingRef.current = true;
setIsChecking(true);
setError(null);
try {
const result = await checkForUpdate({ timeout: 30000 });
if (result.status === "available") {
setHasUpdate(true);
setUpdateInfo(result.info);
setUpdateHandle(result.update);
// 检查是否已经关闭过这个版本的提醒
let dismissedVersion = localStorage.getItem(DISMISSED_VERSION_KEY);
if (!dismissedVersion) {
const legacy = localStorage.getItem(LEGACY_DISMISSED_KEY);
if (legacy) {
localStorage.setItem(DISMISSED_VERSION_KEY, legacy);
localStorage.removeItem(LEGACY_DISMISSED_KEY);
dismissedVersion = legacy;
}
}
setIsDismissed(dismissedVersion === result.info.availableVersion);
} else {
setHasUpdate(false);
setUpdateInfo(null);
setUpdateHandle(null);
setIsDismissed(false);
}
} catch (err) {
console.error("检查更新失败:", err);
setError(err instanceof Error ? err.message : "检查更新失败");
setHasUpdate(false);
} finally {
setIsChecking(false);
isCheckingRef.current = false;
}
}, []);
const dismissUpdate = useCallback(() => {
setIsDismissed(true);
if (updateInfo?.availableVersion) {
localStorage.setItem(DISMISSED_VERSION_KEY, updateInfo.availableVersion);
// 清理旧键
localStorage.removeItem(LEGACY_DISMISSED_KEY);
}
}, [updateInfo?.availableVersion]);
const resetDismiss = useCallback(() => {
setIsDismissed(false);
localStorage.removeItem(DISMISSED_VERSION_KEY);
localStorage.removeItem(LEGACY_DISMISSED_KEY);
}, []);
// 应用启动时自动检查更新
useEffect(() => {
// 延迟1秒后检查避免影响启动体验
const timer = setTimeout(() => {
checkUpdate().catch(console.error);
}, 1000);
return () => clearTimeout(timer);
}, [checkUpdate]);
const value: UpdateContextValue = {
hasUpdate,
updateInfo,
updateHandle,
isChecking,
error,
isDismissed,
dismissUpdate,
checkUpdate,
resetDismiss,
};
return (
<UpdateContext.Provider value={value}>
{children}
</UpdateContext.Provider>
);
}
export function useUpdate() {
const context = useContext(UpdateContext);
if (!context) {
throw new Error("useUpdate must be used within UpdateProvider");
}
return context;
}

View File

@@ -122,25 +122,5 @@ export async function relaunchApp(): Promise<void> {
await relaunch(); await relaunch();
} }
export async function runUpdateFlow( // 旧的聚合更新流程已由调用方直接使用 updateHandle 取代
opts: CheckOptions = {}, // 如需单函数封装,可在需要时基于 checkForUpdate + updateHandle 复合调用
): Promise<{ status: "up-to-date" | "done" }> {
const result = await checkForUpdate(opts);
if (result.status === "up-to-date") return result;
let downloaded = 0;
let total = 0;
await result.update.downloadAndInstall((e) => {
if (e.event === "Started") {
total = e.total ?? 0;
downloaded = 0;
} else if (e.event === "Progress") {
downloaded += e.downloaded ?? 0;
// 调用方可监听此处并更新 UI目前设置页仅显示加载态
console.debug("update progress", { downloaded, total });
}
});
await relaunchApp();
return { status: "done" };
}

View File

@@ -1,6 +1,7 @@
import React from "react"; import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import App from "./App"; import App from "./App";
import { UpdateProvider } from "./contexts/UpdateContext";
import "./index.css"; import "./index.css";
// 导入 Tauri API自动绑定到 window.api // 导入 Tauri API自动绑定到 window.api
import "./lib/tauri-api"; import "./lib/tauri-api";
@@ -19,6 +20,8 @@ try {
ReactDOM.createRoot(document.getElementById("root")!).render( ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode> <React.StrictMode>
<App /> <UpdateProvider>
<App />
</UpdateProvider>
</React.StrictMode>, </React.StrictMode>,
); );