浏览代码

feat: 实现 Redux 主题管理系统

- 新增 src/states/themeSlice.ts 主题状态管理
- 更新 src/states/store.ts 注册主题 reducer
- 修改 src/app.tsx 集成 Redux 主题状态
- 更新 src/pages/patient/components/ActionPanel.tsx 支持主题感知图标
- 新增 src/components/ThemedIcon.tsx 主题感知图标组件
- 新增 docs/实现/主题管理使用指南.md 使用文档
- 新增 src/assets/Icons/base/module-patient/theme-dark/1x/report.svg 深色主题图标

功能特性:
- 支持浅色/深色主题切换
- 主题选择持久化到 localStorage
- 全局 Redux 状态管理,任何组件可访问
- 提供完整的 TypeScript 类型支持
sw 5 天之前
父节点
当前提交
c0a808636f

+ 250 - 0
docs/实现/主题管理使用指南.md

@@ -0,0 +1,250 @@
+# 主题管理使用指南
+
+## 概述
+
+主题信息已集成到 Redux 状态管理中,可以在任何组件中访问当前主题类型和主题配置。
+
+## 文件结构
+
+```
+src/
+├── states/
+│   └── themeSlice.ts          # 主题状态管理
+├── themes/
+│   ├── lightTheme.ts          # 浅色主题配置
+│   ├── darkTheme.ts           # 深色主题配置
+│   └── index.ts               # 主题导出
+└── app.tsx                    # 主题应用入口
+```
+
+## 功能特性
+
+### 1. 主题持久化
+
+- 主题选择自动保存到 `localStorage`
+- 刷新页面后保持用户的主题选择
+- 默认主题:深色主题 (dark)
+
+### 2. 全局访问
+
+- 任何组件都可以通过 Redux 访问主题信息
+- 支持获取主题类型 (`'light'` | `'dark'`)
+- 支持获取完整的主题配置对象
+
+### 3. 主题切换
+
+- 提供 `setTheme` action 设置特定主题
+- 提供 `toggleTheme` action 在两个主题间切换
+
+## 在组件中使用主题
+
+### 方式 1: 获取主题类型(推荐用于条件渲染)
+
+```tsx
+import { useAppSelector } from '@/states/store';
+
+function MyComponent() {
+  // 获取当前主题类型
+  const themeType = useAppSelector((state) => state.theme.themeType);
+
+  // 根据主题类型动态加载图标
+  const iconPath =
+    themeType === 'dark' ? '/icons/dark/report.svg' : '/icons/light/report.svg';
+
+  return <img src={iconPath} alt="report" />;
+}
+```
+
+### 方式 2: 获取完整主题配置(推荐用于样式定制)
+
+```tsx
+import { useAppSelector } from '@/states/store';
+
+function MyComponent() {
+  // 获取完整的主题配置
+  const currentTheme = useAppSelector((state) => state.theme.currentTheme);
+
+  return (
+    <div
+      style={{
+        backgroundColor: currentTheme.token.colorBgContainer,
+        color: currentTheme.token.colorText,
+        borderColor: currentTheme.token.colorPrimary,
+      }}
+    >
+      内容区域
+    </div>
+  );
+}
+```
+
+### 方式 3: 同时获取主题类型和配置
+
+```tsx
+import { useAppSelector } from '@/states/store';
+
+function MyComponent() {
+  const { themeType, currentTheme } = useAppSelector((state) => state.theme);
+
+  const isDark = themeType === 'dark';
+
+  return (
+    <div>
+      <p>当前主题: {isDark ? '深色' : '浅色'}</p>
+      <div style={{ color: currentTheme.token.colorPrimary }}>主题色文字</div>
+    </div>
+  );
+}
+```
+
+## 切换主题
+
+### 方式 1: 设置特定主题
+
+```tsx
+import { useAppDispatch } from '@/states/store';
+import { setTheme } from '@/states/themeSlice';
+
+function ThemeSelector() {
+  const dispatch = useAppDispatch();
+
+  return (
+    <div>
+      <button onClick={() => dispatch(setTheme('light'))}>浅色主题</button>
+      <button onClick={() => dispatch(setTheme('dark'))}>深色主题</button>
+    </div>
+  );
+}
+```
+
+### 方式 2: 切换主题
+
+```tsx
+import { useAppDispatch } from '@/states/store';
+import { toggleTheme } from '@/states/themeSlice';
+
+function ThemeToggle() {
+  const dispatch = useAppDispatch();
+
+  return <button onClick={() => dispatch(toggleTheme())}>切换主题</button>;
+}
+```
+
+## 实际应用场景
+
+### 场景 1: 根据主题动态加载图标
+
+```tsx
+import { useAppSelector } from '@/states/store';
+
+function ReportIcon() {
+  const themeType = useAppSelector((state) => state.theme.themeType);
+
+  // 根据主题类型选择对应的图标路径
+  const iconBasePath = `src/assets/Icons/base/module-patient/theme-${themeType}`;
+
+  return (
+    <img src={`${iconBasePath}/1x/report.svg`} alt="report" className="icon" />
+  );
+}
+```
+
+### 场景 2: 主题感知的组件样式
+
+```tsx
+import { useAppSelector } from '@/states/store';
+
+function Card({ children }) {
+  const { themeType, currentTheme } = useAppSelector((state) => state.theme);
+
+  return (
+    <div
+      className={`card card-${themeType}`}
+      style={{
+        backgroundColor: currentTheme.token.colorBgContainer,
+        borderColor: currentTheme.token.colorPrimary,
+        color: currentTheme.token.colorText,
+      }}
+    >
+      {children}
+    </div>
+  );
+}
+```
+
+### 场景 3: 条件渲染不同的组件
+
+```tsx
+import { useAppSelector } from '@/states/store';
+
+function ThemedLogo() {
+  const themeType = useAppSelector((state) => state.theme.themeType);
+
+  return themeType === 'dark' ? <DarkLogo /> : <LightLogo />;
+}
+```
+
+## 可用的主题 Token
+
+### 常用颜色 Token
+
+```typescript
+currentTheme.token.colorPrimary; // 主色调
+currentTheme.token.colorBgContainer; // 容器背景色
+currentTheme.token.colorBgLayout; // 全局背景色
+currentTheme.token.colorText; // 文字颜色
+currentTheme.token.buttonBgHover; // 按钮悬停背景色
+```
+
+### 浅色主题 (lightTheme)
+
+- `colorPrimary`: `#1DA57A`
+- `colorBgContainer`: `#FFFFFF`
+- `colorText`: `#000000`
+- `colorBgLayout`: `#FAFAFA`
+
+### 深色主题 (darkTheme)
+
+- `colorPrimary`: `#1DA57A`
+- `colorBgContainer`: `#1F1F1F`
+- `colorText`: `#E0E0E0`
+- `colorBgLayout`: `#121212`
+
+## 类型定义
+
+```typescript
+// 主题类型
+type ThemeType = 'light' | 'dark';
+
+// 主题配置类型
+type ThemeConfig = typeof lightTheme;
+
+// Redux 状态中的主题状态
+interface ThemeState {
+  themeType: ThemeType;
+  currentTheme: ThemeConfig;
+}
+```
+
+## 最佳实践
+
+1. **优先使用主题类型判断**: 对于简单的条件渲染(如图标切换),直接使用 `themeType` 即可
+2. **使用主题配置定制样式**: 需要精确控制样式时,使用 `currentTheme.token` 中的值
+3. **避免硬编码颜色**: 所有颜色都应该从主题配置中获取,确保主题切换时样式一致
+4. **组件解耦**: 不要在组件内部导入 `lightTheme` 或 `darkTheme`,始终从 Redux 获取
+
+## 注意事项
+
+- 主题切换是全局的,会影响所有使用 Ant Design `ConfigProvider` 的组件
+- CSS 变量已自动注入到 `:root`,可在 Tailwind CSS 中使用
+- 主题信息会自动保存到 localStorage,无需手动处理
+- 默认主题为深色主题 (dark)
+
+## 调试技巧
+
+在浏览器控制台中查看当前主题状态:
+
+```javascript
+// 在开发环境中,store 已暴露到 window
+window.store.getState().theme;
+```

