From 30d356172696a3fa0621088809d0d14a11a23e28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=A8=E9=9C=96=E9=93=83?= Date: Wed, 10 Jun 2026 20:11:35 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E8=A7=84=E8=8C=83=E5=8C=96?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E7=BB=84=E4=BB=B6=E7=9B=AE=E5=BD=95=E7=BB=93?= =?UTF-8?q?=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将 QrCodePreview、ImageUploader 移动到 QrCode/components/ - 将 DecodeResultPaper、Base64ConverterSection、TextMode 移动到 Base64Converter/components/ - 提取 useTextMode Hook,遵循 UI + Hook 分离模式 - 简化 SwitchButtonGroup 组件,提取常量到外部 Co-authored-by: Cursor --- src/assets/react.svg | 1 - src/components/SwitchButtonGroup.tsx | 56 +++--- src/pages/Base64Converter/TextMode.tsx | 162 ------------------ .../__tests__/DecodeResultPaper.test.tsx | 2 +- .../__tests__/TextMode.test.tsx | 2 +- .../Base64Converter/__tests__/index.test.tsx | 4 +- .../Base64ConverterSection.tsx | 4 +- .../components/DecodeResultPaper.tsx | 0 .../Base64Converter/components/TextMode.tsx | 91 ++++++++++ src/pages/Base64Converter/index.tsx | 4 +- src/pages/Base64Converter/useTextMode.ts | 95 ++++++++++ .../QrCode}/__tests__/ImageUploader.test.tsx | 2 +- .../QrCode}/__tests__/QrCodePreview.test.tsx | 2 +- src/pages/QrCode/components/GeneratePanel.tsx | 2 +- .../QrCode}/components/ImageUploader.tsx | 0 src/pages/QrCode/components/ParsePanel.tsx | 2 +- .../QrCode}/components/QrCodePreview.tsx | 0 .../StorageCleaner/StorageCleanerConfirm.tsx | 4 - src/utils/contextMenu.ts | 14 +- 19 files changed, 232 insertions(+), 215 deletions(-) delete mode 100644 src/assets/react.svg delete mode 100644 src/pages/Base64Converter/TextMode.tsx rename src/{components => pages/Base64Converter}/__tests__/DecodeResultPaper.test.tsx (97%) rename src/pages/Base64Converter/{ => components}/Base64ConverterSection.tsx (98%) rename src/{ => pages/Base64Converter}/components/DecodeResultPaper.tsx (100%) create mode 100644 src/pages/Base64Converter/components/TextMode.tsx create mode 100644 src/pages/Base64Converter/useTextMode.ts rename src/{components => pages/QrCode}/__tests__/ImageUploader.test.tsx (99%) rename src/{components => pages/QrCode}/__tests__/QrCodePreview.test.tsx (98%) rename src/{ => pages/QrCode}/components/ImageUploader.tsx (100%) rename src/{ => pages/QrCode}/components/QrCodePreview.tsx (100%) diff --git a/src/assets/react.svg b/src/assets/react.svg deleted file mode 100644 index 8e0e0f1..0000000 --- a/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/components/SwitchButtonGroup.tsx b/src/components/SwitchButtonGroup.tsx index 77368d1..7333727 100644 --- a/src/components/SwitchButtonGroup.tsx +++ b/src/components/SwitchButtonGroup.tsx @@ -17,6 +17,16 @@ export interface SwitchButtonGroupProps exte buttonClassName?: string; } +const SIZE_CLASSES = { + small: 'text-xs h-8 px-2 py-1 rounded-md', + medium: 'text-sm h-9 px-3 py-1.5 rounded-md', + large: 'text-base h-11 px-4 py-2 rounded-lg', +} as const; + +const SELECTED_CLASSES = + 'bg-background text-foreground shadow-sm font-semibold animate-in fade-in-50 zoom-in-95 duration-150'; +const UNSELECTED_CLASSES = 'hover:bg-background/50 hover:text-foreground/80'; + export default function SwitchButtonGroup({ value, options, @@ -26,12 +36,6 @@ export default function SwitchButtonGroup({ buttonClassName, ...props }: SwitchButtonGroupProps) { - const sizeClasses = { - small: 'text-xs h-8 px-2 py-1 rounded-md', - medium: 'text-sm h-9 px-3 py-1.5 rounded-md', - large: 'text-base h-11 px-4 py-2 rounded-lg', - }; - return (
({ )} {...props} > - {options.map((option) => { - const isSelected = value === option.value; - - return ( - - ); - })} + {options.map((option) => ( + + ))}
); } diff --git a/src/pages/Base64Converter/TextMode.tsx b/src/pages/Base64Converter/TextMode.tsx deleted file mode 100644 index 3392e44..0000000 --- a/src/pages/Base64Converter/TextMode.tsx +++ /dev/null @@ -1,162 +0,0 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; -import TextInputArea from '@/components/TextInputArea'; -import { useI18n } from '@/utils/chromeI18n'; -import { CopyButton } from '@/components/CopyButton'; -import { base64ToText, textToBase64 } from '@/utils/base64Converter'; -import SwitchButtonGroup from '@/components/SwitchButtonGroup'; -import { useContextMenuData } from '@/utils/useContextMenuData'; -import { Button } from '@/components/ui/button'; - -const IMAGE_DATA_URI_PATTERN = /^\s*data:image\//i; - -const ERROR_MESSAGE_TO_I18N: Record = { - 'Invalid Base64 string': 'invalidBase64', - 'Input appears to be binary data (e.g. an image). Please use the Image tab instead.': - 'binaryDataDetected', -}; - -interface TextModeProps { - onSwitchToImageMode?: () => void; -} - -export default function TextMode({ onSwitchToImageMode }: TextModeProps = {}) { - const { t } = useI18n('base64Converter'); - - const [input, setInput] = useState(''); - const [debouncedInput, setDebouncedInput] = useState(''); - const [direction, setDirection] = useState<'encode' | 'decode'>('encode'); - - useEffect(() => { - const handle = setTimeout(() => { - setDebouncedInput(input); - }, 200); - return () => clearTimeout(handle); - }, [input]); - - const handleContextMenuData = useCallback((payload: string) => { - setInput(payload); - setDebouncedInput(payload); - setDirection('decode'); - }, []); - - useContextMenuData({ featureKey: 'base64Converter', onData: handleContextMenuData }); - - const conversionPipeline = useMemo(() => { - const trimmed = debouncedInput.trim(); - if (!trimmed) return { output: '', error: null }; - - try { - if (direction === 'encode') { - const result = textToBase64(debouncedInput); - return { output: result.output, error: null }; - } else { - const decoded = base64ToText(trimmed); - return { output: decoded, error: null }; - } - } catch (e) { - const message = e instanceof Error ? e.message : ''; - const i18nKey = ERROR_MESSAGE_TO_I18N[message]; - return { - output: '', - error: i18nKey ? t(i18nKey) : message || t('conversionFailed'), - }; - } - }, [debouncedInput, direction, t]); - - const output = conversionPipeline.output; - const error = conversionPipeline.error; - - const placeholder = - direction === 'encode' ? t('textInputPlaceholder') : t('base64InputPlaceholder'); - const outputLabel = direction === 'encode' ? t('base64Output') : t('textOutput'); - - const showImageHint = useMemo( - () => direction === 'decode' && IMAGE_DATA_URI_PATTERN.test(input), - [direction, input], - ); - - const handleDirectionChange = (value: 'encode' | 'decode') => { - if (value === direction) return; - setDirection(value); - setInput(''); - setDebouncedInput(''); - }; - - const handleClear = () => { - setInput(''); - setDebouncedInput(''); - }; - - return ( -
- {/* 受控方向切流中枢 */} -
- -
- - {/* 高性能受控文本输入端 */} - - - {/* 图片 URI 类型劫持警告引导区: - - 💡 修复点:彻底废除原生亮色硬编码 hover:bg-blue-100 类名, - - 完美向全站 shadcn 暗黑生态看齐,采用标准的 bg-primary/10 混合变体。 - */} - {showImageHint && ( -
- - {t('imageDataUriHint')} - - -
- )} - - {/* 5. 编码/解码核心数据承载流卡片 */} - {output && ( -
-
- - {outputLabel} - - -
- - 2000 ? `${output.substring(0, 2000)}...` : output} - showClear={false} - minRows={4} - /> -
- )} -
- ); -} diff --git a/src/components/__tests__/DecodeResultPaper.test.tsx b/src/pages/Base64Converter/__tests__/DecodeResultPaper.test.tsx similarity index 97% rename from src/components/__tests__/DecodeResultPaper.test.tsx rename to src/pages/Base64Converter/__tests__/DecodeResultPaper.test.tsx index 7815226..a21c35d 100644 --- a/src/components/__tests__/DecodeResultPaper.test.tsx +++ b/src/pages/Base64Converter/__tests__/DecodeResultPaper.test.tsx @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { fireEvent, render, screen } from '@testing-library/react'; -import DecodeResultPaper from '@/components/DecodeResultPaper'; +import DecodeResultPaper from '../components/DecodeResultPaper'; describe('DecodeResultPaper 组件', () => { const defaultProps = { diff --git a/src/pages/Base64Converter/__tests__/TextMode.test.tsx b/src/pages/Base64Converter/__tests__/TextMode.test.tsx index be2550c..7df3180 100644 --- a/src/pages/Base64Converter/__tests__/TextMode.test.tsx +++ b/src/pages/Base64Converter/__tests__/TextMode.test.tsx @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; -import TextMode from '../TextMode'; +import TextMode from '../components/TextMode'; // Mock CopyButton vi.mock('@/components/CopyButton', () => ({ diff --git a/src/pages/Base64Converter/__tests__/index.test.tsx b/src/pages/Base64Converter/__tests__/index.test.tsx index 325dc50..da1cb0c 100644 --- a/src/pages/Base64Converter/__tests__/index.test.tsx +++ b/src/pages/Base64Converter/__tests__/index.test.tsx @@ -12,7 +12,7 @@ vi.mock('@/config/features', async (importOriginal) => { }); // Mock 子组件 -vi.mock('../TextMode', () => ({ +vi.mock('../components/TextMode', () => ({ default: ({ onSwitchToImageMode }: { onSwitchToImageMode?: () => void }) => (
TextMode @@ -21,7 +21,7 @@ vi.mock('../TextMode', () => ({ ), })); -vi.mock('../Base64ConverterSection', () => ({ +vi.mock('../components/Base64ConverterSection', () => ({ default: ({ mode }: { mode: string }) =>
{mode}
, })); diff --git a/src/pages/Base64Converter/Base64ConverterSection.tsx b/src/pages/Base64Converter/components/Base64ConverterSection.tsx similarity index 98% rename from src/pages/Base64Converter/Base64ConverterSection.tsx rename to src/pages/Base64Converter/components/Base64ConverterSection.tsx index a6f4d96..5a2bbc6 100644 --- a/src/pages/Base64Converter/Base64ConverterSection.tsx +++ b/src/pages/Base64Converter/components/Base64ConverterSection.tsx @@ -2,14 +2,14 @@ import { Image as ImageIcon, Trash2, Upload } from 'lucide-react'; import TextInputArea from '@/components/TextInputArea'; import { useI18n } from '@/utils/chromeI18n'; import { CopyButton } from '@/components/CopyButton'; -import DecodeResultPaper from '@/components/DecodeResultPaper'; +import DecodeResultPaper from './DecodeResultPaper'; import { Button } from '@/components/ui/button'; import { downloadBlob } from '@/utils/base64Converter'; import { formatBytes } from '@/utils/format'; import { useStorageState } from '@/utils/useStorageState'; import type { Base64ConvertDirection } from '@/types/storage'; import SwitchButtonGroup from '@/components/SwitchButtonGroup'; -import { useBase64Converter } from './useBase64Converter'; +import { useBase64Converter } from '../useBase64Converter'; import { cn } from '@/lib/utils'; const isValidDirection = (val: unknown): val is Base64ConvertDirection => diff --git a/src/components/DecodeResultPaper.tsx b/src/pages/Base64Converter/components/DecodeResultPaper.tsx similarity index 100% rename from src/components/DecodeResultPaper.tsx rename to src/pages/Base64Converter/components/DecodeResultPaper.tsx diff --git a/src/pages/Base64Converter/components/TextMode.tsx b/src/pages/Base64Converter/components/TextMode.tsx new file mode 100644 index 0000000..59f2ab9 --- /dev/null +++ b/src/pages/Base64Converter/components/TextMode.tsx @@ -0,0 +1,91 @@ +import TextInputArea from '@/components/TextInputArea'; +import { CopyButton } from '@/components/CopyButton'; +import SwitchButtonGroup from '@/components/SwitchButtonGroup'; +import { Button } from '@/components/ui/button'; +import { useTextMode } from '../useTextMode'; + +interface TextModeProps { + onSwitchToImageMode?: () => void; +} + +export default function TextMode({ onSwitchToImageMode }: TextModeProps = {}) { + const { + input, + setInput, + direction, + handleDirectionChange, + placeholder, + output, + outputLabel, + error, + showImageHint, + handleClear, + t, + } = useTextMode(); + + return ( +
+
+ +
+ + + + {showImageHint && ( +
+ + {t('imageDataUriHint')} + + +
+ )} + + {output && ( +
+
+ + {outputLabel} + + +
+ + 2000 ? `${output.substring(0, 2000)}...` : output} + showClear={false} + minRows={4} + /> +
+ )} +
+ ); +} diff --git a/src/pages/Base64Converter/index.tsx b/src/pages/Base64Converter/index.tsx index 5dbd5a6..d7b3a59 100644 --- a/src/pages/Base64Converter/index.tsx +++ b/src/pages/Base64Converter/index.tsx @@ -1,8 +1,8 @@ import { useI18n } from '@/utils/chromeI18n'; import { useStorageState } from '@/utils/useStorageState'; import type { Base64ConverterPageMode } from '@/types/storage'; -import TextMode from './TextMode'; -import Base64ConverterSection from './Base64ConverterSection'; +import TextMode from './components/TextMode'; +import Base64ConverterSection from './components/Base64ConverterSection'; import SwitchButtonGroup from '@/components/SwitchButtonGroup'; const VALID_PAGE_MODES: readonly Base64ConverterPageMode[] = ['text', 'file', 'image']; diff --git a/src/pages/Base64Converter/useTextMode.ts b/src/pages/Base64Converter/useTextMode.ts new file mode 100644 index 0000000..c911e85 --- /dev/null +++ b/src/pages/Base64Converter/useTextMode.ts @@ -0,0 +1,95 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useI18n } from '@/utils/chromeI18n'; +import { base64ToText, textToBase64 } from '@/utils/base64Converter'; +import { useContextMenuData } from '@/utils/useContextMenuData'; + +const IMAGE_DATA_URI_PATTERN = /^\s*data:image\//i; + +const ERROR_MESSAGE_TO_I18N: Record = { + 'Invalid Base64 string': 'invalidBase64', + 'Input appears to be binary data (e.g. an image). Please use the Image tab instead.': + 'binaryDataDetected', +}; + +export function useTextMode() { + const { t } = useI18n('base64Converter'); + + const [input, setInput] = useState(''); + const [debouncedInput, setDebouncedInput] = useState(''); + const [direction, setDirection] = useState<'encode' | 'decode'>('encode'); + + useEffect(() => { + const handle = setTimeout(() => { + setDebouncedInput(input); + }, 200); + return () => clearTimeout(handle); + }, [input]); + + const handleContextMenuData = useCallback((payload: string) => { + setInput(payload); + setDebouncedInput(payload); + setDirection('decode'); + }, []); + + useContextMenuData({ featureKey: 'base64Converter', onData: handleContextMenuData }); + + const conversionPipeline = useMemo(() => { + const trimmed = debouncedInput.trim(); + if (!trimmed) return { output: '', error: null }; + + try { + if (direction === 'encode') { + const result = textToBase64(debouncedInput); + return { output: result.output, error: null }; + } else { + const decoded = base64ToText(trimmed); + return { output: decoded, error: null }; + } + } catch (e) { + const message = e instanceof Error ? e.message : ''; + const i18nKey = ERROR_MESSAGE_TO_I18N[message]; + return { + output: '', + error: i18nKey ? t(i18nKey) : message || t('conversionFailed'), + }; + } + }, [debouncedInput, direction, t]); + + const output = conversionPipeline.output; + const error = conversionPipeline.error; + + const placeholder = + direction === 'encode' ? t('textInputPlaceholder') : t('base64InputPlaceholder'); + const outputLabel = direction === 'encode' ? t('base64Output') : t('textOutput'); + + const showImageHint = useMemo( + () => direction === 'decode' && IMAGE_DATA_URI_PATTERN.test(input), + [direction, input], + ); + + const handleDirectionChange = (value: 'encode' | 'decode') => { + if (value === direction) return; + setDirection(value); + setInput(''); + setDebouncedInput(''); + }; + + const handleClear = () => { + setInput(''); + setDebouncedInput(''); + }; + + return { + input, + setInput, + direction, + handleDirectionChange, + placeholder, + output, + outputLabel, + error, + showImageHint, + handleClear, + t, + }; +} diff --git a/src/components/__tests__/ImageUploader.test.tsx b/src/pages/QrCode/__tests__/ImageUploader.test.tsx similarity index 99% rename from src/components/__tests__/ImageUploader.test.tsx rename to src/pages/QrCode/__tests__/ImageUploader.test.tsx index e85ef52..96c9b49 100644 --- a/src/components/__tests__/ImageUploader.test.tsx +++ b/src/pages/QrCode/__tests__/ImageUploader.test.tsx @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { act, fireEvent, render, screen } from '@testing-library/react'; -import ImageUploader from '@/components/ImageUploader'; +import ImageUploader from '../components/ImageUploader'; // 配置多端一致性常驻桩(WXT 规范) const storageOnChangedMock = { addListener: vi.fn(), removeListener: vi.fn() }; diff --git a/src/components/__tests__/QrCodePreview.test.tsx b/src/pages/QrCode/__tests__/QrCodePreview.test.tsx similarity index 98% rename from src/components/__tests__/QrCodePreview.test.tsx rename to src/pages/QrCode/__tests__/QrCodePreview.test.tsx index 2c6c8fc..5372652 100644 --- a/src/components/__tests__/QrCodePreview.test.tsx +++ b/src/pages/QrCode/__tests__/QrCodePreview.test.tsx @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { fireEvent, render, screen } from '@testing-library/react'; -import QrCodePreview from '@/components/QrCodePreview'; +import QrCodePreview from '../components/QrCodePreview'; describe('QrCodePreview 组件', () => { const mockOnDownload = vi.fn(); diff --git a/src/pages/QrCode/components/GeneratePanel.tsx b/src/pages/QrCode/components/GeneratePanel.tsx index b67fb39..cbb114e 100644 --- a/src/pages/QrCode/components/GeneratePanel.tsx +++ b/src/pages/QrCode/components/GeneratePanel.tsx @@ -1,6 +1,6 @@ import { Loader2, Pencil, QrCode } from 'lucide-react'; import TextInputArea from '@/components/TextInputArea'; -import QrCodePreview from '@/components/QrCodePreview'; +import QrCodePreview from './QrCodePreview'; import { useI18n } from '@/utils/chromeI18n'; import { useQrCodeContext } from '../contexts/QrCodeContext'; import { Button } from '@/components/ui/button'; diff --git a/src/components/ImageUploader.tsx b/src/pages/QrCode/components/ImageUploader.tsx similarity index 100% rename from src/components/ImageUploader.tsx rename to src/pages/QrCode/components/ImageUploader.tsx diff --git a/src/pages/QrCode/components/ParsePanel.tsx b/src/pages/QrCode/components/ParsePanel.tsx index aed49c8..998397a 100644 --- a/src/pages/QrCode/components/ParsePanel.tsx +++ b/src/pages/QrCode/components/ParsePanel.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect } from 'react'; import { RefreshCw } from 'lucide-react'; import { toast } from 'sonner'; import TextInputArea from '@/components/TextInputArea'; -import ImageUploader from '@/components/ImageUploader'; +import ImageUploader from './ImageUploader'; import { useI18n } from '@/utils/chromeI18n'; import { useQrCodeContext } from '../contexts/QrCodeContext'; import { Button } from '@/components/ui/button'; diff --git a/src/components/QrCodePreview.tsx b/src/pages/QrCode/components/QrCodePreview.tsx similarity index 100% rename from src/components/QrCodePreview.tsx rename to src/pages/QrCode/components/QrCodePreview.tsx diff --git a/src/pages/StorageCleaner/StorageCleanerConfirm.tsx b/src/pages/StorageCleaner/StorageCleanerConfirm.tsx index b3397cc..88a1349 100644 --- a/src/pages/StorageCleaner/StorageCleanerConfirm.tsx +++ b/src/pages/StorageCleaner/StorageCleanerConfirm.tsx @@ -39,20 +39,17 @@ export function StorageCleanerConfirm({ 'w-[90%] max-w-[340px] p-6 gap-0 rounded-2xl overflow-hidden shadow-xl border border-border bg-card text-card-foreground', )} > - {/* 头部标题区域 */} {t('storageCleaner:confirmTitle')} - {/* 内容主体:限制最大宽度,防止内部元素在大分辨率下被横向拉得太松散 */}
{t('storageCleaner:confirmDesc')} - {/* 待清理项目徽章群 */}
{selectedOptions.map((label) => ( - {/* 风险警告横幅 */}
diff --git a/src/utils/contextMenu.ts b/src/utils/contextMenu.ts index 3269530..e033d6b 100644 --- a/src/utils/contextMenu.ts +++ b/src/utils/contextMenu.ts @@ -44,43 +44,43 @@ export const CONTEXT_MENU_CONFIGS: ContextMenuItemConfig[] = [ }, { id: 'jwt', - title: '🔑 解析 JWT', + title: '解析 JWT', contexts: [chrome.contextMenus.ContextType.SELECTION], parentId: PARENT_MENU_ID, }, { id: 'base64Converter', - title: '🔄 Base64 解码', + title: 'Base64 解码', contexts: [chrome.contextMenus.ContextType.SELECTION], parentId: PARENT_MENU_ID, }, { id: 'textStatistics', - title: '📊 统计选中文本', + title: '统计选中文本', contexts: [chrome.contextMenus.ContextType.SELECTION], parentId: PARENT_MENU_ID, }, { id: 'timestamp', - title: '⏰ 转换时间戳', + title: '转换时间戳', contexts: [chrome.contextMenus.ContextType.SELECTION], parentId: PARENT_MENU_ID, }, { id: 'storageCleaner', - title: '🧹 清理当前网站存储', + title: '清理当前网站存储', contexts: [chrome.contextMenus.ContextType.PAGE], parentId: PARENT_MENU_ID, }, { id: 'qrCode-page', - title: '🔗 网页链接转二维码', + title: '网页链接转二维码', contexts: [chrome.contextMenus.ContextType.PAGE], parentId: PARENT_MENU_ID, }, { id: 'qrCode-image', - title: '🖼️ 解析图片二维码', + title: '解析图片二维码', contexts: [chrome.contextMenus.ContextType.IMAGE], parentId: PARENT_MENU_ID, },