feat:优化更新功能与UI

This commit is contained in:
ILoveBingLu
2026-04-04 00:04:14 +08:00
parent 66a96c089f
commit 1c26d8dac2
8 changed files with 437 additions and 85 deletions
+98 -16
View File
@@ -12,14 +12,17 @@
right: 24px;
display: flex;
align-items: center;
gap: 12px;
padding: 16px 20px;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
gap: 14px;
min-width: 320px;
max-width: 420px;
padding: 16px 18px;
background: color-mix(in srgb, var(--bg-primary) 88%, white 12%);
border: 1px solid color-mix(in srgb, var(--primary) 22%, var(--border-color) 78%);
border-radius: 18px;
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.16);
z-index: 1000;
animation: slideUp 0.3s ease;
backdrop-filter: blur(18px);
@keyframes slideUp {
from {
@@ -33,15 +36,25 @@
}
.update-toast-icon {
font-size: 28px;
width: 42px;
height: 42px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 14px;
flex-shrink: 0;
font-size: 22px;
background: color-mix(in srgb, var(--primary) 14%, transparent);
color: var(--primary);
}
.update-toast-content {
flex: 1;
min-width: 0;
.update-toast-title {
font-size: 14px;
font-weight: 600;
font-size: 15px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 2px;
}
@@ -49,13 +62,30 @@
.update-toast-version {
font-size: 12px;
color: var(--text-secondary);
line-height: 1.45;
}
.update-toast-meta {
margin-top: 8px;
display: flex;
flex-wrap: wrap;
gap: 8px;
span {
font-size: 11px;
color: var(--text-secondary);
padding: 4px 8px;
border-radius: 999px;
background: color-mix(in srgb, var(--bg-tertiary) 75%, transparent);
}
}
}
.update-toast-btn {
padding: 8px 16px;
padding: 0 16px;
height: 38px;
border: none;
border-radius: 8px;
border-radius: 10px;
background: var(--primary);
color: white;
font-size: 13px;
@@ -63,7 +93,12 @@
cursor: pointer;
transition: all 0.2s;
&:hover {
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&:hover:not(:disabled) {
background: var(--primary-hover);
}
}
@@ -86,6 +121,30 @@
color: var(--text-primary);
}
}
.update-toast-progress {
width: 88px;
flex-shrink: 0;
}
.update-toast-progress-bar {
width: 100%;
height: 8px;
border-radius: 999px;
overflow: hidden;
background: color-mix(in srgb, var(--bg-tertiary) 82%, transparent);
}
.update-toast-progress-fill {
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, var(--primary), color-mix(in srgb, var(--primary) 65%, white));
transition: width 0.2s linear;
}
&.is-downloading {
min-width: 360px;
}
}
.force-update-overlay {
@@ -374,8 +433,8 @@
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 12px;
padding: 8px 16px;
gap: 14px;
padding: 10px 16px;
background: rgba(30, 30, 30, 0.9);
backdrop-filter: blur(8px);
border-radius: 20px;
@@ -392,8 +451,31 @@
color: var(--primary);
}
span {
white-space: nowrap;
.capsule-copy {
display: flex;
flex-direction: column;
gap: 2px;
span {
white-space: nowrap;
}
small {
font-size: 11px;
color: rgba(255, 255, 255, 0.72);
}
}
.capsule-progress {
display: flex;
flex-direction: column;
gap: 4px;
small {
font-size: 11px;
color: rgba(255, 255, 255, 0.72);
white-space: nowrap;
}
}
.progress-bar-bg {
+124 -24
View File
@@ -51,6 +51,25 @@ type AppUpdateInfo = {
checkedAt: number
updateSource: 'github' | 'custom' | 'none'
policySource: 'github' | 'custom' | 'none'
diagnostics?: {
phase: 'idle' | 'checking' | 'available' | 'downloading' | 'downloaded' | 'installing' | 'failed'
strategy: 'unknown' | 'differential' | 'full'
fallbackToFull: boolean
lastError?: string
lastEvent?: string
progressPercent?: number
downloadedBytes?: number
totalBytes?: number
targetVersion?: string
lastUpdatedAt: number
}
}
type UpdateDownloadProgressPayload = {
percent: number
transferred: number
total: number
bytesPerSecond: number
}
function App() {
@@ -71,7 +90,25 @@ function App() {
// 更新提示状态
const [updateInfo, setUpdateInfo] = useState<AppUpdateInfo | null>(null)
const [downloadProgress, setDownloadProgress] = useState<number | null>(null)
const [downloadProgress, setDownloadProgress] = useState<UpdateDownloadProgressPayload | null>(null)
const formatSpeed = (bytesPerSecond: number) => {
if (!Number.isFinite(bytesPerSecond) || bytesPerSecond <= 0) return '计算中'
if (bytesPerSecond < 1024) return `${bytesPerSecond.toFixed(0)} B/s`
if (bytesPerSecond < 1024 * 1024) return `${(bytesPerSecond / 1024).toFixed(1)} KB/s`
return `${(bytesPerSecond / (1024 * 1024)).toFixed(1)} MB/s`
}
const formatBytes = (bytes?: number) => {
if (!bytes || bytes <= 0) return '0 B'
if (bytes < 1024) return `${bytes.toFixed(0)} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`
}
const isUpdateDownloading = updateInfo?.diagnostics?.phase === 'downloading' || updateInfo?.diagnostics?.phase === 'installing'
const progressPercent = downloadProgress?.percent ?? updateInfo?.diagnostics?.progressPercent ?? null
// 加载主题配置
useEffect(() => {
@@ -197,6 +234,24 @@ function App() {
useEffect(() => {
const removeDownloadListener = window.electronAPI.app.onDownloadProgress?.((progress) => {
setDownloadProgress(progress)
setUpdateInfo((current) => {
if (!current) return current
return {
...current,
diagnostics: {
phase: 'downloading',
strategy: current.diagnostics?.strategy || 'unknown',
fallbackToFull: current.diagnostics?.fallbackToFull || false,
lastError: current.diagnostics?.lastError,
lastEvent: current.diagnostics?.lastEvent,
progressPercent: progress.percent,
downloadedBytes: progress.transferred,
totalBytes: progress.total,
targetVersion: current.version || current.diagnostics?.targetVersion,
lastUpdatedAt: Date.now()
}
}
})
})
return () => {
removeDownloadListener?.()
@@ -204,10 +259,30 @@ function App() {
}, [])
const dismissUpdate = () => {
if (updateInfo?.forceUpdate) return
if (updateInfo?.forceUpdate || isUpdateDownloading) return
setUpdateInfo(null)
}
const handleStartUpdate = () => {
if (isUpdateDownloading) return
setUpdateInfo((current) => current ? {
...current,
diagnostics: {
phase: 'downloading',
strategy: current.diagnostics?.strategy || 'unknown',
fallbackToFull: current.diagnostics?.fallbackToFull || false,
lastError: undefined,
lastEvent: '开始下载更新',
progressPercent: 0,
downloadedBytes: 0,
totalBytes: current.diagnostics?.totalBytes,
targetVersion: current.version || current.diagnostics?.targetVersion,
lastUpdatedAt: Date.now()
}
} : current)
window.electronAPI.app.downloadAndInstall()
}
// 检查是否是独立聊天窗口
const isChatWindow = location.pathname === '/chat-window'
const isGroupAnalyticsWindow = location.pathname === '/group-analytics-window'
@@ -493,22 +568,41 @@ function App() {
<div className="app-container">
<TitleBar />
{updateInfo && !updateInfo.forceUpdate && (
<div className="update-toast">
<div className="update-toast-icon">🎉</div>
<div className={`update-toast ${isUpdateDownloading ? 'is-downloading' : ''}`}>
<div className="update-toast-icon">{isUpdateDownloading ? <Loader2 size={18} className="spin" /> : '🎉'}</div>
<div className="update-toast-content">
<div className="update-toast-title"></div>
<div className="update-toast-version">v{updateInfo.version} </div>
<div className="update-toast-version">{updateInfo.updateSource === 'github' ? 'GitHub Release' : '未知'}</div>
<div className="update-toast-title">{isUpdateDownloading ? '正在下载更新' : '发现新版本'}</div>
<div className="update-toast-version">
{isUpdateDownloading ? `v${updateInfo.version} ${progressPercent !== null ? `${progressPercent.toFixed(0)}%` : ''}` : `v${updateInfo.version} 已发布`}
</div>
<div className="update-toast-version">
{isUpdateDownloading
? `${formatBytes(downloadProgress?.transferred ?? updateInfo.diagnostics?.downloadedBytes)} / ${formatBytes(downloadProgress?.total ?? updateInfo.diagnostics?.totalBytes)}`
: `更新源:${updateInfo.updateSource === 'github' ? 'GitHub Release' : '未知'}`}
</div>
{isUpdateDownloading && (
<div className="update-toast-meta">
<span> {formatSpeed(downloadProgress?.bytesPerSecond ?? 0)}</span>
{updateInfo.diagnostics?.fallbackToFull ? <span>退</span> : null}
</div>
)}
</div>
<button className="update-toast-btn" onClick={() => {
window.electronAPI.app.downloadAndInstall()
dismissUpdate()
}}>
</button>
<button className="update-toast-close" onClick={dismissUpdate}>
<X size={14} />
</button>
{isUpdateDownloading ? (
<div className="update-toast-progress">
<div className="update-toast-progress-bar">
<div className="update-toast-progress-fill" style={{ width: `${progressPercent ?? 0}%` }} />
</div>
</div>
) : (
<>
<button className="update-toast-btn" onClick={handleStartUpdate} disabled={isUpdateDownloading}>
</button>
<button className="update-toast-close" onClick={dismissUpdate}>
<X size={14} />
</button>
</>
)}
</div>
)}
{updateInfo?.forceUpdate && (
@@ -538,20 +632,20 @@ function App() {
</div>
)}
{downloadProgress !== null && (
{progressPercent !== null && (
<div className="force-update-progress">
<div className="force-update-progress-label">
<Loader2 size={16} className="spin" />
<span>... {downloadProgress.toFixed(0)}%</span>
<span>... {progressPercent.toFixed(0)}%</span>
</div>
<div className="force-update-progress-bar">
<div className="force-update-progress-fill" style={{ width: `${downloadProgress}%` }} />
<div className="force-update-progress-fill" style={{ width: `${progressPercent}%` }} />
</div>
</div>
)}
<div className="force-update-actions">
<button className="btn btn-primary" onClick={() => window.electronAPI.app.downloadAndInstall()}>
<button className="btn btn-primary" onClick={handleStartUpdate} disabled={isUpdateDownloading}>
</button>
<button className="btn btn-secondary" onClick={() => window.electronAPI.window.close()}>
@@ -599,12 +693,18 @@ function App() {
</Box>
</Box>
<DecryptProgressOverlay />
{downloadProgress !== null && (
{progressPercent !== null && (
<div className="download-progress-capsule">
<Loader2 className="spin" size={14} />
<span>... {downloadProgress.toFixed(0)}%</span>
<div className="progress-bar-bg">
<div className="progress-bar-fill" style={{ width: `${downloadProgress}%` }} />
<div className="capsule-copy">
<span>... {progressPercent.toFixed(0)}%</span>
<small>{formatSpeed(downloadProgress?.bytesPerSecond ?? 0)}</small>
</div>
<div className="capsule-progress">
<div className="progress-bar-bg">
<div className="progress-bar-fill" style={{ width: `${progressPercent}%` }} />
</div>
<small>{formatBytes(downloadProgress?.transferred ?? updateInfo?.diagnostics?.downloadedBytes)} / {formatBytes(downloadProgress?.total ?? updateInfo?.diagnostics?.totalBytes)}</small>
</div>
</div>
)}
+40 -18
View File
@@ -1001,29 +1001,51 @@ to {
.download-progress {
display: flex;
align-items: center;
gap: 12px;
width: 200px;
flex-direction: column;
gap: 10px;
width: min(320px, 100%);
.progress-bar {
flex: 1;
height: 6px;
background: var(--bg-tertiary);
border-radius: 3px;
overflow: hidden;
.progress-main {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
.progress-fill {
height: 100%;
background: var(--primary);
border-radius: 3px;
transition: width 0.2s ease;
.progress-bar {
flex: 1;
height: 8px;
background: var(--bg-tertiary);
border-radius: 999px;
overflow: hidden;
.progress-fill {
height: 100%;
background: var(--primary);
border-radius: 999px;
transition: width 0.2s ease;
}
}
> span {
font-size: 12px;
color: var(--text-secondary);
min-width: 35px;
}
}
span {
font-size: 12px;
color: var(--text-secondary);
min-width: 35px;
.progress-meta {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 8px;
span {
font-size: 12px;
color: var(--text-secondary);
padding: 4px 8px;
border-radius: 999px;
background: var(--bg-tertiary);
}
}
}
}
+88 -9
View File
@@ -3,6 +3,7 @@ import { useSearchParams, useLocation } from 'react-router-dom'
import { useAppStore } from '../stores/appStore'
import { useThemeStore, themes } from '../stores/themeStore'
import { useActivationStore } from '../stores/activationStore'
import type { UpdateDownloadProgressPayload } from '../types/electron'
import { dialog } from '../services/ipc'
import * as configService from '../services/config'
import AISummarySettings from '../components/ai/AISummarySettings'
@@ -97,6 +98,7 @@ function SettingsPage() {
const [isCheckingUpdate, setIsCheckingUpdate] = useState(false)
const [isDownloading, setIsDownloading] = useState(false)
const [downloadProgress, setDownloadProgress] = useState(0)
const [downloadProgressDetail, setDownloadProgressDetail] = useState<UpdateDownloadProgressPayload | null>(null)
const [appVersion, setAppVersion] = useState('')
const [updateInfo, setUpdateInfo] = useState<{
hasUpdate: boolean
@@ -486,17 +488,61 @@ function SettingsPage() {
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`
}
const formatSpeed = (bytesPerSecond: number): string => {
if (!Number.isFinite(bytesPerSecond) || bytesPerSecond <= 0) return '计算中'
if (bytesPerSecond < 1024) return `${bytesPerSecond.toFixed(0)} B/s`
if (bytesPerSecond < 1024 * 1024) return `${(bytesPerSecond / 1024).toFixed(1)} KB/s`
return `${(bytesPerSecond / (1024 * 1024)).toFixed(1)} MB/s`
}
const syncUpdateState = async () => {
try {
const state = await window.electronAPI.app.getUpdateState?.()
if (!state) return
setUpdateInfo(state)
const phase = state.diagnostics?.phase
setIsDownloading(phase === 'downloading' || phase === 'installing')
if (typeof state.diagnostics?.progressPercent === 'number') {
setDownloadProgress(state.diagnostics.progressPercent)
}
} catch (error) {
console.error('同步更新状态失败:', error)
}
}
// 监听下载进度
useEffect(() => {
const removeListener = window.electronAPI.app.onDownloadProgress?.((progress: number) => {
setDownloadProgress(progress)
syncUpdateState()
const removeListener = window.electronAPI.app.onDownloadProgress?.((progress: UpdateDownloadProgressPayload) => {
setDownloadProgress(progress.percent)
setDownloadProgressDetail(progress)
setIsDownloading(true)
setUpdateInfo((current) => {
if (!current) return current
return {
...current,
diagnostics: {
phase: 'downloading',
strategy: current.diagnostics?.strategy || 'unknown',
fallbackToFull: current.diagnostics?.fallbackToFull || false,
lastError: current.diagnostics?.lastError,
lastEvent: current.diagnostics?.lastEvent,
progressPercent: progress.percent,
downloadedBytes: progress.transferred,
totalBytes: progress.total,
targetVersion: current.version || current.diagnostics?.targetVersion,
lastUpdatedAt: Date.now()
}
}
})
})
return () => removeListener?.()
}, [])
const handleCheckUpdate = async () => {
if (isDownloading || updateInfo?.diagnostics?.phase === 'installing') return
setIsCheckingUpdate(true)
setUpdateInfo(null)
try {
const result = await window.electronAPI.app.checkForUpdates()
if (result.hasUpdate) {
@@ -598,14 +644,31 @@ function SettingsPage() {
}
const handleUpdateNow = async () => {
if (isDownloading) return
setIsDownloading(true)
setDownloadProgress(0)
setUpdateInfo((current) => current ? {
...current,
diagnostics: {
phase: 'downloading',
strategy: current.diagnostics?.strategy || 'unknown',
fallbackToFull: current.diagnostics?.fallbackToFull || false,
lastError: undefined,
lastEvent: '开始下载更新',
progressPercent: 0,
downloadedBytes: 0,
totalBytes: current.diagnostics?.totalBytes,
targetVersion: current.version || current.diagnostics?.targetVersion,
lastUpdatedAt: Date.now()
}
} : current)
try {
showMessage('正在下载更新...', true)
await window.electronAPI.app.downloadAndInstall()
} catch (e) {
showMessage(`更新失败: ${e}`, false)
setIsDownloading(false)
await syncUpdateState()
}
}
@@ -2678,6 +2741,13 @@ function SettingsPage() {
useEffect(() => {
if (location.state?.updateInfo) {
setUpdateInfo(location.state.updateInfo)
const phase = location.state.updateInfo.diagnostics?.phase
setIsDownloading(phase === 'downloading' || phase === 'installing')
if (typeof location.state.updateInfo.diagnostics?.progressPercent === 'number') {
setDownloadProgress(location.state.updateInfo.diagnostics.progressPercent)
}
} else {
syncUpdateState()
}
}, [location.state])
@@ -2709,7 +2779,7 @@ function SettingsPage() {
{updateInfo?.hasUpdate ? (
<>
<p className="update-hint">
{updateInfo.forceUpdate ? '检测到强制更新' : `新版本 v${updateInfo.version} 可用`}
{isDownloading ? `正在下载 v${updateInfo.version}` : updateInfo.forceUpdate ? '检测到强制更新' : `新版本 v${updateInfo.version} 可用`}
</p>
<p className="update-hint">
{updateInfo.updateSource === 'github' ? 'GitHub Release' : '未知'} /
@@ -2730,19 +2800,28 @@ function SettingsPage() {
)}
{isDownloading ? (
<div className="download-progress">
<div className="progress-bar">
<div className="progress-fill" style={{ width: `${downloadProgress}%` }} />
<div className="progress-main">
<div className="progress-bar">
<div className="progress-fill" style={{ width: `${downloadProgress}%` }} />
</div>
<span>{downloadProgress.toFixed(0)}%</span>
</div>
<div className="progress-meta">
<span>
{formatFileSize(downloadProgressDetail?.transferred ?? updateInfo.diagnostics?.downloadedBytes ?? 0)} / {formatFileSize(downloadProgressDetail?.total ?? updateInfo.diagnostics?.totalBytes ?? 0)}
</span>
<span> {formatSpeed(downloadProgressDetail?.bytesPerSecond ?? 0)}</span>
{updateInfo.diagnostics?.fallbackToFull ? <span>退</span> : null}
</div>
<span>{downloadProgress.toFixed(0)}%</span>
</div>
) : (
<button className="btn btn-primary" onClick={handleUpdateNow}>
<button className="btn btn-primary" onClick={handleUpdateNow} disabled={isDownloading}>
<Download size={16} />
</button>
)}
</>
) : (
<button className="btn btn-secondary" onClick={handleCheckUpdate} disabled={isCheckingUpdate}>
<button className="btn btn-secondary" onClick={handleCheckUpdate} disabled={isCheckingUpdate || isDownloading}>
<RefreshCw size={16} className={isCheckingUpdate ? 'spin' : ''} />
{isCheckingUpdate ? '检查中...' : '检查更新'}
</button>
+8 -1
View File
@@ -12,6 +12,13 @@ export interface ImageViewerOpenOptions {
imageDatName?: string
}
export interface UpdateDownloadProgressPayload {
percent: number
transferred: number
total: number
bytesPerSecond: number
}
export interface ElectronAPI {
window: {
minimize: () => void
@@ -146,7 +153,7 @@ export interface ElectronAPI {
downloadAndInstall: () => Promise<void>
getStartupDbConnected?: () => Promise<boolean>
setAppIcon: (iconName: string) => Promise<void>
onDownloadProgress: (callback: (progress: number) => void) => () => void
onDownloadProgress: (callback: (progress: UpdateDownloadProgressPayload) => void) => () => void
onUpdateAvailable: (callback: (info: {
hasUpdate: boolean
forceUpdate: boolean