- {/* 受控方向切流中枢 */}
-
-
-
-
- {/* 高性能受控文本输入端 */}
-
-
- {/* 图片 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,
},