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.
+  }
+}

Reply via email to