新增询问窗口

This commit is contained in:
xuncha
2026-03-16 17:21:59 +08:00
parent 999ddaeb9a
commit f2b1b07f58
7 changed files with 206 additions and 17 deletions

View File

@@ -99,6 +99,8 @@ let isAppQuitting = false
let tray: Tray | null = null let tray: Tray | null = null
let isClosePromptVisible = false let isClosePromptVisible = false
type WindowCloseBehavior = 'ask' | 'tray' | 'quit'
// 更新下载状态管理Issue #294 修复) // 更新下载状态管理Issue #294 修复)
let isDownloadInProgress = false let isDownloadInProgress = false
let downloadProgressHandler: ((progress: any) => void) | null = null let downloadProgressHandler: ((progress: any) => void) | null = null
@@ -254,6 +256,19 @@ const setupCustomTitleBarWindow = (win: BrowserWindow): void => {
win.webContents.on('did-finish-load', emitMaximizeState) win.webContents.on('did-finish-load', emitMaximizeState)
} }
const getWindowCloseBehavior = (): WindowCloseBehavior => {
const behavior = configService?.get('windowCloseBehavior')
return behavior === 'tray' || behavior === 'quit' ? behavior : 'ask'
}
const requestMainWindowCloseConfirmation = (win: BrowserWindow): void => {
if (isClosePromptVisible) return
isClosePromptVisible = true
win.webContents.send('window:confirmCloseRequested', {
canMinimizeToTray: Boolean(tray)
})
}
function createWindow(options: { autoShow?: boolean } = {}) { function createWindow(options: { autoShow?: boolean } = {}) {
// 获取图标路径 - 打包后在 resources 目录 // 获取图标路径 - 打包后在 resources 目录
const { autoShow = true } = options const { autoShow = true } = options
@@ -357,12 +372,20 @@ function createWindow(options: { autoShow?: boolean } = {}) {
win.on('close', (e) => { win.on('close', (e) => {
if (isAppQuitting || win !== mainWindow) return if (isAppQuitting || win !== mainWindow) return
e.preventDefault() e.preventDefault()
if (isClosePromptVisible) return const closeBehavior = getWindowCloseBehavior()
isClosePromptVisible = true if (closeBehavior === 'quit') {
win.webContents.send('window:confirmCloseRequested', { isAppQuitting = true
canMinimizeToTray: Boolean(tray) app.quit()
}) return
}
if (closeBehavior === 'tray' && tray) {
win.hide()
return
}
requestMainWindowCloseConfirmation(win)
}) })
win.on('closed', () => { win.on('closed', () => {

View File

@@ -50,6 +50,7 @@ interface ConfigSchema {
notificationPosition: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center' notificationPosition: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center'
notificationFilterMode: 'all' | 'whitelist' | 'blacklist' notificationFilterMode: 'all' | 'whitelist' | 'blacklist'
notificationFilterList: string[] notificationFilterList: string[]
windowCloseBehavior: 'ask' | 'tray' | 'quit'
wordCloudExcludeWords: string[] wordCloudExcludeWords: string[]
} }
@@ -116,6 +117,7 @@ export class ConfigService {
notificationPosition: 'top-right', notificationPosition: 'top-right',
notificationFilterMode: 'all', notificationFilterMode: 'all',
notificationFilterList: [], notificationFilterList: [],
windowCloseBehavior: 'ask',
wordCloudExcludeWords: [] wordCloudExcludeWords: []
} }
}) })

View File

@@ -327,8 +327,19 @@ function App() {
setUpdateInfo(null) setUpdateInfo(null)
} }
const handleWindowCloseAction = async (action: 'tray' | 'quit' | 'cancel') => { const handleWindowCloseAction = async (
action: 'tray' | 'quit' | 'cancel',
rememberChoice = false
) => {
setShowCloseDialog(false) setShowCloseDialog(false)
if (rememberChoice && action !== 'cancel') {
try {
await configService.setWindowCloseBehavior(action)
} catch (error) {
console.error('保存关闭偏好失败:', error)
}
}
try { try {
await window.electronAPI.window.respondCloseConfirm(action) await window.electronAPI.window.respondCloseConfirm(action)
} catch (error) { } catch (error) {
@@ -617,8 +628,7 @@ function App() {
<WindowCloseDialog <WindowCloseDialog
open={showCloseDialog} open={showCloseDialog}
canMinimizeToTray={canMinimizeToTray} canMinimizeToTray={canMinimizeToTray}
onTray={() => handleWindowCloseAction('tray')} onSelect={(action, rememberChoice) => handleWindowCloseAction(action, rememberChoice)}
onQuit={() => handleWindowCloseAction('quit')}
onCancel={() => handleWindowCloseAction('cancel')} onCancel={() => handleWindowCloseAction('cancel')}
/> />

View File

@@ -140,6 +140,72 @@
justify-content: flex-end; justify-content: flex-end;
} }
.window-close-dialog-remember {
display: flex;
align-items: center;
gap: 10px;
margin: 4px 24px 0;
padding: 12px 14px;
border: 1px solid color-mix(in srgb, var(--border-color) 72%, transparent);
border-radius: 16px;
background: color-mix(in srgb, var(--bg-secondary) 76%, transparent);
cursor: pointer;
user-select: none;
input {
position: absolute;
opacity: 0;
pointer-events: none;
}
}
.window-close-dialog-checkbox {
width: 18px;
height: 18px;
flex: 0 0 18px;
border: 1.5px solid color-mix(in srgb, var(--border-color) 88%, transparent);
border-radius: 6px;
background: var(--bg-primary);
position: relative;
transition:
border-color 0.18s ease,
background 0.18s ease,
box-shadow 0.18s ease;
&::after {
content: '';
position: absolute;
left: 5px;
top: 1px;
width: 5px;
height: 10px;
border: solid white;
border-width: 0 2px 2px 0;
transform: rotate(45deg) scale(0.7);
opacity: 0;
transition:
opacity 0.18s ease,
transform 0.18s ease;
}
}
.window-close-dialog-remember input:checked + .window-close-dialog-checkbox {
background: var(--primary);
border-color: var(--primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 16%, transparent);
}
.window-close-dialog-remember input:checked + .window-close-dialog-checkbox::after {
opacity: 1;
transform: rotate(45deg) scale(1);
}
.window-close-dialog-remember-text {
font-size: 13px;
line-height: 1.5;
color: var(--text-secondary);
}
.window-close-dialog-cancel { .window-close-dialog-cancel {
min-width: 112px; min-width: 112px;
padding: 12px 18px; padding: 12px 18px;

View File

@@ -1,24 +1,25 @@
import { Minimize2, Power, X } from 'lucide-react' import { Minimize2, Power, X } from 'lucide-react'
import { useEffect } from 'react' import { useEffect, useState } from 'react'
import './WindowCloseDialog.scss' import './WindowCloseDialog.scss'
interface WindowCloseDialogProps { interface WindowCloseDialogProps {
open: boolean open: boolean
canMinimizeToTray: boolean canMinimizeToTray: boolean
onTray: () => void onSelect: (action: 'tray' | 'quit', rememberChoice: boolean) => void
onQuit: () => void
onCancel: () => void onCancel: () => void
} }
export default function WindowCloseDialog({ export default function WindowCloseDialog({
open, open,
canMinimizeToTray, canMinimizeToTray,
onTray, onSelect,
onQuit,
onCancel onCancel
}: WindowCloseDialogProps) { }: WindowCloseDialogProps) {
const [rememberChoice, setRememberChoice] = useState(false)
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
setRememberChoice(false)
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') { if (event.key === 'Escape') {
@@ -63,7 +64,11 @@ export default function WindowCloseDialog({
<div className="window-close-dialog-body"> <div className="window-close-dialog-body">
{canMinimizeToTray && ( {canMinimizeToTray && (
<button type="button" className="window-close-dialog-option" onClick={onTray}> <button
type="button"
className="window-close-dialog-option"
onClick={() => onSelect('tray', rememberChoice)}
>
<span className="window-close-dialog-option-icon"> <span className="window-close-dialog-option-icon">
<Minimize2 size={18} /> <Minimize2 size={18} />
</span> </span>
@@ -77,7 +82,7 @@ export default function WindowCloseDialog({
<button <button
type="button" type="button"
className="window-close-dialog-option is-danger" className="window-close-dialog-option is-danger"
onClick={onQuit} onClick={() => onSelect('quit', rememberChoice)}
> >
<span className="window-close-dialog-option-icon"> <span className="window-close-dialog-option-icon">
<Power size={18} /> <Power size={18} />
@@ -89,6 +94,16 @@ export default function WindowCloseDialog({
</button> </button>
</div> </div>
<label className="window-close-dialog-remember">
<input
type="checkbox"
checked={rememberChoice}
onChange={(event) => setRememberChoice(event.target.checked)}
/>
<span className="window-close-dialog-checkbox" aria-hidden="true" />
<span className="window-close-dialog-remember-text"></span>
</label>
<div className="window-close-dialog-actions"> <div className="window-close-dialog-actions">
<button type="button" className="window-close-dialog-cancel" onClick={onCancel}> <button type="button" className="window-close-dialog-cancel" onClick={onCancel}>

View File

@@ -107,9 +107,11 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const [notificationPosition, setNotificationPosition] = useState<'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center'>('top-right') const [notificationPosition, setNotificationPosition] = useState<'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center'>('top-right')
const [notificationFilterMode, setNotificationFilterMode] = useState<'all' | 'whitelist' | 'blacklist'>('all') const [notificationFilterMode, setNotificationFilterMode] = useState<'all' | 'whitelist' | 'blacklist'>('all')
const [notificationFilterList, setNotificationFilterList] = useState<string[]>([]) const [notificationFilterList, setNotificationFilterList] = useState<string[]>([])
const [windowCloseBehavior, setWindowCloseBehavior] = useState<configService.WindowCloseBehavior>('ask')
const [filterSearchKeyword, setFilterSearchKeyword] = useState('') const [filterSearchKeyword, setFilterSearchKeyword] = useState('')
const [filterModeDropdownOpen, setFilterModeDropdownOpen] = useState(false) const [filterModeDropdownOpen, setFilterModeDropdownOpen] = useState(false)
const [positionDropdownOpen, setPositionDropdownOpen] = useState(false) const [positionDropdownOpen, setPositionDropdownOpen] = useState(false)
const [closeBehaviorDropdownOpen, setCloseBehaviorDropdownOpen] = useState(false)
const [wordCloudExcludeWords, setWordCloudExcludeWords] = useState<string[]>([]) const [wordCloudExcludeWords, setWordCloudExcludeWords] = useState<string[]>([])
const [excludeWordsInput, setExcludeWordsInput] = useState('') const [excludeWordsInput, setExcludeWordsInput] = useState('')
@@ -253,15 +255,16 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
if (!target.closest('.custom-select')) { if (!target.closest('.custom-select')) {
setFilterModeDropdownOpen(false) setFilterModeDropdownOpen(false)
setPositionDropdownOpen(false) setPositionDropdownOpen(false)
setCloseBehaviorDropdownOpen(false)
} }
} }
if (filterModeDropdownOpen || positionDropdownOpen) { if (filterModeDropdownOpen || positionDropdownOpen || closeBehaviorDropdownOpen) {
document.addEventListener('click', handleClickOutside) document.addEventListener('click', handleClickOutside)
} }
return () => { return () => {
document.removeEventListener('click', handleClickOutside) document.removeEventListener('click', handleClickOutside)
} }
}, [filterModeDropdownOpen, positionDropdownOpen]) }, [closeBehaviorDropdownOpen, filterModeDropdownOpen, positionDropdownOpen])
const loadConfig = async () => { const loadConfig = async () => {
@@ -283,6 +286,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const savedNotificationPosition = await configService.getNotificationPosition() const savedNotificationPosition = await configService.getNotificationPosition()
const savedNotificationFilterMode = await configService.getNotificationFilterMode() const savedNotificationFilterMode = await configService.getNotificationFilterMode()
const savedNotificationFilterList = await configService.getNotificationFilterList() const savedNotificationFilterList = await configService.getNotificationFilterList()
const savedWindowCloseBehavior = await configService.getWindowCloseBehavior()
const savedAuthEnabled = await window.electronAPI.auth.verifyEnabled() const savedAuthEnabled = await window.electronAPI.auth.verifyEnabled()
const savedAuthUseHello = await configService.getAuthUseHello() const savedAuthUseHello = await configService.getAuthUseHello()
@@ -318,6 +322,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
setNotificationPosition(savedNotificationPosition) setNotificationPosition(savedNotificationPosition)
setNotificationFilterMode(savedNotificationFilterMode) setNotificationFilterMode(savedNotificationFilterMode)
setNotificationFilterList(savedNotificationFilterList) setNotificationFilterList(savedNotificationFilterList)
setWindowCloseBehavior(savedWindowCloseBehavior)
const savedExcludeWords = await configService.getWordCloudExcludeWords() const savedExcludeWords = await configService.getWordCloudExcludeWords()
setWordCloudExcludeWords(savedExcludeWords) setWordCloudExcludeWords(savedExcludeWords)
@@ -1024,6 +1029,61 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
</div> </div>
))} ))}
</div> </div>
<div className="divider" />
<div className="form-group">
<label></label>
<span className="form-hint"></span>
<div className="custom-select">
<div
className={`custom-select-trigger ${closeBehaviorDropdownOpen ? 'open' : ''}`}
onClick={() => setCloseBehaviorDropdownOpen(!closeBehaviorDropdownOpen)}
>
<span className="custom-select-value">
{windowCloseBehavior === 'tray'
? '最小化到系统托盘'
: windowCloseBehavior === 'quit'
? '完全关闭'
: '每次询问'}
</span>
<ChevronDown size={14} className={`custom-select-arrow ${closeBehaviorDropdownOpen ? 'rotate' : ''}`} />
</div>
<div className={`custom-select-dropdown ${closeBehaviorDropdownOpen ? 'open' : ''}`}>
{[
{
value: 'ask' as const,
label: '每次询问',
successMessage: '已恢复关闭确认弹窗'
},
{
value: 'tray' as const,
label: '最小化到系统托盘',
successMessage: '关闭按钮已改为最小化到托盘'
},
{
value: 'quit' as const,
label: '完全关闭',
successMessage: '关闭按钮已改为完全关闭'
}
].map(option => (
<div
key={option.value}
className={`custom-select-option ${windowCloseBehavior === option.value ? 'selected' : ''}`}
onClick={async () => {
setWindowCloseBehavior(option.value)
setCloseBehaviorDropdownOpen(false)
await configService.setWindowCloseBehavior(option.value)
showMessage(option.successMessage, true)
}}
>
{option.label}
{windowCloseBehavior === option.value && <Check size={14} />}
</div>
))}
</div>
</div>
</div>
</div> </div>
) )

View File

@@ -62,6 +62,7 @@ export const CONFIG_KEYS = {
NOTIFICATION_POSITION: 'notificationPosition', NOTIFICATION_POSITION: 'notificationPosition',
NOTIFICATION_FILTER_MODE: 'notificationFilterMode', NOTIFICATION_FILTER_MODE: 'notificationFilterMode',
NOTIFICATION_FILTER_LIST: 'notificationFilterList', NOTIFICATION_FILTER_LIST: 'notificationFilterList',
WINDOW_CLOSE_BEHAVIOR: 'windowCloseBehavior',
// 词云 // 词云
WORD_CLOUD_EXCLUDE_WORDS: 'wordCloudExcludeWords', WORD_CLOUD_EXCLUDE_WORDS: 'wordCloudExcludeWords',
@@ -85,6 +86,8 @@ export interface ExportDefaultMediaConfig {
emojis: boolean emojis: boolean
} }
export type WindowCloseBehavior = 'ask' | 'tray' | 'quit'
const DEFAULT_EXPORT_MEDIA_CONFIG: ExportDefaultMediaConfig = { const DEFAULT_EXPORT_MEDIA_CONFIG: ExportDefaultMediaConfig = {
images: true, images: true,
videos: true, videos: true,
@@ -1188,6 +1191,16 @@ export async function setNotificationFilterList(list: string[]): Promise<void> {
await config.set(CONFIG_KEYS.NOTIFICATION_FILTER_LIST, list) await config.set(CONFIG_KEYS.NOTIFICATION_FILTER_LIST, list)
} }
export async function getWindowCloseBehavior(): Promise<WindowCloseBehavior> {
const value = await config.get(CONFIG_KEYS.WINDOW_CLOSE_BEHAVIOR)
if (value === 'tray' || value === 'quit') return value
return 'ask'
}
export async function setWindowCloseBehavior(behavior: WindowCloseBehavior): Promise<void> {
await config.set(CONFIG_KEYS.WINDOW_CLOSE_BEHAVIOR, behavior)
}
// 获取词云排除词列表 // 获取词云排除词列表
export async function getWordCloudExcludeWords(): Promise<string[]> { export async function getWordCloudExcludeWords(): Promise<string[]> {
const value = await config.get(CONFIG_KEYS.WORD_CLOUD_EXCLUDE_WORDS) const value = await config.get(CONFIG_KEYS.WORD_CLOUD_EXCLUDE_WORDS)