Bläddra i källkod

feat: 封装可拖拽的主题切换器组件

- 创建 ThemeSwitcher.tsx 组件,封装主题切换功能和UI
- 实现拖拽功能,使用 useRef 解决状态异步更新问题
- 添加位置持久化,通过 localStorage 保存和恢复位置
- 优化按钮样式,固定宽高保持圆形,防止 emoji 撑大按钮
- 添加拖拽手柄()提供直观的拖拽交互
- 更新 app.tsx 使用新组件,简化代码结构

改动文件:
- src/components/ThemeSwitcher.tsx(新增)
- src/app.tsx
sw 1 dag sedan
förälder
incheckning
5c81d6b447
2 ändrade filer med 170 tillägg och 35 borttagningar
  1. 3 35
      src/app.tsx
  2. 167 0
      src/components/ThemeSwitcher.tsx

+ 3 - 35
src/app.tsx

@@ -1,14 +1,14 @@
 import { useState, useEffect, ReactNode } from 'react';
 import { useLaunch } from '@tarojs/taro';
 import { IntlProvider } from 'react-intl';
-import { ConfigProvider, Button } from 'antd';
+import { ConfigProvider } from 'antd';
 import { Provider } from 'react-redux';
 import store, { useAppDispatch, useAppSelector } from './states/store';
 import { initializeProductState } from './states/productSlice';
 import { loadI18nMessages } from './states/i18nSlice';
 import './app.css';
 import ProductSelector from './components/ProductSelector';
-import { setTheme } from './states/themeSlice';
+import ThemeSwitcher from './components/ThemeSwitcher';
 import QuotaAlertModal from './pages/security/QuotaAlertModal';
 import AcquisitionTracer from './pages/exam/components/acquisitionTracer';
 import FeatureNotAvailableFeedback from './components/FeatureNotAvailableFeedback';
