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
The following commit(s) were added to refs/heads/rocketmq-studio by this push:
new 9cd4a30 feat: add preferences persistence, accessibility and env
config (#465)
9cd4a30 is described below
commit 9cd4a3058fbf5b21d45fecf25dd9f898992dbf48
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 d3264de..28ed487 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 abddcf3..70db81c 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 b34d8bc..4349887 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 83922f3..b2c02ec 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 9a5a1b8..aedf717 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 83922f3..5ab0845 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 83922f3..7354eb6 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 48d250e..a722c8e 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 b34d8bc..951ca11 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 b34d8bc..6667f5b 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.
+ }
+}