+ 6 - 8
src/app.tsx

@@ -7,8 +7,8 @@ import store, { useAppDispatch, useAppSelector } from './states/store';
 import { initializeProductState } from './states/productSlice';
 import { loadI18nMessages } from './states/i18nSlice';
 import './app.css';
-import { lightTheme, darkTheme } from './themes';
 import ProductSelector from './components/ProductSelector';
+import { setTheme } from './states/themeSlice';
 import QuotaAlertModal from './pages/security/QuotaAlertModal';
 import AcquisitionTracer from './pages/exam/components/acquisitionTracer';
 import FeatureNotAvailableFeedback from './components/FeatureNotAvailableFeedback';
@@ -41,17 +41,13 @@ function AppContent({ children }: { children: ReactNode }): JSX.Element {
   const isFeatureNotAvailableOpen = useAppSelector(
     (state) => state.featureNotAvailable.isOpen
   );
-  const [currentTheme, setCurrentTheme] = useState(darkTheme); // 默认使用 light 主题
+  const { currentTheme, themeType } = useAppSelector((state) => state.theme);
   const [isI18nReady, setIsI18nReady] = useState(false);
 
   useLaunch((): void => {
     console.log('App launched.');
   });
 
-  const changeTheme = (themeConfig: typeof lightTheme): void => {
-    setCurrentTheme(themeConfig);
-  };
-
   useEffect(() => {
     // 先加载软件信息获取语言设置
     store
@@ -167,8 +163,9 @@ function AppContent({ children }: { children: ReactNode }): JSX.Element {
               type="primary"
               shape="circle"
               size="large"
-              onClick={() => changeTheme(darkTheme)}
+              onClick={() => dispatch(setTheme('dark'))}
               title="Switch to Dark Theme"
+              disabled={themeType === 'dark'}
             >
               🌙
             </Button>
@@ -176,8 +173,9 @@ function AppContent({ children }: { children: ReactNode }): JSX.Element {
               type="primary"
               shape="circle"
               size="large"
-              onClick={() => changeTheme(lightTheme)}
+              onClick={() => dispatch(setTheme('light'))}
               title="Switch to Light Theme"
+              disabled={themeType === 'light'}
             >
               ☀️
             </Button>

+ 68 - 0
src/assets/Icons/base/module-patient/theme-dark/1x/report.svg

@@ -0,0 +1,68 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+   id="icon"
+   viewBox="0 0 32 32"
+   version="1.1"
+   sodipodi:docname="report.svg"
+   inkscape:version="1.4.2 (f4327f4, 2025-05-13)"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:svg="http://www.w3.org/2000/svg">
+  <sodipodi:namedview
+     id="namedview3"
+     pagecolor="#ffffff"
+     bordercolor="#000000"
+     borderopacity="0.25"
+     inkscape:showpageshadow="2"
+     inkscape:pageopacity="0.0"
+     inkscape:pagecheckerboard="0"
+     inkscape:deskcolor="#d1d1d1"
+     inkscape:zoom="25.21875"
+     inkscape:cx="16"
+     inkscape:cy="16"
+     inkscape:window-width="1920"
+     inkscape:window-height="1009"
+     inkscape:window-x="1912"
+     inkscape:window-y="-8"
+     inkscape:window-maximized="1"
+     inkscape:current-layer="icon" />
+  <defs
+     id="defs1">
+    <style
+       id="style1">.cls-1{fill:none;}</style>
+  </defs>
+  <title
+     id="title1">report--alt</title>
+  <rect
+     x="10"
+     y="18"
+     width="8"
+     height="2"
+     id="rect1"
+     style="fill:#808000" />
+  <rect
+     x="10"
+     y="13"
+     width="12"
+     height="2"
+     id="rect2"
+     style="fill:#808000" />
+  <rect
+     x="10"
+     y="23"
+     width="5"
+     height="2"
+     id="rect3"
+     style="fill:#808000" />
+  <path
+     d="M25,5H22V4a2,2,0,0,0-2-2H12a2,2,0,0,0-2,2V5H7A2,2,0,0,0,5,7V28a2,2,0,0,0,2,2H25a2,2,0,0,0,2-2V7A2,2,0,0,0,25,5ZM12,4h8V8H12ZM25,28H7V7h3v3H22V7h3Z"
+     id="path3"
+     style="fill:#808000" />
+  <rect
+     id="_Transparent_Rectangle_"
+     data-name="&lt;Transparent Rectangle&gt;"
+     class="cls-1"
+     width="32"
+     height="32" />
+</svg>

+ 62 - 0
src/components/ThemedIcon.tsx

@@ -0,0 +1,62 @@
+import { useAppSelector } from '../states/store';
+
+/**
+ * 主题感知的图标组件示例
+ * 根据当前主题自动选择对应的图标资源
+ */
+
+interface ThemedIconProps {
+  /** 图标名称,例如 'report', 'patient', 'exam' */
+  name: string;
+  /** 图标所属模块,例如 'module-patient', 'module-exam' */
+  module?: string;
+  /** 图标尺寸,默认为 '1x' */
+  size?: '1x' | '2x' | '3x';
+  /** 图标的 alt 文本 */
+  alt?: string;
+  /** 自定义类名 */
+  className?: string;
+  /** 自定义样式 */
+  style?: React.CSSProperties;
+}
+
+export default function ThemedIcon({
+  name,
+  module = 'module-patient',
+  size = '1x',
+  alt,
+  className = '',
+  style = {},
+}: ThemedIconProps): JSX.Element {
+  const themeType = useAppSelector((state) => state.theme.themeType);
+
+  // 根据主题类型构建图标路径
+  const iconPath = `src/assets/Icons/base/${module}/theme-${themeType}/${size}/${name}.svg`;
+
+  return (
+    <img src={iconPath} alt={alt || name} className={className} style={style} />
+  );
+}
+
+/**
+ * 使用示例:
+ *
+ * import ThemedIcon from '@/components/ThemedIcon';
+ *
+ * // 基础使用
+ * <ThemedIcon name="report" />
+ *
+ * // 指定模块和尺寸
+ * <ThemedIcon
+ *   name="patient"
+ *   module="module-patient"
+ *   size="2x"
+ * />
+ *
+ * // 添加自定义样式
+ * <ThemedIcon
+ *   name="exam"
+ *   className="w-6 h-6"
+ *   style={{ marginRight: 8 }}
+ * />
+ */

+ 5 - 3
src/pages/patient/components/ActionPanel.tsx

@@ -4,7 +4,7 @@ import { useDispatch, useSelector } from 'react-redux';
 import { deleteWorkThunk } from '@/states/patient/worklist/slices/workSlice';
 import { switchToSendPanel } from '@/states/patient/worklist/slices/historyPanelSwitchSlice';
 import { FormattedMessage } from 'react-intl';
-import { AppDispatch, RootState } from '@/states/store';
+import { AppDispatch, RootState, useAppSelector } from '@/states/store';
 import Icon from '@/components/Icon';
 import DiagnosticReport from '../DiagnosticReport';
 import { Popup } from 'antd-mobile';
@@ -37,7 +37,7 @@ const ActionPanel: React.FC = () => {
   const visible = useSelector(
     (state: RootState) => state.diagnosticReport.visible
   );
-
+  const themeType = useAppSelector((state) => state.theme.themeType);
   // 使用 worklist 或 history 的选中项,取决于哪个有值
   const selectedIds =
     workSelectedIds.length > 0 ? workSelectedIds : historySelectedIds;
@@ -331,9 +331,11 @@ const ActionPanel: React.FC = () => {
             module="module-patient"
             name="report"
             userId="base"
-            theme="default"
+            theme={themeType}
             size="2x"
             state="normal"
+            width={40}
+            height={40}
           />
         }
         tooltip={

+ 2 - 0
src/states/store.ts

@@ -58,6 +58,7 @@ import diagnosticReportReducer from './patient/DiagnosticReport/slice';
 import permissionReducer from './permissionSlice';
 import i18nReducer from './i18nSlice';
 import featureNotAvailableReducer from './featureNotAvailableSlice';
+import themeReducer from './themeSlice';
 
 const store = configureStore({
   reducer: {
@@ -110,6 +111,7 @@ const store = configureStore({
     permission: permissionReducer,
     i18n: i18nReducer,
     featureNotAvailable: featureNotAvailableReducer,
+    theme: themeReducer,
   },
   middleware: (getDefaultMiddleware) =>
     getDefaultMiddleware().concat(

+ 64 - 0
src/states/themeSlice.ts

@@ -0,0 +1,64 @@
+import { createSlice, PayloadAction } from '@reduxjs/toolkit';
+import { lightTheme, darkTheme } from '../themes';
+
+// 定义主题类型
+export type ThemeType = 'light' | 'dark';
+
+// 定义主题配置类型
+export type ThemeConfig = typeof lightTheme;
+
+interface ThemeState {
+  themeType: ThemeType;
+  currentTheme: ThemeConfig;
+}
+
+// 从 localStorage 读取保存的主题,默认为 dark
+const getSavedTheme = (): ThemeType => {
+  try {
+    const saved = localStorage.getItem('app-theme');
+    return saved === 'light' ? 'light' : 'dark';
+  } catch {
+    return 'dark';
+  }
+};
+
+const initialThemeType = getSavedTheme();
+
+const initialState: ThemeState = {
+  themeType: initialThemeType,
+  currentTheme: initialThemeType === 'light' ? lightTheme : darkTheme,
+};
+
+const themeSlice = createSlice({
+  name: 'theme',
+  initialState,
+  reducers: {
+    setTheme: (state, action: PayloadAction<ThemeType>) => {
+      const themeType = action.payload;
+      state.themeType = themeType;
+      state.currentTheme = themeType === 'light' ? lightTheme : darkTheme;
+
+      // 保存到 localStorage
+      try {
+        localStorage.setItem('app-theme', themeType);
+      } catch (error) {
+        console.error('Failed to save theme to localStorage:', error);
+      }
+    },
+    toggleTheme: (state) => {
+      const newThemeType = state.themeType === 'light' ? 'dark' : 'light';
+      state.themeType = newThemeType;
+      state.currentTheme = newThemeType === 'light' ? lightTheme : darkTheme;
+
+      // 保存到 localStorage
+      try {
+        localStorage.setItem('app-theme', newThemeType);
+      } catch (error) {
+        console.error('Failed to save theme to localStorage:', error);
+      }
+    },
+  },
+});
+
+export const { setTheme, toggleTheme } = themeSlice.actions;
+export default themeSlice.reducer;