diff --git a/electron/main.ts b/electron/main.ts index 076c16d..0727f62 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -97,6 +97,7 @@ let mainWindowReady = false let shouldShowMain = true let isAppQuitting = false let tray: Tray | null = null +let isClosePromptVisible = false // 更新下载状态管理(Issue #294 修复) let isDownloadInProgress = false @@ -354,10 +355,14 @@ function createWindow(options: { autoShow?: boolean } = {}) { }) win.on('close', (e) => { - if (isAppQuitting) return - // 关闭主窗口时隐藏到状态栏而不是退出 + if (isAppQuitting || win !== mainWindow) return e.preventDefault() - win.hide() + if (isClosePromptVisible) return + + isClosePromptVisible = true + win.webContents.send('window:confirmCloseRequested', { + canMinimizeToTray: Boolean(tray) + }) }) win.on('closed', () => { @@ -365,6 +370,7 @@ function createWindow(options: { autoShow?: boolean } = {}) { mainWindow = null mainWindowReady = false + isClosePromptVisible = false if (process.platform !== 'darwin' && !isAppQuitting) { destroyNotificationWindow() @@ -1154,6 +1160,33 @@ function registerIpcHandlers() { BrowserWindow.fromWebContents(event.sender)?.close() }) + ipcMain.handle('window:respondCloseConfirm', async (_event, action: 'tray' | 'quit' | 'cancel') => { + if (!mainWindow || mainWindow.isDestroyed()) { + isClosePromptVisible = false + return false + } + + try { + if (action === 'tray') { + if (tray) { + mainWindow.hide() + return true + } + return false + } + + if (action === 'quit') { + isAppQuitting = true + app.quit() + return true + } + + return true + } finally { + isClosePromptVisible = false + } + }) + // 更新窗口控件主题色 ipcMain.on('window:setTitleBarOverlay', (event, options: { symbolColor: string }) => { const win = BrowserWindow.fromWebContents(event.sender) diff --git a/electron/preload.ts b/electron/preload.ts index 8a0f823..7d56dba 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -94,6 +94,13 @@ contextBridge.exposeInMainWorld('electronAPI', { return () => ipcRenderer.removeListener('window:maximizeStateChanged', listener) }, close: () => ipcRenderer.send('window:close'), + onCloseConfirmRequested: (callback: (payload: { canMinimizeToTray: boolean }) => void) => { + const listener = (_: unknown, payload: { canMinimizeToTray: boolean }) => callback(payload) + ipcRenderer.on('window:confirmCloseRequested', listener) + return () => ipcRenderer.removeListener('window:confirmCloseRequested', listener) + }, + respondCloseConfirm: (action: 'tray' | 'quit' | 'cancel') => + ipcRenderer.invoke('window:respondCloseConfirm', action), openAgreementWindow: () => ipcRenderer.invoke('window:openAgreementWindow'), completeOnboarding: () => ipcRenderer.invoke('window:completeOnboarding'), openOnboardingWindow: () => ipcRenderer.invoke('window:openOnboardingWindow'), diff --git a/src/App.tsx b/src/App.tsx index e287a68..8b9a903 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -37,6 +37,7 @@ import LockScreen from './components/LockScreen' import { GlobalSessionMonitor } from './components/GlobalSessionMonitor' import { BatchTranscribeGlobal } from './components/BatchTranscribeGlobal' import { BatchImageDecryptGlobal } from './components/BatchImageDecryptGlobal' +import WindowCloseDialog from './components/WindowCloseDialog' function RouteStateRedirect({ to }: { to: string }) { const location = useLocation() @@ -85,6 +86,8 @@ function App() { const isExportRoute = routeLocation.pathname === '/export' const [themeHydrated, setThemeHydrated] = useState(false) const [sidebarCollapsed, setSidebarCollapsed] = useState(false) + const [showCloseDialog, setShowCloseDialog] = useState(false) + const [canMinimizeToTray, setCanMinimizeToTray] = useState(false) // 锁定状态 // const [isLocked, setIsLocked] = useState(false) // Moved to store @@ -107,6 +110,15 @@ function App() { } }, [location]) + useEffect(() => { + const removeCloseConfirmListener = window.electronAPI.window.onCloseConfirmRequested((payload) => { + setCanMinimizeToTray(Boolean(payload.canMinimizeToTray)) + setShowCloseDialog(true) + }) + + return () => removeCloseConfirmListener() + }, []) + useEffect(() => { const root = document.documentElement const body = document.body @@ -315,6 +327,15 @@ function App() { setUpdateInfo(null) } + const handleWindowCloseAction = async (action: 'tray' | 'quit' | 'cancel') => { + setShowCloseDialog(false) + try { + await window.electronAPI.window.respondCloseConfirm(action) + } catch (error) { + console.error('处理关闭确认失败:', error) + } + } + // 启动时自动检查配置并连接数据库 useEffect(() => { if (isAgreementWindow || isOnboardingWindow) return @@ -593,6 +614,14 @@ function App() { progress={downloadProgress} /> + handleWindowCloseAction('tray')} + onQuit={() => handleWindowCloseAction('quit')} + onCancel={() => handleWindowCloseAction('cancel')} + /> +
diff --git a/src/components/WindowCloseDialog.scss b/src/components/WindowCloseDialog.scss new file mode 100644 index 0000000..282ac55 --- /dev/null +++ b/src/components/WindowCloseDialog.scss @@ -0,0 +1,240 @@ +.window-close-dialog-overlay { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 32px; + background: + radial-gradient(circle at top, rgba(36, 42, 54, 0.18), transparent 48%), + rgba(7, 10, 18, 0.56); + backdrop-filter: blur(10px); + z-index: 3000; + animation: windowCloseDialogFadeIn 0.2s ease-out; +} + +.window-close-dialog { + width: min(560px, 100%); + border: 1px solid color-mix(in srgb, var(--border-color) 78%, transparent); + border-radius: 24px; + background: + linear-gradient(180deg, color-mix(in srgb, var(--bg-primary) 94%, white 6%) 0%, var(--bg-primary) 100%); + box-shadow: 0 28px 80px rgba(0, 0, 0, 0.32); + overflow: hidden; + position: relative; + animation: windowCloseDialogSlideUp 0.24s cubic-bezier(0.16, 1, 0.3, 1); +} + +.window-close-dialog-header { + padding: 28px 30px 18px; + border-bottom: 1px solid color-mix(in srgb, var(--border-color) 72%, transparent); + + .window-close-dialog-kicker { + display: inline-flex; + align-items: center; + padding: 6px 10px; + border-radius: 999px; + background: color-mix(in srgb, var(--primary) 12%, transparent); + color: var(--primary); + font-size: 12px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + } + + h2 { + margin: 14px 0 8px; + font-size: 26px; + line-height: 1.1; + color: var(--text-primary); + } + + p { + margin: 0; + font-size: 14px; + line-height: 1.7; + color: var(--text-secondary); + } +} + +.window-close-dialog-body { + padding: 20px 24px 10px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.window-close-dialog-option { + width: 100%; + display: flex; + align-items: flex-start; + gap: 14px; + padding: 18px 18px 18px 16px; + border: 1px solid color-mix(in srgb, var(--border-color) 72%, transparent); + border-radius: 18px; + background: + linear-gradient(180deg, color-mix(in srgb, var(--bg-secondary) 86%, white 14%) 0%, var(--bg-secondary) 100%); + color: inherit; + cursor: pointer; + text-align: left; + transition: + transform 0.18s ease, + border-color 0.18s ease, + box-shadow 0.18s ease, + background 0.18s ease; + + &:hover { + transform: translateY(-1px); + border-color: color-mix(in srgb, var(--primary) 34%, var(--border-color)); + box-shadow: 0 14px 28px rgba(0, 0, 0, 0.1); + } + + &:active { + transform: translateY(0); + } + + &.is-danger:hover { + border-color: rgba(205, 73, 73, 0.42); + } +} + +.window-close-dialog-option-icon { + width: 42px; + height: 42px; + flex: 0 0 42px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 14px; + background: color-mix(in srgb, var(--primary) 14%, transparent); + color: var(--primary); +} + +.window-close-dialog-option.is-danger .window-close-dialog-option-icon { + background: rgba(205, 73, 73, 0.12); + color: #cd4949; +} + +.window-close-dialog-option-text { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; + + strong { + font-size: 16px; + font-weight: 700; + color: var(--text-primary); + } + + span { + font-size: 13px; + line-height: 1.6; + color: var(--text-secondary); + } +} + +.window-close-dialog-actions { + padding: 8px 24px 24px; + display: flex; + justify-content: flex-end; +} + +.window-close-dialog-cancel { + min-width: 112px; + padding: 12px 18px; + border: 1px solid color-mix(in srgb, var(--border-color) 76%, transparent); + border-radius: 999px; + background: var(--bg-tertiary); + color: var(--text-secondary); + cursor: pointer; + transition: + background 0.18s ease, + color 0.18s ease, + border-color 0.18s ease; + + &:hover { + background: var(--bg-hover); + color: var(--text-primary); + border-color: color-mix(in srgb, var(--primary) 24%, var(--border-color)); + } +} + +.window-close-dialog-close { + position: absolute; + top: 18px; + right: 18px; + width: 34px; + height: 34px; + border: none; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + background: color-mix(in srgb, var(--bg-secondary) 84%, transparent); + color: var(--text-secondary); + cursor: pointer; + transition: + background 0.18s ease, + color 0.18s ease, + transform 0.18s ease; + + &:hover { + background: var(--bg-hover); + color: var(--text-primary); + transform: rotate(90deg); + } +} + +@media (max-width: 640px) { + .window-close-dialog-overlay { + padding: 16px; + align-items: flex-end; + } + + .window-close-dialog { + border-radius: 24px 24px 18px 18px; + } + + .window-close-dialog-header { + padding: 24px 22px 16px; + + h2 { + font-size: 22px; + } + } + + .window-close-dialog-body { + padding: 18px 18px 10px; + } + + .window-close-dialog-actions { + padding: 8px 18px 18px; + } + + .window-close-dialog-cancel { + width: 100%; + } +} + +@keyframes windowCloseDialogFadeIn { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes windowCloseDialogSlideUp { + from { + transform: translateY(24px) scale(0.98); + opacity: 0; + } + + to { + transform: translateY(0) scale(1); + opacity: 1; + } +} diff --git a/src/components/WindowCloseDialog.tsx b/src/components/WindowCloseDialog.tsx new file mode 100644 index 0000000..3c992c9 --- /dev/null +++ b/src/components/WindowCloseDialog.tsx @@ -0,0 +1,100 @@ +import { Minimize2, Power, X } from 'lucide-react' +import { useEffect } from 'react' +import './WindowCloseDialog.scss' + +interface WindowCloseDialogProps { + open: boolean + canMinimizeToTray: boolean + onTray: () => void + onQuit: () => void + onCancel: () => void +} + +export default function WindowCloseDialog({ + open, + canMinimizeToTray, + onTray, + onQuit, + onCancel +}: WindowCloseDialogProps) { + useEffect(() => { + if (!open) return + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault() + onCancel() + } + } + + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [open, onCancel]) + + if (!open) return null + + return ( +
+
event.stopPropagation()} + role="dialog" + aria-modal="true" + aria-labelledby="window-close-dialog-title" + > + + +
+ 退出行为 +

关闭 WeFlow

+

+ {canMinimizeToTray + ? '你可以保留后台进程与本地 API,或者直接完全退出应用。' + : '当前系统托盘不可用,本次只能完全退出应用。'} +

+
+ +
+ {canMinimizeToTray && ( + + )} + + +
+ +
+ +
+
+
+ ) +} diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 7bfd316..e9023da 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -14,6 +14,8 @@ export interface ElectronAPI { isMaximized: () => Promise onMaximizeStateChanged: (callback: (isMaximized: boolean) => void) => () => void close: () => void + onCloseConfirmRequested: (callback: (payload: { canMinimizeToTray: boolean }) => void) => () => void + respondCloseConfirm: (action: 'tray' | 'quit' | 'cancel') => Promise openAgreementWindow: () => Promise completeOnboarding: () => Promise openOnboardingWindow: () => Promise