@@ -162,39 +162,7 @@ function AppContent({ children }: { children: ReactNode }): JSX.Element {
           />
           {children}
           {process.env.NODE_ENV === 'development' && <ProductSelector />}
-          <div
-            style={{
-              position: 'fixed',
-              top: '50%',
-              right: 16,
-              transform: 'translateY(-50%)',
-              display: 'flex',
-              flexDirection: 'column',
-              gap: 8, // 按钮间距
-              zIndex: 1000,
-            }}
-          >
-            <Button
-              type="primary"
-              shape="circle"
-              size="large"
-              onClick={() => dispatch(setTheme('dark'))}
-              title="Switch to Dark Theme"
-              disabled={themeType === 'dark'}
-            >
-              🌙
-            </Button>
-            <Button
-              type="primary"
-              shape="circle"
-              size="large"
-              onClick={() => dispatch(setTheme('light'))}
-              title="Switch to Light Theme"
-              disabled={themeType === 'light'}
-            >
-              ☀️
-            </Button>
-          </div>
+          <ThemeSwitcher />
         </div>
       </IntlProvider>
     </ConfigProvider>

+ 167 - 0
src/components/ThemeSwitcher.tsx

@@ -0,0 +1,167 @@
+import { useState, useEffect, useRef } from 'react';
+import { Button } from 'antd';
+import { useAppDispatch, useAppSelector } from '../states/store';
+import { setTheme } from '../states/themeSlice';
+
+interface Position {
+  top: number;
+  right: number;
+}
+
+interface ThemeSwitcherProps {
+  initialPosition?: Position;
+  buttonSize?: number;
+}
+
+const STORAGE_KEY = 'theme-switcher-position';
+
+export default function ThemeSwitcher({
+  initialPosition = { top: 50, right: 16 },
+  buttonSize = 24,
+}: ThemeSwitcherProps): JSX.Element {
+  const dispatch = useAppDispatch();
+  const { themeType, currentTheme } = useAppSelector((state) => state.theme);
+  
+  // 从 localStorage 恢复位置或使用初始位置
+  const [position, setPosition] = useState<Position>(() => {
+    try {
+      const saved = localStorage.getItem(STORAGE_KEY);
+      return saved ? JSON.parse(saved) : initialPosition;
+    } catch {
+      return initialPosition;
+    }
+  });
+  
+  const [isDragging, setIsDragging] = useState(false);
+  const isDraggingRef = useRef(false);
+  const dragStartPos = useRef({ x: 0, y: 0 });
+  const elementStartPos = useRef<Position>({ top: 0, right: 0 });
+
+  // 保存位置到 localStorage
+  useEffect(() => {
+    try {
+      localStorage.setItem(STORAGE_KEY, JSON.stringify(position));
+    } catch (error) {
+      console.warn('Failed to save theme switcher position:', error);
+    }
+  }, [position]);
+
+  const handleDragStart = (e: React.MouseEvent): void => {
+    e.preventDefault();
+    isDraggingRef.current = true;
+    setIsDragging(true);
+    dragStartPos.current = { x: e.clientX, y: e.clientY };
+    elementStartPos.current = { ...position };
+    
+    // 添加全局事件监听
+    document.addEventListener('mousemove', handleDrag as any);
+    document.addEventListener('mouseup', handleDragEnd);
+  };
+
+  const handleDrag = (e: MouseEvent): void => {
+    if (!isDraggingRef.current) return;
+
+    const deltaX = e.clientX - dragStartPos.current.x;
+    const deltaY = e.clientY - dragStartPos.current.y;
+
+    // 计算新位置(限制在窗口范围内)
+    const windowHeight = window.innerHeight;
+    const newTopPx = (elementStartPos.current.top / 100) * windowHeight + deltaY;
+    const newTopPercent = (newTopPx / windowHeight) * 100;
+    
+    const newRight = Math.max(0, elementStartPos.current.right - deltaX);
+
+    // 限制 top 在 0-100% 之间
+    const clampedTop = Math.max(0, Math.min(100, newTopPercent));
+
+    setPosition({
+      top: clampedTop,
+      right: newRight,
+    });
+  };
+
+  const handleDragEnd = (): void => {
+    isDraggingRef.current = false;
+    setIsDragging(false);
+    document.removeEventListener('mousemove', handleDrag as any);
+    document.removeEventListener('mouseup', handleDragEnd);
+  };
+
+  return (
+    <div
+      style={{
+        position: 'fixed',
+        top: `${position.top}%`,
+        right: position.right,
+        transform: 'translateY(-50%)',
+        display: 'flex',
+        flexDirection: 'column',
+        alignItems: 'center',
+        gap: 4,
+        zIndex: 1000,
+        userSelect: 'none',
+      }}
+    >
+      {/* 拖拽手柄 */}
+      <div
+        onMouseDown={handleDragStart}
+        style={{
+          cursor: isDragging ? 'grabbing' : 'grab',
+          padding: '4px 8px',
+          fontSize: '14px',
+          color: currentTheme.token.colorText,
+          opacity: 0.6,
+          textAlign: 'center',
+          lineHeight: 1,
+        }}
+        title="拖动以移动主题切换器"
+      >
+        ⋮⋮
+      </div>
+
+      {/* 深色主题按钮 */}
+      <Button
+        type="primary"
+        shape="circle"
+        size="small"
+        onClick={() => dispatch(setTheme('dark'))}
+        title="切换到深色主题"
+        disabled={themeType === 'dark'}
+        style={{
+          width: buttonSize,
+          height: buttonSize,
+          minWidth: buttonSize,
+          padding: 0,
+          display: 'flex',
+          alignItems: 'center',
+          justifyContent: 'center',
+          fontSize: Math.floor(buttonSize * 0.5),
+        }}
+      >
+        🌙
+      </Button>
+
+      {/* 浅色主题按钮 */}
+      <Button
+        type="primary"
+        shape="circle"
+        size="small"
+        onClick={() => dispatch(setTheme('light'))}
+        title="切换到浅色主题"
+        disabled={themeType === 'light'}
+        style={{
+          width: buttonSize,
+          height: buttonSize,
+          minWidth: buttonSize,
+          padding: 0,
+          display: 'flex',
+          alignItems: 'center',
+          justifyContent: 'center',
+          fontSize: Math.floor(buttonSize * 0.5),
+        }}
+      >
+        ☀️
+      </Button>
+    </div>
+  );
+}