refactor: 规范化页面组件目录结构
- 将 QrCodePreview、ImageUploader 移动到 QrCode/components/ - 将 DecodeResultPaper、Base64ConverterSection、TextMode 移动到 Base64Converter/components/ - 提取 useTextMode Hook,遵循 UI + Hook 分离模式 - 简化 SwitchButtonGroup 组件,提取常量到外部 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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<string, string> = {
|
||||
'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 (
|
||||
<div className="w-full flex flex-col space-y-4 px-2">
|
||||
{/* 受控方向切流中枢 */}
|
||||
<div className="flex h-11 items-center px-1.5 bg-secondary/40 rounded-xl border border-border/60 w-fit">
|
||||
<SwitchButtonGroup
|
||||
value={direction}
|
||||
options={[
|
||||
{ value: 'encode', label: t('encode') },
|
||||
{ value: 'decode', label: t('decode') },
|
||||
]}
|
||||
onChange={handleDirectionChange}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 高性能受控文本输入端 */}
|
||||
<TextInputArea
|
||||
placeholder={placeholder}
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
externalError={error || undefined}
|
||||
showClear={true}
|
||||
allowCopy={true}
|
||||
minRows={5}
|
||||
maxRows={10}
|
||||
onClear={handleClear}
|
||||
/>
|
||||
|
||||
{/* 图片 URI 类型劫持警告引导区:
|
||||
- 💡 修复点:彻底废除原生亮色硬编码 hover:bg-blue-100 类名,
|
||||
- 完美向全站 shadcn 暗黑生态看齐,采用标准的 bg-primary/10 混合变体。
|
||||
*/}
|
||||
{showImageHint && (
|
||||
<div className="flex items-center justify-between p-3.5 rounded-xl bg-primary/10 border border-primary/20">
|
||||
<span className="text-xs font-semibold text-primary tracking-tight">
|
||||
{t('imageDataUriHint')}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onSwitchToImageMode}
|
||||
className="h-7 rounded-md text-xs font-bold text-primary hover:text-primary hover:bg-primary/20 dark:hover:bg-primary/10 px-2.5"
|
||||
>
|
||||
{t('switchToImageMode')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 5. 编码/解码核心数据承载流卡片 */}
|
||||
{output && (
|
||||
<div className="p-4 rounded-2xl bg-card border border-border shadow-sm flex flex-col space-y-3">
|
||||
<div className="flex justify-between items-center select-none">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground/90">
|
||||
{outputLabel}
|
||||
</span>
|
||||
<CopyButton
|
||||
text={output}
|
||||
className="h-6 px-2 rounded-md border text-[10px] font-bold"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TextInputArea
|
||||
readOnly
|
||||
value={output.length > 2000 ? `${output.substring(0, 2000)}...` : output}
|
||||
showClear={false}
|
||||
minRows={4}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import DecodeResultPaper from '../components/DecodeResultPaper';
|
||||
|
||||
describe('DecodeResultPaper 组件', () => {
|
||||
const defaultProps = {
|
||||
title: 'decodedFileOutput',
|
||||
mimeType: 'image/png',
|
||||
blobSize: 1024,
|
||||
fileName: 'decoded.png',
|
||||
onFileNameChange: vi.fn(),
|
||||
onDownload: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('渲染测试', () => {
|
||||
it('应渲染标题', () => {
|
||||
render(<DecodeResultPaper {...defaultProps} />);
|
||||
expect(screen.getByText('decodedFileOutput')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应渲染 MIME 类型信息', () => {
|
||||
render(<DecodeResultPaper {...defaultProps} />);
|
||||
expect(screen.getByText(/image\/png/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应通过 formatBytes 渲染文件大小', () => {
|
||||
render(<DecodeResultPaper {...{ ...defaultProps, blobSize: 1536 }} />);
|
||||
expect(screen.getByText(/1\.5 KB/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应渲染文件名输入框', () => {
|
||||
render(<DecodeResultPaper {...defaultProps} />);
|
||||
const input = screen.getByDisplayValue('decoded.png');
|
||||
expect(input).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应渲染下载按钮', () => {
|
||||
render(<DecodeResultPaper {...defaultProps} />);
|
||||
expect(screen.getByRole('button', { name: '下载' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应渲染 children 内容', () => {
|
||||
render(
|
||||
<DecodeResultPaper {...defaultProps}>
|
||||
<div data-testid="preview">预览内容</div>
|
||||
</DecodeResultPaper>,
|
||||
);
|
||||
expect(screen.getByTestId('preview')).toBeInTheDocument();
|
||||
expect(screen.getByText('预览内容')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('交互测试', () => {
|
||||
it('修改文件名时应调用 onFileNameChange', () => {
|
||||
render(<DecodeResultPaper {...defaultProps} />);
|
||||
const input = screen.getByDisplayValue('decoded.png');
|
||||
fireEvent.change(input, { target: { value: 'new-name.png' } });
|
||||
expect(defaultProps.onFileNameChange).toHaveBeenCalledWith('new-name.png');
|
||||
});
|
||||
|
||||
it('点击下载按钮时应调用 onDownload', () => {
|
||||
render(<DecodeResultPaper {...defaultProps} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: '下载' }));
|
||||
expect(defaultProps.onDownload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('按钮状态', () => {
|
||||
it('文件名为空时下载按钮应禁用', () => {
|
||||
render(<DecodeResultPaper {...{ ...defaultProps, fileName: '' }} />);
|
||||
expect(screen.getByRole('button', { name: '下载' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('文件名不为空时下载按钮应启用', () => {
|
||||
render(<DecodeResultPaper {...defaultProps} />);
|
||||
expect(screen.getByRole('button', { name: '下载' })).toBeEnabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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', () => ({
|
||||
|
||||
@@ -12,7 +12,7 @@ vi.mock('@/config/features', async (importOriginal) => {
|
||||
});
|
||||
|
||||
// Mock 子组件
|
||||
vi.mock('../TextMode', () => ({
|
||||
vi.mock('../components/TextMode', () => ({
|
||||
default: ({ onSwitchToImageMode }: { onSwitchToImageMode?: () => void }) => (
|
||||
<div data-testid="text-mode">
|
||||
TextMode
|
||||
@@ -21,7 +21,7 @@ vi.mock('../TextMode', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../Base64ConverterSection', () => ({
|
||||
vi.mock('../components/Base64ConverterSection', () => ({
|
||||
default: ({ mode }: { mode: string }) => <div data-testid={`${mode}-mode`}>{mode}</div>,
|
||||
}));
|
||||
|
||||
|
||||
+2
-2
@@ -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 =>
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* DecodeResultPaper
|
||||
*
|
||||
* FileMode 与 ImageMode 通用的 decode 结果展示组件。
|
||||
* 提取了二者 decode 输出区完全一致的结构:
|
||||
* 标题 → 可选预览(children)→ 文件信息 → 文件名输入 → 下载按钮
|
||||
*
|
||||
* FileMode 直接使用,ImageMode 通过 children 传入图片预览。
|
||||
*/
|
||||
import { Download } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { formatBytes } from '@/utils/format';
|
||||
import { useI18n } from '@/utils/chromeI18n';
|
||||
|
||||
interface DecodeResultPaperProps {
|
||||
/** 标题文案,由调用方传入 i18n key 对应的值(如 decodedFileOutput / decodedImageOutput) */
|
||||
title: string;
|
||||
/** 解码后推断的 MIME 类型 */
|
||||
mimeType: string;
|
||||
/** 解码后 Blob 的大小(字节) */
|
||||
blobSize: number;
|
||||
/** 当前文件名 */
|
||||
fileName: string;
|
||||
/** 文件名变更回调 */
|
||||
onFileNameChange: (name: string) => void;
|
||||
/** 下载按钮点击回调 */
|
||||
onDownload: () => void;
|
||||
/** 可选的预览内容,ImageMode 用于渲染图片预览 */
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function DecodeResultPaper({
|
||||
title,
|
||||
mimeType,
|
||||
blobSize,
|
||||
fileName,
|
||||
onFileNameChange,
|
||||
onDownload,
|
||||
children,
|
||||
}: DecodeResultPaperProps) {
|
||||
const { t } = useI18n('base64Converter');
|
||||
|
||||
return (
|
||||
<div className="p-4 rounded-xl bg-primary/10 border border-primary/30">
|
||||
{/* 标题 */}
|
||||
<span className="block mb-2 text-xs font-bold text-muted-foreground">{title}</span>
|
||||
|
||||
{/* 可选预览内容(ImageMode 的图片) */}
|
||||
{children}
|
||||
|
||||
{/* 文件信息 */}
|
||||
<div className="flex gap-4 mb-3">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('inferredMimeType')}: {mimeType}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('decodedSize')}: {formatBytes(blobSize)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 文件名输入 */}
|
||||
<div className="mb-3">
|
||||
<label className="block text-xs font-medium text-muted-foreground mb-1">
|
||||
{t('decodedFileName')}
|
||||
</label>
|
||||
<Input value={fileName} onChange={(e) => onFileNameChange(e.target.value)} />
|
||||
</div>
|
||||
|
||||
{/* 下载按钮 */}
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={onDownload}
|
||||
disabled={!fileName.trim()}
|
||||
className="w-full rounded-lg font-bold"
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
{t('download')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="w-full flex flex-col space-y-4 px-2">
|
||||
<div className="flex h-11 items-center px-1.5 bg-secondary/40 rounded-xl border border-border/60 w-fit">
|
||||
<SwitchButtonGroup
|
||||
value={direction}
|
||||
options={[
|
||||
{ value: 'encode', label: t('encode') },
|
||||
{ value: 'decode', label: t('decode') },
|
||||
]}
|
||||
onChange={handleDirectionChange}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TextInputArea
|
||||
placeholder={placeholder}
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
externalError={error || undefined}
|
||||
showClear={true}
|
||||
allowCopy={true}
|
||||
minRows={5}
|
||||
maxRows={10}
|
||||
onClear={handleClear}
|
||||
/>
|
||||
|
||||
{showImageHint && (
|
||||
<div className="flex items-center justify-between p-3.5 rounded-xl bg-primary/10 border border-primary/20">
|
||||
<span className="text-xs font-semibold text-primary tracking-tight">
|
||||
{t('imageDataUriHint')}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onSwitchToImageMode}
|
||||
className="h-7 rounded-md text-xs font-bold text-primary hover:text-primary hover:bg-primary/20 dark:hover:bg-primary/10 px-2.5"
|
||||
>
|
||||
{t('switchToImageMode')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{output && (
|
||||
<div className="p-4 rounded-2xl bg-card border border-border shadow-sm flex flex-col space-y-3">
|
||||
<div className="flex justify-between items-center select-none">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground/90">
|
||||
{outputLabel}
|
||||
</span>
|
||||
<CopyButton
|
||||
text={output}
|
||||
className="h-6 px-2 rounded-md border text-[10px] font-bold"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TextInputArea
|
||||
readOnly
|
||||
value={output.length > 2000 ? `${output.substring(0, 2000)}...` : output}
|
||||
showClear={false}
|
||||
minRows={4}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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'];
|
||||
|
||||
@@ -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<string, string> = {
|
||||
'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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import ImageUploader from '../components/ImageUploader';
|
||||
|
||||
// 配置多端一致性常驻桩(WXT 规范)
|
||||
const storageOnChangedMock = { addListener: vi.fn(), removeListener: vi.fn() };
|
||||
(globalThis as any).chrome = { storage: { onChanged: storageOnChangedMock } };
|
||||
(globalThis as any).browser = { storage: { onChanged: storageOnChangedMock } };
|
||||
|
||||
// 模拟 URL API
|
||||
const mockCreateObjectURL = vi.fn();
|
||||
const mockRevokeObjectURL = vi.fn();
|
||||
Object.defineProperty(window.URL, 'createObjectURL', { value: mockCreateObjectURL });
|
||||
Object.defineProperty(window.URL, 'revokeObjectURL', { value: mockRevokeObjectURL });
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('ImageUploader 组件', () => {
|
||||
const mockOnFileChange = vi.fn();
|
||||
const mockOnClearFile = vi.fn();
|
||||
const mockOnPreviewUrlChange = vi.fn();
|
||||
const mockOnDraggingChange = vi.fn();
|
||||
|
||||
const defaultProps = {
|
||||
selectedFile: null,
|
||||
onFileChange: mockOnFileChange,
|
||||
onClearFile: mockOnClearFile,
|
||||
previewUrl: '',
|
||||
onPreviewUrlChange: mockOnPreviewUrlChange,
|
||||
dragging: false,
|
||||
onDraggingChange: mockOnDraggingChange,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockCreateObjectURL.mockReturnValue('blob:test-url');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('渲染测试', () => {
|
||||
it('当没有选中文件时应显示上传提示', () => {
|
||||
render(<ImageUploader {...defaultProps} />);
|
||||
expect(screen.getByText(/点击.*拖拽/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/格式/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('当没有选中文件时应显示 ImageIcon', () => {
|
||||
render(<ImageUploader {...defaultProps} />);
|
||||
expect(screen.getByTestId('ImageIcon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('当选中文件时应显示文件预览', () => {
|
||||
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
|
||||
render(
|
||||
<ImageUploader {...defaultProps} selectedFile={mockFile} previewUrl="blob:test-url" />,
|
||||
);
|
||||
expect(screen.getByText('test.png')).toBeInTheDocument();
|
||||
expect(screen.getByText(/点击更换/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('当选中文件时应显示预览图片', () => {
|
||||
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
|
||||
render(
|
||||
<ImageUploader {...defaultProps} selectedFile={mockFile} previewUrl="blob:test-url" />,
|
||||
);
|
||||
const img = screen.getByAltText('QR Code Preview');
|
||||
expect(img).toBeInTheDocument();
|
||||
expect(img).toHaveAttribute('src', 'blob:test-url');
|
||||
});
|
||||
|
||||
it('当选中文件时应显示清除按钮', () => {
|
||||
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
|
||||
render(
|
||||
<ImageUploader {...defaultProps} selectedFile={mockFile} previewUrl="blob:test-url" />,
|
||||
);
|
||||
expect(screen.getByTestId('ClearIcon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应包含隐藏的文件输入框', () => {
|
||||
render(<ImageUploader {...defaultProps} />);
|
||||
const input = document.getElementById('qr-code-upload') as HTMLInputElement;
|
||||
expect(input).toBeInTheDocument();
|
||||
expect(input).toHaveAttribute('type', 'file');
|
||||
expect(input).toHaveAttribute('accept', 'image/*');
|
||||
});
|
||||
});
|
||||
|
||||
describe('文件选择交互', () => {
|
||||
it('选择文件时应调用 onFileChange 和 onPreviewUrlChange', async () => {
|
||||
render(<ImageUploader {...defaultProps} />);
|
||||
const input = document.getElementById('qr-code-upload') as HTMLInputElement;
|
||||
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { files: [mockFile] } });
|
||||
});
|
||||
|
||||
expect(mockOnFileChange).toHaveBeenCalledWith(mockFile);
|
||||
expect(mockCreateObjectURL).toHaveBeenCalledWith(mockFile);
|
||||
expect(mockOnPreviewUrlChange).toHaveBeenCalledWith('blob:test-url');
|
||||
});
|
||||
});
|
||||
|
||||
describe('拖拽交互', () => {
|
||||
it('拖拽进入时应调用 onDraggingChange(true)', () => {
|
||||
const { container } = render(<ImageUploader {...defaultProps} />);
|
||||
const dropzone = container.firstChild as HTMLElement;
|
||||
|
||||
fireEvent.dragOver(dropzone);
|
||||
expect(mockOnDraggingChange).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('拖拽离开时应调用 onDraggingChange(false)', () => {
|
||||
const { container } = render(<ImageUploader {...defaultProps} />);
|
||||
const dropzone = container.firstChild as HTMLElement;
|
||||
|
||||
fireEvent.dragLeave(dropzone);
|
||||
expect(mockOnDraggingChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('放置文件时应调用 onFileChange 和 onPreviewUrlChange', () => {
|
||||
const { container } = render(<ImageUploader {...defaultProps} />);
|
||||
const dropzone = container.firstChild as HTMLElement;
|
||||
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
|
||||
|
||||
const dropEvent = new Event('drop', { bubbles: true });
|
||||
Object.defineProperty(dropEvent, 'dataTransfer', {
|
||||
value: {
|
||||
files: [mockFile],
|
||||
},
|
||||
});
|
||||
Object.defineProperty(dropEvent, 'preventDefault', {
|
||||
value: vi.fn(),
|
||||
});
|
||||
|
||||
fireEvent(dropzone, dropEvent);
|
||||
|
||||
expect(mockOnDraggingChange).toHaveBeenCalledWith(false);
|
||||
expect(mockOnFileChange).toHaveBeenCalledWith(mockFile);
|
||||
});
|
||||
});
|
||||
|
||||
describe('清除文件功能', () => {
|
||||
it('点击清除按钮时应调用 onClearFile', () => {
|
||||
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
|
||||
render(
|
||||
<ImageUploader {...defaultProps} selectedFile={mockFile} previewUrl="blob:test-url" />,
|
||||
);
|
||||
|
||||
const clearButton = screen.getByTestId('ClearIcon').closest('button')!;
|
||||
fireEvent.click(clearButton);
|
||||
|
||||
expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:test-url');
|
||||
expect(mockOnClearFile).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('粘贴功能', () => {
|
||||
it('监听粘贴事件', () => {
|
||||
const addEventListenerSpy = vi.spyOn(document, 'addEventListener');
|
||||
render(<ImageUploader {...defaultProps} />);
|
||||
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith('paste', expect.any(Function));
|
||||
});
|
||||
|
||||
it('组件卸载时应移除粘贴事件监听', () => {
|
||||
const removeEventListenerSpy = vi.spyOn(document, 'removeEventListener');
|
||||
const { unmount } = render(<ImageUploader {...defaultProps} />);
|
||||
|
||||
unmount();
|
||||
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith('paste', expect.any(Function));
|
||||
});
|
||||
});
|
||||
|
||||
describe('样式测试', () => {
|
||||
it('拖拽状态时应应用拖拽样式', () => {
|
||||
const { container } = render(<ImageUploader {...defaultProps} dragging={true} />);
|
||||
const dropzone = container.firstChild as HTMLElement;
|
||||
expect(dropzone).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('有文件时应应用有文件样式', () => {
|
||||
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
|
||||
const { container } = render(
|
||||
<ImageUploader {...defaultProps} selectedFile={mockFile} previewUrl="blob:test-url" />,
|
||||
);
|
||||
const dropzone = container.firstChild as HTMLElement;
|
||||
expect(dropzone).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import QrCodePreview from '../components/QrCodePreview';
|
||||
|
||||
describe('QrCodePreview 组件', () => {
|
||||
const mockOnDownload = vi.fn();
|
||||
const mockOnCopy = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('渲染测试', () => {
|
||||
it('当 qrCodeDataUrl 为空时应显示占位文本', () => {
|
||||
render(<QrCodePreview qrCodeDataUrl="" onDownload={mockOnDownload} onCopy={mockOnCopy} />);
|
||||
expect(screen.getByText(/二维码将显示/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('当 qrCodeDataUrl 有值时应显示二维码图片', () => {
|
||||
const testDataUrl = 'data:image/png;base64,test123';
|
||||
render(
|
||||
<QrCodePreview
|
||||
qrCodeDataUrl={testDataUrl}
|
||||
onDownload={mockOnDownload}
|
||||
onCopy={mockOnCopy}
|
||||
/>,
|
||||
);
|
||||
const img = screen.getByAltText('QR Code Preview');
|
||||
expect(img).toBeInTheDocument();
|
||||
expect(img).toHaveAttribute('src', testDataUrl);
|
||||
});
|
||||
|
||||
it('当 qrCodeDataUrl 有值时应显示下载按钮', () => {
|
||||
render(
|
||||
<QrCodePreview
|
||||
qrCodeDataUrl="data:image/png;base64,test"
|
||||
onDownload={mockOnDownload}
|
||||
onCopy={mockOnCopy}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/下载二维码/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('当 qrCodeDataUrl 有值时应显示复制按钮', () => {
|
||||
render(
|
||||
<QrCodePreview
|
||||
qrCodeDataUrl="data:image/png;base64,test"
|
||||
onDownload={mockOnDownload}
|
||||
onCopy={mockOnCopy}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/复制二维码/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('当 qrCodeDataUrl 为空时不应显示操作按钮', () => {
|
||||
render(<QrCodePreview qrCodeDataUrl="" onDownload={mockOnDownload} onCopy={mockOnCopy} />);
|
||||
expect(screen.queryByText(/下载二维码/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/复制二维码/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('交互测试', () => {
|
||||
it('点击下载按钮时应调用 onDownload 回调', () => {
|
||||
render(
|
||||
<QrCodePreview
|
||||
qrCodeDataUrl="data:image/png;base64,test"
|
||||
onDownload={mockOnDownload}
|
||||
onCopy={mockOnCopy}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByText(/下载二维码/));
|
||||
expect(mockOnDownload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('点击复制按钮时应调用 onCopy 回调', () => {
|
||||
render(
|
||||
<QrCodePreview
|
||||
qrCodeDataUrl="data:image/png;base64,test"
|
||||
onDownload={mockOnDownload}
|
||||
onCopy={mockOnCopy}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByText(/复制二维码/));
|
||||
expect(mockOnCopy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { Image, X } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useI18n } from '@/utils/chromeI18n';
|
||||
|
||||
interface ImageUploaderProps {
|
||||
/** 选中的文件 */
|
||||
selectedFile: File | null;
|
||||
/** 文件变更回调 */
|
||||
onFileChange: (file: File) => void;
|
||||
/** 清除文件回调 */
|
||||
onClearFile: () => void;
|
||||
/** 文件预览 URL */
|
||||
previewUrl: string;
|
||||
/** 预览 URL 变更回调 */
|
||||
onPreviewUrlChange: (url: string) => void;
|
||||
/** 是否正在拖拽 */
|
||||
dragging: boolean;
|
||||
/** 拖拽状态变更回调 */
|
||||
onDraggingChange: (dragging: boolean) => void;
|
||||
}
|
||||
|
||||
const ImageUploader = ({
|
||||
selectedFile,
|
||||
onFileChange,
|
||||
onClearFile,
|
||||
previewUrl,
|
||||
onPreviewUrlChange,
|
||||
dragging,
|
||||
onDraggingChange,
|
||||
}: ImageUploaderProps) => {
|
||||
const { t } = useI18n('qrCode');
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFileChange = useCallback(
|
||||
(file: File) => {
|
||||
onFileChange(file);
|
||||
onPreviewUrlChange(URL.createObjectURL(file));
|
||||
},
|
||||
[onFileChange, onPreviewUrlChange],
|
||||
);
|
||||
|
||||
const handleClearFile = useCallback(() => {
|
||||
if (previewUrl) {
|
||||
URL.revokeObjectURL(previewUrl);
|
||||
}
|
||||
onClearFile();
|
||||
toast.success(t('qrCode:imageCleared'));
|
||||
}, [previewUrl, onClearFile, t]);
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
handleFileChange(e.target.files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
onDraggingChange(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = () => {
|
||||
onDraggingChange(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
onDraggingChange(false);
|
||||
const droppedFile = e.dataTransfer.files?.[0];
|
||||
if (droppedFile) {
|
||||
handleFileChange(droppedFile);
|
||||
}
|
||||
};
|
||||
|
||||
// 监听粘贴事件
|
||||
useEffect(() => {
|
||||
const handlePaste = async (e: ClipboardEvent) => {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (items[i].type.startsWith('image/')) {
|
||||
e.preventDefault();
|
||||
|
||||
const file = items[i].getAsFile();
|
||||
if (file) {
|
||||
try {
|
||||
handleFileChange(file);
|
||||
toast.success(t('qrCode:imagePasted'));
|
||||
} catch (error) {
|
||||
console.error('处理粘贴图片失败:', error);
|
||||
toast.error(t('qrCode:imagePasteError'));
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('paste', handlePaste);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('paste', handlePaste);
|
||||
};
|
||||
}, [handleFileChange, t]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col items-center justify-center h-[250px] border-2 border-dashed rounded-xl p-4 cursor-pointer transition-all duration-200 ${
|
||||
dragging
|
||||
? 'border-green-600 bg-green-50'
|
||||
: selectedFile
|
||||
? 'border-green-600 bg-green-50/50'
|
||||
: 'border-input bg-muted hover:border-green-600 hover:bg-green-500/10/50'
|
||||
}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleInputChange}
|
||||
className="hidden"
|
||||
id="qr-code-upload"
|
||||
/>
|
||||
<Label htmlFor="qr-code-upload" className="cursor-pointer text-center w-full">
|
||||
{selectedFile ? (
|
||||
<div className="text-center w-full relative">
|
||||
<div className="relative inline-block">
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="QR Code Preview"
|
||||
className="max-w-full max-h-40 rounded-lg object-contain"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
data-testid="ClearIcon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleClearFile();
|
||||
}}
|
||||
className="absolute -top-2 -right-2 h-6 w-6 rounded-full"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<span className="block text-sm text-muted-foreground mt-2">{selectedFile.name}</span>
|
||||
<span className="block text-xs text-muted-foreground">{t('qrCode:clickToChange')}</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Image
|
||||
data-testid="ImageIcon"
|
||||
className="w-12 h-12 text-muted-foreground mx-auto mb-2"
|
||||
/>
|
||||
<span className="block text-sm text-muted-foreground mb-1">
|
||||
{t('qrCode:clickToUpload')}
|
||||
</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{t('qrCode:supportFormats')}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageUploader;
|
||||
@@ -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';
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import React from 'react';
|
||||
import { Copy, Download } from 'lucide-react';
|
||||
import { useI18n } from '@/utils/chromeI18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
// 继承原生 HTML Div 属性,方便外部无缝扩充类名或监听事件
|
||||
interface QrCodePreviewProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
/** 二维码 Data URL */
|
||||
qrCodeDataUrl: string;
|
||||
/** 下载回调 */
|
||||
onDownload: () => void;
|
||||
/** 复制回调 */
|
||||
onCopy: () => void;
|
||||
/** 自定义占位文本,用于空状态提示 */
|
||||
placeholderText?: string;
|
||||
}
|
||||
|
||||
const QrCodePreview = ({
|
||||
qrCodeDataUrl,
|
||||
onDownload,
|
||||
onCopy,
|
||||
placeholderText,
|
||||
className,
|
||||
...props
|
||||
}: QrCodePreviewProps) => {
|
||||
const { t } = useI18n('qrCode');
|
||||
|
||||
// 空状态下的虚线骨架屏
|
||||
if (!qrCodeDataUrl) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col justify-center items-center min-h-[200px] border border-dashed border-input rounded-xl p-4 bg-muted/40',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
{placeholderText || t('qrCode:qrCodeWillShow')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col justify-center items-center border border-input rounded-xl p-4 bg-muted/40',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex flex-col items-center w-full max-w-xs">
|
||||
{/*
|
||||
二维码容器适配:
|
||||
在暗黑模式下,纯黑白的二维码如果直接暴露在暗色背景下,会导致手机摄像头极难识别。
|
||||
通过裹一层 bg-white 和 p-2,确保黑白对比度绝对安全,同时加入 shadow 增强卡片感。
|
||||
*/}
|
||||
<div className="p-2 bg-white rounded-lg shadow-sm border border-border/40">
|
||||
<img
|
||||
src={qrCodeDataUrl}
|
||||
alt="QR Code Preview"
|
||||
className="w-48 h-48 max-w-full object-contain block animate-in fade-in duration-300"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full gap-2 mt-3">
|
||||
<Button variant="outline" size="sm" onClick={onDownload} className="flex-1 h-8">
|
||||
<Download className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<span className="truncate text-xs">{t('qrCode:downloadButton')}</span>
|
||||
</Button>
|
||||
|
||||
<Button variant="default" size="sm" onClick={onCopy} className="flex-1 h-8">
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
<span className="truncate text-xs">{t('qrCode:copyQrButton')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default QrCodePreview;
|
||||
@@ -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',
|
||||
)}
|
||||
>
|
||||
{/* 头部标题区域 */}
|
||||
<DialogHeader className="pt-1">
|
||||
<DialogTitle className="text-center text-lg font-bold tracking-tight text-foreground">
|
||||
{t('storageCleaner:confirmTitle')}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 内容主体:限制最大宽度,防止内部元素在大分辨率下被横向拉得太松散 */}
|
||||
<div className="text-center py-4 flex flex-col items-center w-full max-w-[280px] mx-auto">
|
||||
<DialogDescription className="mb-4 text-xs font-medium text-muted-foreground/90 leading-relaxed">
|
||||
{t('storageCleaner:confirmDesc')}
|
||||
</DialogDescription>
|
||||
|
||||
{/* 待清理项目徽章群 */}
|
||||
<div className="flex flex-wrap gap-1.5 justify-center mb-5 w-full">
|
||||
{selectedOptions.map((label) => (
|
||||
<Badge
|
||||
@@ -65,7 +62,6 @@ export function StorageCleanerConfirm({
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 风险警告横幅 */}
|
||||
<div className="inline-flex items-center justify-center gap-1.5 px-3.5 py-2 rounded-lg bg-destructive/5 border border-dashed border-destructive/20 w-full max-w-[240px]">
|
||||
<AlertTriangle className="h-3.5 w-3.5 text-destructive shrink-0" />
|
||||
<span className="text-[11px] font-bold text-destructive leading-none tracking-tight">
|
||||
|
||||
Reference in New Issue
Block a user