Przeglądaj źródła

V1.1.7菜单界面修改

liuq 3 tygodni temu
rodzic
commit
499ea9313a

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "yunzhu-im",
-  "version": "1.1.6",
+  "version": "1.1.4",
   "main": "./out/main/index.js",
   "author": "example.com",
   "license": "MIT",

+ 143 - 8
src/main/index.ts

@@ -1,7 +1,7 @@
 import { app, shell, session, protocol, BrowserWindow, ipcMain, Tray, Menu, nativeImage, WebContentsView, dialog, screen, type WebContents } from 'electron'
 import { basename, extname, join } from 'path'
 import { fileURLToPath } from 'url'
-import { autoUpdater } from 'electron-updater'
+import { autoUpdater, type CancellationToken, type UpdateCheckResult } from 'electron-updater'
 import { electronApp, optimizer, is } from '@electron-toolkit/utils'
 import { mkdirSync, appendFileSync, existsSync, readFileSync, writeFileSync, readdirSync, unlinkSync } from 'fs'
 import { writeFile } from 'fs/promises'
@@ -54,6 +54,9 @@ let blinkInterval: NodeJS.Timeout | null = null
 let trayPopupWindow: BrowserWindow | null = null
 let trayPopupHideTimer: NodeJS.Timeout | null = null
 let trayHoverCheckTimer: NodeJS.Timeout | null = null
+let autoUpdateCheckTimer: NodeJS.Timeout | null = null
+let activeUpdateCancellationToken: CancellationToken | null = null
+let updateCancellationRequested = false
 
 interface UnreadInfo {
   name: string
@@ -343,6 +346,111 @@ function initStartupFromPreference(): void {
   }
 }
 
+const AUTO_UPDATE_PREFERENCE_FILE = 'auto-update-preference.json'
+const AUTO_UPDATE_CHECK_INTERVAL_MS = 5 * 60 * 60 * 1000
+
+interface AutoUpdatePreference {
+  enabled: boolean
+}
+
+function getAutoUpdatePreferencePath(): string {
+  return join(app.getPath('userData'), AUTO_UPDATE_PREFERENCE_FILE)
+}
+
+function readAutoUpdatePreference(): { enabled: boolean; fileExists: boolean } {
+  const p = getAutoUpdatePreferencePath()
+  if (!existsSync(p)) {
+    return { enabled: false, fileExists: false }
+  }
+  try {
+    const raw = readFileSync(p, 'utf-8')
+    const j = JSON.parse(raw) as Partial<AutoUpdatePreference>
+    return {
+      enabled: typeof j.enabled === 'boolean' ? j.enabled : false,
+      fileExists: true
+    }
+  } catch {
+    return { enabled: false, fileExists: true }
+  }
+}
+
+function writeAutoUpdatePreference(enabled: boolean): void {
+  try {
+    const payload: AutoUpdatePreference = { enabled }
+    writeFileSync(getAutoUpdatePreferencePath(), JSON.stringify(payload, null, 0), 'utf-8')
+  } catch (e) {
+    console.error('writeAutoUpdatePreference', e)
+  }
+}
+
+function sendUpdateError(message: string): void {
+  mainWindow?.webContents.send('update-error', message)
+}
+
+function trackUpdateCheckResult(result: UpdateCheckResult | null): void {
+  if (!result?.isUpdateAvailable) {
+    activeUpdateCancellationToken = null
+    return
+  }
+
+  activeUpdateCancellationToken = result.cancellationToken ?? null
+  if (updateCancellationRequested) {
+    if (activeUpdateCancellationToken && !activeUpdateCancellationToken.cancelled) {
+      activeUpdateCancellationToken.cancel()
+    }
+    activeUpdateCancellationToken = null
+  }
+  if (result.downloadPromise) {
+    void result.downloadPromise
+      .catch(() => undefined)
+      .finally(() => {
+        if (activeUpdateCancellationToken === result.cancellationToken) {
+          activeUpdateCancellationToken = null
+        }
+      })
+  }
+}
+
+function cancelActiveUpdate(): void {
+  updateCancellationRequested = true
+  if (activeUpdateCancellationToken && !activeUpdateCancellationToken.cancelled) {
+    activeUpdateCancellationToken.cancel()
+  }
+  activeUpdateCancellationToken = null
+  autoUpdater.autoInstallOnAppQuit = false
+  mainWindow?.webContents.send('update-cancelled')
+}
+
+function checkForUpdatesSafely(_useNotify: boolean): void {
+  if (is.dev) return
+  void _useNotify
+  updateCancellationRequested = false
+  const check = autoUpdater.checkForUpdates()
+  void check
+    .then(trackUpdateCheckResult)
+    .catch((err: unknown) => {
+      sendUpdateError(err instanceof Error ? err.message : String(err))
+    })
+}
+
+function stopAutoUpdateChecks(): void {
+  if (autoUpdateCheckTimer) {
+    clearInterval(autoUpdateCheckTimer)
+    autoUpdateCheckTimer = null
+  }
+}
+
+function startAutoUpdateChecks(runImmediately: boolean): void {
+  if (is.dev) return
+  stopAutoUpdateChecks()
+  if (runImmediately) {
+    checkForUpdatesSafely(true)
+  }
+  autoUpdateCheckTimer = setInterval(() => {
+    checkForUpdatesSafely(false)
+  }, AUTO_UPDATE_CHECK_INTERVAL_MS)
+}
+
 function sanitizeFilename(name: string): string {
   return name.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').slice(0, 120) || 'file'
 }
