This is an automated email from the ASF dual-hosted git repository. lizhimins pushed a commit to branch rocketmq-studio in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git
commit 9050805bcd0caed0ee962f84be64a15eba6def17 Author: Loyal-Young <[email protected]> AuthorDate: Wed Jul 22 20:23:16 2026 +0800 feat: add preferences persistence, accessibility and env config (#465) Consolidated preferences/a11y/config work (#465,#466,#485,#486,#487,#484). --- web/.env | 4 ++ web/src/components/MiniBar.tsx | 13 +++++- web/src/components/PageHeader.tsx | 5 +- web/src/components/StatusBadge.tsx | 4 +- web/src/i18n/LangContext.tsx | 8 +++- .../languagePreference.test.ts} | 36 +++++++-------- .../StatusBadge.tsx => i18n/languagePreference.ts} | 35 +++++++------- web/src/theme/ThemeContext.tsx | 53 ++++++++++++---------- .../themePreference.test.ts} | 46 +++++++++---------- .../PageHeader.tsx => theme/themePreference.ts} | 42 ++++++++--------- 10 files changed, 132 insertions(+), 114 deletions(-) diff --git a/web/.env b/web/.env index d3264de4..28ed4877 100644 --- a/web/.env +++ b/web/.env @@ -1,2 +1,6 @@ # Set to "true" to use mock data, "false" to use real API VITE_USE_MOCK=true +# Optional local development proxy target (defaults to http://localhost:8888) +# VITE_API_PROXY_TARGET=http://localhost:8888 +# Optional browser API prefix or absolute API URL (defaults to /api) +# VITE_API_BASE_URL=/api diff --git a/web/src/components/MiniBar.tsx b/web/src/components/MiniBar.tsx index abddcf39..70db81c7 100644 --- a/web/src/components/MiniBar.tsx +++ b/web/src/components/MiniBar.tsx @@ -20,14 +20,25 @@ interface MiniBarProps { color?: string; height?: number; width?: number; + label?: string; } -const MiniBar = ({ data, color = '#1677ff', height = 32, width = 120 }: MiniBarProps) => { +const MiniBar = ({ data, color = '#1677ff', height = 32, width = 120, label }: MiniBarProps) => { + if (!data.length) { + return ( + <span aria-label={label || '暂无趋势数据'} style={{ color: '#8c8c8c' }}> + — + </span> + ); + } + const max = Math.max(...data, 1); const barWidth = Math.max(2, (width - (data.length - 1) * 2) / data.length); return ( <div + role="img" + aria-label={label || `趋势数据:${data.join('、')}`} style={{ display: 'inline-flex', alignItems: 'flex-end', diff --git a/web/src/components/PageHeader.tsx b/web/src/components/PageHeader.tsx index b34d8bc3..4349887c 100644 --- a/web/src/components/PageHeader.tsx +++ b/web/src/components/PageHeader.tsx @@ -24,12 +24,13 @@ interface PageHeaderProps { title: string; subtitle?: ReactNode; extra?: ReactNode; + headingLevel?: 1 | 2 | 3 | 4 | 5; } -const PageHeader = ({ title, subtitle, extra }: PageHeaderProps) => ( +const PageHeader = ({ title, subtitle, extra, headingLevel = 1 }: PageHeaderProps) => ( <Flex justify="space-between" align="center" style={{ marginBottom: 24 }}> <Flex align="center" gap={12}> - <Title level={4} style={{ margin: 0, fontWeight: 600 }}> + <Title level={headingLevel} style={{ margin: 0, fontSize: 20, fontWeight: 600 }}> {title} </Title> {subtitle && ( diff --git a/web/src/components/StatusBadge.tsx b/web/src/components/StatusBadge.tsx index 83922f38..b2c02ec9 100644 --- a/web/src/components/StatusBadge.tsx +++ b/web/src/components/StatusBadge.tsx @@ -30,8 +30,8 @@ const StatusBadge = ({ status, text, showDot = true }: StatusBadgeProps) => { const config = STATUS_MAP[status] || STATUS_MAP.offline; const label = text || config.label; return ( - <Space size={4}> - {showDot && <Badge color={config.dot} />} + <Space size={4} role="status" aria-label={`状态:${label}`}> + {showDot && <Badge color={config.dot} aria-hidden="true" />} <Text style={{ color: config.color, fontSize: 13 }}>{label}</Text> </Space> ); diff --git a/web/src/i18n/LangContext.tsx b/web/src/i18n/LangContext.tsx index 9a5a1b8b..aedf7176 100644 --- a/web/src/i18n/LangContext.tsx +++ b/web/src/i18n/LangContext.tsx @@ -17,6 +17,7 @@ import { createContext, useContext, useState, type ReactNode } from 'react'; import translations, { type Lang } from './translations'; +import { getInitialLanguage, persistLanguage } from './languagePreference'; interface LangContextType { lang: Lang; @@ -31,7 +32,12 @@ const LangContext = createContext<LangContextType>({ }); export const LangProvider = ({ children }: { children: ReactNode }) => { - const [lang, setLang] = useState<Lang>('zh'); + const [lang, setLangState] = useState<Lang>(getInitialLanguage); + + const setLang = (nextLang: Lang) => { + setLangState(nextLang); + persistLanguage(nextLang); + }; const t = (key: string, params?: Record<string, string | number>): string => { let text = translations[key]?.[lang] ?? key; diff --git a/web/src/components/StatusBadge.tsx b/web/src/i18n/languagePreference.test.ts similarity index 53% copy from web/src/components/StatusBadge.tsx copy to web/src/i18n/languagePreference.test.ts index 83922f38..5ab08456 100644 --- a/web/src/components/StatusBadge.tsx +++ b/web/src/i18n/languagePreference.test.ts @@ -15,26 +15,24 @@ * limitations under the License. */ -import { Badge, Space, Typography } from 'antd'; -import { STATUS_MAP } from '../constants/theme'; +import { afterEach, describe, expect, it } from 'vitest'; +import { getInitialLanguage, LANGUAGE_STORAGE_KEY, persistLanguage } from './languagePreference'; -const { Text } = Typography; +describe('language preference', () => { + afterEach(() => { + localStorage.clear(); + }); -interface StatusBadgeProps { - status: keyof typeof STATUS_MAP; - text?: string; - showDot?: boolean; -} + it('defaults to Chinese when no valid preference is stored', () => { + expect(getInitialLanguage()).toBe('zh'); + localStorage.setItem(LANGUAGE_STORAGE_KEY, 'fr'); + expect(getInitialLanguage()).toBe('zh'); + }); -const StatusBadge = ({ status, text, showDot = true }: StatusBadgeProps) => { - const config = STATUS_MAP[status] || STATUS_MAP.offline; - const label = text || config.label; - return ( - <Space size={4}> - {showDot && <Badge color={config.dot} />} - <Text style={{ color: config.color, fontSize: 13 }}>{label}</Text> - </Space> - ); -}; + it('persists a supported language for the next application load', () => { + persistLanguage('en'); -export default StatusBadge; + expect(localStorage.getItem(LANGUAGE_STORAGE_KEY)).toBe('en'); + expect(getInitialLanguage()).toBe('en'); + }); +}); diff --git a/web/src/components/StatusBadge.tsx b/web/src/i18n/languagePreference.ts similarity index 57% copy from web/src/components/StatusBadge.tsx copy to web/src/i18n/languagePreference.ts index 83922f38..7354eb66 100644 --- a/web/src/components/StatusBadge.tsx +++ b/web/src/i18n/languagePreference.ts @@ -15,26 +15,23 @@ * limitations under the License. */ -import { Badge, Space, Typography } from 'antd'; -import { STATUS_MAP } from '../constants/theme'; +import type { Lang } from './translations'; -const { Text } = Typography; +export const LANGUAGE_STORAGE_KEY = 'rocketmq-studio-language'; -interface StatusBadgeProps { - status: keyof typeof STATUS_MAP; - text?: string; - showDot?: boolean; +export function getInitialLanguage(): Lang { + try { + const stored = localStorage.getItem(LANGUAGE_STORAGE_KEY); + return stored === 'en' || stored === 'zh' ? stored : 'zh'; + } catch { + return 'zh'; + } } -const StatusBadge = ({ status, text, showDot = true }: StatusBadgeProps) => { - const config = STATUS_MAP[status] || STATUS_MAP.offline; - const label = text || config.label; - return ( - <Space size={4}> - {showDot && <Badge color={config.dot} />} - <Text style={{ color: config.color, fontSize: 13 }}>{label}</Text> - </Space> - ); -}; - -export default StatusBadge; +export function persistLanguage(lang: Lang): void { + try { + localStorage.setItem(LANGUAGE_STORAGE_KEY, lang); + } catch { + // Language selection still works when browser storage is unavailable. + } +} diff --git a/web/src/theme/ThemeContext.tsx b/web/src/theme/ThemeContext.tsx index 48d250eb..a722c8e8 100644 --- a/web/src/theme/ThemeContext.tsx +++ b/web/src/theme/ThemeContext.tsx @@ -16,6 +16,11 @@ */ import { createContext, useContext, useState, useEffect, type ReactNode } from 'react'; +import { + getStoredThemePreference, + getSystemDarkMode, + persistThemePreference, +} from './themePreference'; interface ThemeContextType { darkMode: boolean; @@ -23,41 +28,43 @@ interface ThemeContextType { toggleTheme: () => void; } -const THEME_STORAGE_KEY = 'rocketmq-studio-theme'; - const ThemeContext = createContext<ThemeContextType>({ darkMode: false, setDarkMode: () => {}, toggleTheme: () => {}, }); -/** Read initial theme preference from localStorage, fall back to system preference. */ -const getInitialDarkMode = (): boolean => { - try { - const stored = localStorage.getItem(THEME_STORAGE_KEY); - if (stored !== null) return stored === 'dark'; - } catch { - // localStorage unavailable - } - return window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false; -}; - export const ThemeProvider = ({ children }: { children: ReactNode }) => { - const [darkMode, setDarkMode] = useState<boolean>(getInitialDarkMode); + const [themeState, setThemeState] = useState(() => { + const preference = getStoredThemePreference(); + return { + darkMode: preference ? preference === 'dark' : getSystemDarkMode(), + followsSystem: preference === null, + }; + }); + + const setDarkMode = (darkMode: boolean) => { + persistThemePreference(darkMode); + setThemeState({ darkMode, followsSystem: false }); + }; + + const toggleTheme = () => setDarkMode(!themeState.darkMode); - // Persist theme choice to localStorage useEffect(() => { - try { - localStorage.setItem(THEME_STORAGE_KEY, darkMode ? 'dark' : 'light'); - } catch { - // localStorage unavailable - } - }, [darkMode]); + if (!themeState.followsSystem || !window.matchMedia) return; - const toggleTheme = () => setDarkMode((prev) => !prev); + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + const updateTheme = (event: MediaQueryListEvent) => { + setThemeState((current) => + current.followsSystem ? { ...current, darkMode: event.matches } : current, + ); + }; + mediaQuery.addEventListener('change', updateTheme); + return () => mediaQuery.removeEventListener('change', updateTheme); + }, [themeState.followsSystem]); return ( - <ThemeContext.Provider value={{ darkMode, setDarkMode, toggleTheme }}> + <ThemeContext.Provider value={{ darkMode: themeState.darkMode, setDarkMode, toggleTheme }}> {children} </ThemeContext.Provider> ); diff --git a/web/src/components/PageHeader.tsx b/web/src/theme/themePreference.test.ts similarity index 51% copy from web/src/components/PageHeader.tsx copy to web/src/theme/themePreference.test.ts index b34d8bc3..951ca110 100644 --- a/web/src/components/PageHeader.tsx +++ b/web/src/theme/themePreference.test.ts @@ -15,31 +15,29 @@ * limitations under the License. */ -import { Flex, Typography } from 'antd'; -import type { ReactNode } from 'react'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + getStoredThemePreference, + persistThemePreference, + THEME_STORAGE_KEY, +} from './themePreference'; -const { Title, Text } = Typography; +describe('theme preference', () => { + afterEach(() => { + localStorage.clear(); + }); -interface PageHeaderProps { - title: string; - subtitle?: ReactNode; - extra?: ReactNode; -} + it('uses no explicit preference for missing or invalid stored values', () => { + expect(getStoredThemePreference()).toBeNull(); + localStorage.setItem(THEME_STORAGE_KEY, 'system'); + expect(getStoredThemePreference()).toBeNull(); + }); -const PageHeader = ({ title, subtitle, extra }: PageHeaderProps) => ( - <Flex justify="space-between" align="center" style={{ marginBottom: 24 }}> - <Flex align="center" gap={12}> - <Title level={4} style={{ margin: 0, fontWeight: 600 }}> - {title} - </Title> - {subtitle && ( - <Text type="secondary" style={{ fontSize: 13 }}> - {subtitle} - </Text> - )} - </Flex> - {extra && <Flex gap={8}>{extra}</Flex>} - </Flex> -); + it('persists manual light and dark theme choices', () => { + persistThemePreference(true); + expect(getStoredThemePreference()).toBe('dark'); -export default PageHeader; + persistThemePreference(false); + expect(getStoredThemePreference()).toBe('light'); + }); +}); diff --git a/web/src/components/PageHeader.tsx b/web/src/theme/themePreference.ts similarity index 53% copy from web/src/components/PageHeader.tsx copy to web/src/theme/themePreference.ts index b34d8bc3..6667f5ba 100644 --- a/web/src/components/PageHeader.tsx +++ b/web/src/theme/themePreference.ts @@ -15,31 +15,27 @@ * limitations under the License. */ -import { Flex, Typography } from 'antd'; -import type { ReactNode } from 'react'; +export type ThemePreference = 'dark' | 'light'; -const { Title, Text } = Typography; +export const THEME_STORAGE_KEY = 'rocketmq-studio-theme'; -interface PageHeaderProps { - title: string; - subtitle?: ReactNode; - extra?: ReactNode; +export function getStoredThemePreference(): ThemePreference | null { + try { + const stored = localStorage.getItem(THEME_STORAGE_KEY); + return stored === 'dark' || stored === 'light' ? stored : null; + } catch { + return null; + } } -const PageHeader = ({ title, subtitle, extra }: PageHeaderProps) => ( - <Flex justify="space-between" align="center" style={{ marginBottom: 24 }}> - <Flex align="center" gap={12}> - <Title level={4} style={{ margin: 0, fontWeight: 600 }}> - {title} - </Title> - {subtitle && ( - <Text type="secondary" style={{ fontSize: 13 }}> - {subtitle} - </Text> - )} - </Flex> - {extra && <Flex gap={8}>{extra}</Flex>} - </Flex> -); +export function getSystemDarkMode(): boolean { + return window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false; +} -export default PageHeader; +export function persistThemePreference(darkMode: boolean): void { + try { + localStorage.setItem(THEME_STORAGE_KEY, darkMode ? 'dark' : 'light'); + } catch { + // The active theme still changes when browser storage is unavailable. + } +}
