Merge branch 'feat/desktop-zoom-settings-integration'

This commit is contained in:
程序员阿江(Relakkes) 2026-05-14 17:50:08 +08:00
commit 188214918b
15 changed files with 196 additions and 14 deletions

View File

@ -144,6 +144,7 @@ describe('Settings > General tab', () => {
skipWebFetchPreflight: true,
desktopNotificationsEnabled: true,
responseLanguage: '',
uiZoom: 1,
webSearch: { mode: 'auto', tavilyApiKey: '', braveApiKey: '' },
h5Access: {
enabled: false,
@ -167,6 +168,9 @@ describe('Settings > General tab', () => {
setResponseLanguage: vi.fn().mockImplementation(async (language: string) => {
useSettingsStore.setState({ responseLanguage: language })
}),
setUiZoom: vi.fn().mockImplementation((uiZoom: number) => {
useSettingsStore.setState({ uiZoom })
}),
setWebSearch: vi.fn().mockImplementation(async (webSearch) => {
useSettingsStore.setState({ webSearch })
}),
@ -251,6 +255,26 @@ describe('Settings > General tab', () => {
expect(screen.getByRole('button', { name: 'Warm Classic' })).toHaveAttribute('aria-pressed', 'false')
})
it('updates the shared UI zoom setting from the General display control', async () => {
render(<Settings />)
fireEvent.click(screen.getByText('General'))
await act(async () => {
fireEvent.change(screen.getByLabelText('UI Zoom'), {
target: { value: '1.25', valueAsNumber: 1.25 },
})
})
expect(useSettingsStore.getState().setUiZoom).toHaveBeenCalledWith(1.25)
expect(screen.getByText('125%')).toBeInTheDocument()
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Reset UI zoom to 100%' }))
})
expect(useSettingsStore.getState().setUiZoom).toHaveBeenLastCalledWith(1)
})
it('opens the Token usage tab from Settings navigation above Diagnostics', () => {
render(<Settings />)

View File

@ -593,7 +593,7 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
)}
{!isMobile && (
<div className={`border-t border-[var(--color-border)] p-3 ${expanded ? '' : 'flex justify-center'}`}>
<div className={`absolute bottom-0 left-0 right-0 border-t border-[var(--color-border)] p-3 ${expanded ? '' : 'flex justify-center'}`}>
<NavItem
active={activeTabId === SETTINGS_TAB_ID}
collapsed={!expanded}

View File

@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
import { APP_ZOOM_STORAGE_KEY } from '../lib/appZoom'
import { useSettingsStore } from '../stores/settingsStore'
import { useKeyboardShortcuts } from './useKeyboardShortcuts'
function ShortcutHost() {
@ -22,6 +23,7 @@ describe('useKeyboardShortcuts app zoom', () => {
document.documentElement.removeAttribute('data-app-zoom-percent')
document.documentElement.style.removeProperty('--app-zoom')
document.body.style.removeProperty('zoom')
useSettingsStore.setState({ uiZoom: 1 })
setNavigatorPlatform('Win32')
})
@ -41,6 +43,7 @@ describe('useKeyboardShortcuts app zoom', () => {
await waitFor(() => {
expect(window.localStorage.getItem(APP_ZOOM_STORAGE_KEY)).toBe('1.1')
})
expect(useSettingsStore.getState().uiZoom).toBe(1.1)
expect(document.documentElement.getAttribute('data-app-zoom-percent')).toBe('110')
fireEvent.keyDown(document, {
@ -52,6 +55,7 @@ describe('useKeyboardShortcuts app zoom', () => {
await waitFor(() => {
expect(window.localStorage.getItem(APP_ZOOM_STORAGE_KEY)).toBe('1')
})
expect(useSettingsStore.getState().uiZoom).toBe(1)
fireEvent.keyDown(document, {
code: 'NumpadAdd',

View File

@ -4,12 +4,10 @@ import { useChatStore } from '../stores/chatStore'
import { useTabStore } from '../stores/tabStore'
import { useUIStore } from '../stores/uiStore'
import {
applyAppZoomLevel,
getAppZoomKeyboardAction,
initializeAppZoom,
nextAppZoomLevel,
readStoredAppZoomLevel,
} from '../lib/appZoom'
import { useSettingsStore } from '../stores/settingsStore'
export function useKeyboardShortcuts() {
const setActiveSession = useSessionStore((s) => s.setActiveSession)
@ -20,6 +18,8 @@ export function useKeyboardShortcuts() {
const stopGeneration = useChatStore((s) => s.stopGeneration)
const activeTabId = useTabStore((s) => s.activeTabId)
const chatState = useChatStore((s) => activeTabId ? s.sessions[activeTabId]?.chatState ?? 'idle' : 'idle')
const uiZoom = useSettingsStore((s) => s.uiZoom)
const setUiZoom = useSettingsStore((s) => s.setUiZoom)
const activeModalRef = useRef(activeModal)
activeModalRef.current = activeModal
@ -27,18 +27,17 @@ export function useKeyboardShortcuts() {
chatStateRef.current = chatState
const activeTabIdRef = useRef(activeTabId)
activeTabIdRef.current = activeTabId
const appZoomLevelRef = useRef(readStoredAppZoomLevel())
const appZoomLevelRef = useRef(uiZoom)
appZoomLevelRef.current = uiZoom
useEffect(() => {
void initializeAppZoom()
const handler = (e: KeyboardEvent) => {
const zoomAction = getAppZoomKeyboardAction(e)
if (zoomAction) {
e.preventDefault()
const nextZoom = nextAppZoomLevel(appZoomLevelRef.current, zoomAction)
appZoomLevelRef.current = nextZoom
void applyAppZoomLevel(nextZoom)
setUiZoom(nextZoom)
return
}
@ -80,5 +79,5 @@ export function useKeyboardShortcuts() {
document.addEventListener('keydown', handler)
return () => document.removeEventListener('keydown', handler)
}, [closeModal, setActiveSession, setActiveView, setSidebarOpen, stopGeneration])
}, [closeModal, setActiveSession, setActiveView, setSidebarOpen, setUiZoom, stopGeneration])
}

View File

@ -801,6 +801,9 @@ export const en = {
'settings.general.webSearchBraveFreeHint': 'Create an account to generate a Search API key with free usage for testing.',
'settings.general.webSearchHint': 'Auto uses native Claude web search for Claude model names, then falls back to Tavily and Brave keys.',
'settings.general.webSearchSave': 'Save',
'settings.general.uiZoom': 'UI Zoom',
'settings.general.uiZoomDescription': 'Adjust the size of the entire interface.',
'settings.general.uiZoomReset': 'Reset UI zoom to 100%',
// ─── Empty Session ──────────────────────────────────────
'empty.title': 'New session',

View File

@ -803,6 +803,9 @@ export const zh: Record<TranslationKey, string> = {
'settings.general.webSearchBraveFreeHint': '注册账号后可创建 Search API Key免费额度可用于测试。',
'settings.general.webSearchHint': '自动模式会对 Claude 模型名优先使用原生 WebSearch失败或非 Claude 模型时再使用 Tavily/Brave。',
'settings.general.webSearchSave': '保存',
'settings.general.uiZoom': '界面缩放',
'settings.general.uiZoomDescription': '调整整个界面的显示大小。',
'settings.general.uiZoomReset': '重置界面缩放到 100%',
// ─── Empty Session ──────────────────────────────────────
'empty.title': '新建会话',

View File

@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
APP_ZOOM_STORAGE_KEY,
LEGACY_UI_ZOOM_STORAGE_KEY,
applyAppZoomLevel,
getAppZoomKeyboardAction,
initializeAppZoom,
@ -39,6 +40,15 @@ describe('appZoom', () => {
expect(window.localStorage.getItem(APP_ZOOM_STORAGE_KEY)).toBe('1.2')
})
it('reads the legacy UI zoom key when the app zoom key is absent', async () => {
window.localStorage.setItem(LEGACY_UI_ZOOM_STORAGE_KEY, '1.25')
await initializeAppZoom()
expect(document.documentElement.getAttribute('data-app-zoom-percent')).toBe('125')
expect(window.localStorage.getItem(APP_ZOOM_STORAGE_KEY)).toBeNull()
})
it('persists app zoom changes', async () => {
await applyAppZoomLevel(1.3)

View File

@ -1,8 +1,10 @@
export const APP_ZOOM_STORAGE_KEY = 'cc-haha-app-zoom'
export const LEGACY_UI_ZOOM_STORAGE_KEY = 'cc-haha-ui-zoom'
export const DEFAULT_APP_ZOOM = 1
export const MIN_APP_ZOOM = 0.5
export const MAX_APP_ZOOM = 2
export const APP_ZOOM_STEP = 0.1
export const APP_ZOOM_CONTROL_STEP = 0.05
export type AppZoomAction = 'in' | 'out' | 'reset'
@ -45,7 +47,9 @@ export function isValidStoredAppZoomLevel(value: string | null): boolean {
export function readStoredAppZoomLevel(storage: StorageLike | null = getDefaultStorage()): number {
if (!storage) return DEFAULT_APP_ZOOM
try {
return normalizeAppZoomLevel(storage.getItem(APP_ZOOM_STORAGE_KEY))
const stored = storage.getItem(APP_ZOOM_STORAGE_KEY)
if (stored !== null) return normalizeAppZoomLevel(stored)
return normalizeAppZoomLevel(storage.getItem(LEGACY_UI_ZOOM_STORAGE_KEY))
} catch {
return DEFAULT_APP_ZOOM
}

View File

@ -1,5 +1,5 @@
import { doctorApi, type DoctorReportRepairResponse } from '../api/doctor'
import { APP_ZOOM_STORAGE_KEY } from './appZoom'
import { APP_ZOOM_STORAGE_KEY, LEGACY_UI_ZOOM_STORAGE_KEY } from './appZoom'
import { DESKTOP_PERSISTENCE_VERSION_KEY } from './persistenceMigrations'
export const SAFE_DOCTOR_STORAGE_KEYS = [
@ -8,6 +8,7 @@ export const SAFE_DOCTOR_STORAGE_KEYS = [
'cc-haha-theme',
'cc-haha-locale',
APP_ZOOM_STORAGE_KEY,
LEGACY_UI_ZOOM_STORAGE_KEY,
DESKTOP_PERSISTENCE_VERSION_KEY,
] as const

View File

@ -81,6 +81,19 @@ describe('desktop persistence migrations', () => {
expect(window.localStorage.getItem('cc-haha-app-zoom')).toBeNull()
})
test('migrates the legacy UI zoom key into app zoom storage', () => {
window.localStorage.setItem('cc-haha-ui-zoom', '1.25')
const report = runDesktopPersistenceMigrations()
expect(report.migratedKeys).toEqual(expect.arrayContaining([
'cc-haha-app-zoom',
'cc-haha-ui-zoom',
]))
expect(window.localStorage.getItem('cc-haha-app-zoom')).toBe('1.25')
expect(window.localStorage.getItem('cc-haha-ui-zoom')).toBeNull()
})
test('does not throw if schema version persistence is blocked', () => {
const storage = {
getItem: window.localStorage.getItem.bind(window.localStorage),

View File

@ -1,5 +1,10 @@
import { THEME_MODES } from '../types/settings'
import { APP_ZOOM_STORAGE_KEY, isValidStoredAppZoomLevel } from './appZoom'
import {
APP_ZOOM_STORAGE_KEY,
LEGACY_UI_ZOOM_STORAGE_KEY,
isValidStoredAppZoomLevel,
normalizeAppZoomLevel,
} from './appZoom'
export const CURRENT_DESKTOP_PERSISTENCE_SCHEMA_VERSION = 1
export const DESKTOP_PERSISTENCE_VERSION_KEY = 'cc-haha.persistence.schemaVersion'
@ -121,6 +126,17 @@ function normalizeAppZoomKey(storage: StorageLike, report: DesktopMigrationRepor
storage.removeItem(APP_ZOOM_STORAGE_KEY)
report.migratedKeys.push(APP_ZOOM_STORAGE_KEY)
}
const currentValue = storage.getItem(APP_ZOOM_STORAGE_KEY)
const legacyValue = storage.getItem(LEGACY_UI_ZOOM_STORAGE_KEY)
if (currentValue === null && legacyValue !== null && isValidStoredAppZoomLevel(legacyValue)) {
storage.setItem(APP_ZOOM_STORAGE_KEY, String(normalizeAppZoomLevel(legacyValue)))
report.migratedKeys.push(APP_ZOOM_STORAGE_KEY)
}
if (legacyValue !== null) {
storage.removeItem(LEGACY_UI_ZOOM_STORAGE_KEY)
report.migratedKeys.push(LEGACY_UI_ZOOM_STORAGE_KEY)
}
}
function runMigrationStep(

View File

@ -1,9 +1,11 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import './theme/globals.css'
import { initializeAppZoom } from './lib/appZoom'
import { runDesktopPersistenceMigrations } from './lib/persistenceMigrations'
runDesktopPersistenceMigrations()
void initializeAppZoom()
const [{ App }, { ErrorBoundary }, { installClientDiagnosticsCapture }, { initializeTheme }] = await Promise.all([
import('./App'),
import('./components/ErrorBoundary'),

View File

@ -1,7 +1,7 @@
import { useState, useEffect, useMemo, useRef, type ReactNode } from 'react'
import QRCode from 'qrcode'
import { Copy, Eye, EyeOff, PowerOff, QrCode, RotateCw } from 'lucide-react'
import { useSettingsStore } from '../stores/settingsStore'
import { useSettingsStore, UI_ZOOM_DEFAULT, UI_ZOOM_MIN, UI_ZOOM_MAX, UI_ZOOM_STEP } from '../stores/settingsStore'
import { useProviderStore } from '../stores/providerStore'
import { useTranslation } from '../i18n'
import { Modal } from '../components/shared/Modal'
@ -1385,6 +1385,8 @@ function GeneralSettings() {
setWebSearch,
responseLanguage,
setResponseLanguage,
uiZoom,
setUiZoom,
} = useSettingsStore()
const t = useTranslation()
const [webSearchDraft, setWebSearchDraft] = useState(webSearch)
@ -1533,6 +1535,45 @@ function GeneralSettings() {
))}
</div>
{/* UI Zoom */}
<div className="mb-8">
<div className="mb-3 flex items-end justify-between gap-3">
<div className="min-w-0">
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.uiZoom')}</h2>
<p className="text-sm text-[var(--color-text-tertiary)]">{t('settings.general.uiZoomDescription')}</p>
</div>
<div className="flex flex-shrink-0 items-center gap-2">
<span className="min-w-[48px] rounded-md bg-[var(--color-surface-container-low)] px-2 py-1 text-center text-sm font-medium text-[var(--color-text-secondary)]">
{Math.round(uiZoom * 100)}%
</span>
<button
type="button"
aria-label={t('settings.general.uiZoomReset')}
title={t('settings.general.uiZoomReset')}
onClick={() => setUiZoom(UI_ZOOM_DEFAULT)}
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-[var(--color-border)] px-2 text-xs font-medium text-[var(--color-text-secondary)] transition-colors hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
>
<RotateCw className="h-3.5 w-3.5" aria-hidden="true" />
100%
</button>
</div>
</div>
<div className="flex items-center gap-3">
<span className="w-9 text-right text-xs text-[var(--color-text-tertiary)]">{Math.round(UI_ZOOM_MIN * 100)}%</span>
<input
type="range"
aria-label={t('settings.general.uiZoom')}
min={UI_ZOOM_MIN}
max={UI_ZOOM_MAX}
step={UI_ZOOM_STEP}
value={uiZoom}
onChange={(e) => setUiZoom(e.currentTarget.valueAsNumber)}
className="flex-1"
/>
<span className="w-9 text-xs text-[var(--color-text-tertiary)]">{Math.round(UI_ZOOM_MAX * 100)}%</span>
</div>
</div>
{/* Language selector */}
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.languageTitle')}</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.languageDescription')}</p>
@ -1778,7 +1819,6 @@ function GeneralSettings() {
</div>
</div>
</div>
</div>
)
}

View File

@ -22,6 +22,47 @@ describe('settingsStore locale defaults', () => {
})
})
describe('settingsStore UI zoom', () => {
beforeEach(() => {
vi.resetModules()
window.localStorage.clear()
document.documentElement.removeAttribute('data-app-zoom-percent')
document.documentElement.style.removeProperty('--app-zoom')
document.body.style.removeProperty('zoom')
})
it('hydrates from the app zoom storage key', async () => {
window.localStorage.setItem('cc-haha-app-zoom', '1.25')
const { useSettingsStore } = await import('./settingsStore')
expect(useSettingsStore.getState().uiZoom).toBe(1.25)
})
it('applies and persists UI zoom changes through the shared app zoom controller', async () => {
const { useSettingsStore } = await import('./settingsStore')
useSettingsStore.getState().setUiZoom(1.25)
await vi.waitFor(() => {
expect(window.localStorage.getItem('cc-haha-app-zoom')).toBe('1.25')
})
expect(useSettingsStore.getState().uiZoom).toBe(1.25)
expect(document.documentElement.getAttribute('data-app-zoom-percent')).toBe('125')
})
it('clamps UI zoom changes to the supported range', async () => {
const { useSettingsStore } = await import('./settingsStore')
useSettingsStore.getState().setUiZoom(9)
await vi.waitFor(() => {
expect(window.localStorage.getItem('cc-haha-app-zoom')).toBe('2')
})
expect(useSettingsStore.getState().uiZoom).toBe(2)
})
})
describe('settingsStore desktop notification persistence', () => {
beforeEach(() => {
vi.resetModules()

View File

@ -5,9 +5,22 @@ import { modelsApi } from '../api/models'
import { h5AccessApi } from '../api/h5Access'
import { isThemeMode, type H5AccessSettings, type PermissionMode, type EffortLevel, type ModelInfo, type ThemeMode, type WebSearchSettings } from '../types/settings'
import type { Locale } from '../i18n'
import {
APP_ZOOM_CONTROL_STEP,
DEFAULT_APP_ZOOM,
MAX_APP_ZOOM,
MIN_APP_ZOOM,
applyAppZoomLevel,
normalizeAppZoomLevel,
readStoredAppZoomLevel,
} from '../lib/appZoom'
import { useUIStore } from './uiStore'
const LOCALE_STORAGE_KEY = 'cc-haha-locale'
export const UI_ZOOM_MIN = MIN_APP_ZOOM
export const UI_ZOOM_MAX = MAX_APP_ZOOM
export const UI_ZOOM_STEP = APP_ZOOM_CONTROL_STEP
export const UI_ZOOM_DEFAULT = DEFAULT_APP_ZOOM
let desktopNotificationsSaveQueue: Promise<void> = Promise.resolve()
function getStoredLocale(): Locale {
@ -33,6 +46,7 @@ type SettingsStore = {
h5Access: H5AccessSettings
h5AccessError: string | null
responseLanguage: string
uiZoom: number
isLoading: boolean
error: string | null
@ -55,6 +69,7 @@ type SettingsStore = {
publicBaseUrl?: string | null
}) => Promise<void>
setResponseLanguage: (language: string) => Promise<void>
setUiZoom: (zoom: number) => void
}
const DEFAULT_H5_ACCESS_SETTINGS: H5AccessSettings = {
@ -79,9 +94,16 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
h5Access: DEFAULT_H5_ACCESS_SETTINGS,
h5AccessError: null,
responseLanguage: '',
uiZoom: readStoredAppZoomLevel(),
isLoading: false,
error: null,
setUiZoom: (zoom: number) => {
const level = normalizeAppZoomLevel(zoom)
set({ uiZoom: level })
void applyAppZoomLevel(level)
},
fetchAll: async () => {
set({ isLoading: true, error: null })
try {