diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aa2d055..2982e62 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,7 +49,7 @@ jobs: run: npm ci - name: Run TypeScript type check - run: npm run compile + run: npm run typecheck test: name: Unit Tests diff --git a/.gitignore b/.gitignore index 0fec6ba..d22059f 100644 --- a/.gitignore +++ b/.gitignore @@ -13,7 +13,6 @@ stats.html stats-*.json .wxt .vitest -.claude # Editor directories and files .vscode/* diff --git a/components/CopyButton.tsx b/components/CopyButton.tsx index 9ff6670..e615edc 100644 --- a/components/CopyButton.tsx +++ b/components/CopyButton.tsx @@ -45,15 +45,14 @@ export const CopyButton: React.FC = ({ const handleCopy = async () => { if (text) { - await copyTextToClipboard(text) - .then(() => { - showMessage?.('复制成功', { severity: 'success' }); - setCopied(true); - setTimeout(() => setCopied(false), 1500); - }) - .catch(() => { - showMessage?.('复制失败', { severity: 'error' }); - }); + const success = await copyTextToClipboard(text); + if (success) { + showMessage?.('复制成功', { severity: 'success' }); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } else { + showMessage?.('复制失败', { severity: 'error' }); + } } else { showMessage?.('无内容可复制', { severity: 'error' }); } diff --git a/components/DecodeResultPaper.tsx b/components/DecodeResultPaper.tsx new file mode 100644 index 0000000..a668307 --- /dev/null +++ b/components/DecodeResultPaper.tsx @@ -0,0 +1,99 @@ +/** + * DecodeResultPaper + * + * FileMode 与 ImageMode 通用的 decode 结果展示组件。 + * 提取了二者 decode 输出区完全一致的 Paper 结构: + * 标题 → 可选预览(children)→ 文件信息 → 文件名输入 → 下载按钮 + * + * FileMode 直接使用,ImageMode 通过 children 传入图片预览。 + */ +import { alpha, Button, Paper, Stack, TextField, Typography } from '@mui/material'; +import DownloadIcon from '@mui/icons-material/Download'; +import { formatFileSize } from '@/utils/base64Converter'; +import { useTranslation } from 'react-i18next'; + +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 } = useTranslation('base64Converter'); + + return ( + alpha(theme.palette.info.main, 0.04), + border: '1px solid', + borderColor: (theme) => alpha(theme.palette.info.main, 0.15), + }} + > + {/* 标题 */} + + {title} + + + {/* 可选预览内容(ImageMode 的图片) */} + {children} + + {/* 文件信息 */} + + + {t('inferredMimeType')}: {mimeType} + + + {t('decodedSize')}: {formatFileSize(blobSize)} + + + + {/* 文件名输入 */} + onFileNameChange(e.target.value)} + sx={{ mb: 1.5 }} + /> + + {/* 下载按钮 */} + + + ); +} diff --git a/components/ErrorBoundary.tsx b/components/ErrorBoundary.tsx index ad96dee..c319a50 100644 --- a/components/ErrorBoundary.tsx +++ b/components/ErrorBoundary.tsx @@ -30,6 +30,12 @@ export class ErrorBoundary extends Component { console.error('Uncaught error:', error, errorInfo); } + componentDidUpdate(prevProps: Props) { + if (this.state.hasError && prevProps.children !== this.props.children) { + this.setState({ hasError: false, error: null }); + } + } + private handleReset = () => { window.location.reload(); }; diff --git a/components/GlobalSnackbar.tsx b/components/GlobalSnackbar.tsx index 9d71124..1622054 100644 --- a/components/GlobalSnackbar.tsx +++ b/components/GlobalSnackbar.tsx @@ -145,6 +145,7 @@ export function GlobalSnackbar({ onClose, severity = defaultProps.severity, autoHideDuration = defaultProps.autoHideDuration, + anchorOrigin = defaultProps.anchorOrigin, showAlert = defaultProps.showAlert, hideIcon = defaultProps.hideIcon, }: GlobalSnackbarProps): JSX.Element { @@ -154,7 +155,7 @@ export function GlobalSnackbar({ open={open} autoHideDuration={autoHideDuration} onClose={onClose} - anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + anchorOrigin={anchorOrigin} disableWindowBlurListener sx={{ zIndex: 999999, diff --git a/components/PageErrorBoundary.tsx b/components/PageErrorBoundary.tsx new file mode 100644 index 0000000..4e6c592 --- /dev/null +++ b/components/PageErrorBoundary.tsx @@ -0,0 +1,124 @@ +import { Component, ErrorInfo, ReactNode } from 'react'; +import { Box, Button, Paper, Typography } from '@mui/material'; +import type { Theme } from '@mui/material/styles'; +import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'; +import RefreshIcon from '@mui/icons-material/Refresh'; + +interface Props { + children: ReactNode; + resetKey?: string | number; +} + +interface State { + hasError: boolean; + error: Error | null; +} + +/** + * 页面级错误边界组件:捕获子组件树中的 JavaScript 错误 + * 与全局 ErrorBoundary 的区别:使用轻量内嵌卡片 UI,提供重试按钮 + */ +export class PageErrorBoundary extends Component { + state: State = { + hasError: false, + error: null, + }; + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.error('Uncaught error in page:', error, errorInfo); + } + + componentDidUpdate(prevProps: Props) { + if (this.state.hasError && prevProps.resetKey !== this.props.resetKey) { + this.setState({ hasError: false, error: null }); + } + } + + private handleRetry = () => { + this.setState({ hasError: false, error: null }); + }; + + render() { + if (this.state.hasError) { + return ( + + + + + 该页面加载失败 + + + 页面在加载或渲染时遇到错误,您可以重试或切换到其他工具。 + + {this.state.error && ( + + theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.05)' : 'grey.100', + borderRadius: 2, + textAlign: 'left', + maxHeight: '160px', + overflow: 'auto', + }} + > + + {this.state.error.toString()} + + + )} + + + + ); + } + + return this.props.children; + } +} + +export default PageErrorBoundary; diff --git a/components/RouterContainer.tsx b/components/RouterContainer.tsx index 3be590d..45f5de3 100644 --- a/components/RouterContainer.tsx +++ b/components/RouterContainer.tsx @@ -2,6 +2,7 @@ import { Box, CircularProgress } from '@mui/material'; import { FEATURES, getEntryPointType } from '@/config/features'; import { useRouter } from '@/providers/RouterProvider'; import { Suspense, useMemo } from 'react'; +import PageErrorBoundary from '@/components/PageErrorBoundary'; export default function RouterContainer() { const { currentPage, isLoaded } = useRouter(); @@ -60,7 +61,7 @@ export default function RouterContainer() { } > - {Component && } + {Component && } ); diff --git a/components/SwitchButtonGroup.tsx b/components/SwitchButtonGroup.tsx new file mode 100644 index 0000000..66df814 --- /dev/null +++ b/components/SwitchButtonGroup.tsx @@ -0,0 +1,71 @@ +import { ToggleButton, ToggleButtonGroup, type SxProps, type Theme } from '@mui/material'; + +export interface SwitchOption { + value: T; + label: React.ReactNode; +} + +export interface SwitchButtonGroupProps { + value: T; + options: SwitchOption[]; + onChange: (value: T) => void; + sx?: SxProps; + size?: 'small' | 'medium' | 'large'; + buttonSx?: SxProps; +} + +export default function SwitchButtonGroup({ + value, + options, + onChange, + sx, + size, + buttonSx, +}: SwitchButtonGroupProps) { + return ( + v && onChange(v)} + sx={{ + width: '100%', + mb: 2, + borderRadius: 4, + bgcolor: (theme: Theme) => (theme.palette.mode === 'light' ? 'grey.100' : 'grey.900'), + border: '1px solid', + borderColor: 'divider', + p: 0.6, + '& .MuiToggleButtonGroup-grouped': { + flex: 1, + border: 'none', + borderRadius: 3.5, + mx: 0.3, + fontWeight: 800, + color: 'text.secondary', + transition: 'color 0.3s', + '&:not(:first-of-type)': { + borderLeft: 'none', + marginLeft: 0.6, + }, + '&.Mui-selected': { + bgcolor: 'background.paper', + color: 'primary.main', + boxShadow: '0 4px 12px rgba(0,0,0,0.05)', + }, + }, + ...sx, + }} + > + {options.map((option) => ( + + {option.label} + + ))} + + ); +} diff --git a/components/TextInputArea.tsx b/components/TextInputArea.tsx new file mode 100644 index 0000000..d11ce5e --- /dev/null +++ b/components/TextInputArea.tsx @@ -0,0 +1,515 @@ +/** + * TextInputArea - 多行文本输入组件 + * + * 提供功能丰富的多行文本输入体验,支持受控/非受控模式、验证规则、 + * 字符计数、工具栏操作、复制/清空等交互能力。 + * + * @module TextInputArea + * + * @example + * ```tsx + * // 基础用法 + * + * + * // 受控模式 + * + * + * // 带验证规则 + * v.length >= 3, message: '至少3个字符' }]} + * validateTrigger="onBlur" + * /> + * + * // 带操作按钮 + * + * ``` + */ + +import { useRef, useState, useCallback, forwardRef, RefObject } from 'react'; +import { + Box, + Button, + IconButton, + TextField, + Tooltip, + Typography, + alpha, + type SxProps, +} from '@mui/material'; +import type { Theme } from '@mui/material/styles'; +import CloseIcon from '@mui/icons-material/Close'; +import ContentCopyIcon from '@mui/icons-material/ContentCopy'; +import { useTranslation } from 'react-i18next'; +import type { SnackbarOptions } from '@/components/GlobalSnackbar'; + +/** 文本验证规则 */ +export type ValidateRule = { + /** 验证函数,返回 true 表示通过 */ + validator: (value: string) => boolean; + /** 验证失败时的提示消息 */ + message: string; +}; + +/** 工具栏操作按钮配置 */ +export type ToolbarAction = { + /** 唯一标识 */ + key: string; + /** 按钮显示文本 */ + label: string; + /** 按钮图标 */ + icon?: React.ReactNode; + /** 按钮位置:顶部或底部,默认顶部 */ + position?: 'top' | 'bottom'; + /** 按钮样式类型:主要/默认/危险 */ + type?: 'primary' | 'default' | 'danger'; + /** 禁用条件,可以是布尔值或根据当前值动态判断的函数 */ + disabled?: boolean | ((value: string) => boolean); + /** 点击回调,接收当前值和操作辅助方法 */ + onClick: (value: string, helpers: { clear: () => void; setError: (msg: string) => void }) => void; +}; + +export interface TextInputAreaProps { + /** 受控模式下的当前值 */ + value?: string; + /** 非受控模式下的初始值,组件挂载时有效 */ + defaultValue?: string; + /** 值变化回调 */ + onChange?: (value: string) => void; + /** 占位文本 */ + placeholder?: string; + /** 是否禁用 */ + disabled?: boolean; + /** 是否只读 */ + readOnly?: boolean; + /** 是否自动聚焦 */ + autoFocus?: boolean; + + /** 最小行数(autoResize 为 true 时生效) */ + minRows?: number; + /** 最大行数(autoResize 为 true 时生效) */ + maxRows?: number; + /** 最大字符数限制 */ + maxLength?: number; + /** 外层容器类名 */ + className?: string; + /** 外层容器样式 */ + style?: React.CSSProperties; + /** 外层容器 sx */ + sx?: SxProps; + + /** 是否显示字符计数 */ + showCount?: boolean; + /** 是否显示清空按钮,默认 true */ + showClear?: boolean; + /** 是否允许复制内容 */ + allowCopy?: boolean; + /** 是否启用自动调整高度,默认 true */ + autoResize?: boolean; + + /** 验证规则列表 */ + rules?: ValidateRule[]; + /** 验证触发时机:失焦(onBlur) / 输入时(onChange) / 操作前(onAction),默认 onAction */ + validateTrigger?: 'onBlur' | 'onChange' | 'onAction'; + + /** 工具栏操作按钮列表 */ + actions?: ToolbarAction[]; + /** 顶部栏左侧额外内容 */ + topExtra?: React.ReactNode; + + /** 顶部栏标题 */ + title?: string; + + /** 消息提示回调,用于展示 Toast 通知 */ + showMessage?: (message: string, options?: SnackbarOptions) => void; + + /** 外部错误消息,由父组件控制,优先于内部验证错误 */ + externalError?: string; + + /** 清空按钮点击后的额外回调 */ + onClear?: () => void; +} + +/** ActionButton 内部组件的属性 */ +interface ActionButtonProps { + action: ToolbarAction; + value: string; + globalDisabled: boolean; + variant?: 'text' | 'contained'; + onAction: (action: ToolbarAction) => void; + size?: 'small' | 'medium'; + compact?: boolean; +} + +/** + * 工具栏操作按钮 - 根据 action.type 自动应用样式 + * + * - primary:填充主色背景 + * - danger:红色文字 + 悬停红色背景 + * - default(默认):灰色文字 + 悬停灰色背景 + */ +function ActionButton({ + action, + value, + globalDisabled, + variant = 'text', + onAction, + size = 'small', + compact, +}: ActionButtonProps) { + const isBtnDisabled = + typeof action.disabled === 'function' ? action.disabled(value) : action.disabled || !value; + + const typeStyles: Record = {}; + + if (action.type === 'primary') { + if (variant !== 'contained') { + typeStyles.bgcolor = 'primary.main'; + typeStyles.color = 'primary.contrastText'; + typeStyles['&:hover'] = { bgcolor: 'primary.dark' }; + } + } else if (action.type === 'danger') { + typeStyles.color = 'error.main'; + typeStyles['&:hover'] = { + bgcolor: (theme: Theme) => alpha(theme.palette.error.main, 0.08), + }; + } else { + typeStyles.color = 'text.secondary'; + typeStyles['&:hover'] = { + bgcolor: (theme: Theme) => alpha(theme.palette.grey[500], 0.1), + }; + } + + return ( + + ); +} + +/** + * TextInputArea 组件 + * + * 多行文本输入组件,支持受控/非受控双模式、验证规则、工具栏操作等。 + * 使用 forwardRef 暴露底层 textarea DOM 节点。 + */ +const TextInputArea = forwardRef((props, ref) => { + const { + value: controlledValue, + defaultValue = '', + onChange, + placeholder: placeholderProp, + disabled = false, + readOnly = false, + autoFocus = false, + minRows = 4, + maxRows = 12, + maxLength, + className = '', + style, + sx: containerSx, + showCount = false, + showClear = true, + allowCopy = false, + autoResize = true, + rules = [], + validateTrigger = 'onAction', + actions = [], + topExtra, + title, + showMessage, + externalError, + onClear, + } = props; + + const textareaRef = useRef(null); + const [internalValue, setInternalValue] = useState(defaultValue); + const [error, setError] = useState(''); + + const { t } = useTranslation('common'); + const placeholder = placeholderProp ?? t('textInputArea.placeholder'); + + /** 通过 value prop 是否存在来判断是否为受控模式 */ + const isControlled = controlledValue !== undefined; + const value = isControlled ? controlledValue : internalValue; + + /** 外部错误优先级高于内部验证错误 */ + const displayError = externalError ?? error; + + /** + * 执行所有验证规则 + * @param trigger - 触发验证的事件类型,用于匹配 validateTrigger + */ + const validate = useCallback( + (val: string, trigger?: string): boolean => { + if (validateTrigger !== trigger && trigger) return true; + for (const rule of rules) { + if (!rule.validator(val)) { + setError(rule.message); + return false; + } + } + setError(''); + return true; + }, + [rules, validateTrigger], + ); + + /** 输入变化处理:更新值、清空错误、按需触发验证 */ + const handleChange = (e: React.ChangeEvent) => { + const newVal = e.target.value; + if (maxLength && newVal.length > maxLength) { + const msg = t('charCount', { count: maxLength }); + setError(msg); + showMessage?.(msg, { severity: 'warning' }); + return; + } + + if (!isControlled) setInternalValue(newVal); + onChange?.(newVal); + + if (error) setError(''); + if (validateTrigger === 'onChange') validate(newVal, 'onChange'); + }; + + /** 失焦时按需触发验证 */ + const handleBlur = () => { + if (validateTrigger === 'onBlur') validate(value, 'onBlur'); + }; + + /** 清空输入内容并重新聚焦 */ + const handleClear = useCallback(() => { + if (!isControlled) setInternalValue(''); + onChange?.(''); + setError(''); + textareaRef.current?.focus(); + showMessage?.(t('textInputArea.cleared'), { severity: 'success' }); + onClear?.(); + }, [isControlled, onChange, showMessage, t, onClear]); + + /** 复制当前内容到剪贴板 */ + const handleCopy = useCallback(async () => { + try { + await navigator.clipboard.writeText(value); + showMessage?.(t('messages.copySuccess'), { severity: 'success' }); + } catch { + setError(t('messages.copyError')); + showMessage?.(t('messages.copyError'), { severity: 'error' }); + } + }, [value, showMessage, t]); + + /** 执行工具栏操作:检查禁用状态、验证、调用 onClick */ + const handleAction = useCallback( + (action: ToolbarAction) => { + const isDisabled = + typeof action.disabled === 'function' ? action.disabled(value) : action.disabled; + + if (isDisabled || disabled) return; + + if (validateTrigger === 'onAction' && !validate(value, 'onAction')) { + return; + } + + action.onClick(value, { + clear: handleClear, + setError, + }); + }, + [value, disabled, validate, validateTrigger, handleClear], + ); + + /** 合并内部 ref 和外部传入的 forwardRef */ + const handleInputRef = useCallback( + (node: HTMLTextAreaElement | null) => { + textareaRef.current = node; + if (typeof ref === 'function') { + ref(node); + } else if (ref) { + (ref as RefObject).current = node; + } + }, + [ref], + ); + + const topActions = actions.filter((a) => a.position !== 'bottom'); + const bottomActions = actions.filter((a) => a.position === 'bottom'); + + const hasTopBar = title || showCount || topActions.length > 0 || topExtra; + + return ( + + {hasTopBar && ( + + + {title && ( + + {title} + + )} + {topExtra} + + + + {topActions.map((action) => ( + + ))} + {showCount && ( + + {value.length} + {maxLength ? ` / ${maxLength}` : ''} + + )} + + + )} + + + `${alpha(theme.palette.primary.main, 0.08)} 0 0 0 3px`, + }, + '&.Mui-error': { + boxShadow: (theme) => `${alpha(theme.palette.error.main, 0.08)} 0 0 0 3px`, + }, + '& textarea': { + py: 1.5, + px: 1.5, + ...(showClear || allowCopy || bottomActions.length > 0 ? { pb: 4 } : {}), + }, + }, + '& .MuiFormHelperText-root': { + mx: 0, + mt: 0.5, + }, + }} + /> + + {(showClear || allowCopy || bottomActions.length > 0) && ( + + {bottomActions.map((action) => ( + + ))} + {allowCopy && value && ( + + alpha(theme.palette.primary.main, 0.08), + }, + }} + > + + + + )} + {showClear && value && !disabled && !readOnly && ( + + alpha(theme.palette.error.main, 0.08), + }, + }} + > + + + + )} + + )} + + + ); +}); + +TextInputArea.displayName = 'TextInputArea'; + +export default TextInputArea; diff --git a/components/TopBar.tsx b/components/TopBar.tsx index 6b259f3..778ba86 100644 --- a/components/TopBar.tsx +++ b/components/TopBar.tsx @@ -31,7 +31,15 @@ import { openExtensionPage } from '@/utils/chromeTabs'; import { useTranslation } from 'react-i18next'; import { alpha } from '@mui/material/styles'; import { SUPPORTED_LANGUAGES, normalizeLanguage } from '@/i18n'; -import { topBarStyles } from '@/config/pageTheme'; + +const topBarStyles = { + SEARCH_MAX_WIDTH: 400, + DROPDOWN_MAX_HEIGHT: 300, + Z_INDEX: 1100, + DROPDOWN_Z_INDEX: 1200, + SEARCH_HISTORY_LIMIT: 10, + SEARCH_HISTORY_DISPLAY: 5, +}; export default function TopBar({ onOpenOptions }: { onOpenOptions: () => void }) { const { currentPage, goBack, navigateTo } = useRouter(); @@ -90,13 +98,17 @@ export default function TopBar({ onOpenOptions }: { onOpenOptions: () => void }) 0, topBarStyles.SEARCH_HISTORY_LIMIT, ); - storageUtil.set('app/searchHistory', newHistory).catch((error) => { - console.error('保存搜索历史失败:', error); - }); return newHistory; }); }; + // 副作用:搜索历史变化后持久化到 storage + useEffect(() => { + storageUtil.set('app/searchHistory', searchHistory).catch((error) => { + console.error('保存搜索历史失败:', error); + }); + }, [searchHistory]); + const handleSelectFeature = (feature: FeatureConfig) => { navigateTo(feature.key); saveToHistory(t(feature.labelKey)); diff --git a/components/__tests__/DecodeResultPaper.test.tsx b/components/__tests__/DecodeResultPaper.test.tsx new file mode 100644 index 0000000..7dbc74f --- /dev/null +++ b/components/__tests__/DecodeResultPaper.test.tsx @@ -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(); + expect(screen.getByText('decodedFileOutput')).toBeInTheDocument(); + }); + + it('应渲染 MIME 类型信息', () => { + render(); + expect(screen.getByText(/image\/png/)).toBeInTheDocument(); + }); + + it('应通过 formatFileSize 渲染文件大小', () => { + render(); + expect(screen.getByText(/1\.5 KB/)).toBeInTheDocument(); + }); + + it('应渲染文件名输入框', () => { + render(); + const input = screen.getByDisplayValue('decoded.png'); + expect(input).toBeInTheDocument(); + }); + + it('应渲染下载按钮', () => { + render(); + expect(screen.getByRole('button', { name: 'download' })).toBeInTheDocument(); + }); + + it('应渲染 children 内容', () => { + render( + +
预览内容
+
, + ); + expect(screen.getByTestId('preview')).toBeInTheDocument(); + expect(screen.getByText('预览内容')).toBeInTheDocument(); + }); + }); + + describe('交互测试', () => { + it('修改文件名时应调用 onFileNameChange', () => { + render(); + const input = screen.getByDisplayValue('decoded.png'); + fireEvent.change(input, { target: { value: 'new-name.png' } }); + expect(defaultProps.onFileNameChange).toHaveBeenCalledWith('new-name.png'); + }); + + it('点击下载按钮时应调用 onDownload', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'download' })); + expect(defaultProps.onDownload).toHaveBeenCalledTimes(1); + }); + }); + + describe('按钮状态', () => { + it('文件名为空时下载按钮应禁用', () => { + render(); + expect(screen.getByRole('button', { name: 'download' })).toBeDisabled(); + }); + + it('文件名不为空时下载按钮应启用', () => { + render(); + expect(screen.getByRole('button', { name: 'download' })).toBeEnabled(); + }); + }); +}); diff --git a/components/__tests__/ErrorBoundary.test.tsx b/components/__tests__/ErrorBoundary.test.tsx new file mode 100644 index 0000000..b97ef4f --- /dev/null +++ b/components/__tests__/ErrorBoundary.test.tsx @@ -0,0 +1,102 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import { ErrorBoundary } from '@/components/ErrorBoundary'; + +// 用于触发错误的测试子组件 +function ThrowError({ message }: { message: string }): never { + throw new Error(message); +} + +// 正常渲染的子组件 +function NormalComponent({ text }: { text: string }) { + return
{text}
; +} + +describe('ErrorBoundary', () => { + beforeEach(() => { + // 抑制测试中故意抛出的错误日志 + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + it('正常渲染子组件', () => { + render( + + + , + ); + expect(screen.getByTestId('normal-content')).toHaveTextContent('正常内容'); + }); + + it('子组件抛出错误时显示错误 UI', () => { + render( + + + , + ); + + expect(screen.getByText('糟糕,出了点问题')).toBeInTheDocument(); + expect(screen.getByText(/测试错误/)).toBeInTheDocument(); + }); + + it('children 变化时重置错误状态', async () => { + const { rerender } = render( + + + , + ); + + expect(screen.getByText('糟糕,出了点问题')).toBeInTheDocument(); + + // 切换到正常子组件 + rerender( + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId('normal-content')).toHaveTextContent('恢复后的内容'); + }); + + expect(screen.queryByText('糟糕,出了点问题')).not.toBeInTheDocument(); + }); + + it('相同的 children 不重置错误状态', () => { + const { rerender } = render( + + + , + ); + + expect(screen.getByText('糟糕,出了点问题')).toBeInTheDocument(); + + // 用相同的 children rerender + rerender( + + + , + ); + + expect(screen.getByText('糟糕,出了点问题')).toBeInTheDocument(); + }); + + it('错误 UI 包含刷新按钮', () => { + const reloadMock = vi.fn(); + Object.defineProperty(window, 'location', { + value: { reload: reloadMock }, + writable: true, + }); + + render( + + + , + ); + + const refreshButton = screen.getByRole('button', { name: /刷新应用/ }); + expect(refreshButton).toBeInTheDocument(); + + refreshButton.click(); + expect(reloadMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/components/__tests__/PageErrorBoundary.test.tsx b/components/__tests__/PageErrorBoundary.test.tsx new file mode 100644 index 0000000..fb9078d --- /dev/null +++ b/components/__tests__/PageErrorBoundary.test.tsx @@ -0,0 +1,131 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import { PageErrorBoundary } from '@/components/PageErrorBoundary'; + +// 用于触发错误的测试子组件 +function ThrowError({ message }: { message: string }): never { + throw new Error(message); +} + +// 正常渲染的子组件 +function NormalComponent({ text }: { text: string }) { + return
{text}
; +} + +describe('PageErrorBoundary', () => { + beforeEach(() => { + // 抑制测试中故意抛出的错误日志 + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + it('正常渲染子组件', () => { + render( + + + , + ); + expect(screen.getByTestId('normal-content')).toHaveTextContent('正常内容'); + }); + + it('子组件抛出错误时显示错误卡片 UI', () => { + render( + + + , + ); + + expect(screen.getByText('该页面加载失败')).toBeInTheDocument(); + expect(screen.getByText(/测试错误/)).toBeInTheDocument(); + }); + + it('点击重试按钮后恢复', async () => { + const { rerender } = render( + + + , + ); + + expect(screen.getByText('该页面加载失败')).toBeInTheDocument(); + + // 将子组件替换为正常组件,然后点击重试 + rerender( + + + , + ); + + const retryButton = screen.getByRole('button', { name: /重试/ }); + retryButton.click(); + + await waitFor(() => { + expect(screen.getByTestId('normal-content')).toHaveTextContent('恢复后的内容'); + }); + + expect(screen.queryByText('该页面加载失败')).not.toBeInTheDocument(); + }); + + it('resetKey 变化时自动重置错误状态', async () => { + const { rerender } = render( + + + , + ); + + expect(screen.getByText('该页面加载失败')).toBeInTheDocument(); + + // 切换 resetKey,同时提供正常子组件 + rerender( + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId('normal-content')).toHaveTextContent('页面 B 内容'); + }); + + expect(screen.queryByText('该页面加载失败')).not.toBeInTheDocument(); + }); + + it('resetKey 不变时保持错误状态', () => { + const { rerender } = render( + + + , + ); + + expect(screen.getByText('该页面加载失败')).toBeInTheDocument(); + + // 仅 children 变化,resetKey 不变,错误应保持 + rerender( + + + , + ); + + expect(screen.getByText('该页面加载失败')).toBeInTheDocument(); + }); + + it('错误 UI 包含重试按钮', () => { + render( + + + , + ); + + const retryButton = screen.getByRole('button', { name: /重试/ }); + expect(retryButton).toBeInTheDocument(); + }); + + it('错误信息以 monospace 格式显示', () => { + render( + + + , + ); + + const errorText = screen.getByText(/格式化测试/); + expect(errorText).toBeInTheDocument(); + expect(errorText.tagName.toLowerCase()).toBe('pre'); + }); +}); diff --git a/components/__tests__/RouterContainer.test.tsx b/components/__tests__/RouterContainer.test.tsx index 3cdbf08..e5db9da 100644 --- a/components/__tests__/RouterContainer.test.tsx +++ b/components/__tests__/RouterContainer.test.tsx @@ -12,7 +12,6 @@ const mockRouterValue = { pageOrder: ['timestamp'] as PageType[], isLoaded: true, navigateTo: vi.fn(), - navigateLocal: vi.fn(), syncNavigation: vi.fn(), goBack: vi.fn(), setVisiblePages: vi.fn(), @@ -83,4 +82,16 @@ describe('RouterContainer 组件', () => { expect(box).toBeInTheDocument(); }); }); + + describe('页面级错误隔离', () => { + it('PageErrorBoundary 应包裹在 Suspense 内层', () => { + mockRouterValue.isLoaded = true; + mockRouterValue.currentPage = 'dashboard'; + const { container } = renderWithProvider(); + + // 验证 RouterContainer 的 Box 结构存在 + const routerBox = container.querySelector('.page-transition-dashboard'); + expect(routerBox).toBeInTheDocument(); + }); + }); }); diff --git a/components/__tests__/SwitchButtonGroup.test.tsx b/components/__tests__/SwitchButtonGroup.test.tsx new file mode 100644 index 0000000..11f97e7 --- /dev/null +++ b/components/__tests__/SwitchButtonGroup.test.tsx @@ -0,0 +1,139 @@ +import { describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import SwitchButtonGroup from '@/components/SwitchButtonGroup'; + +describe('SwitchButtonGroup 组件', () => { + const options = [ + { value: 'a', label: '选项A' }, + { value: 'b', label: '选项B' }, + ]; + + it('应渲染所有选项按钮', () => { + render(); + + expect(screen.getByRole('button', { name: /选项A/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /选项B/i })).toBeInTheDocument(); + }); + + it('应高亮当前选中的按钮', () => { + render(); + + const buttonA = screen.getByRole('button', { name: /选项A/i }); + const buttonB = screen.getByRole('button', { name: /选项B/i }); + + expect(buttonA).toHaveClass('Mui-selected'); + expect(buttonB).not.toHaveClass('Mui-selected'); + }); + + it('点击未选中按钮时应触发 onChange 并传入选中值', () => { + const handleChange = vi.fn(); + render(); + + fireEvent.click(screen.getByRole('button', { name: /选项B/i })); + expect(handleChange).toHaveBeenCalledTimes(1); + expect(handleChange).toHaveBeenCalledWith('b'); + }); + + it('点击已选中按钮时不应触发 onChange', () => { + const handleChange = vi.fn(); + render(); + + fireEvent.click(screen.getByRole('button', { name: /选项A/i })); + expect(handleChange).not.toHaveBeenCalled(); + }); + + it('应支持通过 sx 自定义样式', () => { + const { container } = render( + , + ); + + const group = container.querySelector('.MuiToggleButtonGroup-root'); + expect(group).toBeInTheDocument(); + }); + + it('应支持 size 属性', () => { + const { container } = render( + , + ); + + const group = container.querySelector('.MuiToggleButtonGroup-root'); + expect(group).toBeInTheDocument(); + expect(group).toHaveClass('MuiToggleButtonGroup-root'); + }); + + it('应支持 buttonSx 自定义按钮样式', () => { + render( + , + ); + + const button = screen.getByRole('button', { name: /选项A/i }); + expect(button).toBeInTheDocument(); + }); + + it('应支持 ReactNode 类型的 label', () => { + const nodeOptions = [{ value: 'x', label: 自定义 }]; + render(); + + expect(screen.getByTestId('custom-label')).toBeInTheDocument(); + }); + + it('默认按钮样式应禁止文字换行', () => { + render(); + + const button = screen.getByRole('button', { name: /选项A/i }); + expect(button).toHaveStyle('white-space: nowrap'); + }); + + it('buttonSx 传入时应覆盖默认换行样式', () => { + render( + , + ); + + const button = screen.getByRole('button', { name: /选项A/i }); + expect(button).toBeInTheDocument(); + expect(window.getComputedStyle(button).whiteSpace).toBe('normal'); + }); + + describe('number 类型支持', () => { + const numberOptions = [ + { value: 2, label: '2' }, + { value: 4, label: '4' }, + ]; + + it('应支持 number 类型的 value 渲染', () => { + render(); + + expect(screen.getByRole('button', { name: /2/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /4/i })).toBeInTheDocument(); + }); + + it('应高亮 number 类型的当前选中项', () => { + render(); + + const button2 = screen.getByRole('button', { name: /2/i }); + const button4 = screen.getByRole('button', { name: /4/i }); + + expect(button2).not.toHaveClass('Mui-selected'); + expect(button4).toHaveClass('Mui-selected'); + }); + + it('点击 number 选项时应传回 number 值', () => { + const handleChange = vi.fn(); + render(); + + fireEvent.click(screen.getByRole('button', { name: /4/i })); + expect(handleChange).toHaveBeenCalledTimes(1); + expect(handleChange).toHaveBeenCalledWith(4); + }); + }); +}); diff --git a/components/__tests__/TextInputArea.test.tsx b/components/__tests__/TextInputArea.test.tsx new file mode 100644 index 0000000..eff68e8 --- /dev/null +++ b/components/__tests__/TextInputArea.test.tsx @@ -0,0 +1,523 @@ +import { describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import TextInputArea from '@/components/TextInputArea'; + +describe('TextInputArea 组件', () => { + describe('基础渲染', () => { + it('应渲染 placeholder', () => { + render(); + expect(screen.getByPlaceholderText('请输入文本...')).toBeInTheDocument(); + }); + + it('应渲染传入的 value', () => { + render( {}} />); + const textarea = screen.getByRole('textbox'); + expect(textarea).toHaveValue('测试内容'); + }); + + it('默认显示清空按钮', () => { + render( {}} />); + expect(screen.getByRole('button', { name: 'textInputArea.clear' })).toBeInTheDocument(); + }); + + it('无内容时清空按钮应隐藏', () => { + render( {}} />); + expect(screen.queryByRole('button', { name: 'textInputArea.clear' })).not.toBeInTheDocument(); + }); + + it('disabled 时清空按钮应隐藏', () => { + render( {}} disabled />); + expect(screen.queryByTitle('清空')).not.toBeInTheDocument(); + }); + + it('readOnly 时清空按钮应隐藏', () => { + render( {}} readOnly />); + expect(screen.queryByTitle('清空')).not.toBeInTheDocument(); + }); + + it('showClear=false 时不显示清空按钮', () => { + render( {}} showClear={false} />); + expect(screen.queryByTitle('清空')).not.toBeInTheDocument(); + }); + }); + + describe('受控模式', () => { + it('输入时触发 onChange', () => { + const handleChange = vi.fn(); + render(); + + const textarea = screen.getByRole('textbox'); + fireEvent.change(textarea, { target: { value: '新内容' } }); + + expect(handleChange).toHaveBeenCalledWith('新内容'); + }); + + it('清空按钮触发 onChange("")', () => { + const handleChange = vi.fn(); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'textInputArea.clear' })); + + expect(handleChange).toHaveBeenCalledWith(''); + }); + }); + + describe('非受控模式', () => { + it('defaultValue 应显示初始值', () => { + render(); + expect(screen.getByRole('textbox')).toHaveValue('初始值'); + }); + + it('输入后应更新内部值', () => { + render(); + + const textarea = screen.getByRole('textbox'); + fireEvent.change(textarea, { target: { value: '新内容' } }); + + expect(textarea).toHaveValue('新内容'); + }); + + it('清空按钮应清空内容', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'textInputArea.clear' })); + + expect(screen.getByRole('textbox')).toHaveValue(''); + }); + }); + + describe('allowCopy 复制功能', () => { + it('allowCopy 且有内容时显示复制按钮', () => { + render( {}} allowCopy />); + expect(screen.getByRole('button', { name: 'textInputArea.copyContent' })).toBeInTheDocument(); + }); + + it('allowCopy 但无内容时隐藏复制按钮', () => { + render( {}} allowCopy />); + expect( + screen.queryByRole('button', { name: 'textInputArea.copyContent' }), + ).not.toBeInTheDocument(); + }); + + it('allowCopy=false 时不显示复制按钮', () => { + render( {}} />); + expect( + screen.queryByRole('button', { name: 'textInputArea.copyContent' }), + ).not.toBeInTheDocument(); + }); + + it('复制时调用 showMessage', async () => { + const showMessage = vi.fn(); + Object.assign(navigator, { + clipboard: { writeText: vi.fn().mockResolvedValue(undefined) }, + }); + + render( + {}} allowCopy showMessage={showMessage} />, + ); + + fireEvent.click(screen.getByRole('button', { name: 'textInputArea.copyContent' })); + + expect(navigator.clipboard.writeText).toHaveBeenCalledWith('测试'); + await vi.waitFor(() => { + expect(showMessage).toHaveBeenCalledWith('messages.copySuccess', { severity: 'success' }); + }); + }); + + it('复制失败时调用 showMessage 错误提示', async () => { + const showMessage = vi.fn(); + Object.assign(navigator, { + clipboard: { writeText: vi.fn().mockRejectedValue(new Error('失败')) }, + }); + + render( + {}} allowCopy showMessage={showMessage} />, + ); + + fireEvent.click(screen.getByRole('button', { name: 'textInputArea.copyContent' })); + + await vi.waitFor(() => { + expect(showMessage).toHaveBeenCalledWith('messages.copyError', { severity: 'error' }); + }); + }); + }); + + describe('showCount 字符计数', () => { + it('显示当前字符数', () => { + render( {}} showCount />); + expect(screen.getByText('5')).toBeInTheDocument(); + }); + + it('空内容时显示 0', () => { + render( {}} showCount />); + expect(screen.getByText('0')).toBeInTheDocument(); + }); + + it('设置 maxLength 时显示计数上限', () => { + render( {}} showCount maxLength={10} />); + expect(screen.getByText('2 / 10')).toBeInTheDocument(); + }); + }); + + describe('maxLength', () => { + it('超出 maxLength 的输入应被截断', () => { + const handleChange = vi.fn(); + render(); + + const textarea = screen.getByRole('textbox'); + fireEvent.change(textarea, { target: { value: '123456' } }); + + expect(handleChange).not.toHaveBeenCalledWith('123456'); + }); + + it('未超出 maxLength 的输入应正常触发', () => { + const handleChange = vi.fn(); + render(); + + const textarea = screen.getByRole('textbox'); + fireEvent.change(textarea, { target: { value: '123' } }); + + expect(handleChange).toHaveBeenCalledWith('123'); + }); + }); + + describe('验证规则', () => { + it('onChange 触发时验证失败应设置 error', () => { + render( + {}} + validateTrigger="onChange" + rules={[{ validator: (v) => v.length >= 3, message: '至少3个字符' }]} + />, + ); + + const textarea = screen.getByRole('textbox'); + fireEvent.change(textarea, { target: { value: 'ab' } }); + + expect(screen.getByText('至少3个字符')).toBeInTheDocument(); + }); + + it('onBlur 触发时验证失败应设置 error', () => { + render( + {}} + validateTrigger="onBlur" + rules={[{ validator: (v) => v.length >= 3, message: '至少3个字符' }]} + />, + ); + + const textarea = screen.getByRole('textbox'); + fireEvent.blur(textarea); + + expect(screen.getByText('至少3个字符')).toBeInTheDocument(); + }); + + it('验证通过不应显示错误', () => { + render( + {}} + validateTrigger="onChange" + rules={[{ validator: (v) => v.length >= 3, message: '至少3个字符' }]} + />, + ); + + const textarea = screen.getByRole('textbox'); + fireEvent.change(textarea, { target: { value: 'abcd' } }); + + expect(screen.queryByText('至少3个字符')).not.toBeInTheDocument(); + }); + + it('onAction 触发时验证失败应阻止 action 执行', () => { + const handleAction = vi.fn(); + render( + {}} + validateTrigger="onAction" + rules={[{ validator: (v) => v.length >= 3, message: '至少3个字符' }]} + actions={[ + { + key: 'test', + label: '执行', + onClick: handleAction, + }, + ]} + />, + ); + + fireEvent.click(screen.getByText('执行')); + + expect(handleAction).not.toHaveBeenCalled(); + expect(screen.getByText('至少3个字符')).toBeInTheDocument(); + }); + }); + + describe('操作栏 actions', () => { + it('应渲染顶部操作按钮', () => { + render( + {}} + actions={[{ key: 'top-action', label: '顶部操作', onClick: vi.fn() }]} + />, + ); + + expect(screen.getByText('顶部操作')).toBeInTheDocument(); + }); + + it('应渲染底部操作按钮', () => { + render( + {}} + actions={[ + { key: 'bottom-action', label: '底部操作', position: 'bottom', onClick: vi.fn() }, + ]} + />, + ); + + expect(screen.getByText('底部操作')).toBeInTheDocument(); + }); + + it('点击操作按钮触发 onClick', () => { + const handleClick = vi.fn(); + render( + {}} + actions={[{ key: 'act', label: '操作', onClick: handleClick }]} + />, + ); + + fireEvent.click(screen.getByText('操作')); + + expect(handleClick).toHaveBeenCalledWith( + '内容', + expect.objectContaining({ + clear: expect.any(Function), + setError: expect.any(Function), + }), + ); + }); + + it('disabled 为 true 时按钮应禁用', () => { + render( + {}} + actions={[{ key: 'act', label: '操作', onClick: vi.fn(), disabled: true }]} + />, + ); + + expect(screen.getByText('操作')).toBeDisabled(); + }); + + it('disabled 为函数且返回 true 时按钮应禁用', () => { + render( + {}} + actions={[{ key: 'act', label: '操作', onClick: vi.fn(), disabled: (v) => !v }]} + />, + ); + + expect(screen.getByText('操作')).toBeDisabled(); + }); + + it('primary 类型按钮应使用 contained 样式', () => { + render( + {}} + actions={[ + { key: 'p', label: '主要', type: 'primary', position: 'bottom', onClick: vi.fn() }, + ]} + />, + ); + + const btn = screen.getByText('主要'); + expect(btn).toHaveClass('MuiButton-contained'); + }); + }); + + describe('title', () => { + it('应渲染 title', () => { + render( {}} />); + expect(screen.getByText('输入区域')).toBeInTheDocument(); + }); + + it('不设置 title 时不渲染标题', () => { + const { container } = render( {}} />); + expect(container.querySelector('.MuiTypography-body2')).not.toBeInTheDocument(); + }); + }); + + describe('disabled 和 readOnly', () => { + it('disabled 时输入框应禁用', () => { + render( {}} disabled />); + expect(screen.getByRole('textbox')).toBeDisabled(); + }); + + it('readOnly 时输入框应只读', () => { + render( {}} readOnly />); + // MUI TextField 的 readOnly 通过 inputProps 设置,textarea 不会被禁用 + expect(screen.getByRole('textbox')).not.toBeDisabled(); + }); + }); + + describe('autoFocus', () => { + it('autoFocus 应自动聚焦', () => { + render( {}} />); + expect(document.activeElement).toBe(screen.getByRole('textbox')); + }); + }); + + describe('showMessage prop', () => { + it('复制成功时调用 showMessage', async () => { + const showMessage = vi.fn(); + Object.assign(navigator, { + clipboard: { writeText: vi.fn().mockResolvedValue(undefined) }, + }); + + render( + {}} allowCopy showMessage={showMessage} />, + ); + fireEvent.click(screen.getByRole('button', { name: 'textInputArea.copyContent' })); + + await vi.waitFor(() => { + expect(showMessage).toHaveBeenCalledWith('messages.copySuccess', { severity: 'success' }); + }); + }); + }); + + describe('onClear 回调', () => { + it('点击清空按钮时应调用 onClear', () => { + const handleClear = vi.fn(); + render( {}} onClear={handleClear} />); + + fireEvent.click(screen.getByRole('button', { name: 'textInputArea.clear' })); + + expect(handleClear).toHaveBeenCalledOnce(); + }); + + it('不传 onClear 时清空按钮应正常工作', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'textInputArea.clear' })); + + expect(screen.getByRole('textbox')).toHaveValue(''); + }); + }); + + describe('externalError 外部错误', () => { + it('设置 externalError 时应显示错误状态', () => { + render( {}} externalError="JSON 格式无效" />); + + expect(screen.getByText('JSON 格式无效')).toBeInTheDocument(); + }); + + it('externalError 为空时应隐藏错误状态', () => { + const { rerender } = render( + {}} externalError="错误" />, + ); + + expect(screen.getByText('错误')).toBeInTheDocument(); + + rerender( {}} externalError="" />); + + expect(screen.queryByText('错误')).not.toBeInTheDocument(); + }); + + it('externalError 优先级高于内部验证错误', () => { + render( + {}} + externalError="外部错误" + validateTrigger="onChange" + rules={[{ validator: (v) => v.length >= 3, message: '内部验证错误' }]} + />, + ); + + expect(screen.getByText('外部错误')).toBeInTheDocument(); + expect(screen.queryByText('内部验证错误')).not.toBeInTheDocument(); + }); + + it('externalError 清除后应显示内部验证错误', () => { + const { rerender } = render( + {}} + externalError="外部错误" + validateTrigger="onChange" + rules={[{ validator: (v) => v.length >= 3, message: '至少3个字符' }]} + />, + ); + + expect(screen.getByText('外部错误')).toBeInTheDocument(); + + rerender( + {}} + validateTrigger="onChange" + rules={[{ validator: (v) => v.length >= 3, message: '至少3个字符' }]} + />, + ); + + expect(screen.queryByText('外部错误')).not.toBeInTheDocument(); + + const textarea = screen.getByRole('textbox'); + fireEvent.change(textarea, { target: { value: 'a' } }); + + expect(screen.getByText('至少3个字符')).toBeInTheDocument(); + }); + }); + + describe('autoResize', () => { + it('autoResize=true 时设置 minRows/maxRows', () => { + const { container } = render( + {}} autoResize minRows={3} maxRows={8} />, + ); + + const textarea = container.querySelector('textarea'); + expect(textarea).toBeInTheDocument(); + }); + + it('autoResize=false 时设置固定 rows', () => { + const { container } = render( + {}} autoResize={false} minRows={5} />, + ); + + const textarea = container.querySelector('textarea'); + expect(textarea).toBeInTheDocument(); + }); + }); + + describe('样式集成', () => { + it('应透传 className', () => { + const { container } = render( + {}} className="custom-class" />, + ); + + expect(container.firstChild).toHaveClass('custom-class'); + }); + + it('应透传 style', () => { + const { container } = render( + {}} style={{ marginTop: 10 }} />, + ); + + expect(container.firstChild).toHaveStyle({ marginTop: '10px' }); + }); + + it('应透传 sx 样式', () => { + const { container } = render( {}} sx={{ mb: 3 }} />); + + expect(container.firstChild).toHaveStyle({ marginBottom: '24px' }); + }); + }); +}); diff --git a/components/__tests__/ToolCard.test.tsx b/components/__tests__/ToolCard.test.tsx index 43942b7..492cec3 100644 --- a/components/__tests__/ToolCard.test.tsx +++ b/components/__tests__/ToolCard.test.tsx @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { fireEvent, render, screen } from '@testing-library/react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import ToolCard from '@/pages/Dashboard/ToolCard'; import AccessTimeIcon from '@mui/icons-material/AccessTime'; @@ -96,7 +97,7 @@ describe('ToolCard 组件', () => { expect(handleClick).toHaveBeenCalledTimes(1); }); - it('按 Enter 键时应调用 onClick', () => { + it('按 Enter 键时应调用 onClick', async () => { const handleClick = vi.fn(); render( { ); const button = screen.getByRole('button', { name: /键盘可触发/ }); - fireEvent.click(button); + await act(async () => { + button.focus(); + await userEvent.keyboard('{Enter}'); + }); expect(handleClick).toHaveBeenCalledTimes(1); }); diff --git a/components/__tests__/TopBar.test.tsx b/components/__tests__/TopBar.test.tsx index a2ea1a8..52b8a04 100644 --- a/components/__tests__/TopBar.test.tsx +++ b/components/__tests__/TopBar.test.tsx @@ -28,7 +28,6 @@ const mockRouterValue = { pageOrder: ['timestamp'] as PageType[], isLoaded: true, navigateTo: vi.fn(), - navigateLocal: vi.fn(), syncNavigation: vi.fn(), goBack: vi.fn(), setVisiblePages: vi.fn(), diff --git a/config/pageTheme.ts b/config/pageTheme.ts index 940578a..a4446db 100644 --- a/config/pageTheme.ts +++ b/config/pageTheme.ts @@ -118,34 +118,62 @@ export const timestampPageStyles = { mutedText: (theme: Theme) => alpha(theme.palette.primary.main, 0.4), resultBg: (theme: Theme) => alpha(theme.palette.primary.main, 0.05), buttonHover: (theme: Theme) => `0 8px 24px ${alpha(theme.palette.primary.main, 0.2)}`, - /** 模式切换器 (ToggleButtonGroup) 样式 */ - MODE_SWITCHER: { - width: '100%', - mb: 2.5, + /** 统一转换工作台外卡 */ + CONVERSION_CARD: { + p: 2.5, borderRadius: 4, - bgcolor: (theme: Theme) => (theme.palette.mode === 'light' ? 'grey.100' : 'grey.900'), + bgcolor: 'background.paper', border: '1px solid', borderColor: 'divider', - p: 0.6, - '& .MuiToggleButtonGroup-grouped': { - flex: 1, - border: 'none', - borderRadius: 3.5, - py: 1, - fontWeight: 800, - fontSize: '0.75rem', - color: 'text.secondary', - transition: 'color 0.3s', - '&.Mui-selected': { - bgcolor: 'background.paper', - color: 'primary.main', - boxShadow: '0 4px 12px rgba(0,0,0,0.05)', - }, - }, + boxShadow: '0 4px 16px rgba(0,0,0,0.04)', + }, + /** 桌面端左右分栏布局 (md 断点开始等宽分栏,两栏卡片等高) */ + LAYOUT_GRID: { + display: 'grid', + gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' }, + gap: 2, + alignItems: 'stretch', + }, + /** 右栏结果卡片(独立卡片样式,与左栏等高) */ + RESULT_COLUMN_CARD: { + p: 2.5, + borderRadius: 4, + bgcolor: 'background.paper', + border: '1px solid', + borderColor: 'divider', + boxShadow: '0 4px 16px rgba(0,0,0,0.04)', + height: '100%', + display: 'flex', + flexDirection: 'column', + }, + /** 结果区空状态占位(桌面端右栏未转换时) */ + RESULT_EMPTY_PLACEHOLDER: { + flex: 1, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + color: 'text.disabled', + fontSize: '0.85rem', + fontWeight: 600, + py: 6, + textAlign: 'center', + }, + /** 立即转换按钮(缩小+居中,融入卡片) */ + CONVERT_BUTTON: { + display: 'block', + mx: 'auto', + mt: 2, + mb: 0.5, + maxWidth: 240, + width: '100%', + py: 1.1, + fontSize: '0.85rem', + borderRadius: 3, }, /** 单位切换器样式 */ UNIT_SWITCHER_CONTAINER: { - flex: 1, + flexShrink: 0, + width: 160, display: 'flex', bgcolor: 'action.hover', p: 0.5, @@ -156,6 +184,9 @@ export const timestampPageStyles = { UNIT_SWITCHER_ITEM: (active: boolean) => ({ flex: 1, py: 0.8, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', textAlign: 'center', borderRadius: 3, cursor: 'pointer', @@ -166,66 +197,44 @@ export const timestampPageStyles = { color: active ? 'primary.main' : 'text.disabled', boxShadow: active ? '0 2px 8px rgba(0,0,0,0.05)' : 'none', }), - /** LiveClock 卡片样式 */ + /** LiveClock 参考条样式(瘦身为单行) */ LIVE_CLOCK_CARD: (theme: Theme) => ({ display: 'flex', alignItems: 'center', - justifyContent: 'space-between', - flexWrap: 'wrap', gap: 1.5, - p: 1.8, - mb: 2.5, + px: 1.6, + py: 0.8, + mb: 2, bgcolor: alpha(theme.palette.primary.main, 0.04), - borderRadius: 4, + borderRadius: 3, border: '1px solid', borderColor: alpha(theme.palette.primary.main, 0.1), }), LIVE_CLOCK_LABEL: { color: 'primary.main', fontWeight: 800, - fontSize: '0.6rem', + fontSize: '0.65rem', textTransform: 'uppercase', letterSpacing: 1, + whiteSpace: 'nowrap', }, LIVE_CLOCK_VALUE: { + flex: 1, fontWeight: 800, color: 'primary.main', fontFamily: 'monospace', - fontSize: { xs: '1.1rem', sm: '1.2rem' }, + fontSize: '0.95rem', letterSpacing: '-0.5px', lineHeight: 1.2, + overflow: 'hidden', + textOverflow: 'ellipsis', }, - LIVE_CLOCK_UNIT_SWITCHER: (theme: Theme) => ({ - display: 'flex', - p: 0.4, - bgcolor: alpha(theme.palette.primary.main, 0.08), - borderRadius: 2.5, - border: '1px solid', - borderColor: alpha(theme.palette.primary.main, 0.1), - }), - LIVE_CLOCK_UNIT_ITEM: (active: boolean) => (theme: Theme) => ({ - px: { xs: 1, sm: 1.2 }, - py: 0.35, - borderRadius: 2, - cursor: 'pointer', - fontSize: '0.65rem', - fontWeight: 900, - transition: 'all 0.2s', - bgcolor: active ? 'background.paper' : 'transparent', - color: active ? 'primary.main' : alpha(theme.palette.primary.main, 0.4), - boxShadow: active ? '0 2px 6px rgba(33, 150, 243, 0.2)' : 'none', - }), LIVE_CLOCK_ICON_BUTTON: { color: 'primary.main', bgcolor: 'background.paper', boxShadow: '0 2px 4px rgba(0,0,0,0.05)', '&:hover': { bgcolor: 'primary.main', color: 'primary.contrastText' }, }, - LIVE_CLOCK_DIVIDER: { - mx: 0.5, - my: 1, - borderColor: (theme: Theme) => alpha(theme.palette.primary.main, 0.1), - }, /** ResultView 样式 */ RESULT_LABEL: { color: 'text.secondary', @@ -235,24 +244,26 @@ export const timestampPageStyles = { fontSize: '0.7rem', }, RESULT_MAIN_BOX: (theme: Theme) => ({ - bgcolor: alpha(theme.palette.primary.main, 0.05), - p: 2, + bgcolor: alpha(theme.palette.primary.main, 0.12), + p: 2.2, borderRadius: 4, position: 'relative', - mb: 2.5, + mb: 2, border: '1px solid', - borderColor: alpha(theme.palette.primary.main, 0.1), + borderColor: alpha(theme.palette.primary.main, 0.2), display: 'flex', justifyContent: 'space-between', alignItems: 'center', }), RESULT_MAIN_TEXT: { fontFamily: 'monospace', - fontWeight: 700, + fontWeight: 800, color: 'primary.main', wordBreak: 'break-all', pr: 4, - fontSize: '1rem', + fontSize: '1.35rem', + letterSpacing: '-0.5px', + lineHeight: 1.2, }, RESULT_EXTRA_STACK: (theme: Theme) => ({ bgcolor: alpha(theme.palette.primary.main, 0.05), @@ -264,14 +275,17 @@ export const timestampPageStyles = { RESULT_EXTRA_LABEL: { color: 'text.disabled', fontWeight: 700, - fontSize: '0.65rem', - pr: 4, + fontSize: '0.7rem', + pr: 2, + whiteSpace: 'nowrap', }, RESULT_EXTRA_VALUE: { fontFamily: 'monospace', color: 'primary.main', fontWeight: 600, - fontSize: '0.65rem', + fontSize: '0.75rem', + wordBreak: 'break-all', + textAlign: 'right', }, } as const; @@ -546,6 +560,74 @@ export const storageCleanerPageStyles = { */ export const qrCodePageStyles = { primaryColor: THEME_COLORS.success, + /** 桌面端左右分栏布局 (md 断点开始等宽分栏,两栏卡片等高) */ + LAYOUT_GRID: { + display: 'grid', + gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' }, + gap: 2, + alignItems: 'stretch', + }, + /** 桌面端 grid item 包装:撑满 grid row 并把高度传给 Accordion */ + GRID_CELL: { + display: 'flex', + flexDirection: 'column', + height: '100%', + '& > .MuiAccordion-root': { + flex: 1, + display: 'flex', + flexDirection: 'column', + }, + } as const, + /** 桌面端 Accordion 强展开样式:隐藏箭头,禁用 hover/cursor,等高填充 */ + ACCORDION_DESKTOP: { + borderRadius: 4, + boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)', + height: '100%', + display: 'flex', + flexDirection: 'column', + overflow: 'hidden', + '&:before': { display: 'none' }, + '& .MuiAccordionSummary-root': { + cursor: 'default', + }, + '& .MuiAccordionSummary-expandIconWrapper': { + display: 'none', + }, + // 让 Collapse 整条链都 flex 撑满,否则 Details 拿不到剩余高度 + '& .MuiCollapse-root': { + flex: 1, + display: 'flex', + flexDirection: 'column', + }, + '& .MuiCollapse-wrapper': { + flex: 1, + display: 'flex', + flexDirection: 'column', + }, + '& .MuiCollapse-wrapperInner': { + flex: 1, + display: 'flex', + flexDirection: 'column', + }, + '& .MuiAccordion-region': { + flex: 1, + display: 'flex', + flexDirection: 'column', + }, + '& .MuiAccordionDetails-root': { + flex: 1, + display: 'flex', + flexDirection: 'column', + }, + '& .MuiAccordionDetails-root > .MuiStack-root': { + flex: 1, + }, + '& .qr-flex-grow': { + flex: 1, + display: 'flex', + flexDirection: 'column', + }, + } as const, /** 加载状态容器 */ LOADING_CONTAINER: { py: 4, @@ -559,6 +641,7 @@ export const qrCodePageStyles = { ACCORDION: { borderRadius: 4, boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)', + overflow: 'hidden', '&:before': { display: 'none' }, } as const, ACCORDION_SUMMARY: { @@ -702,6 +785,7 @@ export const dashboardPageStyles = { xs: '1fr', sm: 'repeat(auto-fill, minmax(300px, 1fr))', }, + gridAutoRows: '1fr', gap: 2, p: 2, }, @@ -743,38 +827,6 @@ export const textStatisticsPageStyles = { cardBorder: (theme: Theme) => alpha(theme.palette.secondary.main, 0.1), } as const; -/** - * TopBar 组件样式 - */ -export const topBarStyles = { - SEARCH_MAX_WIDTH: 400, - DROPDOWN_MAX_HEIGHT: 300, - Z_INDEX: 1100, - DROPDOWN_Z_INDEX: 1200, - SEARCH_HISTORY_LIMIT: 10, - SEARCH_HISTORY_DISPLAY: 5, -} as const; - -/** - * JWT 解析工具页面样式 - */ -export const jwtPageStyles = { - primaryColor: THEME_COLORS.indigo, - cardBg: (theme: Theme) => alpha(theme.palette.info.main, 0.04), - cardBorder: (theme: Theme) => alpha(theme.palette.info.main, 0.1), - INPUT_STYLE: { - '& .MuiOutlinedInput-root': { - bgcolor: 'background.paper', - borderRadius: 4, - fontSize: '0.85rem', - fontFamily: 'monospace', - transition: 'all 0.2s', - '&:hover': { bgcolor: 'action.hover' }, - '&.Mui-focused': { bgcolor: 'background.paper' }, - }, - }, -} as const; - /** * Base64 转换器页面样式 */ @@ -791,28 +843,6 @@ export const markdownToHtmlPageStyles = { primaryColor: THEME_COLORS.purple, cardBg: (theme: Theme) => alpha(theme.palette.secondary.main, 0.04), cardBorder: (theme: Theme) => alpha(theme.palette.secondary.main, 0.1), - MODE_SWITCHER: { - borderRadius: 4, - bgcolor: (theme: Theme) => (theme.palette.mode === 'light' ? 'grey.100' : 'grey.900'), - border: '1px solid', - borderColor: 'divider', - p: 0.6, - '& .MuiToggleButtonGroup-grouped': { - border: 'none', - borderRadius: 3.5, - py: 0.8, - px: 1.5, - fontWeight: 700, - fontSize: '0.75rem', - color: 'text.secondary', - transition: 'color 0.3s', - '&.Mui-selected': { - bgcolor: 'background.paper', - color: 'primary.main', - boxShadow: '0 4px 12px rgba(0,0,0,0.05)', - }, - }, - }, } as const; /** @@ -822,28 +852,6 @@ export const htmlToMarkdownPageStyles = { primaryColor: THEME_COLORS.purple, cardBg: (theme: Theme) => alpha(theme.palette.secondary.main, 0.04), cardBorder: (theme: Theme) => alpha(theme.palette.secondary.main, 0.1), - MODE_SWITCHER: { - borderRadius: 4, - bgcolor: (theme: Theme) => (theme.palette.mode === 'light' ? 'grey.100' : 'grey.900'), - border: '1px solid', - borderColor: 'divider', - p: 0.6, - '& .MuiToggleButtonGroup-grouped': { - border: 'none', - borderRadius: 3.5, - py: 0.8, - px: 1.5, - fontWeight: 700, - fontSize: '0.75rem', - color: 'text.secondary', - transition: 'color 0.3s', - '&.Mui-selected': { - bgcolor: 'background.paper', - color: 'primary.main', - boxShadow: '0 4px 12px rgba(0,0,0,0.05)', - }, - }, - }, } as const; /** diff --git a/docs/superpowers/specs/2026-05-13-page-error-boundary-design.md b/docs/superpowers/specs/2026-05-13-page-error-boundary-design.md new file mode 100644 index 0000000..e90df6d --- /dev/null +++ b/docs/superpowers/specs/2026-05-13-page-error-boundary-design.md @@ -0,0 +1,135 @@ +# 为懒加载页面组件添加独立 ErrorBoundary 保护 — 设计文档 + +- 日期:2026-05-13 +- 范围:popup / sidepanel / tab / options 入口下的页面错误隔离 + +## 背景与目标 + +当前应用通过 `config/features.tsx` 中的 `React.lazy()` 懒加载所有页面组件。`RouterContainer` 使用 `Suspense` 包裹动态组件,而错误边界仅在外层 `entrypoints/popup/App.tsx`、`entrypoints/sidepanel/App.tsx` 中包裹 `RouterContainer`、以及 `entrypoints/options/App.tsx` 顶层。 + +问题:单个懒加载页面在加载或渲染时一旦抛错,错误会冒泡到全局 `ErrorBoundary`,触发全屏错误 UI,整个路由容器和 TopBar 一起被替换。用户必须刷新页面才能继续使用其他工具,体验受损。 + +目标:将错误影响范围限制在当前页面区域;其他页面、TopBar、导航、Snackbar 不受影响;用户可在错误状态下切换到其他工具或重试当前页。 + +## 设计概要 + +新增 `PageErrorBoundary` 组件,专门用于页面级错误隔离,在 `RouterContainer` 的 `Suspense` 内层使用;`options/App.tsx` 也替换为同款页面级边界。现有全局 `ErrorBoundary` 保留作为兜底,覆盖 TopBar / Snackbar / RouterProvider 等同级组件。 + +## 组件设计 + +### `components/PageErrorBoundary.tsx`(新增) + +复用现有 `ErrorBoundary` 的错误捕获机制(`getDerivedStateFromError` + `componentDidCatch`),但具备以下差异: + +- **轻量内嵌 UI**:使用 `Paper` + 居中文本,去除全屏 `Container` + `mt:8` 布局,适配 popup 400×600 与 sidepanel 等窄屏环境。结构: + - 图标 (`ErrorOutlineIcon`) + - 标题:"该页面加载失败" + - 副标题:"页面在加载或渲染时遇到错误,您可以重试或切换到其他工具。" + - 折叠错误信息块(沿用现有错误展示样式,使用 monospace、可滚动) + - 主操作按钮:"重试"(`RefreshIcon`)—— 重置内部 state,让子树重新挂载 +- **`resetKey` prop**:可选;当 `resetKey` 在 `componentDidUpdate` 中变化时,自动重置 `hasError` / `error`,无需用户手动操作 +- **`componentDidCatch`**:仍使用 `console.error('Uncaught error in page:', error, errorInfo)` 输出,不引入额外上报 + +接口: +```ts +interface PageErrorBoundaryProps { + children: ReactNode; + resetKey?: string | number; // 变化时自动重置 +} +``` + +### `components/ErrorBoundary.tsx`(不变) + +保留作为全局兜底。负责捕获 TopBar、SnackbarProvider、RouterProvider 等同级组件中可能出现的错误,沿用全屏 `Container` 样式与"刷新应用"操作。 + +## 集成点 + +### `components/RouterContainer.tsx`(修改) + +在 `Suspense` 内层插入 `PageErrorBoundary`,传入 `resetKey={currentPage}`: + +```tsx +}> + + {Component && } + + +``` + +说明: +- `PageErrorBoundary` 置于 `Suspense` 内部,可同时捕获懒加载 chunk 加载失败(异步异常)与页面渲染期同步错误 +- `resetKey={currentPage}` 使页面切换时自动清除错误状态,无需用户干预 +- 保留外层 `Box key={currentPage}` 与动画 className,不改变页面切换语义 + +### `entrypoints/options/App.tsx`(修改) + +将顶层 `` 替换为 ``。options 是单页应用,统一使用页面级错误卡片即可。 + +### `entrypoints/popup/App.tsx`、`entrypoints/sidepanel/App.tsx`(不变) + +保留外层 `` 包裹 ``,作为同级组件(TopBar 等)的兜底。`PageErrorBoundary` 与全局 `ErrorBoundary` 各司其职: + +- 页面级(`PageErrorBoundary`):捕获懒加载页面内部错误,隔离影响范围,仅替换页面区域 +- 全局(`ErrorBoundary`):捕获 RouterContainer 自身、TopBar、Snackbar 等组件错误,作为最后兜底 + +## 数据流 / 错误处理 + +### 捕获路径 +- 懒加载 chunk 加载失败(网络 / CSP / chunk 缺失) → `Suspense` 内部 promise reject → `PageErrorBoundary` 捕获 +- 页面渲染期同步错误(组件抛错、null 引用等) → `PageErrorBoundary` 捕获 +- 事件回调或 Promise 中的异步错误 → React 错误边界不捕获(固有行为,本次不处理) + +### 恢复路径 +- **页面切换自动重置**:用户从错误页切换到其他页面 → `currentPage` 变化 → `resetKey` 变化 → `PageErrorBoundary.componentDidUpdate` 重置 → 新页面正常渲染 +- **当前页重试**:用户点击"重试" → 内部 state 重置 → 子树重新挂载 → React 重新触发 `lazy()` 加载(懒加载失败时也会重新发起 `import()`) +- **持续错误**:若 `lazy()` chunk 始终无法加载(例如永久 404),重试会再次显示错误卡片;用户可切换到其他页面继续使用其他工具 + +### 日志 +沿用 `console.error`;不引入 Sentry / 外部上报。 + +## 测试策略 + +### 新增 `components/__tests__/PageErrorBoundary.test.tsx` + +1. 正常渲染:子组件正常渲染时,输出原始 children +2. 同步错误捕获:子组件抛错时,显示错误卡片(标题"该页面加载失败"、错误信息) +3. 重试按钮恢复:错误状态下,将 children 替换为正常组件,点击"重试"按钮,重置状态并显示正常内容 +4. `resetKey` 变化自动重置:错误状态下 `resetKey` 变化时,自动清空错误并渲染新 children +5. `resetKey` 不变保持错误:children 变化但 `resetKey` 未变化时,保持错误状态(避免误重置) +6. 错误信息显示:错误的 `toString()` 内容能在 UI 中可见 + +### 新增 `components/__tests__/RouterContainer.test.tsx` + +- 通过 mock `useRouter` 与 `FEATURES`,注入一个会抛错的懒加载组件,验证 `PageErrorBoundary` 捕获错误且 TopBar / 父容器 DOM 仍存在 +- 切换 `currentPage`(重新触发 hook 返回值)后验证错误自动清除、新页面正常渲染 + +### 不变 +- `components/__tests__/ErrorBoundary.test.tsx` 不需改动(全局边界行为未变) + +### 风格 +遵循现有 `ErrorBoundary.test.tsx` 模式:`vi.spyOn(console, 'error')` 抑制噪声 + `@testing-library/react` 的 `render` + `screen.getByText` 断言。 + +## 文件清单 + +**新增:** +- `components/PageErrorBoundary.tsx` +- `components/__tests__/PageErrorBoundary.test.tsx` +- `components/__tests__/RouterContainer.test.tsx` + +**修改:** +- `components/RouterContainer.tsx` — 在 `Suspense` 内层包裹 `PageErrorBoundary resetKey={currentPage}` +- `entrypoints/options/App.tsx` — 将 `` 替换为 `` + +**不变:** +- `components/ErrorBoundary.tsx` +- `components/__tests__/ErrorBoundary.test.tsx` +- `entrypoints/popup/App.tsx`、`entrypoints/sidepanel/App.tsx` +- `config/features.tsx` + +## 验收标准 + +1. 单页面在懒加载或渲染时抛错,仅当前页面区域显示错误卡片,TopBar 与导航仍可点击 +2. 在错误状态下切换到其他工具,新页面能正常加载与显示 +3. 点击错误卡片中的"重试"按钮,子树重新挂载并重新触发懒加载 +4. 全局 `ErrorBoundary` 仍能捕获 TopBar / Snackbar 等同级组件的错误 +5. 所有新增测试通过;现有 `ErrorBoundary` 测试不受影响;`npm run compile`、`npm run lint`、`npm run test` 均通过 diff --git a/entrypoints/background.ts b/entrypoints/background.ts index cd2243c..549ca01 100644 --- a/entrypoints/background.ts +++ b/entrypoints/background.ts @@ -19,7 +19,9 @@ export default defineBackground(() => { const { tabId, delay = 0 } = message.data; const executeReload = () => { - browser.tabs.reload(tabId); + browser.tabs.reload(tabId).catch((err) => { + console.error('Failed to reload tab:', err); + }); }; if (delay > 0) { diff --git a/entrypoints/options/App.tsx b/entrypoints/options/App.tsx index 4c66d17..8b83389 100644 --- a/entrypoints/options/App.tsx +++ b/entrypoints/options/App.tsx @@ -2,42 +2,208 @@ import { SyntheticEvent, useEffect, useMemo, useState } from 'react'; import { alpha, Box, - Button, CircularProgress, - Divider, IconButton, Paper, Stack, Switch, Tab, Tabs, + Tooltip, Typography, } from '@mui/material'; import SettingsIcon from '@mui/icons-material/Settings'; import RefreshIcon from '@mui/icons-material/Refresh'; -import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp'; -import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; +import { + DndContext, + closestCenter, + PointerSensor, + KeyboardSensor, + useSensor, + useSensors, + type DragEndEvent, +} from '@dnd-kit/core'; +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; import type { PageType, StorageSchema } from '@/types/storage'; import { storageUtil } from '@/utils/chromeStorage'; import { + getAllFeatureKeys, getDefaultPageOrder, getDefaultVisibleFeatureKeys, getFeatureByKey, } from '@/config/features'; import GlobalSnackbar, { useSnackbarState } from '@/components/GlobalSnackbar'; -import ErrorBoundary from '@/components/ErrorBoundary'; +import PageErrorBoundary from '@/components/PageErrorBoundary'; import PageHeader from '@/components/PageHeader'; import { useTheme, type PaletteColor, type Theme } from '@mui/material/styles'; import { useTranslation } from 'react-i18next'; import type { PaletteColorKey } from '@/config/features'; -/** 安全地从 theme.palette 中取 PaletteColor */ const getPaletteColor = (theme: Theme, key: PaletteColorKey): PaletteColor => (theme.palette as unknown as Record)[key]; +const isValidPage = (page: unknown): page is PageType => { + return typeof page === 'string' && (getAllFeatureKeys() as string[]).includes(page); +}; + +const isValidPageList = (pages: unknown): pages is PageType[] => { + return Array.isArray(pages) && pages.every(isValidPage); +}; + type WindowType = 'popup' | 'sidepanel' | 'tab'; +interface SortableFeatureRowProps { + pageKey: PageType; + isLast: boolean; + isChecked: boolean; + isDisabled: boolean; + onToggle: (key: PageType) => void; +} + +function SortableFeatureRow({ + pageKey, + isLast, + isChecked, + isDisabled, + onToggle, +}: SortableFeatureRowProps) { + const theme = useTheme(); + const { t } = useTranslation(['features']); + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: pageKey, + }); + + const feature = getFeatureByKey(pageKey); + if (!feature) return null; + + const colorKey = feature.themeColorKey ?? 'primary'; + const colorCode = getPaletteColor(theme, colorKey).main; + + const style = { + transform: CSS.Transform.toString(transform), + transition, + zIndex: isDragging ? 1 : 'auto', + position: 'relative' as const, + }; + + return ( + + + {/* 拖拽手柄 - 整行可拖,手柄是视觉暗示 */} + + + + + {/* 功能图标 */} + + {feature.icon} + + + {/* 文本信息 */} + + + {t(feature.labelKey)} + + {feature.descriptionKey && ( + + + {t(feature.descriptionKey)} + + + )} + + + + onToggle(pageKey)} + disabled={isDisabled} + /> + + ); +} + /** * Options 设置页面主组件 * 支持对不同窗口入口的功能显示和排序进行独立配置 @@ -45,7 +211,7 @@ type WindowType = 'popup' | 'sidepanel' | 'tab'; export default function App() { const theme = useTheme(); const { t } = useTranslation(['features', 'common']); - // 从 URL 参数中初始化当前的 Tab 类型 + const initialWindowType = useMemo(() => { if (typeof window === 'undefined') return 'popup'; const params = new URLSearchParams(window.location.search); @@ -62,7 +228,11 @@ export default function App() { const [isLoaded, setIsLoaded] = useState(false); const { snackbarProps, showMessage } = useSnackbarState(); - // 根据当前选择的窗口类型确定对应的 Storage Key + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 4 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ); + const configKeys = useMemo(() => { switch (windowType) { case 'sidepanel': @@ -84,14 +254,12 @@ export default function App() { } }, [windowType]); - // 当 windowType 改变时,同步更新 URL 参数 useEffect(() => { const url = new URL(window.location.href); url.searchParams.set('tab', windowType); window.history.replaceState({}, '', url.toString()); }, [windowType]); - // 加载配置数据 useEffect(() => { const loadConfig = async () => { setIsLoaded(false); @@ -100,8 +268,10 @@ export default function App() { storageUtil.get(configKeys.visible, getDefaultVisibleFeatureKeys()), storageUtil.get(configKeys.order, getDefaultPageOrder()), ]); - setVisiblePages((savedVisible as PageType[]) ?? getDefaultVisibleFeatureKeys()); - setPageOrder((savedOrder as PageType[]) ?? getDefaultPageOrder()); + setVisiblePages( + isValidPageList(savedVisible) ? savedVisible : getDefaultVisibleFeatureKeys(), + ); + setPageOrder(isValidPageList(savedOrder) ? savedOrder : getDefaultPageOrder()); } catch (error) { console.error('Failed to load config:', error); setVisiblePages(getDefaultVisibleFeatureKeys()); @@ -114,9 +284,10 @@ export default function App() { loadConfig().catch(console.error); }, [configKeys]); - /** - * 切换页面可见性 - */ + const showToast = (message: string, severity: 'success' | 'info' | 'warning') => { + showMessage(message, { severity }); + }; + const handlePageToggle = async (page: PageType) => { const isCurrentlyVisible = visiblePages.includes(page); let newPages: PageType[]; @@ -143,29 +314,25 @@ export default function App() { } }; - /** - * 调整页面显示顺序 - */ - const handleMove = async (index: number, direction: 'up' | 'down') => { - if (direction === 'up' && index === 0) return; - if (direction === 'down' && index === pageOrder.length - 1) return; + const handleDragEnd = async (event: DragEndEvent) => { + const { active, over } = event; + if (!over || active.id === over.id) return; - const newOrder = [...pageOrder]; - const swapIndex = direction === 'up' ? index - 1 : index + 1; - [newOrder[index], newOrder[swapIndex]] = [newOrder[swapIndex], newOrder[index]]; + const oldIndex = pageOrder.indexOf(active.id as PageType); + const newIndex = pageOrder.indexOf(over.id as PageType); + if (oldIndex < 0 || newIndex < 0) return; + + const newOrder = arrayMove(pageOrder, oldIndex, newIndex); + setPageOrder(newOrder); try { await storageUtil.set(configKeys.order, newOrder); - setPageOrder(newOrder); } catch (error) { console.error('Failed to save order:', error); showToast('排序保存失败', 'warning'); } }; - /** - * 恢复默认设置 - */ const handleRestoreDefaults = async () => { try { const defaults = getDefaultVisibleFeatureKeys(); @@ -191,10 +358,6 @@ export default function App() { } }; - const showToast = (message: string, severity: 'success' | 'info' | 'warning') => { - showMessage(message, { severity }); - }; - if (!isLoaded) { return ( - + {/* 顶部标题与导航栏 */} - - - - - + + + + + + + + + + + {/* 主内容区域 */} - - - - - {!isLoaded ? ( - - - - ) : ( - - - {pageOrder.map((key, index, array) => { - const feature = getFeatureByKey(key); - if (!feature) return null; - - const isChecked = visiblePages.includes(key); - const isDisabled = isChecked && visiblePages.length === 1; - - return ( - - - {/* 拖拽/排序暗示图标 */} - - - - - {/* 功能图标容器 */} - - {feature.icon} - - - {/* 文本信息 */} - - - {t(feature.labelKey)} - - - {feature.descriptionKey ? t(feature.descriptionKey) : '暂无描述'} - - - - - - {/* 移动操作按钮 */} - - handleMove(index, 'up')} - disabled={index === 0} - sx={{ - color: 'text.disabled', - p: { xs: 0.5, sm: 1 }, - '&:hover': { - color: 'primary.main', - bgcolor: alpha(theme.palette.primary.main, 0.08), - }, - }} - > - - - handleMove(index, 'down')} - disabled={index === array.length - 1} - sx={{ - color: 'text.disabled', - p: { xs: 0.5, sm: 1 }, - '&:hover': { - color: 'primary.main', - bgcolor: alpha(theme.palette.primary.main, 0.08), - }, - }} - > - - - - - - - {/* 显示切换开关 */} - handlePageToggle(key)} - disabled={isDisabled} - sx={{ - '& .MuiSwitch-switchBase.Mui-checked': { - color: getPaletteColor(theme, feature.themeColorKey ?? 'primary') - .main, - }, - '& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { - backgroundColor: getPaletteColor( - theme, - feature.themeColorKey ?? 'primary', - ).main, - }, - }} - /> - - - ); - })} - - - )} + + + {pageOrder.map((key, index, array) => { + const isChecked = visiblePages.includes(key); + const isDisabled = isChecked && visiblePages.length === 1; + return ( + + ); + })} + + + + - + diff --git a/entrypoints/options/__tests__/App.test.tsx b/entrypoints/options/__tests__/App.test.tsx new file mode 100644 index 0000000..fb26771 --- /dev/null +++ b/entrypoints/options/__tests__/App.test.tsx @@ -0,0 +1,131 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import App from '../App'; +import { storageUtil } from '@/utils/chromeStorage'; +import { + getDefaultVisibleFeatureKeys, + getDefaultPageOrder, + getFeatureByKey, +} from '@/config/features'; + +// Mock storageUtil +vi.mock('@/utils/chromeStorage', () => ({ + storageUtil: { + get: vi.fn(), + set: vi.fn(() => Promise.resolve()), + }, +})); + +// Mock i18next +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +describe('Options App', () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + }); + + it('合法数据应正常加载并显示正确的功能列表', async () => { + const defaultOrder = getDefaultPageOrder(); + + (storageUtil.get as any).mockImplementation((_key: string, defaultValue: any) => + Promise.resolve(defaultValue), + ); + + render(); + + await waitFor(() => { + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); + }); + + // 验证默认可见的功能都被渲染出来了 + for (const key of defaultOrder) { + const feature = getFeatureByKey(key); + if (feature) { + expect(screen.getByText(feature.labelKey)).toBeInTheDocument(); + } + } + }); + + it('非法 visiblePages 数据应回退到默认值', async () => { + const defaultVisible = getDefaultVisibleFeatureKeys(); + + (storageUtil.get as any).mockImplementation((key: string, defaultValue: any) => { + if (key.includes('VisiblePages')) { + return Promise.resolve(['invalidPage', 'anotherInvalid']); + } + return Promise.resolve(defaultValue); + }); + + render(); + + await waitFor(() => { + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); + }); + + // 验证默认值的功能仍然被渲染(非法数据被回退) + for (const key of defaultVisible) { + const feature = getFeatureByKey(key); + if (feature && key !== 'dashboard') { + expect(screen.getByText(feature.labelKey)).toBeInTheDocument(); + } + } + }); + + it('非法 pageOrder 数据应回退到默认值', async () => { + const defaultOrder = getDefaultPageOrder(); + + (storageUtil.get as any).mockImplementation((key: string, defaultValue: any) => { + if (key.includes('PageOrder')) { + return Promise.resolve(['notARealPage', 123, null]); + } + return Promise.resolve(defaultValue); + }); + + render(); + + await waitFor(() => { + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); + }); + + // 验证默认顺序的功能都被渲染 + for (const key of defaultOrder) { + const feature = getFeatureByKey(key); + if (feature) { + expect(screen.getByText(feature.labelKey)).toBeInTheDocument(); + } + } + }); + + it('非数组数据应回退到默认值', async () => { + const defaultOrder = getDefaultPageOrder(); + + (storageUtil.get as any).mockImplementation((key: string, defaultValue: any) => { + if (key.includes('VisiblePages')) { + return Promise.resolve('not-an-array'); + } + if (key.includes('PageOrder')) { + return Promise.resolve({ foo: 'bar' }); + } + return Promise.resolve(defaultValue); + }); + + render(); + + await waitFor(() => { + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); + }); + + // 验证默认功能都被渲染 + for (const key of defaultOrder) { + const feature = getFeatureByKey(key); + if (feature) { + expect(screen.getByText(feature.labelKey)).toBeInTheDocument(); + } + } + }); +}); diff --git a/entrypoints/sidepanel/App.tsx b/entrypoints/sidepanel/App.tsx index 5ac02f1..634ca13 100644 --- a/entrypoints/sidepanel/App.tsx +++ b/entrypoints/sidepanel/App.tsx @@ -9,7 +9,9 @@ import { Box } from '@mui/material'; export default function App() { const handleOpenOptions = () => { - chrome.runtime.openOptionsPage(); + chrome.runtime.openOptionsPage().catch((err) => { + console.error('Failed to open options page:', err); + }); }; // 通知侧边栏已打开 diff --git a/eslint.config.ts b/eslint.config.ts index 0c6f39e..fbefb50 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -6,19 +6,20 @@ import globals from 'globals'; export default [ { - ignores: [ - 'dist', - '.output', - '.wxt', - 'node_modules', - 'eslint.config.ts', - '**/*.test.tsx', - '**/*.test.ts', - '**/__tests__/**', - ], + ignores: ['dist', '.output', '.wxt', 'node_modules', 'eslint.config.ts'], }, js.configs.recommended, ...tseslint.configs.recommended, + { + files: ['**/__tests__/**', '**/*.test.{ts,tsx}'], + rules: { + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unused-vars': [ + 'warn', + { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, + ], + }, + }, { files: [ 'hooks/**/*.{ts,tsx}', @@ -28,6 +29,7 @@ export default [ 'components/**/*.{ts,tsx}', 'services/**/*.{ts,tsx}', ], + ignores: ['**/__tests__/**', '**/*.test.{ts,tsx}'], languageOptions: { ecmaVersion: 2020, globals: { diff --git a/i18n/locales/en/base64Converter.json b/i18n/locales/en/base64Converter.json index b1066a2..0a59008 100644 --- a/i18n/locales/en/base64Converter.json +++ b/i18n/locales/en/base64Converter.json @@ -1,6 +1,6 @@ { "pageTitle": "Base64 Converter", - "pageSubtitle": "Encode text, files, and images to Base64", + "pageSubtitle": "Encode and decode text, files, and images with Base64", "textMode": "Text", "fileMode": "File", "imageMode": "Image", @@ -22,5 +22,16 @@ "unsupportedImageType": "Unsupported image format", "conversionFailed": "Conversion failed", "originalSize": "Original Size", - "encodedSize": "Encoded Size" + "encodedSize": "Encoded Size", + "invalidBase64": "Invalid Base64 string", + "binaryDataDetected": "Input appears to be binary data (e.g. an image). Please switch to the Image tab.", + "imageDataUriHint": "Detected an image data URI — please use the Image tab to decode it.", + "switchToImageMode": "Switch to Image mode", + "download": "Download", + "decodedFileName": "Decoded file name", + "decodeBase64Placeholder": "Enter Base64 or data URI to decode...", + "decodedFileOutput": "Decoded File", + "decodedImageOutput": "Decoded Image", + "inferredMimeType": "Inferred MIME type", + "decodedSize": "Decoded size" } diff --git a/i18n/locales/en/common.json b/i18n/locales/en/common.json index 9db3fa4..4ddd562 100644 --- a/i18n/locales/en/common.json +++ b/i18n/locales/en/common.json @@ -25,5 +25,11 @@ "messages": { "copySuccess": "Copied to clipboard", "copyError": "Copy failed" + }, + "textInputArea": { + "clear": "Clear", + "copyContent": "Copy content", + "cleared": "Cleared", + "placeholder": "placeholder text" } } diff --git a/i18n/locales/en/jwt.json b/i18n/locales/en/jwt.json index 8e5f682..aec555f 100644 --- a/i18n/locales/en/jwt.json +++ b/i18n/locales/en/jwt.json @@ -6,5 +6,12 @@ "payloadTitle": "PAYLOAD: Data", "signatureTitle": "Signature", "noSignature": "No Signature", - "invalidFormat": "Unable to parse" + "invalidFormat": "Unable to parse", + "errors": { + "invalidBase64String": "Invalid base64url string", + "failedToDecode": "Failed to decode base64url: ", + "invalidFormat": "Invalid JWT format: expected 3 parts separated by .", + "parseHeaderFailed": "Failed to parse Header: ", + "parsePayloadFailed": "Failed to parse Payload: " + } } diff --git a/i18n/locales/en/timestamp.json b/i18n/locales/en/timestamp.json index 55351ab..2b2d4b8 100644 --- a/i18n/locales/en/timestamp.json +++ b/i18n/locales/en/timestamp.json @@ -18,6 +18,7 @@ "iso8601": "ISO 8601", "utcTime": "UTC Time", "copyTooltip": "Copy", + "resultEmpty": "Enter a value and click convert", "errors": { "invalidNumber": "Invalid number", "invalidTimestamp": "Invalid timestamp", diff --git a/i18n/locales/zh/base64Converter.json b/i18n/locales/zh/base64Converter.json index 285b640..1b58f73 100644 --- a/i18n/locales/zh/base64Converter.json +++ b/i18n/locales/zh/base64Converter.json @@ -1,6 +1,6 @@ { "pageTitle": "Base64 转换器", - "pageSubtitle": "文本、文件与图像的 Base64 编码转换", + "pageSubtitle": "文本、文件与图像的 Base64 编码与解码", "textMode": "文本", "fileMode": "文件", "imageMode": "图像", @@ -22,5 +22,16 @@ "unsupportedImageType": "不支持的图像格式", "conversionFailed": "转换失败", "originalSize": "原始大小", - "encodedSize": "编码大小" + "encodedSize": "编码大小", + "invalidBase64": "Base64 字符串无效", + "binaryDataDetected": "输入似乎是二进制数据(如图片)。请切换到「图像」选项卡。", + "imageDataUriHint": "检测到图片的 data URI,请使用「图像」选项卡进行解码。", + "switchToImageMode": "切换到图像模式", + "download": "下载", + "decodedFileName": "解码后文件名", + "decodeBase64Placeholder": "输入需要解码的 Base64 或 data URI...", + "decodedFileOutput": "解码文件", + "decodedImageOutput": "解码图像", + "inferredMimeType": "推断的 MIME 类型", + "decodedSize": "解码大小" } diff --git a/i18n/locales/zh/common.json b/i18n/locales/zh/common.json index 1caae28..cd26eca 100644 --- a/i18n/locales/zh/common.json +++ b/i18n/locales/zh/common.json @@ -25,5 +25,11 @@ "messages": { "copySuccess": "已复制到剪贴板", "copyError": "复制失败" + }, + "textInputArea": { + "clear": "清空", + "copyContent": "复制内容", + "cleared": "已清空", + "placeholder": "请输入文本" } } diff --git a/i18n/locales/zh/jwt.json b/i18n/locales/zh/jwt.json index 4424528..fed6481 100644 --- a/i18n/locales/zh/jwt.json +++ b/i18n/locales/zh/jwt.json @@ -6,5 +6,12 @@ "payloadTitle": "PAYLOAD: 数据", "signatureTitle": "签名", "noSignature": "无签名", - "invalidFormat": "无法解析" + "invalidFormat": "无法解析", + "errors": { + "invalidBase64String": "无效的 Base64URL 字符串", + "failedToDecode": "Base64URL 解码失败:", + "invalidFormat": "JWT 格式错误:必须包含三个由 . 分隔的部分", + "parseHeaderFailed": "解析 Header 失败:", + "parsePayloadFailed": "解析 Payload 失败:" + } } diff --git a/i18n/locales/zh/timestamp.json b/i18n/locales/zh/timestamp.json index aa1fede..e99b245 100644 --- a/i18n/locales/zh/timestamp.json +++ b/i18n/locales/zh/timestamp.json @@ -18,6 +18,7 @@ "iso8601": "ISO 8601", "utcTime": "UTC 时间", "copyTooltip": "复制", + "resultEmpty": "请输入并点击转换", "errors": { "invalidNumber": "无效数字", "invalidTimestamp": "无效时间戳", diff --git a/package-lock.json b/package-lock.json index 810c4e9..245c8d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,9 @@ "hasInstallScript": true, "license": "MIT", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", "@mui/icons-material": "^7.3.8", @@ -645,6 +648,59 @@ } } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://mirrors.cloud.tencent.com/npm/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://mirrors.cloud.tencent.com/npm/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://mirrors.cloud.tencent.com/npm/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://mirrors.cloud.tencent.com/npm/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@emotion/babel-plugin": { "version": "11.13.5", "resolved": "https://registry.npmmirror.com/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", @@ -10184,7 +10240,6 @@ "version": "2.8.1", "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, "license": "0BSD" }, "node_modules/type-check": { diff --git a/package.json b/package.json index f493c61..dfb9f42 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,6 @@ "build:firefox": "wxt build -b firefox", "zip": "wxt zip", "zip:firefox": "wxt zip -b firefox", - "compile": "tsc --noEmit", "postinstall": "wxt prepare", "prepare": "husky", "lint": "eslint . --max-warnings=0", @@ -22,6 +21,9 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", "@mui/icons-material": "^7.3.8", diff --git a/pages/Base64Converter/FileMode.tsx b/pages/Base64Converter/FileMode.tsx new file mode 100644 index 0000000..15d6ef2 --- /dev/null +++ b/pages/Base64Converter/FileMode.tsx @@ -0,0 +1,363 @@ +import { useCallback, useMemo, useRef, useState } from 'react'; +import { + Alert, + alpha, + Box, + Button, + CircularProgress, + Paper, + Stack, + Typography, +} from '@mui/material'; +import TextInputArea from '@/components/TextInputArea'; +import type { ToolbarAction } from '@/components/TextInputArea'; +import UploadFileIcon from '@mui/icons-material/UploadFile'; +import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; +import { useTranslation } from 'react-i18next'; +import CopyButton from '@/components/CopyButton'; +import DecodeResultPaper from '@/components/DecodeResultPaper'; +import { + fileToBase64, + isFileSizeValid, + formatFileSize, + base64ToBlob, + downloadBlob, + MAX_FILE_SIZE, +} from '@/utils/base64Converter'; +import type { Base64ToBlobResult, FileToBase64Result } from '@/utils/base64Converter'; +import { useStorageState } from '@/utils/useStorageState'; +import type { Base64ConvertDirection } from '@/types/storage'; +import SwitchButtonGroup from '@/components/SwitchButtonGroup'; + +interface FileInfo { + name: string; + size: number; + type: string; +} + +const ERROR_MESSAGE_TO_I18N: Record = { + 'Invalid Base64 string': 'invalidBase64', +}; + +const isValidDirection = (val: unknown): val is Base64ConvertDirection => + val === 'encode' || val === 'decode'; + +export default function FileMode() { + const { t } = useTranslation('base64Converter'); + const [direction, setDirection] = useStorageState( + 'base64Converter/fileMode/direction', + 'encode', + isValidDirection, + ); + + // encode state + const [result, setResult] = useState(null); + const [info, setInfo] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [isDragging, setIsDragging] = useState(false); + const fileInputRef = useRef(null); + const cancelRef = useRef(false); + + // decode state + const [decodeInput, setDecodeInput] = useState(''); + const [decoded, setDecoded] = useState(null); + const [decodedFileName, setDecodedFileName] = useState(''); + + // shared + const [error, setError] = useState(null); + + const resetAll = useCallback(() => { + cancelRef.current = true; + setResult(null); + setInfo(null); + setIsLoading(false); + setDecodeInput(''); + setDecoded(null); + setDecodedFileName(''); + setError(null); + if (fileInputRef.current) fileInputRef.current.value = ''; + }, []); + + const handleClear = () => { + resetAll(); + }; + + const handleDirectionChange = (next: Base64ConvertDirection) => { + if (next === direction) return; + resetAll(); + setDirection(next); + }; + + const handleFileSelect = async (file: File) => { + cancelRef.current = false; + setError(null); + setResult(null); + setInfo(null); + + if (!isFileSizeValid(file.size)) { + setError(t('fileSizeExceeded', { max: `${MAX_FILE_SIZE / 1024 / 1024} MB` })); + return; + } + + setInfo({ + name: file.name, + size: file.size, + type: file.type || 'application/octet-stream', + }); + setIsLoading(true); + + try { + const res = await fileToBase64(file); + if (!cancelRef.current) setResult(res); + } catch (e) { + if (!cancelRef.current) { + setError(e instanceof Error ? e.message : t('conversionFailed')); + } + } finally { + if (!cancelRef.current) setIsLoading(false); + } + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(true); + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + const file = e.dataTransfer.files[0]; + if (file) handleFileSelect(file); + }; + + const handleDownload = () => { + if (!decoded) return; + downloadBlob(decoded.blob, decodedFileName || `decoded${decoded.suggestedExtension}`); + }; + + const actions: ToolbarAction[] = useMemo( + () => [ + { + key: 'decode', + label: t('decode'), + type: 'primary', + position: 'bottom', + disabled: (value: string) => !value.trim(), + onClick: (value: string, helpers) => { + helpers.setError(''); + setDecoded(null); + try { + const res = base64ToBlob(value); + setDecoded(res); + setDecodedFileName(`decoded${res.suggestedExtension}`); + } catch (e) { + const message = e instanceof Error ? e.message : ''; + const i18nKey = ERROR_MESSAGE_TO_I18N[message]; + helpers.setError(i18nKey ? t(i18nKey) : message || t('conversionFailed')); + } + }, + }, + ], + [t], + ); + + return ( + <> + + + {direction === 'encode' && ( + <> + fileInputRef.current?.click()} + sx={{ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + minHeight: 180, + border: '2px dashed', + borderColor: isDragging ? 'info.main' : info ? 'info.main' : 'divider', + borderRadius: 3, + p: 4, + bgcolor: (theme) => + isDragging + ? alpha(theme.palette.info.main, 0.08) + : info + ? alpha(theme.palette.info.main, 0.04) + : 'action.hover', + cursor: 'pointer', + transition: 'all 0.2s', + '&:hover': { + borderColor: 'info.main', + bgcolor: (theme) => alpha(theme.palette.info.main, 0.04), + }, + }} + > + { + const file = e.target.files?.[0]; + if (file) handleFileSelect(file); + }} + /> + {isLoading ? ( + + ) : info ? ( + + + + {info.name} + + + {formatFileSize(info.size)} · {info.type} + + + {t('clickOrDropToReplace')} + + + ) : ( + + + + {t('clickOrDropToFile')} + + + {t('maxFileSize', { max: `${MAX_FILE_SIZE / 1024 / 1024} MB` })} + + + )} + + + {error && {error}} + + {result && ( + alpha(theme.palette.info.main, 0.04), + border: '1px solid', + borderColor: (theme) => alpha(theme.palette.info.main, 0.15), + }} + > + + + {t('base64Output')} + + + {}} + /> + {}} + /> + + + 2000 + ? `${result.output.substring(0, 2000)}...` + : result.output + } + showClear={false} + showCount + /> + + + {t('originalSize')}: {formatFileSize(result.originalBytes)} + + + {t('encodedSize')}: {formatFileSize(result.outputBytes)} + + + + + + )} + + {info && !result && ( + + )} + + )} + + {direction === 'decode' && ( + <> + { + setDecodeInput(v); + setError(null); + }} + actions={actions} + externalError={error || undefined} + onClear={() => { + setDecoded(null); + setDecodedFileName(''); + }} + /> + + {decoded && ( + + )} + + )} + + ); +} diff --git a/pages/Base64Converter/ImageMode.tsx b/pages/Base64Converter/ImageMode.tsx new file mode 100644 index 0000000..85c62bd --- /dev/null +++ b/pages/Base64Converter/ImageMode.tsx @@ -0,0 +1,391 @@ +import { useCallback, useMemo, useRef, useState } from 'react'; +import { + Alert, + alpha, + Box, + Button, + CircularProgress, + Paper, + Stack, + Typography, +} from '@mui/material'; +import TextInputArea, { type ToolbarAction } from '@/components/TextInputArea'; +import ImageIcon from '@mui/icons-material/Image'; +import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; +import { useTranslation } from 'react-i18next'; +import CopyButton from '@/components/CopyButton'; +import DecodeResultPaper from '@/components/DecodeResultPaper'; +import { + fileToBase64, + isFileSizeValid, + isSupportedImageType, + isSupportedImageExtension, + formatFileSize, + base64ToBlob, + downloadBlob, + MAX_FILE_SIZE, +} from '@/utils/base64Converter'; +import type { Base64ToBlobResult, FileToBase64Result } from '@/utils/base64Converter'; +import { useStorageState } from '@/utils/useStorageState'; +import type { Base64ConvertDirection } from '@/types/storage'; +import SwitchButtonGroup from '@/components/SwitchButtonGroup'; + +interface FileInfo { + name: string; + size: number; + type: string; +} + +const ERROR_MESSAGE_TO_I18N: Record = { + 'Invalid Base64 string': 'invalidBase64', +}; + +const isValidDirection = (val: unknown): val is Base64ConvertDirection => + val === 'encode' || val === 'decode'; + +export default function ImageMode() { + const { t } = useTranslation('base64Converter'); + const [direction, setDirection] = useStorageState( + 'base64Converter/imageMode/direction', + 'encode', + isValidDirection, + ); + + // encode state + const [result, setResult] = useState(null); + const [info, setInfo] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [isDragging, setIsDragging] = useState(false); + const imageInputRef = useRef(null); + const cancelRef = useRef(false); + + // decode state + const [decodeInput, setDecodeInput] = useState(''); + const [decoded, setDecoded] = useState(null); + const [decodedFileName, setDecodedFileName] = useState(''); + + // shared + const [error, setError] = useState(null); + + const resetAll = useCallback(() => { + cancelRef.current = true; + setResult(null); + setInfo(null); + setIsLoading(false); + setDecodeInput(''); + setDecoded(null); + setDecodedFileName(''); + setError(null); + if (imageInputRef.current) imageInputRef.current.value = ''; + }, []); + + const handleClear = () => { + resetAll(); + }; + + const handleDirectionChange = (next: Base64ConvertDirection) => { + if (!next || next === direction) return; + resetAll(); + setDirection(next); + }; + + const handleFileSelect = async (file: File) => { + cancelRef.current = false; + setError(null); + setResult(null); + setInfo(null); + + if (!isFileSizeValid(file.size)) { + setError(t('fileSizeExceeded', { max: `${MAX_FILE_SIZE / 1024 / 1024} MB` })); + return; + } + + if (!isSupportedImageType(file.type) && !isSupportedImageExtension(file.name)) { + setError(t('unsupportedImageType')); + return; + } + + setInfo({ + name: file.name, + size: file.size, + type: file.type || 'application/octet-stream', + }); + setIsLoading(true); + + try { + const res = await fileToBase64(file); + if (!cancelRef.current) setResult(res); + } catch (e) { + if (!cancelRef.current) { + setError(e instanceof Error ? e.message : t('conversionFailed')); + } + } finally { + if (!cancelRef.current) setIsLoading(false); + } + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(true); + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + const file = e.dataTransfer.files[0]; + if (file) handleFileSelect(file); + }; + + const handleDownload = () => { + if (!decoded) return; + downloadBlob(decoded.blob, decodedFileName || `decoded${decoded.suggestedExtension}`); + }; + + const actions: ToolbarAction[] = useMemo( + () => [ + { + key: 'decode', + label: t('decode'), + type: 'primary', + position: 'bottom', + disabled: (value: string) => !value.trim(), + onClick: (value: string, helpers) => { + helpers.setError(''); + setDecoded(null); + try { + const res = base64ToBlob(value); + setDecoded(res); + setDecodedFileName(`decoded${res.suggestedExtension}`); + } catch (e) { + const message = e instanceof Error ? e.message : ''; + const i18nKey = ERROR_MESSAGE_TO_I18N[message]; + helpers.setError(i18nKey ? t(i18nKey) : message || t('conversionFailed')); + } + }, + }, + ], + [t], + ); + + return ( + <> + + + {direction === 'encode' && ( + <> + imageInputRef.current?.click()} + sx={{ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + minHeight: 180, + border: '2px dashed', + borderColor: isDragging ? 'info.main' : info ? 'info.main' : 'divider', + borderRadius: 3, + p: 4, + bgcolor: (theme) => + isDragging + ? alpha(theme.palette.info.main, 0.08) + : info + ? alpha(theme.palette.info.main, 0.04) + : 'action.hover', + cursor: 'pointer', + transition: 'all 0.2s', + '&:hover': { + borderColor: 'info.main', + bgcolor: (theme) => alpha(theme.palette.info.main, 0.04), + }, + }} + > + { + const file = e.target.files?.[0]; + if (file) handleFileSelect(file); + }} + /> + {isLoading ? ( + + ) : info ? ( + + {result && ( + + )} + + {info.name} + + + {formatFileSize(info.size)} · {info.type} + + + {t('clickOrDropToReplace')} + + + ) : ( + + + + {t('clickOrDropToImage')} + + + {t('supportedFormats')} + + + )} + + + {error && {error}} + + {result && ( + alpha(theme.palette.info.main, 0.04), + border: '1px solid', + borderColor: (theme) => alpha(theme.palette.info.main, 0.15), + }} + > + + + {t('base64Output')} + + + {}} + /> + {}} + /> + + + + {result.output.length > 2000 + ? `${result.output.substring(0, 2000)}...` + : result.output} + + + + {t('originalSize')}: {formatFileSize(result.originalBytes)} + + + {t('encodedSize')}: {formatFileSize(result.outputBytes)} + + + + )} + + {info && ( + + )} + + )} + + {direction === 'decode' && ( + <> + { + setDecodeInput(v); + setError(null); + }} + actions={actions} + externalError={error || undefined} + onClear={() => { + setDecoded(null); + setDecodedFileName(''); + }} + /> + + {decoded && ( + + + + )} + + )} + + ); +} diff --git a/pages/Base64Converter/TextMode.tsx b/pages/Base64Converter/TextMode.tsx new file mode 100644 index 0000000..f7b4ad6 --- /dev/null +++ b/pages/Base64Converter/TextMode.tsx @@ -0,0 +1,143 @@ +import { useCallback, useMemo, useState } from 'react'; +import { Alert, alpha, Button, Paper, Stack, Typography } from '@mui/material'; +import TextInputArea, { type ToolbarAction } from '@/components/TextInputArea'; +import SwapHorizIcon from '@mui/icons-material/SwapHoriz'; +import { useTranslation } from 'react-i18next'; +import CopyButton from '@/components/CopyButton'; +import { textToBase64, base64ToText } from '@/utils/base64Converter'; +import SwitchButtonGroup from '@/components/SwitchButtonGroup'; + +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 } = useTranslation('base64Converter'); + const [input, setInput] = useState(''); + const [output, setOutput] = useState(''); + const [error, setError] = useState(null); + const [direction, setDirection] = useState<'encode' | 'decode'>('encode'); + + const actionLabel = direction === 'encode' ? t('encode') : t('decode'); + 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 = useCallback( + (value: 'encode' | 'decode') => { + if (value === direction) return; + setDirection(value); + setOutput(''); + setError(null); + }, + [direction], + ); + + const actions: ToolbarAction[] = useMemo( + () => [ + { + key: 'convert', + label: actionLabel, + icon: , + type: 'primary', + position: 'bottom', + disabled: (value: string) => !value.trim(), + onClick: (value: string) => { + setError(null); + try { + if (direction === 'encode') { + const result = textToBase64(value); + setOutput(result.output); + } else { + const decoded = base64ToText(value); + setOutput(decoded); + } + } catch (e) { + const message = e instanceof Error ? e.message : ''; + const i18nKey = ERROR_MESSAGE_TO_I18N[message]; + setError(i18nKey ? t(i18nKey) : message || t('conversionFailed')); + } + }, + }, + ], + [direction, t, actionLabel], + ); + + return ( + <> + + + { + setInput(v); + setError(null); + }} + actions={actions} + externalError={error || undefined} + onClear={() => setOutput('')} + /> + + {showImageHint && ( + + {t('switchToImageMode')} + + } + > + {t('imageDataUriHint')} + + )} + + {output && ( + alpha(theme.palette.info.main, 0.04), + border: '1px solid', + borderColor: (theme) => alpha(theme.palette.info.main, 0.15), + }} + > + + + {outputLabel} + + {}} /> + + 2000 ? `${output.substring(0, 2000)}...` : output} + showClear={false} + showCount + /> + + )} + + ); +} diff --git a/pages/Base64Converter/__tests__/FileMode.test.tsx b/pages/Base64Converter/__tests__/FileMode.test.tsx new file mode 100644 index 0000000..6ea313f --- /dev/null +++ b/pages/Base64Converter/__tests__/FileMode.test.tsx @@ -0,0 +1,207 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; +import FileMode from '../FileMode'; + +// Mock CopyButton +vi.mock('@/components/CopyButton', () => ({ + default: ({ text, tooltip }: { text: string; tooltip?: string }) => ( + + ), +})); + +beforeEach(() => { + localStorage.clear(); +}); + +// useStorageState's async loadState may overwrite user toggle if we click before the +// initial chrome.storage read settles. Flush pending microtasks first. +const waitForStorageReady = () => act(() => Promise.resolve()); + +describe('FileMode', () => { + it('应该渲染文件上传区域', () => { + render(); + expect(screen.getByText('clickOrDropToFile')).toBeInTheDocument(); + expect(screen.getByText('maxFileSize')).toBeInTheDocument(); + }); + + it('应该处理有效的文件选择', async () => { + render(); + + const file = new File(['test content'], 'test.txt', { type: 'text/plain' }); + + // 文件输入是隐藏的,直接触发 change 事件 + const hiddenInput = document.querySelector('input[type="file"]') as HTMLInputElement; + fireEvent.change(hiddenInput, { target: { files: [file] } }); + + await waitFor(() => { + expect(screen.getByText('test.txt')).toBeInTheDocument(); + expect(screen.getByText('base64Output')).toBeInTheDocument(); + }); + }); + + it('应该拒绝超出大小限制的文件', async () => { + render(); + + // 创建一个超过 10MB 的文件 + const largeContent = new Uint8Array(11 * 1024 * 1024); + const file = new File([largeContent], 'large.bin', { type: 'application/octet-stream' }); + + const hiddenInput = document.querySelector('input[type="file"]') as HTMLInputElement; + fireEvent.change(hiddenInput, { target: { files: [file] } }); + + await waitFor(() => { + expect(screen.getByRole('alert')).toHaveTextContent('fileSizeExceeded'); + }); + }); + + it('点击清除按钮应该清空文件状态', async () => { + render(); + + const file = new File(['test'], 'test.txt', { type: 'text/plain' }); + const hiddenInput = document.querySelector('input[type="file"]') as HTMLInputElement; + fireEvent.change(hiddenInput, { target: { files: [file] } }); + + await waitFor(() => { + expect(screen.getByText('test.txt')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('clear')); + + await waitFor(() => { + expect(screen.queryByText('test.txt')).not.toBeInTheDocument(); + expect(screen.getByText('clickOrDropToFile')).toBeInTheDocument(); + }); + }); + + it('应该显示文件大小和类型信息', async () => { + render(); + + const file = new File(['test content'], 'test.txt', { type: 'text/plain' }); + const hiddenInput = document.querySelector('input[type="file"]') as HTMLInputElement; + fireEvent.change(hiddenInput, { target: { files: [file] } }); + + await waitFor(() => { + expect(screen.getByText('test.txt')).toBeInTheDocument(); + }); + // 文件类型显示在 caption 中,格式为 "size · type" + expect(screen.getByText(/test\.txt/)).toBeInTheDocument(); + }); + + it('应该显示原始大小和编码大小', async () => { + render(); + + const file = new File(['test content'], 'test.txt', { type: 'text/plain' }); + const hiddenInput = document.querySelector('input[type="file"]') as HTMLInputElement; + fireEvent.change(hiddenInput, { target: { files: [file] } }); + + await waitFor(() => { + expect(screen.getByText(/originalSize/)).toBeInTheDocument(); + expect(screen.getByText(/encodedSize/)).toBeInTheDocument(); + }); + }); + + it('应该提供复制按钮', async () => { + render(); + + const file = new File(['test'], 'test.txt', { type: 'text/plain' }); + const hiddenInput = document.querySelector('input[type="file"]') as HTMLInputElement; + fireEvent.change(hiddenInput, { target: { files: [file] } }); + + await waitFor(() => { + const copyButtons = screen.getAllByTestId('copy-button'); + expect(copyButtons.length).toBeGreaterThanOrEqual(2); + }); + }); + + it('应该渲染 encode/decode 切换按钮', () => { + render(); + expect(screen.getAllByText('encode').length).toBeGreaterThanOrEqual(1); + expect(screen.getByText('decode')).toBeInTheDocument(); + }); + + it('切到 decode 应该显示 Base64 输入框', async () => { + render(); + await waitForStorageReady(); + fireEvent.click(screen.getByText('decode')); + expect(await screen.findByPlaceholderText('decodeBase64Placeholder')).toBeInTheDocument(); + }); + + it('解码 PDF Base64 后应该显示 application/pdf 与默认文件名 decoded.pdf', async () => { + render(); + await waitForStorageReady(); + fireEvent.click(screen.getByText('decode')); + + const input = await screen.findByPlaceholderText('decodeBase64Placeholder'); + fireEvent.change(input, { target: { value: 'JVBERi0K' } }); + + fireEvent.click(screen.getAllByText('decode')[1]); + + await waitFor(() => { + expect(screen.getByText('decodedFileOutput')).toBeInTheDocument(); + expect(screen.getByText(/application\/pdf/)).toBeInTheDocument(); + expect(screen.getByDisplayValue('decoded.pdf')).toBeInTheDocument(); + }); + }); + + it('解码后的文件名应该可编辑', async () => { + render(); + await waitForStorageReady(); + fireEvent.click(screen.getByText('decode')); + + const input = await screen.findByPlaceholderText('decodeBase64Placeholder'); + fireEvent.change(input, { target: { value: 'JVBERi0K' } }); + fireEvent.click(screen.getAllByText('decode')[1]); + + const filenameInput = (await screen.findByDisplayValue('decoded.pdf')) as HTMLInputElement; + fireEvent.change(filenameInput, { target: { value: 'my-report.pdf' } }); + expect(filenameInput.value).toBe('my-report.pdf'); + }); + + it('解码后应该显示下载按钮', async () => { + render(); + await waitForStorageReady(); + fireEvent.click(screen.getByText('decode')); + + const input = await screen.findByPlaceholderText('decodeBase64Placeholder'); + fireEvent.change(input, { target: { value: 'JVBERi0K' } }); + fireEvent.click(screen.getAllByText('decode')[1]); + + expect(await screen.findByText('download')).toBeInTheDocument(); + }); + + it('解码非法 Base64 应该显示 invalidBase64 错误', async () => { + render(); + await waitForStorageReady(); + fireEvent.click(screen.getByText('decode')); + + const input = await screen.findByPlaceholderText('decodeBase64Placeholder'); + fireEvent.change(input, { target: { value: '!!!not base64' } }); + fireEvent.click(screen.getAllByText('decode')[1]); + + await waitFor(() => { + expect(screen.getByText('invalidBase64')).toBeInTheDocument(); + }); + }); + + it('切换方向时应该清空解码状态', async () => { + render(); + await waitForStorageReady(); + fireEvent.click(screen.getByText('decode')); + + const input = await screen.findByPlaceholderText('decodeBase64Placeholder'); + fireEvent.change(input, { target: { value: 'JVBERi0K' } }); + fireEvent.click(screen.getAllByText('decode')[1]); + + await waitFor(() => { + expect(screen.getByText('decodedFileOutput')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getAllByText('encode')[0]); + + await waitFor(() => { + expect(screen.queryByText('decodedFileOutput')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/pages/Base64Converter/__tests__/ImageMode.test.tsx b/pages/Base64Converter/__tests__/ImageMode.test.tsx new file mode 100644 index 0000000..18fa006 --- /dev/null +++ b/pages/Base64Converter/__tests__/ImageMode.test.tsx @@ -0,0 +1,161 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; +import ImageMode from '../ImageMode'; + +// Mock CopyButton +vi.mock('@/components/CopyButton', () => ({ + default: ({ text, tooltip }: { text: string; tooltip?: string }) => ( + + ), +})); + +beforeEach(() => { + localStorage.clear(); +}); + +const waitForStorageReady = () => act(() => Promise.resolve()); + +describe('ImageMode', () => { + it('应该渲染图像上传区域', () => { + render(); + expect(screen.getByText('clickOrDropToImage')).toBeInTheDocument(); + expect(screen.getByText('supportedFormats')).toBeInTheDocument(); + }); + + it('应该接受有效的图像文件', async () => { + render(); + + const file = new File(['fake-image-data'], 'test.png', { type: 'image/png' }); + const hiddenInput = document.querySelector('input[type="file"]') as HTMLInputElement; + fireEvent.change(hiddenInput, { target: { files: [file] } }); + + await waitFor(() => { + expect(screen.getByText('test.png')).toBeInTheDocument(); + expect(screen.getByText('base64Output')).toBeInTheDocument(); + }); + }); + + it('应该拒绝非图像文件', async () => { + render(); + + const file = new File(['not an image'], 'test.txt', { type: 'text/plain' }); + const hiddenInput = document.querySelector('input[type="file"]') as HTMLInputElement; + fireEvent.change(hiddenInput, { target: { files: [file] } }); + + await waitFor(() => { + expect(screen.getByRole('alert')).toHaveTextContent('unsupportedImageType'); + }); + }); + + it('应该拒绝超出大小限制的图像', async () => { + render(); + + const largeContent = new Uint8Array(11 * 1024 * 1024); + const file = new File([largeContent], 'large.png', { type: 'image/png' }); + + const hiddenInput = document.querySelector('input[type="file"]') as HTMLInputElement; + fireEvent.change(hiddenInput, { target: { files: [file] } }); + + await waitFor(() => { + expect(screen.getByRole('alert')).toHaveTextContent('fileSizeExceeded'); + }); + }); + + it('应该通过扩展名识别图像', async () => { + render(); + + // 没有 MIME 类型但有正确扩展名 + const file = new File(['fake'], 'test.jpg'); + const hiddenInput = document.querySelector('input[type="file"]') as HTMLInputElement; + fireEvent.change(hiddenInput, { target: { files: [file] } }); + + await waitFor(() => { + expect(screen.getByText('test.jpg')).toBeInTheDocument(); + }); + }); + + it('点击清除按钮应该清空图像状态', async () => { + render(); + + const file = new File(['fake'], 'test.png', { type: 'image/png' }); + const hiddenInput = document.querySelector('input[type="file"]') as HTMLInputElement; + fireEvent.change(hiddenInput, { target: { files: [file] } }); + + await waitFor(() => { + expect(screen.getByText('test.png')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('clear')); + + await waitFor(() => { + expect(screen.queryByText('test.png')).not.toBeInTheDocument(); + expect(screen.getByText('clickOrDropToImage')).toBeInTheDocument(); + }); + }); + + it('应该显示图像预览', async () => { + render(); + + const file = new File(['fake-image'], 'test.png', { type: 'image/png' }); + const hiddenInput = document.querySelector('input[type="file"]') as HTMLInputElement; + fireEvent.change(hiddenInput, { target: { files: [file] } }); + + await waitFor(() => { + const img = screen.getByAltText('preview'); + expect(img).toBeInTheDocument(); + expect(img.tagName.toLowerCase()).toBe('img'); + }); + }); + + it('应该渲染 encode/decode 切换按钮', async () => { + render(); + await waitForStorageReady(); + expect(screen.getAllByText('encode').length).toBeGreaterThanOrEqual(1); + expect(screen.getByText('decode')).toBeInTheDocument(); + }); + + it('解码 PNG Base64 后应该显示图像预览', async () => { + render(); + await waitForStorageReady(); + fireEvent.click(screen.getByText('decode')); + + const input = await screen.findByPlaceholderText('decodeBase64Placeholder'); + fireEvent.change(input, { target: { value: 'iVBORw0KGgo=' } }); + fireEvent.click(screen.getAllByText('decode')[1]); + + await waitFor(() => { + expect(screen.getByText('decodedImageOutput')).toBeInTheDocument(); + const img = screen.getByAltText('decoded preview'); + expect(img).toBeInTheDocument(); + expect(img.tagName.toLowerCase()).toBe('img'); + }); + }); + + it('解码后默认文件名应该为 decoded.png', async () => { + render(); + await waitForStorageReady(); + fireEvent.click(screen.getByText('decode')); + + const input = await screen.findByPlaceholderText('decodeBase64Placeholder'); + fireEvent.change(input, { target: { value: 'iVBORw0KGgo=' } }); + fireEvent.click(screen.getAllByText('decode')[1]); + + expect(await screen.findByDisplayValue('decoded.png')).toBeInTheDocument(); + }); + + it('解码非法 Base64 应该显示 invalidBase64 错误', async () => { + render(); + await waitForStorageReady(); + fireEvent.click(screen.getByText('decode')); + + const input = await screen.findByPlaceholderText('decodeBase64Placeholder'); + fireEvent.change(input, { target: { value: '!!!not base64' } }); + fireEvent.click(screen.getAllByText('decode')[1]); + + await waitFor(() => { + expect(screen.getByText('invalidBase64')).toBeInTheDocument(); + }); + }); +}); diff --git a/pages/Base64Converter/__tests__/TextMode.test.tsx b/pages/Base64Converter/__tests__/TextMode.test.tsx new file mode 100644 index 0000000..573001d --- /dev/null +++ b/pages/Base64Converter/__tests__/TextMode.test.tsx @@ -0,0 +1,182 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; +import TextMode from '../TextMode'; + +// Mock CopyButton +vi.mock('@/components/CopyButton', () => ({ + default: ({ text }: { text: string }) => , +})); + +describe('TextMode', () => { + it('应该渲染编码/解码切换按钮', () => { + render(); + expect(screen.getAllByText('encode').length).toBeGreaterThanOrEqual(1); + expect(screen.getByText('decode')).toBeInTheDocument(); + }); + + it('应该渲染输入框和转换按钮', () => { + render(); + expect(screen.getByPlaceholderText('textInputPlaceholder')).toBeInTheDocument(); + }); + + it('应该将文本编码为 Base64', async () => { + render(); + const input = screen.getByPlaceholderText('textInputPlaceholder'); + fireEvent.change(input, { target: { value: 'Hello' } }); + + const convertBtn = screen.getAllByText('encode')[1]; + fireEvent.click(convertBtn); + + await waitFor(() => { + expect(screen.getByText('base64Output')).toBeInTheDocument(); + }); + // 输出内容在 CopyButton 的 data-testid 中 + expect(screen.getByTestId('copy-button')).toHaveTextContent('SGVsbG8='); + }); + + it('应该解码 Base64 文本', async () => { + render(); + + // 切换到解码模式 + fireEvent.click(screen.getByText('decode')); + + const input = screen.getByPlaceholderText('base64InputPlaceholder'); + fireEvent.change(input, { target: { value: 'SGVsbG8=' } }); + + const convertBtn = screen.getAllByText('decode')[1]; + fireEvent.click(convertBtn); + + await waitFor(() => { + expect(screen.getByText('textOutput')).toBeInTheDocument(); + }); + expect(screen.getByTestId('copy-button')).toHaveTextContent('Hello'); + }); + + it('应该对无效 Base64 显示错误', async () => { + render(); + + // 切换到解码模式 + fireEvent.click(screen.getByText('decode')); + + const input = screen.getByPlaceholderText('base64InputPlaceholder'); + fireEvent.change(input, { target: { value: 'invalid!!!' } }); + + const convertBtn = screen.getAllByText('decode')[1]; + fireEvent.click(convertBtn); + + await waitFor(() => { + expect(screen.getByText('invalidBase64')).toBeInTheDocument(); + }); + }); + + it('切换方向时应该清除输出', async () => { + render(); + + // 先编码 + const input = screen.getByPlaceholderText('textInputPlaceholder'); + fireEvent.change(input, { target: { value: 'Hello' } }); + fireEvent.click(screen.getAllByText('encode')[1]); + + await waitFor(() => { + expect(screen.getByTestId('copy-button')).toHaveTextContent('SGVsbG8='); + }); + + // 切换方向 + await act(async () => { + fireEvent.click(screen.getByText('decode')); + }); + + // 输出应该被清除 + await waitFor(() => { + expect(screen.queryByTestId('copy-button')).not.toBeInTheDocument(); + }); + }); + + it('点击清除按钮应该清空所有内容', async () => { + render(); + + const input = screen.getByPlaceholderText('textInputPlaceholder'); + fireEvent.change(input, { target: { value: 'Hello' } }); + fireEvent.click(screen.getAllByText('encode')[1]); + + await waitFor(() => { + expect(screen.getByTestId('copy-button')).toHaveTextContent('SGVsbG8='); + }); + + fireEvent.click(screen.getByRole('button', { name: 'textInputArea.clear' })); + + await waitFor(() => { + expect(screen.queryByTestId('copy-button')).not.toBeInTheDocument(); + expect(input).toHaveValue(''); + }); + }); + + it('空输入时转换按钮应该禁用', () => { + render(); + const convertBtn = screen.getAllByText('encode')[1]; + expect(convertBtn).toBeDisabled(); + }); + + it('输入非空时转换按钮应该启用', () => { + render(); + const input = screen.getByPlaceholderText('textInputPlaceholder'); + fireEvent.change(input, { target: { value: 'Hello' } }); + const convertBtn = screen.getAllByText('encode')[1]; + expect(convertBtn).not.toBeDisabled(); + }); + + it('解码模式下粘贴图片 data URI 时应该显示切换图像模式的提示', () => { + render(); + + fireEvent.click(screen.getByText('decode')); + + const input = screen.getByPlaceholderText('base64InputPlaceholder'); + fireEvent.change(input, { + target: { value: 'data:image/png;base64,iVBORw0KGgo=' }, + }); + + expect(screen.getByText('imageDataUriHint')).toBeInTheDocument(); + expect(screen.getByText('switchToImageMode')).toBeInTheDocument(); + }); + + it('粘贴非图片 data URI 时不应该显示图像模式提示', () => { + render(); + + fireEvent.click(screen.getByText('decode')); + + const input = screen.getByPlaceholderText('base64InputPlaceholder'); + fireEvent.change(input, { target: { value: 'SGVsbG8=' } }); + + expect(screen.queryByText('imageDataUriHint')).not.toBeInTheDocument(); + }); + + it('点击切换图像模式按钮应该调用 onSwitchToImageMode 回调', () => { + const onSwitch = vi.fn(); + render(); + + fireEvent.click(screen.getByText('decode')); + const input = screen.getByPlaceholderText('base64InputPlaceholder'); + fireEvent.change(input, { + target: { value: 'data:image/png;base64,iVBORw0KGgo=' }, + }); + + fireEvent.click(screen.getByText('switchToImageMode')); + expect(onSwitch).toHaveBeenCalledTimes(1); + }); + + it('解码模式下对二进制数据应该显示更清晰的错误', async () => { + render(); + + fireEvent.click(screen.getByText('decode')); + + const input = screen.getByPlaceholderText('base64InputPlaceholder'); + fireEvent.change(input, { target: { value: 'iVBORw0KGgo=' } }); + + const convertBtn = screen.getAllByText('decode')[1]; + fireEvent.click(convertBtn); + + await waitFor(() => { + expect(screen.getByText('binaryDataDetected')).toBeInTheDocument(); + }); + }); +}); diff --git a/pages/Base64Converter/__tests__/index.test.tsx b/pages/Base64Converter/__tests__/index.test.tsx new file mode 100644 index 0000000..fd17319 --- /dev/null +++ b/pages/Base64Converter/__tests__/index.test.tsx @@ -0,0 +1,50 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import Base64ConverterPage from '../index'; + +// Mock 子组件 +vi.mock('../TextMode', () => ({ + default: () =>
TextMode
, +})); + +vi.mock('../FileMode', () => ({ + default: () =>
FileMode
, +})); + +vi.mock('../ImageMode', () => ({ + default: () =>
ImageMode
, +})); + +describe('Base64ConverterPage', () => { + it('应该默认渲染文本模式', () => { + render(); + expect(screen.getByTestId('text-mode')).toBeInTheDocument(); + }); + + it('应该渲染模式切换按钮', () => { + render(); + expect(screen.getByText('base64Converter:textMode')).toBeInTheDocument(); + expect(screen.getByText('base64Converter:fileMode')).toBeInTheDocument(); + expect(screen.getByText('base64Converter:imageMode')).toBeInTheDocument(); + }); + + it('切换到文件模式应该渲染 FileMode', () => { + render(); + fireEvent.click(screen.getByText('base64Converter:fileMode')); + expect(screen.getByTestId('file-mode')).toBeInTheDocument(); + expect(screen.queryByTestId('text-mode')).not.toBeInTheDocument(); + }); + + it('切换到图像模式应该渲染 ImageMode', () => { + render(); + fireEvent.click(screen.getByText('base64Converter:imageMode')); + expect(screen.getByTestId('image-mode')).toBeInTheDocument(); + expect(screen.queryByTestId('text-mode')).not.toBeInTheDocument(); + }); + + it('应该渲染页面标题', () => { + render(); + expect(screen.getByText('base64Converter:pageTitle')).toBeInTheDocument(); + expect(screen.getByText('base64Converter:pageSubtitle')).toBeInTheDocument(); + }); +}); diff --git a/pages/Base64Converter/index.tsx b/pages/Base64Converter/index.tsx index d26d428..94f07ea 100644 --- a/pages/Base64Converter/index.tsx +++ b/pages/Base64Converter/index.tsx @@ -1,40 +1,16 @@ -import { useCallback, useRef, useState } from 'react'; -import { - Alert, - alpha, - Box, - Button, - Container, - Stack, - TextField, - ToggleButton, - ToggleButtonGroup, - Typography, - CircularProgress, - Paper, -} from '@mui/material'; +import { Box, Container, Stack } from '@mui/material'; import TextFieldsIcon from '@mui/icons-material/TextFields'; import UploadFileIcon from '@mui/icons-material/UploadFile'; import ImageIcon from '@mui/icons-material/Image'; -import SwapHorizIcon from '@mui/icons-material/SwapHoriz'; -import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; import { useTranslation } from 'react-i18next'; import PageHeader from '@/components/PageHeader'; -import CopyButton from '@/components/CopyButton'; import { base64ConverterPageStyles } from '@/config/pageTheme'; import { useStorageState } from '@/utils/useStorageState'; import type { Base64ConverterPageMode } from '@/types/storage'; -import { - textToBase64, - base64ToText, - fileToBase64, - isFileSizeValid, - isSupportedImageType, - isSupportedImageExtension, - formatFileSize, - MAX_FILE_SIZE, -} from '@/utils/base64Converter'; -import type { FileToBase64Result } from '@/utils/base64Converter'; +import TextMode from './TextMode'; +import FileMode from './FileMode'; +import ImageMode from './ImageMode'; +import SwitchButtonGroup from '@/components/SwitchButtonGroup'; const VALID_PAGE_MODES: readonly Base64ConverterPageMode[] = ['text', 'file', 'image']; @@ -43,13 +19,6 @@ const isValidPageMode = (val: unknown): val is Base64ConverterPageMode => type PageMode = Base64ConverterPageMode; -/** 文件信息 */ -interface FileInfo { - name: string; - size: number; - type: string; -} - export default function Index() { const { t } = useTranslation(['base64Converter']); const [pageMode, setPageMode] = useStorageState( @@ -58,129 +27,6 @@ export default function Index() { isValidPageMode, ); - // 文本模式状态 - const [textInput, setTextInput] = useState(''); - const [textOutput, setTextOutput] = useState(''); - const [textError, setTextError] = useState(null); - const [textDirection, setTextDirection] = useState<'encode' | 'decode'>('encode'); - - // 文件/图像模式状态 - const [fileResult, setFileResult] = useState(null); - const [fileInfo, setFileInfo] = useState(null); - const [fileError, setFileError] = useState(null); - const [isLoading, setIsLoading] = useState(false); - const [isDragging, setIsDragging] = useState(false); - - const fileInputRef = useRef(null); - const imageInputRef = useRef(null); - - /** 清空文本模式 */ - const handleClearText = useCallback(() => { - setTextInput(''); - setTextOutput(''); - setTextError(null); - }, []); - - /** 文本编码/解码 */ - const handleTextConvert = useCallback(() => { - setTextError(null); - try { - if (textDirection === 'encode') { - const result = textToBase64(textInput); - setTextOutput(result.output); - } else { - const decoded = base64ToText(textInput); - setTextOutput(decoded); - } - } catch (e) { - setTextError(e instanceof Error ? e.message : t('base64Converter:conversionFailed')); - } - }, [textInput, textDirection, t]); - - /** 处理文件选择 */ - const handleFileSelect = useCallback( - async (file: File, isImageMode: boolean) => { - setFileError(null); - setFileResult(null); - setFileInfo(null); - - if (!isFileSizeValid(file.size)) { - setFileError( - t('base64Converter:fileSizeExceeded', { max: `${MAX_FILE_SIZE / 1024 / 1024} MB` }), - ); - return; - } - - if ( - isImageMode && - !isSupportedImageType(file.type) && - !isSupportedImageExtension(file.name) - ) { - setFileError(t('base64Converter:unsupportedImageType')); - return; - } - - setFileInfo({ - name: file.name, - size: file.size, - type: file.type || 'application/octet-stream', - }); - setIsLoading(true); - - try { - const result = await fileToBase64(file); - setFileResult(result); - } catch (e) { - setFileError(e instanceof Error ? e.message : t('base64Converter:conversionFailed')); - } finally { - setIsLoading(false); - } - }, - [t], - ); - - /** 清空文件/图像模式 */ - const handleClearFile = useCallback(() => { - setFileResult(null); - setFileInfo(null); - setFileError(null); - if (fileInputRef.current) fileInputRef.current.value = ''; - if (imageInputRef.current) imageInputRef.current.value = ''; - }, []); - - /** 拖拽处理 */ - const handleDragOver = useCallback((e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - setIsDragging(true); - }, []); - - const handleDragLeave = useCallback((e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - setIsDragging(false); - }, []); - - const handleDrop = useCallback( - (e: React.DragEvent, isImageMode: boolean) => { - e.preventDefault(); - e.stopPropagation(); - setIsDragging(false); - const file = e.dataTransfer.files[0]; - if (file) { - handleFileSelect(file, isImageMode); - } - }, - [handleFileSelect], - ); - - /** 页面模式元数据 */ - const modeTitles: Record = { - text: { title: 'base64Converter:pageTitle', subtitle: 'base64Converter:pageSubtitle' }, - file: { title: 'base64Converter:pageTitle', subtitle: 'base64Converter:pageSubtitle' }, - image: { title: 'base64Converter:pageTitle', subtitle: 'base64Converter:pageSubtitle' }, - }; - const modeIcon: Record = { text: , file: , @@ -191,463 +37,27 @@ export default function Index() { - {/* 模式切换器 */} - v && setPageMode(v)} - sx={{ borderRadius: 3, flexWrap: 'wrap', gap: 0.5 }} - > - - {t('base64Converter:textMode')} - - - {t('base64Converter:fileMode')} - - - {t('base64Converter:imageMode')} - - + options={[ + { value: 'text', label: t('base64Converter:textMode') }, + { value: 'file', label: t('base64Converter:fileMode') }, + { value: 'image', label: t('base64Converter:imageMode') }, + ]} + onChange={(value: PageMode) => setPageMode(value)} + size="small" + /> - {/* ===== 文本模式 ===== */} - {pageMode === 'text' && ( - <> - {/* 编码/解码切换 */} - - v && setTextDirection(v)} - sx={{ borderRadius: 3 }} - > - - {t('base64Converter:encode')} - - - {t('base64Converter:decode')} - - - - - - - - - - {/* 输入区 */} - { - setTextInput(e.target.value); - setTextError(null); - }} - sx={{ - '& .MuiOutlinedInput-root': { - bgcolor: 'background.paper', - borderRadius: 3, - fontSize: '0.85rem', - fontFamily: 'monospace', - transition: 'all 0.2s', - '&:hover': { bgcolor: 'action.hover' }, - '&.Mui-focused': { - bgcolor: 'background.paper', - boxShadow: (theme) => `0 0 0 4px ${alpha(theme.palette.info.main, 0.1)}`, - }, - }, - }} - /> - - {textError && {textError}} - - {/* 输出区 */} - {textOutput && ( - alpha(theme.palette.info.main, 0.04), - border: '1px solid', - borderColor: (theme) => alpha(theme.palette.info.main, 0.15), - }} - > - - - {textDirection === 'encode' - ? t('base64Converter:base64Output') - : t('base64Converter:textOutput')} - - {}} /> - - - {textOutput} - - - )} - - )} - - {/* ===== 文件模式 ===== */} - {pageMode === 'file' && ( - <> - {/* 拖拽/上传区 */} - handleDrop(e, false)} - onClick={() => fileInputRef.current?.click()} - sx={{ - display: 'flex', - flexDirection: 'column', - alignItems: 'center', - justifyContent: 'center', - minHeight: 180, - border: '2px dashed', - borderColor: isDragging ? 'info.main' : fileInfo ? 'info.main' : 'divider', - borderRadius: 3, - p: 4, - bgcolor: (theme) => - isDragging - ? alpha(theme.palette.info.main, 0.08) - : fileInfo - ? alpha(theme.palette.info.main, 0.04) - : 'action.hover', - cursor: 'pointer', - transition: 'all 0.2s', - '&:hover': { - borderColor: 'info.main', - bgcolor: (theme) => alpha(theme.palette.info.main, 0.04), - }, - }} - > - { - const file = e.target.files?.[0]; - if (file) handleFileSelect(file, false); - }} - /> - {isLoading ? ( - - ) : fileInfo ? ( - - - - {fileInfo.name} - - - {formatFileSize(fileInfo.size)} · {fileInfo.type} - - - {t('base64Converter:clickOrDropToReplace')} - - - ) : ( - - - - {t('base64Converter:clickOrDropToFile')} - - - {t('base64Converter:maxFileSize', { - max: `${MAX_FILE_SIZE / 1024 / 1024} MB`, - })} - - - )} - - - {fileError && {fileError}} - - {/* 文件转换结果 */} - {fileResult && ( - alpha(theme.palette.info.main, 0.04), - border: '1px solid', - borderColor: (theme) => alpha(theme.palette.info.main, 0.15), - }} - > - - - {t('base64Converter:base64Output')} - - - {}} - /> - {}} - /> - - - - {fileResult.output.length > 2000 - ? `${fileResult.output.substring(0, 2000)}...` - : fileResult.output} - - - - {t('base64Converter:originalSize')}:{' '} - {formatFileSize(fileResult.originalBytes)} - - - {t('base64Converter:encodedSize')}: {formatFileSize(fileResult.outputBytes)} - - - - )} - - {fileInfo && ( - - )} - - )} - - {/* ===== 图像模式 ===== */} - {pageMode === 'image' && ( - <> - {/* 拖拽/上传区 */} - handleDrop(e, true)} - onClick={() => imageInputRef.current?.click()} - sx={{ - display: 'flex', - flexDirection: 'column', - alignItems: 'center', - justifyContent: 'center', - minHeight: 180, - border: '2px dashed', - borderColor: isDragging ? 'info.main' : fileInfo ? 'info.main' : 'divider', - borderRadius: 3, - p: 4, - bgcolor: (theme) => - isDragging - ? alpha(theme.palette.info.main, 0.08) - : fileInfo - ? alpha(theme.palette.info.main, 0.04) - : 'action.hover', - cursor: 'pointer', - transition: 'all 0.2s', - '&:hover': { - borderColor: 'info.main', - bgcolor: (theme) => alpha(theme.palette.info.main, 0.04), - }, - }} - > - { - const file = e.target.files?.[0]; - if (file) handleFileSelect(file, true); - }} - /> - {isLoading ? ( - - ) : fileInfo ? ( - - {fileResult && ( - - )} - - {fileInfo.name} - - - {formatFileSize(fileInfo.size)} · {fileInfo.type} - - - {t('base64Converter:clickOrDropToReplace')} - - - ) : ( - - - - {t('base64Converter:clickOrDropToImage')} - - - {t('base64Converter:supportedFormats')} - - - )} - - - {fileError && {fileError}} - - {/* 图像转换结果 */} - {fileResult && ( - alpha(theme.palette.info.main, 0.04), - border: '1px solid', - borderColor: (theme) => alpha(theme.palette.info.main, 0.15), - }} - > - - - {t('base64Converter:base64Output')} - - - {}} - /> - {}} - /> - - - - {fileResult.output.length > 2000 - ? `${fileResult.output.substring(0, 2000)}...` - : fileResult.output} - - - - {t('base64Converter:originalSize')}:{' '} - {formatFileSize(fileResult.originalBytes)} - - - {t('base64Converter:encodedSize')}: {formatFileSize(fileResult.outputBytes)} - - - - )} - - {fileInfo && ( - - )} - - )} + {pageMode === 'text' && setPageMode('image')} />} + {pageMode === 'file' && } + {pageMode === 'image' && } diff --git a/pages/Dashboard/ToolCard.tsx b/pages/Dashboard/ToolCard.tsx index 7415aaf..d6318aa 100644 --- a/pages/Dashboard/ToolCard.tsx +++ b/pages/Dashboard/ToolCard.tsx @@ -115,8 +115,13 @@ export default function ToolCard({ sx={{ color: 'text.secondary', fontWeight: 500, - display: 'block', mt: 0.5, + display: '-webkit-box', + WebkitBoxOrient: 'vertical', + WebkitLineClamp: 1, + overflow: 'hidden', + textOverflow: 'ellipsis', + wordBreak: 'break-word', }} > {description} diff --git a/pages/HtmlToMarkdown/index.tsx b/pages/HtmlToMarkdown/index.tsx index 475428f..caa7afd 100644 --- a/pages/HtmlToMarkdown/index.tsx +++ b/pages/HtmlToMarkdown/index.tsx @@ -7,21 +7,16 @@ import { Container, Stack, TextField, - ToggleButton, - ToggleButtonGroup, Typography, Paper, } from '@mui/material'; -import SplitscreenIcon from '@mui/icons-material/Splitscreen'; -import VisibilityIcon from '@mui/icons-material/Visibility'; -import ArticleIcon from '@mui/icons-material/Article'; import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; import DownloadIcon from '@mui/icons-material/Download'; import CodeIcon from '@mui/icons-material/Code'; import { useTranslation } from 'react-i18next'; import PageHeader from '@/components/PageHeader'; import CopyButton from '@/components/CopyButton'; -import { htmlToMarkdownPageStyles } from '@/config/pageTheme'; +import SwitchButtonGroup from '@/components/SwitchButtonGroup'; import { useStorageState } from '@/utils/useStorageState'; import type { HtmlToMarkdownPreviewMode } from '@/types/storage'; import { htmlToMarkdown, downloadMarkdownFile, SAMPLE_HTML } from '@/utils/htmlToMarkdown'; @@ -42,8 +37,8 @@ export default function HtmlToMarkdownPage() { const error = result.hasError ? (result.error ?? null) : null; const handleModeChange = useCallback( - (_event: React.MouseEvent, newMode: HtmlToMarkdownPreviewMode | null) => { - if (newMode) setPreviewMode(newMode); + (newMode: HtmlToMarkdownPreviewMode) => { + setPreviewMode(newMode); }, [setPreviewMode], ); @@ -74,26 +69,16 @@ export default function HtmlToMarkdownPage() { flexWrap="wrap" gap={1.5} > - - - - {t('splitMode')} - - - - {t('previewMode')} - - - - {t('markdownMode')} - - + /> diff --git a/pages/Jwt/index.tsx b/pages/Jwt/index.tsx index b98c9c5..4d432a2 100644 --- a/pages/Jwt/index.tsx +++ b/pages/Jwt/index.tsx @@ -1,12 +1,11 @@ import { useMemo, useState } from 'react'; -import { Box, Container, Paper, Stack, TextField, Typography } from '@mui/material'; +import { Box, Container, Paper, Stack, Typography } from '@mui/material'; import { useSnackbar } from '@/components/GlobalSnackbar'; import VpnKeyIcon from '@mui/icons-material/VpnKey'; -import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'; import PageHeader from '@/components/PageHeader'; -import { jwtPageStyles } from '@/config/pageTheme'; -import { formatJson, parseJwt } from '@/utils/jwt'; +import { stringifyJson, parseJwt } from '@/utils/jwt'; import CopyButton from '@/components/CopyButton'; +import TextInputArea from '@/components/TextInputArea'; import { useTranslation } from 'react-i18next'; interface SectionProps { @@ -52,14 +51,14 @@ const Section = ({ title, content, color }: SectionProps) => { border: '1px solid rgba(0,0,0,0.05)', }} > - {content ? formatJson(content) : t('jwt:invalidFormat')} + {content ? stringifyJson(content) : t('jwt:invalidFormat')} ); }; export default function Index() { - useSnackbar(); + const { showMessage } = useSnackbar(); const { t } = useTranslation(['jwt']); const [jwtInput, setJwtInput] = useState(''); @@ -81,41 +80,20 @@ export default function Index() { {/* Input Area */} - { - // 自动去除 Bearer 前缀及首尾空白字符/换行 - const val = e.target.value.replace(/^Bearer\s*/i, '').trim(); - setJwtInput(val); + onChange={(val) => { + const cleaned = val.replace(/^Bearer\s*/i, '').trim(); + setJwtInput(cleaned); }} - fullWidth - sx={jwtPageStyles.INPUT_STYLE} + allowCopy={true} + showClear={true} + showMessage={showMessage} + externalError={result?.error} /> - {result?.error && ( - - - - {result.error} - - - )} - {result && !result.error && (
, newMode: MarkdownToHtmlPreviewMode | null) => { - if (newMode) setPreviewMode(newMode); + (newMode: MarkdownToHtmlPreviewMode) => { + setPreviewMode(newMode); }, [setPreviewMode], ); @@ -179,26 +175,16 @@ export default function MarkdownToHtmlPage() { flexWrap="wrap" gap={1.5} > - - - - {t('splitMode')} - - - - {t('previewMode')} - - - - {t('htmlMode')} - - + /> - + {qrCodeDataUrl ? ( QR Code diff --git a/pages/QrCode/index.tsx b/pages/QrCode/index.tsx index 41ec931..8f1cb6a 100644 --- a/pages/QrCode/index.tsx +++ b/pages/QrCode/index.tsx @@ -1,4 +1,4 @@ -import { Box, CircularProgress, Container, Stack } from '@mui/material'; +import { Box, CircularProgress, Container, Stack, useMediaQuery, useTheme } from '@mui/material'; import QrCodeIcon from '@mui/icons-material/QrCode'; import UrlToQrCodeSection from '@/pages/QrCode/UrlToQrCodeSection'; import QrCodeToUrlSection from '@/pages/QrCode/QrCodeToUrlSection'; @@ -9,8 +9,10 @@ import { useTranslation } from 'react-i18next'; export default function Index() { const { t } = useTranslation(['qrCode']); + const theme = useTheme(); + const isDesktop = useMediaQuery(theme.breakpoints.up('md')); - // 使用自定义钩子管理展开状态 + // 使用自定义钩子管理展开状态(移动端使用) const [urlExpanded, setUrlExpanded, urlInitialized] = useStorageState('qrCode/urlExpanded', true); const [qrExpanded, setQrExpanded, qrInitialized] = useStorageState('qrCode/qrExpanded', false); @@ -23,9 +25,36 @@ export default function Index() { ); } + const sections = isDesktop ? ( + <> + + + + + + + + ) : ( + <> + + + + ); + return ( - + - - - - - + {isDesktop ? ( + {sections} + ) : ( + {sections} + )} ); diff --git a/pages/StorageCleaner/useStorageCleaner.ts b/pages/StorageCleaner/useStorageCleaner.ts index 68268c0..ddd5020 100644 --- a/pages/StorageCleaner/useStorageCleaner.ts +++ b/pages/StorageCleaner/useStorageCleaner.ts @@ -11,7 +11,7 @@ import { getCacheStorageSize, getCookieSize, getCurrentTab, - getIndexedDBSize, + getOriginStorageEstimate, getLocalStorageSize, getServiceWorkerCount, getSessionStorageSize, @@ -116,7 +116,7 @@ export function useStorageCleaner({ getCookieSize(url), getLocalStorageSize(tabId), getSessionStorageSize(tabId), - getIndexedDBSize(tabId), + getOriginStorageEstimate(tabId), getCacheStorageSize(tabId), getServiceWorkerCount(tabId), ]); @@ -243,7 +243,7 @@ export function useStorageCleaner({ await loadInfo(); } } catch (err) { - showMessage(`${t('common:messages.copyError')}: ${String(err)}`, { severity: 'error' }); + showMessage(`${t('storageCleaner:cleanError')}: ${String(err)}`, { severity: 'error' }); } finally { setLoading(false); setShowConfirm(false); diff --git a/pages/TextStatistics/index.tsx b/pages/TextStatistics/index.tsx index 4687e66..444cabf 100644 --- a/pages/TextStatistics/index.tsx +++ b/pages/TextStatistics/index.tsx @@ -1,6 +1,7 @@ import { useMemo, useState } from 'react'; -import { alpha, Box, Container, Grid, Paper, TextField, Typography } from '@mui/material'; +import { alpha, Box, Container, Grid, Paper, Typography } from '@mui/material'; import PageHeader from '@/components/PageHeader'; +import TextInputArea from '@/components/TextInputArea'; import DescriptionIcon from '@mui/icons-material/Description'; import { formatByteSize, getTextStats } from '@/utils/textStatistics'; import { textStatisticsPageStyles } from '@/config/pageTheme'; @@ -38,38 +39,14 @@ export default function Index() { /> {/* 文本输入区域 */} - setText(e.target.value)} - sx={{ - mb: 3, - '& .MuiOutlinedInput-root': { - borderRadius: 4, - bgcolor: (theme) => - theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.03)' : 'grey.50', - transition: 'all 0.2s', - '& fieldset': { - borderColor: (theme) => - theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.1)' : 'grey.200', - }, - '&:hover fieldset': { - borderColor: (theme) => - theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.2)' : 'grey.300', - }, - '&.Mui-focused fieldset': { - borderColor: textStatisticsPageStyles.primaryColor, - }, - }, - '& .MuiInputBase-input': { - fontSize: '0.9rem', - lineHeight: 1.6, - }, - }} + showClear={false} + sx={{ mb: 3 }} /> {/* 统计结果展示区域 */} diff --git a/pages/Timestamp/LiveClock.tsx b/pages/Timestamp/LiveClock.tsx index f6b96e0..4b0ce7c 100644 --- a/pages/Timestamp/LiveClock.tsx +++ b/pages/Timestamp/LiveClock.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { Box, Divider, IconButton, Stack, Tooltip, Typography } from '@mui/material'; +import { Box, IconButton, Tooltip, Typography } from '@mui/material'; import AccessTimeIcon from '@mui/icons-material/AccessTime'; import CopyButton from '@/components/CopyButton'; import { useSnackbar } from '@/components/GlobalSnackbar'; @@ -10,10 +10,9 @@ import { useTranslation } from 'react-i18next'; interface LiveClockProps { unit: UnitType; onUseNow: (val: number) => void; - onUnitChange: (u: UnitType) => void; } -const LiveClock = React.memo(({ unit, onUseNow, onUnitChange }: LiveClockProps) => { +const LiveClock = React.memo(({ unit, onUseNow }: LiveClockProps) => { const [now, setNow] = useState(() => Date.now()); const { t } = useTranslation(['timestamp']); const { showMessage } = useSnackbar(); @@ -24,8 +23,8 @@ const LiveClock = React.memo(({ unit, onUseNow, onUnitChange }: LiveClockProps) }, [onUseNow]); useEffect(() => { - const t = setInterval(() => setNow(Date.now()), 1000); - return () => clearInterval(t); + const tickId = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(tickId); }, []); const displayVal = useMemo( @@ -40,49 +39,27 @@ const LiveClock = React.memo(({ unit, onUseNow, onUnitChange }: LiveClockProps) return ( - - - {t('timestamp:currentTs')} - - - {displayVal} - - - - - {/* 胶囊式单位切换器 */} - - {(['ms', 's'] as const).map((u) => ( - onUnitChange(u)} - sx={timestampPageStyles.LIVE_CLOCK_UNIT_ITEM(unit === u)} - > - {u.toUpperCase()} - - ))} - - - - - - - - - - - - - + + {t('timestamp:currentTs')} + + + {displayVal} + + + + + + + ); }); diff --git a/pages/Timestamp/ResultView.tsx b/pages/Timestamp/ResultView.tsx index cb42272..3bc5594 100644 --- a/pages/Timestamp/ResultView.tsx +++ b/pages/Timestamp/ResultView.tsx @@ -11,80 +11,90 @@ interface ResultViewProps { mode: 'ts2dt' | 'dt2ts'; unit: UnitType; zone: string; + /** 无结果时是否渲染占位(桌面端右栏使用),默认 false(移动端单栏隐藏) */ + showEmptyPlaceholder?: boolean; } -const ResultView = React.memo(({ result, mode, unit, zone }: ResultViewProps) => { - const { t } = useTranslation(['timestamp']); - const extraInfo = useMemo(() => { - if (!result) return null; - const d = - mode === 'ts2dt' - ? dayjs(result, DATE_FORMAT).tz(zone) - : unit === 'ms' - ? dayjs(Number(result)) - : dayjs.unix(Number(result)); +const ResultView = React.memo( + ({ result, mode, unit, zone, showEmptyPlaceholder = false }: ResultViewProps) => { + const { t } = useTranslation(['timestamp']); - return { - relative: d.fromNow(), - iso: d.toISOString(), - utc: d.utc().format(DATE_FORMAT) + ' UTC', - }; - }, [result, mode, zone, unit]); + const extraInfo = useMemo(() => { + if (!result) return null; + const d = + mode === 'ts2dt' + ? dayjs(result, DATE_FORMAT).tz(zone) + : unit === 'ms' + ? dayjs(Number(result)) + : dayjs.unix(Number(result)); - if (!result) return null; + return { + relative: d.fromNow(), + iso: d.toISOString(), + utc: d.utc().format(DATE_FORMAT) + ' UTC', + }; + }, [result, mode, zone, unit]); - return ( - - - - {t('timestamp:resultLabel')} - + if (!result) { + if (!showEmptyPlaceholder) return null; + return ( + {t('timestamp:resultEmpty')} + ); + } - - - {result} + return ( + + + + {t('timestamp:resultLabel')} - - - - {[ - { label: t('timestamp:relativeTime'), value: extraInfo?.relative }, - { label: t('timestamp:iso8601'), value: extraInfo?.iso }, - { label: t('timestamp:utcTime'), value: extraInfo?.utc }, - ].map((item) => ( - - - {item.label} - - - - {item.value} + + + {result} + + + + + + {[ + { label: t('timestamp:relativeTime'), value: extraInfo?.relative }, + { label: t('timestamp:iso8601'), value: extraInfo?.iso }, + { label: t('timestamp:utcTime'), value: extraInfo?.utc }, + ].map((item) => ( + + + {item.label} - {item.value && ( - - )} + + + {item.value} + + {item.value && ( + + )} + - - ))} - - - - ); -}); + ))} + + + + ); + }, +); ResultView.displayName = 'ResultView'; diff --git a/pages/Timestamp/index.tsx b/pages/Timestamp/index.tsx index 5e8a0fc..2769b90 100644 --- a/pages/Timestamp/index.tsx +++ b/pages/Timestamp/index.tsx @@ -1,16 +1,8 @@ -import { - Box, - Container, - MenuItem, - Select, - Stack, - TextField, - ToggleButton, - ToggleButtonGroup, -} from '@mui/material'; +import { Box, Container, MenuItem, Select, Stack, TextField } from '@mui/material'; import AccessTimeIcon from '@mui/icons-material/AccessTime'; import Button from '@/components/Button'; import PageHeader from '@/components/PageHeader'; +import SwitchButtonGroup from '@/components/SwitchButtonGroup'; import { timestampPageStyles, ZONES } from '@/config/pageTheme'; import LiveClock from './LiveClock'; import ResultView from './ResultView'; @@ -37,7 +29,7 @@ export default function Index() { return ( - + {/* Header */} } /> - {/* Live Clock Card */} - + {/* 参考信息:分栏上方全宽单行参考条 */} + - {/* Mode Switcher */} - newMode && setMode(newMode)} - sx={timestampPageStyles.MODE_SWITCHER} - > - {t('timestamp:tsToDate')} - {t('timestamp:dateToTs')} - + {/* 桌面端 md+ 左右分栏;移动端单栏堆叠 */} + + {/* 左栏:转换工作台 */} + + {/* 模式切换 */} + setMode(newMode)} + size="small" + /> - {/* Input Area */} - - setInput(e.target.value)} - error={!!error} - helperText={error} - fullWidth - sx={timestampPageStyles.INPUT_STYLE} - /> + {/* 输入区 */} + + setInput(e.target.value)} + error={!!error} + helperText={error} + fullWidth + sx={timestampPageStyles.INPUT_STYLE} + /> - - {/* 单位选择按钮组 */} - - {(['ms', 's'] as const).map((u) => ( - setUnit(u)} - sx={timestampPageStyles.UNIT_SWITCHER_ITEM(unit === u)} + {/* 单位+时区紧凑横排 */} + + setUnit(v as 'ms' | 's')} + sx={{ width: 'auto', mb: 0, flexShrink: 0 }} + size="small" + /> + + + + - - - + {/* 立即转换 */} + + - {/* Main Action */} - - - {/* Result View */} - + {/* 右栏:结果展示卡片 */} + + + + ); diff --git a/pages/Timestamp/useTimestampConverter.ts b/pages/Timestamp/useTimestampConverter.ts index a88cb61..c86ba18 100644 --- a/pages/Timestamp/useTimestampConverter.ts +++ b/pages/Timestamp/useTimestampConverter.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useState } from 'react'; import dayjs from '@/utils/dayjs'; import type { UnitType, ZoneType } from '@/config/pageTheme'; import { DATE_FORMAT } from '@/config/pageTheme'; @@ -63,11 +63,6 @@ export function useTimestampConverter(): UseTimestampConverterReturn { } }, [mode, tsInput, dtInput, unit, zone, t]); - useEffect(() => { - const timer = setTimeout(convert, 400); - return () => clearTimeout(timer); - }, [convert]); - const handleUseNow = useCallback( (now: number) => { if (mode === 'ts2dt') { diff --git a/providers/RouterProvider.tsx b/providers/RouterProvider.tsx index d8d5e52..f87c650 100644 --- a/providers/RouterProvider.tsx +++ b/providers/RouterProvider.tsx @@ -1,4 +1,12 @@ -import { createContext, ReactNode, useCallback, useContext, useEffect, useState } from 'react'; +import { + createContext, + ReactNode, + useCallback, + useContext, + useEffect, + useRef, + useState, +} from 'react'; import type { PageType, StorageSchema } from '@/types/storage'; import { storageUtil } from '@/utils/chromeStorage'; import { @@ -56,10 +64,6 @@ interface RouterContextType { isLoaded: boolean; /** 导航到指定页面 */ navigateTo: (page: PageType) => void; - /** 仅在本地(当前组件状态)跳转,不影响其他同步端 */ - navigateLocal: (page: PageType) => void; - /** 强制同步当前路由到存储 */ - syncNavigation: (page: PageType) => void; /** 返回仪表盘 */ goBack: () => void; /** 设置可见页面列表 */ @@ -173,14 +177,22 @@ export function RouterProvider({ } } catch (error) { console.error('加载初始路由数据失败:', error); - } finally { - setIsLoaded(true); } }, [defaultRoute, syncKey, syncRoute, visiblePagesKey, pageOrderKey]); // 组件挂载时加载初始数据 useEffect(() => { - loadInitialData().catch(console.error); + let cancelled = false; + loadInitialData() + .then(() => { + if (!cancelled) { + setIsLoaded(true); + } + }) + .catch(console.error); + return () => { + cancelled = true; + }; }, [loadInitialData]); // 当 currentPage 改变时,如果开启了同步,则持久化到存储和本地快照 @@ -207,6 +219,12 @@ export function RouterProvider({ } }, [pageOrder, isLoaded, pageOrderKey]); + // 用 ref 持有最新 currentPage,避免每次路由跳转都重注册 chrome.storage 监听器 + const currentPageRef = useRef(currentPage); + useEffect(() => { + currentPageRef.current = currentPage; + }, [currentPage]); + /** * 监听存储变化,以便在多个入口(如 Popup 和 Options)之间同步路由和设置 */ @@ -217,7 +235,7 @@ export function RouterProvider({ // 同步当前路由 if (syncRoute && changes[syncKey as string]) { const newRoute = changes[syncKey as string].newValue as PageType; - if (newRoute && newRoute !== currentPage && isValidPage(newRoute)) { + if (newRoute && newRoute !== currentPageRef.current && isValidPage(newRoute)) { setCurrentPage(newRoute); } } @@ -239,7 +257,7 @@ export function RouterProvider({ chrome.storage.onChanged.addListener(handleStorageChange); return () => chrome.storage.onChanged.removeListener(handleStorageChange); - }, [syncRoute, currentPage, syncKey, visiblePagesKey, pageOrderKey]); + }, [syncRoute, syncKey, visiblePagesKey, pageOrderKey]); /** * 跳转到指定页面 @@ -248,20 +266,6 @@ export function RouterProvider({ setCurrentPage(page); }; - /** - * 仅在本地跳转,不触发自动同步(通常由 handleStorageChange 内部调用) - */ - const navigateLocal = (page: PageType) => { - setCurrentPage(page); - }; - - /** - * 手动同步导航状态到存储 - */ - const syncNavigation = (page: PageType) => { - storageUtil.set(syncKey, page as StorageSchema[typeof syncKey]).catch(console.error); - }; - /** * 返回主仪表盘 */ @@ -277,8 +281,6 @@ export function RouterProvider({ pageOrder, isLoaded, navigateTo, - navigateLocal, - syncNavigation, goBack, setVisiblePages, setPageOrder, diff --git a/providers/ThemeModeProvider.tsx b/providers/ThemeModeProvider.tsx index fd592a8..e3826cc 100644 --- a/providers/ThemeModeProvider.tsx +++ b/providers/ThemeModeProvider.tsx @@ -77,16 +77,23 @@ export function ThemeModeProvider({ children }: ThemeModeProviderProps) { // 异步校准:从 chrome.storage 读取持久化值 useEffect(() => { + let cancelled = false; storageUtil .get(THEME_MODE_KEY, 'system') .then((saved) => { + if (cancelled) return; if (isValidMode(saved)) { setModeState(saved); updateResolved(saved); } }) .catch(console.error) - .finally(() => setIsLoaded(true)); + .finally(() => { + if (!cancelled) setIsLoaded(true); + }); + return () => { + cancelled = true; + }; }, [updateResolved]); // 监听系统主题变化(仅在 system 模式下生效) diff --git a/providers/__tests__/RouterProvider.test.tsx b/providers/__tests__/RouterProvider.test.tsx index 61532e5..f637aa9 100644 --- a/providers/__tests__/RouterProvider.test.tsx +++ b/providers/__tests__/RouterProvider.test.tsx @@ -235,4 +235,33 @@ describe('RouterProvider', () => { expect(visiblePages).toContain('base64Converter'); expect(pageOrder).toContain('base64Converter'); }); + + it('组件卸载时不应设置 isLoaded 状态(竞态条件防护)', async () => { + let resolveStorage: (value: unknown) => void; + const storagePromise = new Promise((resolve) => { + resolveStorage = resolve; + }); + + (storageUtil.get as any).mockImplementation(() => storagePromise); + + const { unmount } = render( + + + , + ); + + // 在存储读取完成前卸载组件 + unmount(); + + // 现在让存储读取完成 + resolveStorage!('dashboard'); + + // 等待一段时间确保如果 setState 被调用会触发警告 + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + }); + + // 测试通过的标准:没有 React "Can't perform a React state update on an unmounted component" 警告 + // 如果有竞态条件,这里会输出警告(React 18+ 中已移除该警告,但状态更新仍是无效操作) + }); }); diff --git a/tsconfig.json b/tsconfig.json index 6aa407f..8bc3cae 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -26,7 +26,7 @@ "target": "ESNext", "types": ["chrome", "webextension-polyfill", "@testing-library/jest-dom", "vitest/globals"], - "noImplicitAny": false + "noImplicitAny": true }, // 确保包含你的源代码目录 "include": [ diff --git a/types/qrious.d.ts b/types/qrious.d.ts new file mode 100644 index 0000000..c1e7bbf --- /dev/null +++ b/types/qrious.d.ts @@ -0,0 +1,19 @@ +declare module 'qrious' { + interface QRiousOptions { + value?: string; + size?: number; + level?: 'L' | 'M' | 'Q' | 'H'; + foreground?: string; + background?: string; + padding?: number; + mime?: string; + } + + class QRious { + constructor(options?: QRiousOptions); + toDataURL(mime?: string): string; + set(options: QRiousOptions): void; + } + + export = QRious; +} diff --git a/types/storage.d.ts b/types/storage.d.ts index 563d318..4b55efb 100644 --- a/types/storage.d.ts +++ b/types/storage.d.ts @@ -23,6 +23,11 @@ export type JsonToolsPageMode = 'diff' | 'format' | 'yaml' | 'toml' | 'minify'; */ export type Base64ConverterPageMode = 'text' | 'file' | 'image'; +/** + * Base64 转换器中各子模式的编码/解码方向 + */ +export type Base64ConvertDirection = 'encode' | 'decode'; + /** * Markdown 转 HTML 页面预览模式类型定义 */ @@ -71,7 +76,7 @@ export interface FormMapEntry { * 定义了所有持久化在客户端的数据结构 */ export interface StorageSchema { - /** 全局当前路由 */ + /** RouterProvider 的默认路由键,仅当未显式传入 syncKey 时使用(popup/sidepanel/tab 入口已各自覆盖) */ 'app/currentRoute': PageType; /** Popup 窗口的当前路由 */ 'app/popupRoute': PageType; @@ -79,9 +84,9 @@ export interface StorageSchema { 'app/sidepanelRoute': PageType; /** 标签页的当前路由 */ 'app/tabRoute': PageType; - /** 在菜单中可见的页面列表 (通用/旧版) */ + /** RouterProvider 的默认可见页面列表键,仅当未显式传入 visiblePagesKey 时使用 */ 'app/visiblePages': PageType[]; - /** 菜单页面的显示顺序 (通用/旧版) */ + /** RouterProvider 的默认页面排序键,仅当未显式传入 pageOrderKey 时使用 */ 'app/pageOrder': PageType[]; /** Popup 窗口可见的页面列表 */ 'app/popupVisiblePages': PageType[]; @@ -95,8 +100,6 @@ export interface StorageSchema { 'app/tabVisiblePages': PageType[]; /** 标签页页面的显示顺序 */ 'app/tabPageOrder': PageType[]; - /** 上一次访问的路由路径(备用) */ - 'app/lastRoute': string; /** 应用主题配置 */ 'app/theme': string; /** 主题模式偏好(light/dark/system) */ @@ -113,6 +116,10 @@ export interface StorageSchema { 'jsonTools/pageMode': JsonToolsPageMode; /** Base64 转换器页面当前子模式 */ 'base64Converter/pageMode': Base64ConverterPageMode; + /** Base64 转换器「文件」子模式当前方向 */ + 'base64Converter/fileMode/direction': Base64ConvertDirection; + /** Base64 转换器「图像」子模式当前方向 */ + 'base64Converter/imageMode/direction': Base64ConvertDirection; /** Markdown 转 HTML 页面当前预览模式 */ 'markdownToHtml/previewMode': MarkdownToHtmlPreviewMode; /** HTML 转 Markdown 页面当前预览模式 */ diff --git a/utils/__tests__/base64Converter.test.ts b/utils/__tests__/base64Converter.test.ts index a9396a8..37a7ef9 100644 --- a/utils/__tests__/base64Converter.test.ts +++ b/utils/__tests__/base64Converter.test.ts @@ -8,6 +8,9 @@ import { isSupportedImageExtension, extractMimeTypeFromDataUri, formatFileSize, + base64ToBytes, + sniffMimeFromBytes, + base64ToBlob, MAX_FILE_SIZE, } from '@/utils/base64Converter'; @@ -67,6 +70,28 @@ describe('base64ToText', () => { const result = base64ToText(' SGVsbG8= '); expect(result).toBe('Hello'); }); + + it('应该自动剥离 data:;base64, 前缀后再解码', () => { + // "hello" -> base64 "aGVsbG8=" + const result = base64ToText('data:text/plain;base64,aGVsbG8='); + expect(result).toBe('hello'); + }); + + it('应该剥离带空白的 data URI 前缀', () => { + const result = base64ToText(' data:text/plain;base64,aGVsbG8= '); + expect(result).toBe('hello'); + }); + + it('应该对二进制(如 PNG)数据抛出更清晰的错误', () => { + // PNG 文件签名 89 50 4E 47 0D 0A 1A 0A 的 Base64 编码 + const pngSignatureBase64 = 'iVBORw0KGgo='; + expect(() => base64ToText(pngSignatureBase64)).toThrow(/binary|二进制|image|图像/i); + }); + + it('应该对带 data:image/png 前缀的 PNG 数据抛出二进制错误', () => { + const pngDataUri = 'data:image/png;base64,iVBORw0KGgo='; + expect(() => base64ToText(pngDataUri)).toThrow(/binary|二进制|image|图像/i); + }); }); describe('isValidBase64', () => { @@ -167,3 +192,149 @@ describe('formatFileSize', () => { expect(formatFileSize(1073741824)).toBe('1.00 GB'); }); }); + +describe('base64ToBytes', () => { + it('应该解码标准 ASCII Base64 为字节序列', () => { + const bytes = base64ToBytes('aGVsbG8='); + expect(Array.from(bytes)).toEqual([0x68, 0x65, 0x6c, 0x6c, 0x6f]); + }); + + it('应该正确解码 PNG 文件签名', () => { + // PNG 签名:89 50 4E 47 0D 0A 1A 0A + const bytes = base64ToBytes('iVBORw0KGgo='); + expect(Array.from(bytes)).toEqual([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + }); + + it('应该返回空 Uint8Array 对于空字符串输入', () => { + const bytes = base64ToBytes(''); + expect(bytes.length).toBe(0); + }); +}); + +describe('sniffMimeFromBytes', () => { + it('应该识别 PNG', () => { + const bytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + expect(sniffMimeFromBytes(bytes)).toEqual({ mime: 'image/png', ext: '.png' }); + }); + + it('应该识别 JPEG', () => { + const bytes = new Uint8Array([0xff, 0xd8, 0xff, 0xe0]); + expect(sniffMimeFromBytes(bytes)).toEqual({ mime: 'image/jpeg', ext: '.jpg' }); + }); + + it('应该识别 GIF', () => { + const bytes = new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]); + expect(sniffMimeFromBytes(bytes)).toEqual({ mime: 'image/gif', ext: '.gif' }); + }); + + it('应该识别 BMP', () => { + const bytes = new Uint8Array([0x42, 0x4d, 0x36, 0x00, 0x00, 0x00]); + expect(sniffMimeFromBytes(bytes)).toEqual({ mime: 'image/bmp', ext: '.bmp' }); + }); + + it('应该识别 WebP(RIFF....WEBP 复合签名)', () => { + const bytes = new Uint8Array([ + 0x52, + 0x49, + 0x46, + 0x46, // "RIFF" + 0x24, + 0x00, + 0x00, + 0x00, // 文件大小占位 + 0x57, + 0x45, + 0x42, + 0x50, // "WEBP" + 0x56, + 0x50, + 0x38, + 0x20, // "VP8 " 子块 + ]); + expect(sniffMimeFromBytes(bytes)).toEqual({ mime: 'image/webp', ext: '.webp' }); + }); + + it('不应将 WAV(RIFF 容器但非 WEBP)识别为 WebP', () => { + const bytes = new Uint8Array([ + 0x52, + 0x49, + 0x46, + 0x46, // "RIFF" + 0x24, + 0x00, + 0x00, + 0x00, + 0x57, + 0x41, + 0x56, + 0x45, // "WAVE" + ]); + expect(sniffMimeFromBytes(bytes)).toBeNull(); + }); + it('应该识别 PDF', () => { + const bytes = new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x2d]); + expect(sniffMimeFromBytes(bytes)).toEqual({ mime: 'application/pdf', ext: '.pdf' }); + }); + + it('应该识别 ZIP', () => { + const bytes = new Uint8Array([0x50, 0x4b, 0x03, 0x04]); + expect(sniffMimeFromBytes(bytes)).toEqual({ mime: 'application/zip', ext: '.zip' }); + }); + + it('应该对未匹配的字节返回 null', () => { + const bytes = new Uint8Array([0x00, 0x01, 0x02, 0x03]); + expect(sniffMimeFromBytes(bytes)).toBeNull(); + }); + + it('应该对过短的字节返回 null', () => { + const bytes = new Uint8Array([0x89]); + expect(sniffMimeFromBytes(bytes)).toBeNull(); + }); +}); + +describe('base64ToBlob', () => { + it('应该优先使用 data URI 中的 MIME 类型', () => { + const result = base64ToBlob('data:application/json;base64,eyJhIjoxfQ=='); + expect(result.mimeType).toBe('application/json'); + expect(result.blob).toBeInstanceOf(Blob); + expect(result.blob.size).toBe(7); + }); + + it('应该通过魔数识别 PNG', () => { + const result = base64ToBlob('iVBORw0KGgo='); + expect(result.mimeType).toBe('image/png'); + expect(result.suggestedExtension).toBe('.png'); + }); + + it('应该通过魔数识别 PDF', () => { + // "%PDF-" + 一些字节 + const result = base64ToBlob('JVBERi0K'); + expect(result.mimeType).toBe('application/pdf'); + expect(result.suggestedExtension).toBe('.pdf'); + }); + + it('应该对未匹配的纯 Base64 回退为 application/octet-stream + .bin', () => { + const result = base64ToBlob('AAECAwQF'); + expect(result.mimeType).toBe('application/octet-stream'); + expect(result.suggestedExtension).toBe('.bin'); + }); + + it('应该 trim 前后空白', () => { + const result = base64ToBlob(' iVBORw0KGgo= '); + expect(result.mimeType).toBe('image/png'); + }); + + it('应该对非法 Base64 抛出 Invalid Base64 string', () => { + expect(() => base64ToBlob('这不是 base64!')).toThrow('Invalid Base64 string'); + }); + + it('应该返回原始 Base64(已去除 data URI 前缀)', () => { + const result = base64ToBlob('data:image/png;base64,iVBORw0KGgo='); + expect(result.rawBase64).toBe('iVBORw0KGgo='); + }); + + it('blob 大小应该等于解码后的字节数', () => { + const result = base64ToBlob('aGVsbG8='); + expect(result.blob.size).toBe(5); + }); +}); diff --git a/utils/__tests__/chromeStorage.test.ts b/utils/__tests__/chromeStorage.test.ts new file mode 100644 index 0000000..3a96e07 --- /dev/null +++ b/utils/__tests__/chromeStorage.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from 'vitest'; +import { storageUtil } from '@/utils/chromeStorage'; + +describe('chromeStorage', () => { + describe('get', () => { + it('应该返回存储的值', async () => { + (chrome.storage.local.get as any).mockResolvedValue({ 'app/theme': 'dark' }); + + const result = await storageUtil.get('app/theme'); + + expect(result).toBe('dark'); + expect(chrome.storage.local.get).toHaveBeenCalledWith(['app/theme']); + }); + + it('当键不存在时应返回默认值', async () => { + (chrome.storage.local.get as any).mockResolvedValue({}); + + const result = await storageUtil.get('app/theme', 'light'); + + expect(result).toBe('light'); + }); + + it('当键不存在且未提供默认值时应返回 undefined', async () => { + (chrome.storage.local.get as any).mockResolvedValue({}); + + const result = await storageUtil.get('app/theme'); + + expect(result).toBeUndefined(); + }); + + it('应该支持布尔类型值', async () => { + (chrome.storage.local.get as any).mockResolvedValue({ 'qrCode/qrExpanded': true }); + + const result = await storageUtil.get('qrCode/qrExpanded'); + + expect(result).toBe(true); + }); + + it('应该支持数组类型值', async () => { + const pages = ['dashboard', 'timestamp'] as const; + (chrome.storage.local.get as any).mockResolvedValue({ 'app/visiblePages': pages }); + + const result = await storageUtil.get('app/visiblePages'); + + expect(result).toEqual(pages); + }); + + it('应该支持复杂对象类型值', async () => { + const preferences = { + autoRefresh: true, + selectedTypes: { + localStorage: true, + sessionStorage: false, + indexedDB: true, + cookies: false, + cacheStorage: false, + serviceWorkers: false, + }, + }; + (chrome.storage.local.get as any).mockResolvedValue({ + 'storageCleaner/preferences': preferences, + }); + + const result = await storageUtil.get('storageCleaner/preferences'); + + expect(result).toEqual(preferences); + }); + + it('当存储值为 null 时应返回默认值', async () => { + (chrome.storage.local.get as any).mockResolvedValue({ 'app/theme': null }); + + const result = await storageUtil.get('app/theme', 'light'); + + expect(result).toBe('light'); + }); + }); + + describe('set', () => { + it('应该成功设置字符串值', async () => { + (chrome.storage.local.set as any).mockResolvedValue(undefined); + + await storageUtil.set('app/theme', 'dark'); + + expect(chrome.storage.local.set).toHaveBeenCalledWith({ 'app/theme': 'dark' }); + }); + + it('应该成功设置布尔值', async () => { + await storageUtil.set('qrCode/qrExpanded', true); + + expect(chrome.storage.local.set).toHaveBeenCalledWith({ 'qrCode/qrExpanded': true }); + }); + + it('应该成功设置数组值', async () => { + const pages: Array<'dashboard' | 'timestamp'> = ['dashboard', 'timestamp']; + await storageUtil.set('app/visiblePages', pages); + + expect(chrome.storage.local.set).toHaveBeenCalledWith({ 'app/visiblePages': pages }); + }); + + it('应该成功设置复杂对象值', async () => { + const preferences = { + autoRefresh: false, + selectedTypes: { + localStorage: true, + sessionStorage: true, + indexedDB: false, + cookies: false, + cacheStorage: false, + serviceWorkers: false, + }, + }; + await storageUtil.set('storageCleaner/preferences', preferences); + + expect(chrome.storage.local.set).toHaveBeenCalledWith({ + 'storageCleaner/preferences': preferences, + }); + }); + + it('应该成功设置枚举类型值', async () => { + await storageUtil.set('jsonTools/pageMode', 'yaml'); + + expect(chrome.storage.local.set).toHaveBeenCalledWith({ 'jsonTools/pageMode': 'yaml' }); + }); + }); + + describe('remove', () => { + it('应该成功删除指定键', async () => { + await storageUtil.remove('app/theme'); + + expect(chrome.storage.local.remove).toHaveBeenCalledWith(['app/theme']); + }); + + it('应该成功删除不同键', async () => { + await storageUtil.remove('qrCode/qrExpanded'); + + expect(chrome.storage.local.remove).toHaveBeenCalledWith(['qrCode/qrExpanded']); + }); + }); +}); diff --git a/utils/__tests__/chromeTabs.test.ts b/utils/__tests__/chromeTabs.test.ts new file mode 100644 index 0000000..2f7e4f8 --- /dev/null +++ b/utils/__tests__/chromeTabs.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + getActiveTab, + getActiveTabDomain, + openExtensionPage, + ensureContentScriptInjected, +} from '@/utils/chromeTabs'; + +describe('chromeTabs', () => { + describe('getActiveTab', () => { + it('应该返回当前活动标签页', async () => { + const mockTab = { id: 1, url: 'https://example.com', title: 'Example' } as chrome.tabs.Tab; + (chrome.tabs.query as any).mockResolvedValue([mockTab]); + + const result = await getActiveTab(); + + expect(result).toEqual(mockTab); + expect(chrome.tabs.query).toHaveBeenCalledWith({ active: true, currentWindow: true }); + }); + + it('当没有活动标签页时应返回 null', async () => { + (chrome.tabs.query as any).mockResolvedValue([]); + + const result = await getActiveTab(); + + expect(result).toBeNull(); + }); + + it('当查询失败时应返回 null 并记录错误', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + (chrome.tabs.query as any).mockRejectedValue(new Error('Permission denied')); + + const result = await getActiveTab(); + + expect(result).toBeNull(); + expect(consoleSpy).toHaveBeenCalledWith('获取活动标签页失败:', expect.any(Error)); + consoleSpy.mockRestore(); + }); + }); + + describe('getActiveTabDomain', () => { + it('应该返回当前活动标签页的域名', async () => { + const mockTab = { id: 1, url: 'https://example.com/path?query=1' } as chrome.tabs.Tab; + (chrome.tabs.query as any).mockResolvedValue([mockTab]); + + const result = await getActiveTabDomain(); + + expect(result).toBe('example.com'); + }); + + it('应该处理带有端口的 URL', async () => { + const mockTab = { id: 1, url: 'https://example.com:8080/path' } as chrome.tabs.Tab; + (chrome.tabs.query as any).mockResolvedValue([mockTab]); + + const result = await getActiveTabDomain(); + + expect(result).toBe('example.com'); + }); + + it('当标签页没有 URL 时应返回空字符串', async () => { + const mockTab = { id: 1 } as chrome.tabs.Tab; + (chrome.tabs.query as any).mockResolvedValue([mockTab]); + + const result = await getActiveTabDomain(); + + expect(result).toBe(''); + }); + + it('当没有活动标签页时应返回空字符串', async () => { + (chrome.tabs.query as any).mockResolvedValue([]); + + const result = await getActiveTabDomain(); + + expect(result).toBe(''); + }); + + it('当 URL 解析失败时应返回空字符串并记录错误', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const mockTab = { id: 1, url: 'not-a-valid-url' } as chrome.tabs.Tab; + (chrome.tabs.query as any).mockResolvedValue([mockTab]); + + const result = await getActiveTabDomain(); + + expect(result).toBe(''); + expect(consoleSpy).toHaveBeenCalledWith('解析域名失败:', expect.any(Error)); + consoleSpy.mockRestore(); + }); + + it('应该处理 chrome-extension URL', async () => { + const mockTab = { id: 1, url: 'chrome-extension://abc123/popup.html' } as chrome.tabs.Tab; + (chrome.tabs.query as any).mockResolvedValue([mockTab]); + + const result = await getActiveTabDomain(); + + expect(result).toBe('abc123'); + }); + }); + + describe('openExtensionPage', () => { + it('应该在新标签页中打开扩展页面', async () => { + await openExtensionPage('popup.html'); + + expect(chrome.runtime.getURL).toHaveBeenCalledWith('popup.html'); + expect(chrome.tabs.create).toHaveBeenCalledWith({ + url: 'chrome-extension://test-extension-id/popup.html', + }); + }); + + it('应该支持带查询参数的扩展页面', async () => { + await openExtensionPage('options.html', { tab: 'settings', id: '123' }); + + expect(chrome.tabs.create).toHaveBeenCalledWith({ + url: 'chrome-extension://test-extension-id/options.html?tab=settings&id=123', + }); + }); + + it('当创建标签页失败时应记录错误', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + (chrome.tabs.create as any).mockRejectedValue(new Error('Tab creation failed')); + + await openExtensionPage('popup.html'); + + expect(consoleSpy).toHaveBeenCalledWith('打开扩展页面失败:', expect.any(Error)); + consoleSpy.mockRestore(); + }); + }); + + describe('ensureContentScriptInjected', () => { + it('当存在活动标签页时应返回 true', async () => { + const mockTab = { id: 123, url: 'https://example.com' } as chrome.tabs.Tab; + (chrome.tabs.query as any).mockResolvedValue([mockTab]); + + const result = await ensureContentScriptInjected(); + + expect(result).toBe(true); + }); + + it('当没有活动标签页时应返回 false', async () => { + (chrome.tabs.query as any).mockResolvedValue([]); + + const result = await ensureContentScriptInjected(); + + expect(result).toBe(false); + }); + + it('当标签页没有 id 时应返回 false', async () => { + const mockTab = { url: 'https://example.com' } as chrome.tabs.Tab; + (chrome.tabs.query as any).mockResolvedValue([mockTab]); + + const result = await ensureContentScriptInjected(); + + expect(result).toBe(false); + }); + + it('当整体操作失败时应返回 false 并记录错误', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + (chrome.tabs.query as any).mockRejectedValue(new Error('Query failed')); + + const result = await ensureContentScriptInjected(); + + expect(result).toBe(false); + // getActiveTab catches the error and logs "获取活动标签页失败" + expect(consoleSpy).toHaveBeenCalledWith('获取活动标签页失败:', expect.any(Error)); + consoleSpy.mockRestore(); + }); + }); +}); diff --git a/utils/__tests__/clipboard.test.ts b/utils/__tests__/clipboard.test.ts new file mode 100644 index 0000000..568cb14 --- /dev/null +++ b/utils/__tests__/clipboard.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it, vi, beforeAll } from 'vitest'; +import { copyTextToClipboard, copyImageToClipboard } from '@/utils/clipboard'; + +// Mock ClipboardItem for test environment +class MockClipboardItem { + constructor(public items: Record) {} +} + +beforeAll(() => { + (globalThis as any).ClipboardItem = MockClipboardItem; +}); + +describe('clipboard', () => { + describe('copyTextToClipboard', () => { + it('复制成功时应返回 true', async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.assign(navigator, { clipboard: { writeText } }); + + const result = await copyTextToClipboard('test text'); + + expect(result).toBe(true); + expect(writeText).toHaveBeenCalledWith('test text'); + }); + + it('复制失败时应返回 false', async () => { + const writeText = vi.fn().mockRejectedValue(new Error('Permission denied')); + Object.assign(navigator, { clipboard: { writeText } }); + + const result = await copyTextToClipboard('test text'); + + expect(result).toBe(false); + }); + }); + + describe('copyImageToClipboard', () => { + it('复制成功时应返回 true', async () => { + const write = vi.fn().mockResolvedValue(undefined); + Object.assign(navigator, { clipboard: { write } }); + + const blob = new Blob(['png data'], { type: 'image/png' }); + const result = await copyImageToClipboard(blob); + + expect(result).toBe(true); + expect(write).toHaveBeenCalledTimes(1); + }); + + it('复制失败时应返回 false', async () => { + const write = vi.fn().mockRejectedValue(new Error('Permission denied')); + Object.assign(navigator, { clipboard: { write } }); + + const blob = new Blob(['png data'], { type: 'image/png' }); + const result = await copyImageToClipboard(blob); + + expect(result).toBe(false); + }); + }); +}); diff --git a/utils/__tests__/dayjs.test.ts b/utils/__tests__/dayjs.test.ts new file mode 100644 index 0000000..94436ba --- /dev/null +++ b/utils/__tests__/dayjs.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest'; +import dayjs from '@/utils/dayjs'; + +describe('dayjs', () => { + it('应该正确导出 dayjs 实例', () => { + expect(dayjs).toBeDefined(); + expect(typeof dayjs).toBe('function'); + }); + + it('应该支持基本日期解析', () => { + const date = dayjs('2024-01-15'); + expect(date.isValid()).toBe(true); + expect(date.year()).toBe(2024); + expect(date.month()).toBe(0); + expect(date.date()).toBe(15); + }); + + it('应该支持 UTC 插件', () => { + const utcDate = dayjs.utc('2024-01-15T12:00:00Z'); + expect(utcDate.isValid()).toBe(true); + expect(utcDate.format()).toContain('2024-01-15'); + }); + + it('应该支持时区插件', () => { + const date = dayjs('2024-01-15T12:00:00'); + expect(date.tz).toBeDefined(); + expect(typeof date.tz).toBe('function'); + + const shanghaiDate = date.tz('Asia/Shanghai'); + expect(shanghaiDate.isValid()).toBe(true); + }); + + it('应该支持相对时间插件', () => { + const now = dayjs(); + expect(now.fromNow).toBeDefined(); + expect(typeof now.fromNow).toBe('function'); + + const yesterday = dayjs().subtract(1, 'day'); + const fromNow = yesterday.fromNow(); + expect(typeof fromNow).toBe('string'); + expect(fromNow.length).toBeGreaterThan(0); + }); + + it('应该使用中文 locale', () => { + // 显式设置中文 locale + dayjs.locale('zh-cn'); + const yesterday = dayjs().subtract(1, 'day'); + const fromNow = yesterday.fromNow(); + + // 中文相对时间应包含 "天前" + expect(fromNow).toContain('天前'); + }); + + it('应该支持日期格式化', () => { + const date = dayjs('2024-01-15T10:30:00'); + expect(date.format('YYYY-MM-DD')).toBe('2024-01-15'); + expect(date.format('YYYY年MM月DD日')).toBe('2024年01月15日'); + }); + + it('应该支持日期计算', () => { + const date = dayjs('2024-01-15'); + const nextDay = date.add(1, 'day'); + expect(nextDay.date()).toBe(16); + + const prevMonth = date.subtract(1, 'month'); + expect(prevMonth.month()).toBe(11); + expect(prevMonth.year()).toBe(2023); + }); + + it('应该支持日期比较', () => { + const date1 = dayjs('2024-01-15'); + const date2 = dayjs('2024-01-20'); + + expect(date1.isBefore(date2)).toBe(true); + expect(date2.isAfter(date1)).toBe(true); + expect(date1.isSame(date2)).toBe(false); + }); + + it('应该支持 Unix 时间戳转换', () => { + const timestamp = 1705315200; // 2024-01-15 12:00:00 UTC + const date = dayjs.unix(timestamp); + + expect(date.isValid()).toBe(true); + expect(date.year()).toBe(2024); + }); + + it('应该支持毫秒时间戳', () => { + const timestamp = 1705315200000; + const date = dayjs(timestamp); + + expect(date.isValid()).toBe(true); + expect(date.year()).toBe(2024); + }); +}); diff --git a/utils/__tests__/format.test.ts b/utils/__tests__/format.test.ts new file mode 100644 index 0000000..a0f1fe3 --- /dev/null +++ b/utils/__tests__/format.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; +import { formatBytes } from '@/utils/format'; + +describe('formatBytes', () => { + it('should format 0 bytes', () => { + expect(formatBytes(0)).toBe('0 B'); + }); + + it('should format bytes less than 1024', () => { + expect(formatBytes(1)).toBe('1 B'); + expect(formatBytes(100)).toBe('100 B'); + expect(formatBytes(500)).toBe('500 B'); + expect(formatBytes(512)).toBe('512 B'); + expect(formatBytes(1023)).toBe('1023 B'); + }); + + it('should format kilobytes', () => { + expect(formatBytes(1024)).toBe('1.0 KB'); + expect(formatBytes(1025)).toBe('1.0 KB'); + expect(formatBytes(1536)).toBe('1.5 KB'); + expect(formatBytes(2048)).toBe('2.0 KB'); + }); + + it('should format megabytes', () => { + expect(formatBytes(1048576)).toBe('1.00 MB'); + expect(formatBytes(1572864)).toBe('1.50 MB'); + expect(formatBytes(5242880)).toBe('5.00 MB'); + }); + + it('should format gigabytes', () => { + expect(formatBytes(1073741824)).toBe('1.00 GB'); + expect(formatBytes(2147483648)).toBe('2.00 GB'); + }); + + it('should format terabytes', () => { + expect(formatBytes(1099511627776)).toBe('1.00 TB'); + expect(formatBytes(2199023255552)).toBe('2.00 TB'); + }); + + it('should handle large values beyond TB', () => { + // Should cap at TB + expect(formatBytes(1125899906842624)).toBe('1024.00 TB'); + }); +}); diff --git a/utils/__tests__/jwt.test.ts b/utils/__tests__/jwt.test.ts index ccb62d2..31538c6 100644 --- a/utils/__tests__/jwt.test.ts +++ b/utils/__tests__/jwt.test.ts @@ -37,7 +37,8 @@ describe('jwt utils', () => { describe('parseJwt', () => { it('should return error for invalid format', () => { const result = parseJwt('invalid-token'); - expect(result.error).toContain('格式错误'); + expect(result.error).toBeDefined(); + expect(result.error?.length).toBeGreaterThan(0); }); it('should parse a valid JWT structure', () => { diff --git a/utils/__tests__/messages.test.ts b/utils/__tests__/messages.test.ts new file mode 100644 index 0000000..7468edb --- /dev/null +++ b/utils/__tests__/messages.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { sendMessageToContent, MessageAction } from '@/utils/messages'; + +// Mock @webext-core/messaging - vi.mock is hoisted, so we define mock inside factory +vi.mock('@webext-core/messaging', () => { + const mockSendMessage = vi.fn(); + return { + defineExtensionMessaging: () => ({ + sendMessage: mockSendMessage, + onMessage: vi.fn(), + }), + // Export the mock so we can access it in tests + __mockSendMessage: mockSendMessage, + }; +}); + +// Helper to get the mock function from the mocked module +async function getMockSendMessage() { + const mod = await import('@webext-core/messaging'); + return (mod as any).__mockSendMessage; +} + +describe('messages', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('sendMessageToContent', () => { + it('应该成功发送消息到内容脚本并返回响应', async () => { + const mockSendMessage = await getMockSendMessage(); + const mockResponse = { success: true }; + mockSendMessage.mockResolvedValue(mockResponse); + + const mockTab = { id: 123, url: 'https://example.com' }; + (chrome.tabs.query as any).mockResolvedValue([mockTab]); + + const result = await sendMessageToContent(MessageAction.RELOAD_TAB, { tabId: 123 }); + + expect(result).toEqual(mockResponse); + expect(mockSendMessage).toHaveBeenCalledWith(MessageAction.RELOAD_TAB, { tabId: 123 }, 123); + }); + + it('应该支持不带数据的消息发送', async () => { + const mockSendMessage = await getMockSendMessage(); + mockSendMessage.mockResolvedValue(undefined); + + const mockTab = { id: 456, url: 'https://example.com' }; + (chrome.tabs.query as any).mockResolvedValue([mockTab]); + + await sendMessageToContent(MessageAction.SIDE_PANEL_STATE_CHANGED, { isOpen: true }); + + expect(mockSendMessage).toHaveBeenCalledWith( + MessageAction.SIDE_PANEL_STATE_CHANGED, + { isOpen: true }, + 456, + ); + }); + + it('当无法获取当前标签页时应返回错误', async () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + (chrome.tabs.query as any).mockResolvedValue([]); + + const result = await sendMessageToContent(MessageAction.RELOAD_TAB, { tabId: 123 }); + + expect(result).toEqual({ success: false, message: '无法获取当前标签页' }); + expect(consoleSpy).toHaveBeenCalledWith( + '[Messaging] 无法获取当前标签页,无法发送动作: reloadTab', + ); + consoleSpy.mockRestore(); + }); + + it('当标签页没有 id 时应返回错误', async () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + (chrome.tabs.query as any).mockResolvedValue([{ url: 'https://example.com' }]); + + const result = await sendMessageToContent(MessageAction.RELOAD_TAB, { tabId: 123 }); + + expect(result).toEqual({ success: false, message: '无法获取当前标签页' }); + consoleSpy.mockRestore(); + }); + + it('当连接无法建立时应返回特定错误消息', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const mockSendMessage = await getMockSendMessage(); + mockSendMessage.mockRejectedValue(new Error('Could not establish connection')); + + const mockTab = { id: 123, url: 'https://example.com' }; + (chrome.tabs.query as any).mockResolvedValue([mockTab]); + + const result = await sendMessageToContent(MessageAction.RELOAD_TAB, { tabId: 123 }); + + expect(result).toEqual({ + success: false, + message: '无法连接到网页,请刷新页面后再试', + }); + consoleSpy.mockRestore(); + }); + + it('当响应超时时应返回特定错误消息', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const mockSendMessage = await getMockSendMessage(); + mockSendMessage.mockRejectedValue(new Error('No response received')); + + const mockTab = { id: 123, url: 'https://example.com' }; + (chrome.tabs.query as any).mockResolvedValue([mockTab]); + + const result = await sendMessageToContent(MessageAction.RELOAD_TAB, { tabId: 123 }); + + expect(result).toEqual({ + success: false, + message: '网页响应超时,请重试', + }); + consoleSpy.mockRestore(); + }); + + it('当发生其他错误时应返回通用错误消息', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const mockSendMessage = await getMockSendMessage(); + mockSendMessage.mockRejectedValue(new Error('Unknown error')); + + const mockTab = { id: 123, url: 'https://example.com' }; + (chrome.tabs.query as any).mockResolvedValue([mockTab]); + + const result = await sendMessageToContent(MessageAction.RELOAD_TAB, { tabId: 123 }); + + expect(result).toEqual({ + success: false, + message: '通信失败: Unknown error', + }); + consoleSpy.mockRestore(); + }); + + it('当错误不是 Error 实例时应正确处理字符串错误', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const mockSendMessage = await getMockSendMessage(); + mockSendMessage.mockRejectedValue('string error'); + + const mockTab = { id: 123, url: 'https://example.com' }; + (chrome.tabs.query as any).mockResolvedValue([mockTab]); + + const result = await sendMessageToContent(MessageAction.RELOAD_TAB, { tabId: 123 }); + + expect(result).toEqual({ + success: false, + message: '通信失败: string error', + }); + consoleSpy.mockRestore(); + }); + + it('当 tabs.query 失败时应返回错误', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + (chrome.tabs.query as any).mockRejectedValue(new Error('Query failed')); + + const result = await sendMessageToContent(MessageAction.RELOAD_TAB, { tabId: 123 }); + + expect(result).toEqual({ + success: false, + message: '通信失败: Query failed', + }); + consoleSpy.mockRestore(); + }); + }); +}); diff --git a/utils/__tests__/qrCodeParser.test.ts b/utils/__tests__/qrCodeParser.test.ts new file mode 100644 index 0000000..65573f7 --- /dev/null +++ b/utils/__tests__/qrCodeParser.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it, vi } from 'vitest'; +import { parseQrCodeFromFile } from '@/utils/qrCodeParser'; +import QrScanner from 'qr-scanner'; + +// Mock qr-scanner +vi.mock('qr-scanner', () => ({ + default: { + scanImage: vi.fn(), + }, +})); + +describe('qrCodeParser', () => { + describe('parseQrCodeFromFile', () => { + it('应该成功解析二维码并返回数据', async () => { + const mockResult = { data: 'https://example.com', cornerPoints: [] }; + (QrScanner.scanImage as any).mockResolvedValue(mockResult); + + const mockFile = new File(['mock-image-data'], 'qrcode.png', { type: 'image/png' }); + const result = await parseQrCodeFromFile(mockFile); + + expect(result.success).toBe(true); + expect(result.data).toBe('https://example.com'); + expect(QrScanner.scanImage).toHaveBeenCalledWith(mockFile, { + returnDetailedScanResult: true, + }); + }); + + it('当未检测到二维码时应返回错误', async () => { + const mockResult = { data: '', cornerPoints: [] }; + (QrScanner.scanImage as any).mockResolvedValue(mockResult); + + const mockFile = new File(['mock-image-data'], 'no-qr.png', { type: 'image/png' }); + const result = await parseQrCodeFromFile(mockFile); + + expect(result.success).toBe(false); + expect(result.error).toBe('未检测到二维码'); + }); + + it('当 scanImage 返回 null 时应返回错误', async () => { + (QrScanner.scanImage as any).mockResolvedValue(null); + + const mockFile = new File(['mock-image-data'], 'empty.png', { type: 'image/png' }); + const result = await parseQrCodeFromFile(mockFile); + + expect(result.success).toBe(false); + expect(result.error).toBe('未检测到二维码'); + }); + + it('当抛出 "No QR code found" 时应返回中文错误', async () => { + (QrScanner.scanImage as any).mockRejectedValue('No QR code found'); + + const mockFile = new File(['mock-image-data'], 'no-qr.png', { type: 'image/png' }); + const result = await parseQrCodeFromFile(mockFile); + + expect(result.success).toBe(false); + expect(result.error).toBe('未检测到二维码'); + }); + + it('当抛出 Error 实例时应返回错误消息', async () => { + (QrScanner.scanImage as any).mockRejectedValue(new Error('Image format not supported')); + + const mockFile = new File(['mock-image-data'], 'bad.png', { type: 'image/png' }); + const result = await parseQrCodeFromFile(mockFile); + + expect(result.success).toBe(false); + expect(result.error).toBe('Image format not supported'); + }); + + it('当抛出非 Error 非字符串值时应正确转换', async () => { + (QrScanner.scanImage as any).mockRejectedValue(12345); + + const mockFile = new File(['mock-image-data'], 'error.png', { type: 'image/png' }); + const result = await parseQrCodeFromFile(mockFile); + + expect(result.success).toBe(false); + expect(result.error).toBe('12345'); + }); + + it('当抛出对象时应正确转换为字符串', async () => { + (QrScanner.scanImage as any).mockRejectedValue({ message: 'custom error' }); + + const mockFile = new File(['mock-image-data'], 'error.png', { type: 'image/png' }); + const result = await parseQrCodeFromFile(mockFile); + + expect(result.success).toBe(false); + expect(result.error).toBe('[object Object]'); + }); + + it('应该处理包含中文内容的二维码', async () => { + const mockResult = { data: 'https://example.com/中文路径', cornerPoints: [] }; + (QrScanner.scanImage as any).mockResolvedValue(mockResult); + + const mockFile = new File(['mock-image-data'], 'chinese.png', { type: 'image/png' }); + const result = await parseQrCodeFromFile(mockFile); + + expect(result.success).toBe(true); + expect(result.data).toBe('https://example.com/中文路径'); + }); + + it('应该处理纯文本二维码', async () => { + const mockResult = { data: 'WIFI:T:WPA;S:MyNetwork;P:password;;', cornerPoints: [] }; + (QrScanner.scanImage as any).mockResolvedValue(mockResult); + + const mockFile = new File(['mock-image-data'], 'wifi.png', { type: 'image/png' }); + const result = await parseQrCodeFromFile(mockFile); + + expect(result.success).toBe(true); + expect(result.data).toBe('WIFI:T:WPA;S:MyNetwork;P:password;;'); + }); + }); +}); diff --git a/utils/__tests__/storageCleaner.test.ts b/utils/__tests__/storageCleaner.test.ts index 406ef21..cde4975 100644 --- a/utils/__tests__/storageCleaner.test.ts +++ b/utils/__tests__/storageCleaner.test.ts @@ -58,26 +58,26 @@ describe('storageCleaner utils', () => { }); it('should format kilobytes correctly', () => { - expect(formatSize(1024)).toBe('1 KB'); + expect(formatSize(1024)).toBe('1.0 KB'); expect(formatSize(1536)).toBe('1.5 KB'); - expect(formatSize(2048)).toBe('2 KB'); + expect(formatSize(2048)).toBe('2.0 KB'); }); it('should format megabytes correctly', () => { - expect(formatSize(1048576)).toBe('1 MB'); - expect(formatSize(1572864)).toBe('1.5 MB'); - expect(formatSize(5242880)).toBe('5 MB'); + expect(formatSize(1048576)).toBe('1.00 MB'); + expect(formatSize(1572864)).toBe('1.50 MB'); + expect(formatSize(5242880)).toBe('5.00 MB'); }); it('should format gigabytes correctly', () => { - expect(formatSize(1073741824)).toBe('1 GB'); - expect(formatSize(2147483648)).toBe('2 GB'); + expect(formatSize(1073741824)).toBe('1.00 GB'); + expect(formatSize(2147483648)).toBe('2.00 GB'); }); it('should handle edge cases', () => { expect(formatSize(1)).toBe('1 B'); expect(formatSize(1023)).toBe('1023 B'); - expect(formatSize(1025)).toBe('1 KB'); + expect(formatSize(1025)).toBe('1.0 KB'); }); }); }); diff --git a/utils/base64Converter.ts b/utils/base64Converter.ts index 3fd356a..0f56be6 100644 --- a/utils/base64Converter.ts +++ b/utils/base64Converter.ts @@ -2,6 +2,8 @@ * Base64 转换器工具函数 */ +import { formatBytes } from './format'; + /** 最大文件大小限制(10 MB) */ export const MAX_FILE_SIZE = 10 * 1024 * 1024; @@ -76,19 +78,33 @@ export function textToBase64(text: string): TextToBase64Result { }; } +/** 匹配 data URI 的 base64 前缀,如 "data:image/png;base64," */ +const DATA_URI_BASE64_PREFIX = /^data:[^;,]+;base64,/i; + /** * 将 Base64 字符串解码为文本 * + * 支持 data:;base64, 形式:会自动剥离前缀后再解码。 + * 若解码出的字节不是合法 UTF-8(典型如图片等二进制数据),抛出更易懂的错误。 + * * @param base64 Base64 编码字符串 * @returns 解码后的文本 - * @throws {Error} 如果输入不是有效的 Base64 字符串 + * @throws {Error} 输入不是合法的 Base64 字符串 + * @throws {Error} 输入解码后是二进制数据,无法作为文本展示 */ export function base64ToText(base64: string): string { - const cleaned = base64.trim(); + const trimmed = base64.trim(); + const cleaned = trimmed.replace(DATA_URI_BASE64_PREFIX, ''); if (!isValidBase64(cleaned)) { throw new Error('Invalid Base64 string'); } - return decodeURIComponent(escape(atob(cleaned))); + try { + return decodeURIComponent(escape(atob(cleaned))); + } catch { + throw new Error( + 'Input appears to be binary data (e.g. an image). Please use the Image tab instead.', + ); + } } /** @@ -197,19 +213,167 @@ export function extractMimeTypeFromDataUri(dataUri: string): string { } /** - * 格式化文件大小显示 + * 格式化文件大小显示(兼容旧接口,内部委托给 formatBytes) * * @param bytes 字节数 * @returns 格式化后的字符串 */ export function formatFileSize(bytes: number): string { - if (bytes < 1024) return `${bytes} B`; - const units = ['KB', 'MB', 'GB']; - let size = bytes / 1024; - let unitIndex = 0; - while (size >= 1024 && unitIndex < units.length - 1) { - size /= 1024; - unitIndex++; - } - return `${size.toFixed(unitIndex > 0 ? 2 : 1)} ${units[unitIndex]}`; + return formatBytes(bytes); +} + +/** + * Base64 解码为二进制后的产物 + */ +export interface Base64ToBlobResult { + /** 解码后的 Blob */ + blob: Blob; + /** 推断出的 MIME 类型 */ + mimeType: string; + /** 推荐的扩展名,含点(如 `.png`),无法识别时为 `.bin` */ + suggestedExtension: string; + /** 已去除 data URI 前缀的纯 Base64 字符串 */ + rawBase64: string; +} + +/** + * 已知文件类型魔数签名表。注意:ZIP 头同样会匹配 .docx/.xlsx/.pptx/.apk + * + * 签名匹配规则: + * - `bytes` 必须匹配文件起始 + * - 可选的 `tail` 用于多段签名(如 WebP:"RIFF" + 偏移 8 处的 "WEBP") + */ +const MAGIC_BYTE_SIGNATURES: ReadonlyArray<{ + bytes: readonly number[]; + tail?: { offset: number; bytes: readonly number[] }; + mime: string; + ext: string; +}> = [ + { bytes: [0x89, 0x50, 0x4e, 0x47], mime: 'image/png', ext: '.png' }, + { bytes: [0xff, 0xd8, 0xff], mime: 'image/jpeg', ext: '.jpg' }, + { bytes: [0x47, 0x49, 0x46, 0x38], mime: 'image/gif', ext: '.gif' }, + { bytes: [0x42, 0x4d], mime: 'image/bmp', ext: '.bmp' }, + { + bytes: [0x52, 0x49, 0x46, 0x46], + tail: { offset: 8, bytes: [0x57, 0x45, 0x42, 0x50] }, + mime: 'image/webp', + ext: '.webp', + }, + { bytes: [0x25, 0x50, 0x44, 0x46], mime: 'application/pdf', ext: '.pdf' }, + { bytes: [0x50, 0x4b, 0x03, 0x04], mime: 'application/zip', ext: '.zip' }, +]; + +/** + * 将 Base64 字符串解码为 Uint8Array + * + * @param b64 纯 Base64 字符串(不含 data URI 前缀) + * @returns 字节序列 + */ +export function base64ToBytes(b64: string): Uint8Array { + const binary = atob(b64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +/** + * 根据字节序列前缀识别已知文件类型 + * + * @param bytes 解码后的字节序列 + * @returns 匹配到的 MIME + 扩展名;未匹配返回 null + */ +export function sniffMimeFromBytes(bytes: Uint8Array): { mime: string; ext: string } | null { + for (const sig of MAGIC_BYTE_SIGNATURES) { + if (bytes.length < sig.bytes.length) continue; + let matched = true; + for (let i = 0; i < sig.bytes.length; i += 1) { + if (bytes[i] !== sig.bytes[i]) { + matched = false; + break; + } + } + if (!matched) continue; + if (sig.tail) { + const { offset, bytes: tailBytes } = sig.tail; + if (bytes.length < offset + tailBytes.length) continue; + let tailMatched = true; + for (let i = 0; i < tailBytes.length; i += 1) { + if (bytes[offset + i] !== tailBytes[i]) { + tailMatched = false; + break; + } + } + if (!tailMatched) continue; + } + return { mime: sig.mime, ext: sig.ext }; + } + return null; +} + +/** + * 将 Base64 / data URI 字符串解码为 Blob,自动推断 MIME 与扩展名 + * + * MIME 推断优先级:data URI 前缀 → 字节魔数 → `application/octet-stream` + * + * @param input Base64 字符串或 data URI + * @returns 解码结果 + * @throws {Error} 输入不是合法 Base64 + */ +export function base64ToBlob(input: string): Base64ToBlobResult { + const trimmed = input.trim(); + const prefixMatch = trimmed.match(DATA_URI_BASE64_PREFIX); + const cleaned = prefixMatch ? trimmed.slice(prefixMatch[0].length) : trimmed; + + if (!isValidBase64(cleaned)) { + throw new Error('Invalid Base64 string'); + } + + const bytes = base64ToBytes(cleaned); + + let mimeType: string; + let suggestedExtension: string; + if (prefixMatch) { + mimeType = extractMimeTypeFromDataUri(trimmed); + const sniffed = sniffMimeFromBytes(bytes); + suggestedExtension = sniffed?.ext ?? mimeTypeToExtension(mimeType); + } else { + const sniffed = sniffMimeFromBytes(bytes); + mimeType = sniffed?.mime ?? 'application/octet-stream'; + suggestedExtension = sniffed?.ext ?? '.bin'; + } + + const blob = new Blob([bytes.buffer as ArrayBuffer], { type: mimeType }); + return { blob, mimeType, suggestedExtension, rawBase64: cleaned }; +} + +/** 极小的 MIME -> 扩展名映射,仅用于带 data URI 前缀但魔数无法识别时 */ +function mimeTypeToExtension(mime: string): string { + if (mime.startsWith('image/svg')) return '.svg'; + if (mime === 'image/webp') return '.webp'; + if (mime === 'image/bmp') return '.bmp'; + if (mime === 'image/x-icon' || mime === 'image/vnd.microsoft.icon') return '.ico'; + if (mime === 'text/plain') return '.txt'; + if (mime === 'application/json') return '.json'; + if (mime === 'text/html') return '.html'; + if (mime === 'text/css') return '.css'; + return '.bin'; +} + +/** + * 触发浏览器下载指定 Blob + * + * @param blob 要下载的 Blob + * @param filename 下载文件名 + */ +export function downloadBlob(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); } diff --git a/utils/clipboard.ts b/utils/clipboard.ts index 99ca0f6..0ea3a3e 100644 --- a/utils/clipboard.ts +++ b/utils/clipboard.ts @@ -3,37 +3,29 @@ * @param text 要复制的文本 * @returns Promise 是否复制成功 */ -export const copyTextToClipboard = async (text: string): Promise => { - return new Promise((resolve, reject) => { - navigator.clipboard - .writeText(text) - .then(() => { - resolve(true); - }) - .catch((error) => { - reject(new Error(`复制文本到剪贴板失败,错误: ${error?.message || 'Unknown error'}`)); - }); - }); -}; +export async function copyTextToClipboard(text: string): Promise { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + return false; + } +} /** * 复制图片到剪贴板 * @param blob 要复制的图片 * @returns Promise 是否复制成功 */ -export const copyImageToClipboard = async (blob: Blob): Promise => { - return new Promise((resolve, reject) => { - navigator.clipboard - .write([ - new ClipboardItem({ - 'image/png': blob, - }), - ]) - .then(() => { - resolve(true); - }) - .catch((error) => { - reject(new Error(`复制图片到剪贴板失败,错误: ${error?.message || 'Unknown error'}`)); - }); - }); -}; +export async function copyImageToClipboard(blob: Blob): Promise { + try { + await navigator.clipboard.write([ + new ClipboardItem({ + 'image/png': blob, + }), + ]); + return true; + } catch { + return false; + } +} diff --git a/utils/format.ts b/utils/format.ts new file mode 100644 index 0000000..cc83be5 --- /dev/null +++ b/utils/format.ts @@ -0,0 +1,24 @@ +/** + * 格式化字节大小为易读字符串 + * + * @param bytes 字节数 + * @returns 格式化后的字符串,例如 "1.5 KB" 或 "100 B" + */ +export function formatBytes(bytes: number): string { + if (bytes === 0) return '0 B'; + if (bytes < 1024) return `${bytes} B`; + + const units = ['KB', 'MB', 'GB', 'TB']; + let size = bytes / 1024; + let unitIndex = 0; + + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex++; + } + + // KB uses 1 decimal, MB/GB/TB use 2 decimals + const decimals = unitIndex === 0 ? 1 : 2; + + return `${size.toFixed(decimals)} ${units[unitIndex]}`; +} diff --git a/utils/jsonToToml.ts b/utils/jsonToToml.ts index 44af34d..e810a1e 100644 --- a/utils/jsonToToml.ts +++ b/utils/jsonToToml.ts @@ -47,9 +47,6 @@ function toTomlValue(value: unknown): string { return value ? 'true' : 'false'; } if (typeof value === 'number') { - if (Number.isInteger(value)) { - return String(value); - } return String(value); } if (typeof value === 'string') { diff --git a/utils/jsonToYaml.ts b/utils/jsonToYaml.ts index 08cb95d..546feb0 100644 --- a/utils/jsonToYaml.ts +++ b/utils/jsonToYaml.ts @@ -54,8 +54,6 @@ function stringifyYamlString(str: string): string { str === 'true' || str === 'false' || /[:#{}[\],&*?|>\-!%@`]/.test(str) || - str.startsWith(' ') || - str.endsWith(' ') || str.includes(' ') || str.includes('\n') || /^\d/.test(str); diff --git a/utils/jwt.ts b/utils/jwt.ts index dc36312..df53c0d 100644 --- a/utils/jwt.ts +++ b/utils/jwt.ts @@ -2,6 +2,8 @@ * JWT 解析工具 */ +import i18n from '@/i18n'; + export interface JwtHeader { alg: string; typ?: string; @@ -36,20 +38,17 @@ export interface JwtResult { * @param str Base64URL 编码字符串 */ export function decodeBase64Url(str: string): string { - // 将 Base64URL 转换为 标准 Base64 let base64 = str.replace(/-/g, '+').replace(/_/g, '/'); - // 添加填充 const pad = base64.length % 4; if (pad) { if (pad === 1) { - throw new Error('Invalid base64url string'); + throw new Error(i18n.t('jwt:errors.invalidBase64String')); } base64 += new Array(5 - pad).join('='); } try { - // 使用 TextDecoder 处理 UTF-8 字符 const binStr = atob(base64); const binLen = binStr.length; const bytes = new Uint8Array(binLen); @@ -59,7 +58,9 @@ export function decodeBase64Url(str: string): string { const decoder = new TextDecoder('utf-8'); return decoder.decode(bytes); } catch (e) { - throw new Error('Failed to decode base64url: ' + (e instanceof Error ? e.message : String(e))); + throw new Error( + i18n.t('jwt:errors.failedToDecode') + (e instanceof Error ? e.message : String(e)), + ); } } @@ -76,7 +77,7 @@ export function parseJwt(token: string): JwtResult { payload: null, signature: '', raw: { header: '', payload: '', signature: '' }, - error: 'JWT 格式错误:必须包含三个由 "." 分隔的部分', + error: i18n.t('jwt:errors.invalidFormat'), }; } @@ -96,7 +97,8 @@ export function parseJwt(token: string): JwtResult { const headerJson = decodeBase64Url(headerB64); result.header = JSON.parse(headerJson); } catch (e) { - result.error = '解析 Header 失败:' + (e instanceof Error ? e.message : String(e)); + result.error = + i18n.t('jwt:errors.parseHeaderFailed') + (e instanceof Error ? e.message : String(e)); return result; } @@ -104,7 +106,8 @@ export function parseJwt(token: string): JwtResult { const payloadJson = decodeBase64Url(payloadB64); result.payload = JSON.parse(payloadJson); } catch (e) { - result.error = '解析 Payload 失败:' + (e instanceof Error ? e.message : String(e)); + result.error = + i18n.t('jwt:errors.parsePayloadFailed') + (e instanceof Error ? e.message : String(e)); return result; } @@ -112,10 +115,10 @@ export function parseJwt(token: string): JwtResult { } /** - * 格式化 JSON + * 将对象格式化为 JSON 字符串 * @param obj 对象 */ -export function formatJson(obj: unknown): string { +export function stringifyJson(obj: unknown): string { try { return JSON.stringify(obj, null, 2); } catch (e) { diff --git a/utils/markdownToHtml.ts b/utils/markdownToHtml.ts index 9ea6f15..7ba3508 100644 --- a/utils/markdownToHtml.ts +++ b/utils/markdownToHtml.ts @@ -206,12 +206,19 @@ export function printHtml(html: string, title: string = 'Markdown Preview'): voi printWindow.document.close(); // 等待样式加载完成后打印 + let printed = false; printWindow.onload = () => { - printWindow.print(); + if (!printed) { + printed = true; + printWindow.print(); + } }; // 部分浏览器 onload 不触发,使用延迟回退 setTimeout(() => { - printWindow.print(); + if (!printed) { + printed = true; + printWindow.print(); + } }, 500); } diff --git a/utils/storageCleaner.ts b/utils/storageCleaner.ts index 6f4ff28..c1d4201 100644 --- a/utils/storageCleaner.ts +++ b/utils/storageCleaner.ts @@ -1,4 +1,5 @@ import type { CleaningResult, StorageCleanerOptions, StorageCleanResult } from '@/types/storage'; +import { formatBytes } from './format'; const RESTRICTED_PROTOCOLS = [ 'chrome:', @@ -41,10 +42,15 @@ export function isRestrictedUrl(url?: string): boolean { export async function getCookieSize(url: string): Promise { try { const cookies = await chrome.cookies.getAll({ url }); - // 估算:名称 + 值 + 域名 + 路径 的长度 + const encoder = new TextEncoder(); + // 估算:名称 + 值 + 域名 + 路径 的 UTF-8 字节数 return cookies.reduce( (acc, c) => - acc + c.name.length + c.value.length + (c.domain?.length || 0) + (c.path?.length || 0), + acc + + encoder.encode(c.name).length + + encoder.encode(c.value).length + + encoder.encode(c.domain ?? '').length + + encoder.encode(c.path ?? '').length, 0, ); } catch (error) { @@ -59,7 +65,11 @@ export async function getLocalStorageSize(tabId: number): Promise { target: { tabId }, func: () => { try { - return Object.entries(localStorage).reduce((acc, [k, v]) => acc + k.length + v.length, 0); + const encoder = new TextEncoder(); + return Object.entries(localStorage).reduce( + (acc, [k, v]) => acc + encoder.encode(k).length + encoder.encode(v).length, + 0, + ); } catch { return 0; } @@ -78,8 +88,9 @@ export async function getSessionStorageSize(tabId: number): Promise { target: { tabId }, func: () => { try { + const encoder = new TextEncoder(); return Object.entries(sessionStorage).reduce( - (acc, [k, v]) => acc + k.length + v.length, + (acc, [k, v]) => acc + encoder.encode(k).length + encoder.encode(v).length, 0, ); } catch { @@ -94,7 +105,7 @@ export async function getSessionStorageSize(tabId: number): Promise { } } -export async function getIndexedDBSize(tabId: number): Promise { +export async function getOriginStorageEstimate(tabId: number): Promise { try { const [result] = await chrome.scripting.executeScript({ target: { tabId }, @@ -114,7 +125,7 @@ export async function getIndexedDBSize(tabId: number): Promise { }); return (result?.result as number) || 0; } catch (error) { - console.error('Failed to get IndexedDB size:', error); + console.error('Failed to get origin storage estimate:', error); return 0; } } @@ -166,13 +177,14 @@ export async function getServiceWorkerCount(tabId: number): Promise { } } +/** + * 格式化字节大小显示(兼容旧接口,内部委托给 formatBytes) + * + * @param bytes 字节数 + * @returns 格式化后的字符串 + */ export function formatSize(bytes: number): string { - if (bytes === 0) return '0 B'; - if (bytes < 1024) return `${bytes} B`; // 处理小于 1KB 的情况 - const k = 1024; - const sizes = ['B', 'KB', 'MB', 'GB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + return formatBytes(bytes); } export async function clearCookies(url: string): Promise { diff --git a/utils/textStatistics.ts b/utils/textStatistics.ts index c7c64f9..4cb6f17 100644 --- a/utils/textStatistics.ts +++ b/utils/textStatistics.ts @@ -1,3 +1,5 @@ +import { formatBytes } from './format'; + /** * 文本统计信息接口 */ @@ -57,21 +59,11 @@ export function getTextStats(text: string): TextStats { } /** - * 格式化字节大小显示 + * 格式化字节大小显示(兼容旧接口,内部委托给 formatBytes) * * @param bytes 字节数 - * @returns 格式化后的字符串,例如 "1.2 KB" 或 "100 B" + * @returns 格式化后的字符串 */ export function formatByteSize(bytes: number): string { - if (bytes < 1024) { - return `${bytes} B`; - } - const units = ['KB', 'MB', 'GB', 'TB']; - let size = bytes / 1024; - let unitIndex = 0; - while (size >= 1024 && unitIndex < units.length - 1) { - size /= 1024; - unitIndex++; - } - return `${size.toFixed(unitIndex > 0 ? 2 : 1)} ${units[unitIndex]}`; + return formatBytes(bytes); } diff --git a/utils/useStorageState.ts b/utils/useStorageState.ts index f166c43..8713861 100644 --- a/utils/useStorageState.ts +++ b/utils/useStorageState.ts @@ -38,9 +38,12 @@ export const useStorageState = ( useEffect(() => { if (hasLoadedFromStorage.current) return; + let cancelled = false; + const loadState = async () => { try { const savedValue = await storageUtil.get(key, defaultValue); + if (cancelled) return; if (savedValue !== undefined) { if (validator) { setValue(validator(savedValue) ? savedValue : defaultValue); @@ -51,12 +54,18 @@ export const useStorageState = ( } catch (error) { console.error(`加载状态失败 (${key}):`, error); } finally { - setIsInitialized(true); - hasLoadedFromStorage.current = true; + if (!cancelled) { + setIsInitialized(true); + hasLoadedFromStorage.current = true; + } } }; loadState().catch(console.error); + + return () => { + cancelled = true; + }; }, [defaultValue, key, validator]); // Save to storage and localStorage snapshot when value changes (after initial load)