@@ -1288,6 +1396,24 @@ app.whenReady().then(() => {
     return on
   })
 
+  ipcMain.handle('get-auto-update-enabled', () => {
+    return {
+      enabled: readAutoUpdatePreference().enabled,
+      isDev: is.dev
+    }
+  })
+
+  ipcMain.handle('set-auto-update-enabled', (_, enabled: unknown) => {
+    const on = enabled === true
+    writeAutoUpdatePreference(on)
+    if (on) {
+      startAutoUpdateChecks(true)
+    } else {
+      stopAutoUpdateChecks()
+    }
+    return on
+  })
+
   registerLaunchpadIconProtocol()
 
   ipcMain.handle('launchpad-icon-resolve', async (_, payload: unknown) => {
@@ -1311,11 +1437,12 @@ app.whenReady().then(() => {
 
   // 生产环境自动更新(自建服务器)
   if (!is.dev) {
-    autoUpdater.checkForUpdatesAndNotify()
+    autoUpdater.autoInstallOnAppQuit = false
     autoUpdater.on('update-available', (info) => {
       mainWindow?.webContents.send('update-available', info)
     })
     autoUpdater.on('update-downloaded', () => {
+      activeUpdateCancellationToken = null
       mainWindow?.webContents.send('update-downloaded')
     })
     autoUpdater.on('download-progress', (info: { percent: number; transferred: number; total: number; bytesPerSecond: number }) => {
@@ -1327,23 +1454,31 @@ app.whenReady().then(() => {
       })
     })
     autoUpdater.on('update-not-available', () => {
+      activeUpdateCancellationToken = null
       mainWindow?.webContents.send('update-not-available')
     })
     autoUpdater.on('error', (err) => {
+      activeUpdateCancellationToken = null
       mainWindow?.webContents.send('update-error', err.message)
     })
-    // 运行时每 5 小时自动检测一次更新
-    const FIVE_HOURS_MS = 5 * 60 * 60 * 1000
-    setInterval(() => {
-      autoUpdater.checkForUpdates()
-    }, FIVE_HOURS_MS)
+    // 仅在偏好开启时启动自动更新检测;手动检查不受该偏好影响。
+    if (readAutoUpdatePreference().enabled) {
+      startAutoUpdateChecks(true)
+    }
   }
   ipcMain.on('check-for-updates', () => {
-    if (!is.dev) autoUpdater.checkForUpdates()
+    if (is.dev) {
+      sendUpdateError('开发环境不支持检查更新')
+      return
+    }
+    checkForUpdatesSafely(false)
   })
   ipcMain.on('quit-and-install', () => {
     autoUpdater.quitAndInstall(false, true)
   })
+  ipcMain.on('cancel-update', () => {
+    cancelActiveUpdate()
+  })
 
   ipcMain.on('sync-unread-total', (_, total: number) => {
     appUnreadTotalFromApi = typeof total === 'number' && !Number.isNaN(total) ? Math.max(0, Math.floor(total)) : 0

+ 96 - 26
src/renderer/src/App.tsx

@@ -6,6 +6,7 @@ import ContactList from './components/ContactList'
 import AppMappingList from './components/AppMappingList'
 import { Navigation } from './components/Navigation'
 import { SettingsModal } from './components/Modals/SettingsModal'
+import { AccountCenterModal } from './components/Modals/AccountCenterModal'
 import { ForwardMessageModal } from './components/Modals/ForwardMessageModal'
 import { ChatPage } from './pages/ChatPage'
 import { useAuth } from './hooks/useAuth'
@@ -40,8 +41,9 @@ function App(): JSX.Element {
   const activeContactIdRef = useRef(activeContactId)
   const [selectedContactDetail, setSelectedContactDetail] = useState<UserContact | null>(null)
   const [showSettings, setShowSettings] = useState(false)
+  const [showAccountCenter, setShowAccountCenter] = useState(false)
   const [showChatSearchModal, setShowChatSearchModal] = useState(false)
-  const [showForceUpdateModal, setShowForceUpdateModal] = useState(false)
+  const [showUpdateModal, setShowUpdateModal] = useState(false)
   const [updateStatus, setUpdateStatus] = useState<'idle' | 'checking' | 'update-available' | 'update-downloaded' | 'latest' | 'error'>('idle')
   const [updateError, setUpdateError] = useState('')
   const [updateDownloadProgress, setUpdateDownloadProgress] = useState<number | null>(null)
@@ -202,12 +204,13 @@ function App(): JSX.Element {
     fetchContacts
   })
 
-  // 监听自动更新事件(检测到更新则必须更新,支持后台下载并显示进度
+  // 监听自动更新事件(检测到更新后展示弹窗,支持取消或下载完成后重启安装
   useEffect(() => {
     if (!window.electron?.ipcRenderer) return
     const onUpdateAvailable = (_: any, info: { version?: string; releaseNotes?: string | null }) => {
       setUpdateStatus('update-available')
       setUpdateDownloadProgress(0)
+      setShowUpdateModal(true)
       const notes = info?.releaseNotes != null ? String(info.releaseNotes).trim() : ''
       setUpdateInfo({
         version: info?.version ?? '',
@@ -217,7 +220,7 @@ function App(): JSX.Element {
     const onUpdateDownloaded = () => {
       setUpdateStatus('update-downloaded')
       setUpdateDownloadProgress(null)
-      setShowForceUpdateModal(true)
+      setShowUpdateModal(true)
     }
     const onUpdateDownloadProgress = (_: any, info: { percent: number }) => {
       setUpdateDownloadProgress(info.percent)
@@ -225,23 +228,34 @@ function App(): JSX.Element {
     const onUpdateNotAvailable = () => {
       setUpdateStatus('latest')
       setUpdateDownloadProgress(null)
+      setShowUpdateModal(false)
     }
     const onUpdateError = (_: any, message: string) => {
       setUpdateStatus('error')
       setUpdateError(message || '检查更新失败')
       setUpdateDownloadProgress(null)
+      setShowUpdateModal(false)
+    }
+    const onUpdateCancelled = () => {
+      setUpdateStatus('idle')
+      setUpdateError('')
+      setUpdateDownloadProgress(null)
+      setUpdateInfo(null)
+      setShowUpdateModal(false)
     }
     window.electron.ipcRenderer.on('update-available', onUpdateAvailable)
     window.electron.ipcRenderer.on('update-downloaded', onUpdateDownloaded)
     window.electron.ipcRenderer.on('update-download-progress', onUpdateDownloadProgress)
     window.electron.ipcRenderer.on('update-not-available', onUpdateNotAvailable)
     window.electron.ipcRenderer.on('update-error', onUpdateError)
+    window.electron.ipcRenderer.on('update-cancelled', onUpdateCancelled)
     return () => {
       window.electron.ipcRenderer.removeAllListeners('update-available')
       window.electron.ipcRenderer.removeAllListeners('update-downloaded')
       window.electron.ipcRenderer.removeAllListeners('update-download-progress')
       window.electron.ipcRenderer.removeAllListeners('update-not-available')
       window.electron.ipcRenderer.removeAllListeners('update-error')
+      window.electron.ipcRenderer.removeAllListeners('update-cancelled')
     }
   }, [])
 
@@ -273,10 +287,21 @@ function App(): JSX.Element {
   // 处理发送消息
   const handleCheckForUpdates = useCallback(() => {
     setUpdateError('')
+    setUpdateInfo(null)
+    setShowUpdateModal(false)
     setUpdateStatus('checking')
     window.electron?.ipcRenderer?.send('check-for-updates')
   }, [])
 
+  const handleCancelUpdate = useCallback(() => {
+    window.electron?.ipcRenderer?.send('cancel-update')
+    setUpdateStatus('idle')
+    setUpdateError('')
+    setUpdateDownloadProgress(null)
+    setUpdateInfo(null)
+    setShowUpdateModal(false)
+  }, [])
+
   // 处理发送消息
   const handleSend = useCallback(async () => {
     if (activeContactId == null || activeContactId < 0) return
@@ -730,6 +755,7 @@ function App(): JSX.Element {
         activeTab={activeTab}
         onTabChange={setActiveTab}
         onSettingsClick={() => setShowSettings(true)}
+        onAccountCenterClick={() => setShowAccountCenter(true)}
         currentUserId={currentUserId}
         currentUserName={currentUserName}
         totalUnread={totalUnread}
@@ -919,17 +945,22 @@ function App(): JSX.Element {
       <SettingsModal
         isOpen={showSettings}
         onClose={() => setShowSettings(false)}
-        currentUserId={currentUserId}
-        currentUserName={currentUserName}
-        onLogout={handleLogout}
         updateStatus={updateStatus}
         updateError={updateError}
         updateDownloadProgress={updateDownloadProgress}
         onCheckForUpdates={handleCheckForUpdates}
       />
 
-      {/* 强制更新弹窗:检测到更新后必须重启安装,无取消选项 */}
-      {showForceUpdateModal && (
+      <AccountCenterModal
+        isOpen={showAccountCenter}
+        onClose={() => setShowAccountCenter(false)}
+        currentUserId={currentUserId}
+        currentUserName={currentUserName}
+        onLogout={handleLogout}
+      />
+
+      {/* 更新弹窗:检测到更新后可取消,下载完成后可选择重启安装 */}
+      {showUpdateModal && (
         <div
           style={{
             position: 'fixed',
@@ -955,9 +986,28 @@ function App(): JSX.Element {
               textAlign: 'center'
             }}
           >
-            <div style={{ fontSize: '16px', color: '#333', marginBottom: '24px' }}>
-              新版本已下载,请重启以完成更新。
+            <div style={{ fontSize: '16px', color: '#333', marginBottom: '12px', fontWeight: 600 }}>
+              {updateStatus === 'update-downloaded'
+                ? '新版本已下载,请重启以完成更新。'
+                : `发现新版本${updateInfo?.version ? ` ${updateInfo.version}` : ''},正在下载更新...`}
             </div>
+            {updateStatus === 'update-available' && updateDownloadProgress != null ? (
+              <div style={{ marginBottom: '20px' }}>
+                <div style={{ fontSize: '13px', color: '#4b5563', marginBottom: 8 }}>
+                  {Math.round(updateDownloadProgress)}%
+                </div>
+                <div style={{ height: 6, backgroundColor: '#edf2f7', borderRadius: 999, overflow: 'hidden' }}>
+                  <div
+                    style={{
+                      height: '100%',
+                      width: `${Math.min(100, Math.round(updateDownloadProgress))}%`,
+                      backgroundColor: '#20b86f',
+                      transition: 'width 0.2s ease'
+                    }}
+                  />
+                </div>
+              </div>
+            ) : null}
             {updateInfo?.releaseNotes ? (
               <div
                 style={{
@@ -977,22 +1027,42 @@ function App(): JSX.Element {
                 {updateInfo.releaseNotes}
               </div>
             ) : null}
-            <button
-              onClick={() => window.electron?.ipcRenderer?.send('quit-and-install')}
-              style={{
-                width: '100%',
-                padding: '12px 24px',
-                backgroundColor: '#1aad19',
-                color: '#fff',
-                border: 'none',
-                borderRadius: '6px',
-                cursor: 'pointer',
-                fontSize: '15px',
-                fontWeight: '500'
-              }}
-            >
-              立即重启并更新
-            </button>
+            <div style={{ display: 'flex', gap: 12 }}>
+              <button
+                onClick={handleCancelUpdate}
+                style={{
+                  flex: 1,
+                  padding: '12px 18px',
+                  backgroundColor: '#f3f4f6',
+                  color: '#374151',
+                  border: '1px solid #d1d5db',
+                  borderRadius: '6px',
+                  cursor: 'pointer',
+                  fontSize: '15px',
+                  fontWeight: '500'
+                }}
+              >
+                取消更新
+              </button>
+              {updateStatus === 'update-downloaded' ? (
+                <button
+                  onClick={() => window.electron?.ipcRenderer?.send('quit-and-install')}
+                  style={{
+                    flex: 1,
+                    padding: '12px 18px',
+                    backgroundColor: '#1aad19',
+                    color: '#fff',
+                    border: 'none',
+                    borderRadius: '6px',
+                    cursor: 'pointer',
+                    fontSize: '15px',
+                    fontWeight: '500'
+                  }}
+                >
+                  立即重启并更新
+                </button>
+              ) : null}
+            </div>
           </div>
         </div>
       )}

+ 97 - 0
src/renderer/src/components/Modals/AccountCenterModal.tsx

@@ -0,0 +1,97 @@
+import React from 'react'
+import { getDefaultAvatar } from '../../utils/avatarUtils'
+
+interface AccountCenterModalProps {
+  isOpen: boolean
+  onClose: () => void
+  currentUserId: number | null
+  currentUserName: string
+  onLogout: () => void
+}
+
+export const AccountCenterModal: React.FC<AccountCenterModalProps> = ({
+  isOpen,
+  onClose,
+  currentUserId,
+  currentUserName,
+  onLogout
+}) => {
+  if (!isOpen) return null
+
+  const displayName = currentUserName || '未知用户'
+
+  return (
+    <div
+      style={{
+        position: 'fixed',
+        top: 0,
+        left: 0,
+        right: 0,
+        bottom: 0,
+        backgroundColor: 'rgba(0,0,0,0.5)',
+        display: 'flex',
+        alignItems: 'center',
+        justifyContent: 'center',
+        zIndex: 1000
+      }}
+      onClick={onClose}
+    >
+      <div
+        style={{
+          backgroundColor: '#fff',
+          padding: '28px 30px 30px',
+          borderRadius: 8,
+          width: 420,
+          maxWidth: 'calc(100vw - 48px)',
+          boxShadow: '0 16px 44px rgba(15,23,42,0.18)'
+        }}
+        onClick={(event) => event.stopPropagation()}
+      >
+        <h2 style={{ margin: 0, fontSize: 20, lineHeight: '28px', fontWeight: 600, color: '#111827' }}>账号中心</h2>
+
+        <div style={{ display: 'flex', alignItems: 'center', gap: 16, padding: '24px 0 18px' }}>
+          <div style={{ width: 56, height: 56, borderRadius: '50%', overflow: 'hidden', flex: '0 0 auto' }}>
+            {getDefaultAvatar(currentUserId || 0, displayName, { sizePx: 56 })}
+          </div>
+          <div style={{ minWidth: 0 }}>
+            <div style={{ color: '#111827', fontSize: 17, fontWeight: 600, lineHeight: '24px', wordBreak: 'break-word' }}>
+              {displayName}
+            </div>
+            <div style={{ marginTop: 4, color: '#6b7280', fontSize: 13 }}>
+              用户ID:{currentUserId || '未知'}
+            </div>
+          </div>
+        </div>
+
+        <div style={{ borderTop: '1px solid #edf0f3', paddingTop: 18 }}>
+          <button
+            onClick={() => {
+              onLogout()
+              onClose()
+            }}
+            style={{
+              width: '100%',
+              padding: '12px 20px',
+              backgroundColor: '#ef4444',
+              color: '#fff',
+              border: 'none',
+              borderRadius: 6,
+              cursor: 'pointer',
+              fontSize: 14,
+              fontWeight: 600,
+              transition: 'background-color 0.2s'
+            }}
+            onMouseOver={(event) => {
+              event.currentTarget.style.backgroundColor = '#dc2626'
+            }}
+            onMouseOut={(event) => {
+              event.currentTarget.style.backgroundColor = '#ef4444'
+            }}
+          >
+            退出登录
+          </button>
+        </div>
+      </div>
+    </div>
+  )
+}

+ 161 - 103
src/renderer/src/components/Modals/SettingsModal.tsx

@@ -5,21 +5,44 @@ type UpdateStatus = 'idle' | 'checking' | 'update-available' | 'update-downloade
 interface SettingsModalProps {
   isOpen: boolean
   onClose: () => void
-  currentUserId: number | null
-  currentUserName: string
-  onLogout: () => void
   updateStatus?: UpdateStatus
   updateError?: string
   updateDownloadProgress?: number | null
   onCheckForUpdates?: () => void
 }
 
+const sectionStyle: React.CSSProperties = {
+  padding: '18px 0',
+  borderBottom: '1px solid #edf0f3'
+}
+
+const labelStyle: React.CSSProperties = {
+  color: '#6b7280',
+  fontSize: 13,
+  marginBottom: 10,
+  fontWeight: 500
+}
+
+const rowStyle: React.CSSProperties = {
+  display: 'flex',
+  alignItems: 'center',
+  justifyContent: 'space-between',
+  gap: 16
+}
+
+const switchLabelStyle: React.CSSProperties = {
+  display: 'flex',
+  alignItems: 'center',
+  gap: 10,
+  cursor: 'pointer',
+  userSelect: 'none',
+  fontSize: 14,
+  color: '#1f2937'
+}
+
 export const SettingsModal: React.FC<SettingsModalProps> = ({
   isOpen,
   onClose,
-  currentUserId,
-  currentUserName,
-  onLogout,
   updateStatus = 'idle',
   updateError = '',
   updateDownloadProgress = null,
@@ -29,6 +52,9 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({
   const [openAtLogin, setOpenAtLogin] = useState(true)
   const [isPackagedApp, setIsPackagedApp] = useState(true)
   const [openAtLoginLoaded, setOpenAtLoginLoaded] = useState(false)
+  const [autoUpdateEnabled, setAutoUpdateEnabled] = useState(false)
+  const [autoUpdateIsDev, setAutoUpdateIsDev] = useState(false)
+  const [autoUpdateLoaded, setAutoUpdateLoaded] = useState(false)
 
   useEffect(() => {
     if (!isOpen) return
@@ -40,8 +66,8 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({
     let cancelled = false
     ipc
       .invoke('get-app-version')
-      .then((v: unknown) => {
-        if (!cancelled && typeof v === 'string') setAppVersion(v)
+      .then((version: unknown) => {
+        if (!cancelled && typeof version === 'string') setAppVersion(version)
       })
       .catch(() => {
         if (!cancelled) setAppVersion(null)
@@ -62,12 +88,12 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({
     setOpenAtLoginLoaded(false)
     ipc
       .invoke('get-open-at-login')
-      .then((r: unknown) => {
+      .then((result: unknown) => {
         if (cancelled) return
-        if (r && typeof r === 'object' && 'openAtLogin' in r) {
-          const o = r as { openAtLogin: unknown; isPackaged?: unknown }
-          if (typeof o.openAtLogin === 'boolean') setOpenAtLogin(o.openAtLogin)
-          setIsPackagedApp(o.isPackaged !== false)
+        if (result && typeof result === 'object' && 'openAtLogin' in result) {
+          const payload = result as { openAtLogin: unknown; isPackaged?: unknown }
+          if (typeof payload.openAtLogin === 'boolean') setOpenAtLogin(payload.openAtLogin)
+          setIsPackagedApp(payload.isPackaged !== false)
         }
         setOpenAtLoginLoaded(true)
       })
@@ -83,25 +109,61 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({
     }
   }, [isOpen])
 
-  if (!isOpen) return null
+  useEffect(() => {
+    if (!isOpen) return
+    const ipc = typeof window !== 'undefined' && window.electron?.ipcRenderer
+    if (!ipc) {
+      setAutoUpdateLoaded(false)
+      return
+    }
+    let cancelled = false
+    setAutoUpdateLoaded(false)
+    ipc
+      .invoke('get-auto-update-enabled')
+      .then((result: unknown) => {
+        if (cancelled) return
+        if (result && typeof result === 'object' && 'enabled' in result) {
+          const payload = result as { enabled: unknown; isDev?: unknown }
+          setAutoUpdateEnabled(payload.enabled === true)
+          setAutoUpdateIsDev(payload.isDev === true)
+        } else {
+          setAutoUpdateEnabled(false)
+          setAutoUpdateIsDev(false)
+        }
+        setAutoUpdateLoaded(true)
+      })
+      .catch(() => {
+        if (!cancelled) {
+          setAutoUpdateEnabled(false)
+          setAutoUpdateIsDev(false)
+          setAutoUpdateLoaded(true)
+        }
+      })
+    return () => {
+      cancelled = true
+    }
+  }, [isOpen])
 
-  const hasUpdateAPI = typeof window !== 'undefined' && window.electron?.ipcRenderer
+  if (!isOpen) return null
 
+  const hasIpc = typeof window !== 'undefined' && window.electron?.ipcRenderer
+  const updateBusy = updateStatus === 'checking' || updateStatus === 'update-available'
+  const updateDisabled = updateBusy || updateStatus === 'update-downloaded'
   const updateStatusText =
     updateStatus === 'checking'
-      ? '检查中…'
+      ? '正在检查更新...'
       : updateStatus === 'update-available'
-        ? '发现新版本,正在下载…'
+        ? '发现新版本,正在下载...'
         : updateStatus === 'update-downloaded'
-          ? '新版本已下载,请重启以更新'
+          ? '新版本已下载,请重启以完成更新'
           : updateStatus === 'latest'
-            ? '当前已是最新版本'
+            ? '当前已是最新版本'
             : updateStatus === 'error'
-              ? updateError || '检查失败'
+              ? updateError || '检查更新失败'
               : ''
 
   return (
-    <div 
+    <div
       style={{
         position: 'fixed',
         top: 0,
@@ -113,54 +175,32 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({
         alignItems: 'center',
         justifyContent: 'center',
         zIndex: 1000
-      }} 
+      }}
       onClick={onClose}
     >
-      <div 
+      <div
         style={{
           backgroundColor: '#fff',
-          padding: '30px',
-          borderRadius: '8px',
-          minWidth: '400px',
-          maxWidth: '500px',
-          boxShadow: '0 4px 12px rgba(0,0,0,0.15)'
-        }} 
-        onClick={(e) => e.stopPropagation()}
+          padding: '28px 30px 30px',
+          borderRadius: 8,
+          width: 460,
+          maxWidth: 'calc(100vw - 48px)',
+          boxShadow: '0 16px 44px rgba(15,23,42,0.18)'
+        }}
+        onClick={(event) => event.stopPropagation()}
       >
-        <h2 style={{ marginTop: 0, marginBottom: '20px', fontSize: '20px', fontWeight: '500' }}>设置</h2>
-        <div style={{ marginTop: '20px' }}>
-          <div style={{ marginBottom: '15px', paddingBottom: '15px', borderBottom: '1px solid #f0f0f0' }}>
-            <div style={{ color: '#999', fontSize: '12px', marginBottom: '5px' }}>用户ID</div>
-            <div style={{ color: '#333', fontSize: '14px' }}>{currentUserId || '未知'}</div>
-          </div>
-          <div style={{ marginBottom: '20px', paddingBottom: '15px', borderBottom: '1px solid #f0f0f0' }}>
-            <div style={{ color: '#999', fontSize: '12px', marginBottom: '5px' }}>用户名</div>
-            <div style={{ color: '#333', fontSize: '14px' }}>{currentUserName || '未知'}</div>
-          </div>
-          <div style={{ marginBottom: '20px', paddingBottom: '15px', borderBottom: '1px solid #f0f0f0' }}>
-            <div style={{ color: '#999', fontSize: '12px', marginBottom: '5px' }}>当前版本</div>
-            <div style={{ color: '#333', fontSize: '14px' }}>{appVersion ?? '—'}</div>
-          </div>
+        <h2 style={{ margin: 0, fontSize: 20, lineHeight: '28px', fontWeight: 600, color: '#111827' }}>设置</h2>
 
-          {hasUpdateAPI && openAtLoginLoaded && (
-            <div style={{ marginBottom: '20px', paddingBottom: '15px', borderBottom: '1px solid #f0f0f0' }}>
-              <div style={{ color: '#999', fontSize: '12px', marginBottom: '8px' }}>开机自启</div>
-              <label
-                style={{
-                  display: 'flex',
-                  alignItems: 'center',
-                  gap: 10,
-                  cursor: 'pointer',
-                  userSelect: 'none',
-                  fontSize: '14px',
-                  color: '#333'
-                }}
-              >
+        <div style={{ marginTop: 14 }}>
+          {hasIpc && openAtLoginLoaded && (
+            <div style={sectionStyle}>
+              <div style={labelStyle}>开机启动</div>
+              <label style={switchLabelStyle}>
                 <input
                   type="checkbox"
                   checked={openAtLogin}
-                  onChange={async (e) => {
-                    const next = e.target.checked
+                  onChange={async (event) => {
+                    const next = event.target.checked
                     setOpenAtLogin(next)
                     try {
                       await window.electron?.ipcRenderer?.invoke('set-open-at-login', next)
@@ -169,83 +209,101 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({
                     }
                   }}
                 />
-                开机时自动打开韫珠IM
+                <span>开机时自动打开韫珠IM</span>
               </label>
               {!isPackagedApp && (
-                <div style={{ marginTop: '8px', fontSize: '12px', color: '#999', lineHeight: 1.5 }}>
-                  开发环境不会写入系统启动项;本偏好仍保存,随安装包生效。
+                <div style={{ marginTop: 8, fontSize: 12, color: '#9ca3af', lineHeight: 1.5 }}>
+                  开发环境不会写入系统启动项;该偏好会保存并随安装包生效。
                 </div>
               )}
             </div>
           )}
 
-          {hasUpdateAPI && onCheckForUpdates && (
-            <div style={{ marginBottom: '20px', paddingBottom: '15px', borderBottom: '1px solid #f0f0f0' }}>
-              <div style={{ color: '#999', fontSize: '12px', marginBottom: '8px' }}>检查更新</div>
+          <div style={sectionStyle}>
+            <div style={rowStyle}>
+              <div>
+                <div style={labelStyle}>当前版本</div>
+                <div style={{ color: '#1f2937', fontSize: 14 }}>{appVersion ?? '-'}</div>
+              </div>
+            </div>
+          </div>
+
+          {hasIpc && autoUpdateLoaded && (
+            <div style={sectionStyle}>
+              <div style={labelStyle}>版本更新设置</div>
+              <label style={switchLabelStyle}>
+                <input
+                  type="checkbox"
+                  checked={autoUpdateEnabled}
+                  onChange={async (event) => {
+                    const next = event.target.checked
+                    setAutoUpdateEnabled(next)
+                    try {
+                      const saved = await window.electron?.ipcRenderer?.invoke('set-auto-update-enabled', next)
+                      if (typeof saved === 'boolean') setAutoUpdateEnabled(saved)
+                    } catch {
+                      setAutoUpdateEnabled(!next)
+                    }
+                  }}
+                />
+                <span>自动检查并下载更新</span>
+              </label>
+              <div style={{ marginTop: 8, fontSize: 12, color: '#9ca3af', lineHeight: 1.5 }}>
+                默认关闭。关闭后不会在启动时或后台定时检查更新,仍可手动检查。
+              </div>
+              {autoUpdateIsDev && (
+                <div style={{ marginTop: 6, fontSize: 12, color: '#9ca3af', lineHeight: 1.5 }}>
+                  开发环境不会连接更新服务;该偏好会保存并随安装包生效。
+                </div>
+              )}
+            </div>
+          )}
+
+          {hasIpc && onCheckForUpdates && (
+            <div style={{ paddingTop: 18 }}>
               <button
                 onClick={onCheckForUpdates}
-                disabled={updateStatus === 'checking' || updateStatus === 'update-available' || updateStatus === 'update-downloaded'}
+                disabled={updateDisabled}
                 style={{
                   width: '100%',
-                  padding: '10px 20px',
-                  backgroundColor: updateStatus === 'checking' || updateStatus === 'update-available' ? '#ccc' : '#1aad19',
+                  padding: '11px 20px',
+                  backgroundColor: updateBusy ? '#cbd5e1' : '#20b86f',
                   color: '#fff',
                   border: 'none',
-                  borderRadius: '4px',
-                  cursor: updateStatus === 'checking' || updateStatus === 'update-available' || updateStatus === 'update-downloaded' ? 'not-allowed' : 'pointer',
-                  fontSize: '14px',
-                  fontWeight: '500'
+                  borderRadius: 6,
+                  cursor: updateDisabled ? 'not-allowed' : 'pointer',
+                  fontSize: 14,
+                  fontWeight: 600
                 }}
               >
-                {updateStatus === 'checking' || updateStatus === 'update-available' ? '检查中…' : '检查更新'}
+                {updateBusy ? '检查中...' : '检查更新'}
               </button>
+
               {updateStatus === 'update-available' && updateDownloadProgress != null && (
-                <div style={{ marginTop: '10px' }}>
-                  <div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>
-                    正在下载… {Math.round(updateDownloadProgress)}%
+                <div style={{ marginTop: 12 }}>
+                  <div style={{ fontSize: 12, color: '#4b5563', marginBottom: 6 }}>
+                    正在下载...{Math.round(updateDownloadProgress)}%
                   </div>
-                  <div style={{ height: 6, backgroundColor: '#eee', borderRadius: 3, overflow: 'hidden' }}>
+                  <div style={{ height: 6, backgroundColor: '#edf2f7', borderRadius: 999, overflow: 'hidden' }}>
                     <div
                       style={{
                         height: '100%',
                         width: `${Math.min(100, Math.round(updateDownloadProgress))}%`,
-                        backgroundColor: '#1aad19',
+                        backgroundColor: '#20b86f',
                         transition: 'width 0.2s ease'
                       }}
                     />
                   </div>
                 </div>
               )}
+
               {updateStatusText && (
-                <div style={{ marginTop: '8px', fontSize: '13px', color: updateStatus === 'error' ? '#ff3b30' : '#666' }}>
+                <div style={{ marginTop: 10, fontSize: 13, color: updateStatus === 'error' ? '#ef4444' : '#4b5563' }}>
                   {updateStatusText}
                 </div>
               )}
             </div>
           )}
-
-          <button 
-            onClick={() => {
-              onLogout()
-              onClose()
-            }}
-            style={{
-              width: '100%',
-              padding: '12px 20px',
-              backgroundColor: '#ff3b30',
-              color: '#fff',
-              border: 'none',
-              borderRadius: '4px',
-              cursor: 'pointer',
-              fontSize: '14px',
-              fontWeight: '500',
-              transition: 'background-color 0.2s'
-            }}
-            onMouseOver={(e) => e.currentTarget.style.backgroundColor = '#ff2d20'}
-            onMouseOut={(e) => e.currentTarget.style.backgroundColor = '#ff3b30'}
-          >
-            退出登录
-          </button>
         </div>
       </div>
     </div>

+ 72 - 11
src/renderer/src/components/Navigation.tsx

@@ -1,11 +1,12 @@
-import React from 'react'
-import { BsChatDots, BsPeople, BsGrid3X3Gap, BsGear } from 'react-icons/bs'
+import React, { useEffect, useRef, useState } from 'react'
+import { BsChatDots, BsGear, BsGrid3X3Gap, BsList, BsPeople, BsPersonCircle } from 'react-icons/bs'
 import { getDefaultAvatar } from '../utils/avatarUtils'
 
 interface NavigationProps {
   activeTab: 'chat' | 'contact' | 'browser'
   onTabChange: (tab: 'chat' | 'contact' | 'browser') => void
   onSettingsClick: () => void
+  onAccountCenterClick: () => void
   currentUserId: number | null
   currentUserName: string
   totalUnread: number
@@ -15,22 +16,57 @@ export const Navigation: React.FC<NavigationProps> = ({
   activeTab,
   onTabChange,
   onSettingsClick,
+  onAccountCenterClick,
   currentUserId,
   currentUserName,
   totalUnread
 }) => {
+  const [menuOpen, setMenuOpen] = useState(false)
+  const menuRef = useRef<HTMLDivElement | null>(null)
+
+  useEffect(() => {
+    if (!menuOpen) return
+
+    const handlePointerDown = (event: MouseEvent) => {
+      const target = event.target as Node | null
+      if (target && menuRef.current?.contains(target)) return
+      setMenuOpen(false)
+    }
+
+    const handleKeyDown = (event: KeyboardEvent) => {
+      if (event.key === 'Escape') setMenuOpen(false)
+    }
+
+    document.addEventListener('mousedown', handlePointerDown)
+    document.addEventListener('keydown', handleKeyDown)
+    return () => {
+      document.removeEventListener('mousedown', handlePointerDown)
+      document.removeEventListener('keydown', handleKeyDown)
+    }
+  }, [menuOpen])
+
   const getNavAvatar = (): React.ReactNode => {
     return getDefaultAvatar(currentUserId || 0, currentUserName, { sizePx: 36 })
   }
 
+  const handleSettingsClick = () => {
+    setMenuOpen(false)
+    onSettingsClick()
+  }
+
+  const handleAccountCenterClick = () => {
+    setMenuOpen(false)
+    onAccountCenterClick()
+  }
+
   return (
     <div className="main-nav">
       <div className="nav-avatar">
         {getNavAvatar()}
       </div>
-      
-      <div 
-        className={`nav-icon ${activeTab === 'chat' ? 'active' : ''}`} 
+
+      <div
+        className={`nav-icon ${activeTab === 'chat' ? 'active' : ''}`}
         onClick={() => onTabChange('chat')}
         title="聊天"
       >
@@ -38,16 +74,16 @@ export const Navigation: React.FC<NavigationProps> = ({
         {totalUnread > 0 && <span className="badge">{totalUnread}</span>}
       </div>
 
-      <div 
-        className={`nav-icon ${activeTab === 'contact' ? 'active' : ''}`} 
+      <div
+        className={`nav-icon ${activeTab === 'contact' ? 'active' : ''}`}
         onClick={() => onTabChange('contact')}
         title="通讯录"
       >
         <BsPeople size={24} />
       </div>
 
-      <div 
-        className={`nav-icon ${activeTab === 'browser' ? 'active' : ''}`} 
+      <div
+        className={`nav-icon ${activeTab === 'browser' ? 'active' : ''}`}
         onClick={() => onTabChange('browser')}
         title="应用中心"
       >
@@ -55,8 +91,33 @@ export const Navigation: React.FC<NavigationProps> = ({
       </div>
 
       <div className="nav-bottom">
-        <div className="nav-icon" title="设置" onClick={onSettingsClick}>
-          <BsGear size={22} />
+        <div className="nav-menu-wrapper" ref={menuRef}>
+          <button
+            type="button"
+            className={`nav-icon nav-menu-button ${menuOpen ? 'menu-open' : ''}`}
+            title="菜单"
+            aria-label="菜单"
+            aria-expanded={menuOpen}
+            onClick={(event) => {
+              event.stopPropagation()
+              setMenuOpen(open => !open)
+            }}
+          >
+            <BsList size={26} />
+          </button>
+
+          {menuOpen && (
+            <div className="nav-menu-popover" role="menu">
+              <button type="button" className="nav-menu-item" role="menuitem" onClick={handleSettingsClick}>
+                <BsGear size={18} />
+                <span>设置</span>
+              </button>
+              <button type="button" className="nav-menu-item" role="menuitem" onClick={handleAccountCenterClick}>
+                <BsPersonCircle size={18} />
+                <span>账号中心</span>
+              </button>
+            </div>
+          )}
         </div>
       </div>
     </div>

+ 65 - 0
src/renderer/src/index.css

@@ -143,6 +143,71 @@ input {
   -webkit-app-region: no-drag;
 }
 
+.nav-menu-wrapper {
+  position: relative;
+  -webkit-app-region: no-drag;
+}
+
+.nav-menu-button {
+  margin-bottom: 0;
+  border: 0;
+  padding: 0;
+  background: transparent;
+}
+
+.nav-menu-button.menu-open {
+  background-color: var(--im-sidebar-hover);
+  color: #ffffff;
+}
+
+.nav-menu-popover {
+  position: absolute;
+  left: 50px;
+  bottom: 0;
+  width: 148px;
+  padding: 6px;
+  background: var(--im-panel);
+  border: 1px solid var(--im-border);
+  border-radius: var(--im-radius);
+  box-shadow: 0 12px 32px rgba(15, 23, 42, 0.22);
+  z-index: 1200;
+}
+
+.nav-menu-popover::before {
+  content: '';
+  position: absolute;
+  left: -6px;
+  bottom: 15px;
+  width: 10px;
+  height: 10px;
+  background: var(--im-panel);
+  border-left: 1px solid var(--im-border);
+  border-bottom: 1px solid var(--im-border);
+  transform: rotate(45deg);
+}
+
+.nav-menu-item {
+  position: relative;
+  z-index: 1;
+  width: 100%;
+  height: 40px;
+  border: 0;
+  border-radius: var(--im-radius-sm);
+  background: transparent;
+  color: var(--im-text);
+  cursor: pointer;
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  padding: 0 10px;
+  font-size: 14px;
+  text-align: left;
+}
+
+.nav-menu-item:hover {
+  background: var(--im-panel-muted);
+}
+
 .list-panel {
   width: 250px;
   background-color: var(--im-panel);