Merge develop into main (#27)
* feat: 添加 contextMenus 权限和消息类型定义 * feat: 实现右键菜单注册与点击分流逻辑 - 新增 utils/contextMenu.ts 封装菜单配置和解析函数 - 在 background.ts 监听 onInstalled 初始化菜单 - 实现 contextMenus.onClicked 点击事件处理 - 分流逻辑:优先发送到侧边栏,否则打开 options 页面 - 添加 contextMenu 单元测试(15个测试用例) - 更新 vitest.setup.ts 添加 contextMenus mock * feat: 实现 Content Script 原位轻量提示 UI - 新增 uiPopover.ts 实现原位弹窗组件(深色主题、自动定位、自动隐藏) - 新增 contextMenuHandler.ts 处理右键菜单消息 - 时间戳转换:在点击位置显示转换结果 - 文本统计:显示字符/单词/行数/字节统计 - 更新 messageHandler.ts 挂载右键菜单消息监听 * feat: 实现 React 页面层右键菜单数据联动 - 新增 useContextMenuData Hook 处理右键菜单数据传递 - 重构 Jwt/Base64Converter/TextStatistics/QrCode 页面接收右键数据 - RouterProvider 支持从 URL 参数解析右键菜单数据 - 添加 contextMenu/pendingData 存储键到 StorageSchema - 添加 useContextMenuData 单元测试(12个测试用例) * test: 添加 background 单元测试与边界情况处理 - 新增 entrypoints/__tests__/background.test.ts (12个测试用例) - 超长文本截断限制 (>10000字符) - Base64 内联图片拦截并返回错误提示 - 更新 parseContextMenuClick 返回 ParseResult 格式 - 更新测试匹配新的返回格式 - lint/typecheck/test 全部通过 (579个测试) * fix: 修复右键菜单跳转到错误页面的问题 - 将 options 页面路径从 '/entrypoints/options/index.html' 修正为 '/options.html' - WXT 构建后 entrypoints/options/index.html 会输出为根目录的 options.html * fix: 修复右键菜单跳转目标为 popup 页面 - 将 fallback 页面从 options.html 改为 popup.html - popup 页面使用 RouterProvider,可以正确处理 URL 参数并跳转到对应功能页面 - options 页面是独立设置页,不支持 URL 参数路由 * fix: 使用 openPopup() 替代 tabs.create() 打开弹窗 - background.ts: 使用 browser.action.openPopup() 打开 popup 弹窗 - 先保存数据到 storage,然后打开 popup - RouterProvider.tsx: 初始化时检查 storage 中的 pendingData 并跳转到对应页面 - 移除之前错误的 tabs.create() 方式 * fix: 修复右键菜单数据未传递到目标页面的问题 - RouterProvider 不再提前清除 pendingData - 让目标页面的 useContextMenuData 来消费和清除数据 - 这样确保 TextStatistics 等页面能正确接收选中的文本 * fix: 修复 popup 已打开时右键菜单不生效的问题 - 在 RouterProvider 中添加对 contextMenu/pendingData 的 storage 变化监听 - 当 pendingData 变化时,自动跳转到对应功能页面 - 解决了 openPopup() 只聚焦已有窗口而不触发重新挂载的问题 * feat: 支持右键菜单图片二维码识别 - 在 qrCodeParser.ts 中添加 parseQrCodeFromUrl 函数,支持从图片 URL 解析二维码 - 在 QrCodeToUrlSection 中使用 useContextMenuData 接收右键菜单传递的图片 URL - 自动下载图片并解析二维码,显示解析结果 * refactor: 暂时移除图片二维码识别功能 - 删除 qrCode-image 右键菜单配置 - 删除 parseQrCodeFromUrl 函数 - 删除 QrCodeToUrlSection 中的 useContextMenuData 相关代码 - 更新相关测试用例 * feat: 支持右键菜单时间戳转换 - 在 useTimestampConverter 中添加 useContextMenuData hook - 智能识别输入是时间戳还是日期时间字符串 - 自动切换到对应模式并执行转换 - 时间戳自动识别秒/毫秒单位 * fix: 修复右键菜单功能的多个逻辑漏洞 1. 修复时区硬编码问题 - 使用用户选择的时区而非固定 Asia/Shanghai 2. 修复 openPopup() 失败后数据残留 - 失败时清除 storage 中的待处理数据 3. 统一数据过期时间常量 - 导出 CONTEXT_MENU_DATA_EXPIRY_MS 并统一使用 4. 修复 featureKey 类型断言不安全 - 使用映射表处理非常规菜单 ID * refactor(qrcode): 提取独立组件并添加单元测试 - 创建 pages/QrCode/types.ts 定义状态类型接口 - 提取 QrCodePreview 组件用于二维码预览和操作 - 提取 ImageUploader 组件封装图片上传、拖拽、粘贴逻辑 - 重构 UrlToQrCodeSection 状态提升到父组件 - 重构 QrCodeToUrlSection 通过回调传递解析结果 - 更新主页面 index.tsx 集中管理所有状态 - 为 QrCodePreview 和 ImageUploader 添加单元测试 (23 个用例) * refactor(qrcode): 引入通用组件并重构双栏布局 - 引入 SwitchButtonGroup 用于模式切换 - 引入 TextInputArea 用于文本输入和结果展示 - 使用 MUI Grid 构建响应式双栏布局 - 实现 generate/parse 模式下的左右面板内容切换 - 修复 iconColor 颜色格式错误,使用 qrCodePageStyles.primaryColor * refactor(qrcode): 国际化补充、质量保障与错误处理优化 - 补充 i18n 翻译键(generateMode, parseMode, pasteHint 等) - 创建 useDebounce Hook 实现输入防抖 (200ms) - 使用 useRef 解决 useEffect 无限循环问题 - 编写 pages/QrCode 单元测试 (7 个用例) - 优化右键菜单错误处理,改进用户提示 - 运行 603 个测试全部通过 * refactor(qrcode): 组件化重构,采用 Context + Hook 模式 - 创建 QrCodeContext 和 QrCodeProvider 管理共享状态 - 提取 useQrCode Hook 封装所有状态和业务逻辑 - 创建 GeneratePanel 组件处理二维码生成模式 - 创建 ParsePanel 组件处理二维码解析模式 - 简化 index.tsx 为容器组件 (340行 → 47行) - 删除未使用的旧组件文件 (QrCodeToUrlSection, UrlToQrCodeSection) - 清理 types.ts 中未使用的类型定义 - 603 个测试全部通过 * fix(qrcode): 固定二维码预览图片尺寸,避免布局抖动 - 设置 QR_PREVIEW_IMAGE 固定尺寸 250x250 - 与 QRious 生成的二维码大小保持一致 - 解决输入内容变化时预览窗口大小跳动问题 * fix(qrcode): 修复切换 tab 后二维码图片失效问题 - 移除 ImageUploader 组件卸载时的预览 URL 释放逻辑 - 在 useQrCode hook 中统一管理预览 URL 生命周期 - 创建新预览 URL 前释放旧的,避免内存泄漏 * fix(qrcode): 固定图片上传区域高度,避免布局抖动 - 将 DROPZONE 的 minHeight: 200 改为 height: 250 - 与二维码预览图片高度保持一致 - 解决上传图片时 div 高度变化问题 * refactor(qrCode): 优化二维码解析功能 - 将'二维码转 URL'改为'二维码转文本' - 将解析结果的文本预览框的 placeholder 置为空 - 去除手动解析二维码功能,只保留自动解析 * fix(pageHeader): 使用 MUI 主题色替代硬编码颜色,支持暗色模式 * feat(pageHeader): popup 模式下隐藏 PageHeader 组件 * fix(i18n): 统一使用 useLazyTranslation 避免翻译 key 闪烁 * perf: 配置 manualChunks 拆分 vendor chunk,优化打包体积 - 使用 Vite 插件 manualChunksForHtmlOnly 仅对 HTML 多入口构建生效 - 跳过 background/content-script 的 IIFE 构建(不支持 manualChunks) - 拆分 vendor-react (193KB)、vendor-mui (326KB)、vendor-i18n (55KB) - 按需加载 vendor-qr (78KB)、vendor-dnd (45KB)、vendor-markdown (41KB) - PageErrorBoundary chunk 从 454KB 降至 36KB
This commit is contained in:
@@ -0,0 +1,179 @@
|
|||||||
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
|
import { Box, IconButton, Typography } from '@mui/material';
|
||||||
|
import ImageIcon from '@mui/icons-material/Image';
|
||||||
|
import ClearIcon from '@mui/icons-material/Clear';
|
||||||
|
import { qrCodePageStyles } from '@/config/pageTheme';
|
||||||
|
import { useSnackbar } from '@/components/GlobalSnackbar';
|
||||||
|
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
||||||
|
|
||||||
|
interface ImageUploaderProps {
|
||||||
|
/** 选中的文件 */
|
||||||
|
selectedFile: File | null;
|
||||||
|
/** 文件变更回调 */
|
||||||
|
onFileChange: (file: File) => void;
|
||||||
|
/** 清除文件回调 */
|
||||||
|
onClearFile: () => void;
|
||||||
|
/** 文件预览 URL */
|
||||||
|
previewUrl: string;
|
||||||
|
/** 预览 URL 变更回调 */
|
||||||
|
onPreviewUrlChange: (url: string) => void;
|
||||||
|
/** 是否正在拖拽 */
|
||||||
|
dragging: boolean;
|
||||||
|
/** 拖拽状态变更回调 */
|
||||||
|
onDraggingChange: (dragging: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ImageUploader = ({
|
||||||
|
selectedFile,
|
||||||
|
onFileChange,
|
||||||
|
onClearFile,
|
||||||
|
previewUrl,
|
||||||
|
onPreviewUrlChange,
|
||||||
|
dragging,
|
||||||
|
onDraggingChange,
|
||||||
|
}: ImageUploaderProps) => {
|
||||||
|
const { t } = useLazyTranslation('qrCode');
|
||||||
|
const { showMessage } = useSnackbar();
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleFileChange = useCallback(
|
||||||
|
(file: File) => {
|
||||||
|
onFileChange(file);
|
||||||
|
onPreviewUrlChange(URL.createObjectURL(file));
|
||||||
|
},
|
||||||
|
[onFileChange, onPreviewUrlChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleClearFile = () => {
|
||||||
|
if (previewUrl) {
|
||||||
|
URL.revokeObjectURL(previewUrl);
|
||||||
|
}
|
||||||
|
onClearFile();
|
||||||
|
showMessage(t('qrCode:imageCleared'), {
|
||||||
|
severity: 'success',
|
||||||
|
autoHideDuration: 1000,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (e.target.files && e.target.files.length > 0) {
|
||||||
|
handleFileChange(e.target.files[0]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onDraggingChange(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragLeave = () => {
|
||||||
|
onDraggingChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onDraggingChange(false);
|
||||||
|
const droppedFile = e.dataTransfer.files?.[0];
|
||||||
|
if (droppedFile) {
|
||||||
|
handleFileChange(droppedFile);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 监听粘贴事件
|
||||||
|
useEffect(() => {
|
||||||
|
const handlePaste = async (e: ClipboardEvent) => {
|
||||||
|
const items = e.clipboardData?.items;
|
||||||
|
if (!items) return;
|
||||||
|
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
if (items[i].type.startsWith('image/')) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const file = items[i].getAsFile();
|
||||||
|
if (file) {
|
||||||
|
try {
|
||||||
|
handleFileChange(file);
|
||||||
|
showMessage(t('qrCode:imagePasted'), { severity: 'success', autoHideDuration: 1000 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('处理粘贴图片失败:', error);
|
||||||
|
showMessage(t('qrCode:imagePasteError'), {
|
||||||
|
severity: 'error',
|
||||||
|
autoHideDuration: 3000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('paste', handlePaste);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('paste', handlePaste);
|
||||||
|
};
|
||||||
|
}, [showMessage, handleFileChange, t]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
className="qr-flex-grow"
|
||||||
|
sx={qrCodePageStyles.DROPZONE(dragging, !!selectedFile)}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleInputChange}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
id="qr-code-upload"
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="qr-code-upload"
|
||||||
|
style={{ cursor: 'pointer', textAlign: 'center', width: '100%' }}
|
||||||
|
>
|
||||||
|
{selectedFile ? (
|
||||||
|
<Box sx={qrCodePageStyles.IMAGE_PREVIEW_WRAPPER}>
|
||||||
|
<Box sx={qrCodePageStyles.IMAGE_PREVIEW_BOX}>
|
||||||
|
<img
|
||||||
|
src={previewUrl}
|
||||||
|
alt="QR Code Preview"
|
||||||
|
style={qrCodePageStyles.IMAGE_PREVIEW_IMG}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleClearFile();
|
||||||
|
}}
|
||||||
|
sx={qrCodePageStyles.CLEAR_BUTTON}
|
||||||
|
>
|
||||||
|
<ClearIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
|
||||||
|
{selectedFile.name}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{t('qrCode:clickToChange')}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ImageIcon sx={{ fontSize: 48, color: 'text.disabled', mb: 2 }} />
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||||
|
{t('qrCode:clickToUpload')}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{t('qrCode:supportFormats')}
|
||||||
|
</Typography>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ImageUploader;
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { alpha, Box, Stack, SxProps, Theme, Typography } from '@mui/material';
|
import { alpha, Box, Stack, SxProps, Theme, Typography, useTheme } from '@mui/material';
|
||||||
import { ReactNode } from 'react';
|
import { ReactNode, useMemo } from 'react';
|
||||||
|
import { getEntryPointType } from '@/config/features';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PageHeader 组件属性接口
|
* PageHeader 组件属性接口
|
||||||
@@ -7,7 +8,7 @@ import { ReactNode } from 'react';
|
|||||||
export interface PageHeaderProps {
|
export interface PageHeaderProps {
|
||||||
/** 要显示的图标组件 */
|
/** 要显示的图标组件 */
|
||||||
icon: ReactNode;
|
icon: ReactNode;
|
||||||
/** 图标的颜色,默认为 '#1976d2'(蓝色) */
|
/** 图标的颜色,默认使用主题 primary.main 色 */
|
||||||
iconColor?: string;
|
iconColor?: string;
|
||||||
/** 主标题文本 */
|
/** 主标题文本 */
|
||||||
title: string;
|
title: string;
|
||||||
@@ -53,7 +54,7 @@ export interface PageHeaderProps {
|
|||||||
*/
|
*/
|
||||||
export default function PageHeader({
|
export default function PageHeader({
|
||||||
icon,
|
icon,
|
||||||
iconColor = '#1976d2',
|
iconColor,
|
||||||
title,
|
title,
|
||||||
subtitle,
|
subtitle,
|
||||||
badge,
|
badge,
|
||||||
@@ -62,6 +63,14 @@ export default function PageHeader({
|
|||||||
subtitleSx,
|
subtitleSx,
|
||||||
sx,
|
sx,
|
||||||
}: PageHeaderProps) {
|
}: PageHeaderProps) {
|
||||||
|
const theme = useTheme();
|
||||||
|
const resolvedIconColor = iconColor ?? theme.palette.primary.main;
|
||||||
|
const entryPointType = useMemo(() => getEntryPointType(), []);
|
||||||
|
|
||||||
|
if (entryPointType === 'popup') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ mb: 2.5, ...sx }}>
|
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ mb: 2.5, ...sx }}>
|
||||||
{/* 图标容器 */}
|
{/* 图标容器 */}
|
||||||
@@ -69,8 +78,8 @@ export default function PageHeader({
|
|||||||
sx={{
|
sx={{
|
||||||
p: 1,
|
p: 1,
|
||||||
borderRadius: 2.5,
|
borderRadius: 2.5,
|
||||||
bgcolor: alpha(iconColor, 0.1),
|
bgcolor: alpha(resolvedIconColor, 0.1),
|
||||||
color: iconColor,
|
color: resolvedIconColor,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
...iconSx,
|
...iconSx,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { Box, Button, Typography } from '@mui/material';
|
||||||
|
import DownloadIcon from '@mui/icons-material/Download';
|
||||||
|
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||||
|
import { qrCodePageStyles } from '@/config/pageTheme';
|
||||||
|
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
||||||
|
|
||||||
|
interface QrCodePreviewProps {
|
||||||
|
/** 二维码 Data URL */
|
||||||
|
qrCodeDataUrl: string;
|
||||||
|
/** 下载回调 */
|
||||||
|
onDownload: () => void;
|
||||||
|
/** 复制回调 */
|
||||||
|
onCopy: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QrCodePreview = ({ qrCodeDataUrl, onDownload, onCopy }: QrCodePreviewProps) => {
|
||||||
|
const { t } = useLazyTranslation('qrCode');
|
||||||
|
|
||||||
|
if (!qrCodeDataUrl) {
|
||||||
|
return (
|
||||||
|
<Box sx={qrCodePageStyles.QR_PREVIEW_CONTAINER}>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={qrCodePageStyles.PLACEHOLDER_TEXT}>
|
||||||
|
{t('qrCode:qrCodeWillShow')}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={qrCodePageStyles.QR_PREVIEW_CONTAINER}>
|
||||||
|
<Box sx={qrCodePageStyles.QR_PREVIEW_INNER}>
|
||||||
|
<img src={qrCodeDataUrl} alt="QR Code" style={qrCodePageStyles.QR_PREVIEW_IMAGE} />
|
||||||
|
<Box sx={qrCodePageStyles.QR_PREVIEW_ACTIONS}>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<DownloadIcon />}
|
||||||
|
onClick={onDownload}
|
||||||
|
sx={qrCodePageStyles.DOWNLOAD_BUTTON}
|
||||||
|
>
|
||||||
|
{t('qrCode:downloadButton')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<ContentCopyIcon />}
|
||||||
|
onClick={onCopy}
|
||||||
|
sx={qrCodePageStyles.COPY_BUTTON}
|
||||||
|
>
|
||||||
|
{t('qrCode:copyQrButton')}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default QrCodePreview;
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { render, screen, fireEvent, act } from '@testing-library/react';
|
||||||
|
import ImageUploader from '@/components/ImageUploader';
|
||||||
|
|
||||||
|
// 模拟 URL API
|
||||||
|
const mockCreateObjectURL = vi.fn();
|
||||||
|
const mockRevokeObjectURL = vi.fn();
|
||||||
|
Object.defineProperty(window.URL, 'createObjectURL', { value: mockCreateObjectURL });
|
||||||
|
Object.defineProperty(window.URL, 'revokeObjectURL', { value: mockRevokeObjectURL });
|
||||||
|
|
||||||
|
// 模拟 showMessage
|
||||||
|
vi.mock('@/components/GlobalSnackbar', () => ({
|
||||||
|
useSnackbar: () => ({
|
||||||
|
showMessage: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('ImageUploader 组件', () => {
|
||||||
|
const mockOnFileChange = vi.fn();
|
||||||
|
const mockOnClearFile = vi.fn();
|
||||||
|
const mockOnPreviewUrlChange = vi.fn();
|
||||||
|
const mockOnDraggingChange = vi.fn();
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
selectedFile: null,
|
||||||
|
onFileChange: mockOnFileChange,
|
||||||
|
onClearFile: mockOnClearFile,
|
||||||
|
previewUrl: '',
|
||||||
|
onPreviewUrlChange: mockOnPreviewUrlChange,
|
||||||
|
dragging: false,
|
||||||
|
onDraggingChange: mockOnDraggingChange,
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockCreateObjectURL.mockReturnValue('blob:test-url');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('渲染测试', () => {
|
||||||
|
it('当没有选中文件时应显示上传提示', () => {
|
||||||
|
render(<ImageUploader {...defaultProps} />);
|
||||||
|
expect(screen.getByText('qrCode:clickToUpload')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('qrCode:supportFormats')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('当没有选中文件时应显示 ImageIcon', () => {
|
||||||
|
render(<ImageUploader {...defaultProps} />);
|
||||||
|
expect(screen.getByTestId('ImageIcon')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('当选中文件时应显示文件预览', () => {
|
||||||
|
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
|
||||||
|
render(
|
||||||
|
<ImageUploader {...defaultProps} selectedFile={mockFile} previewUrl="blob:test-url" />,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('test.png')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('qrCode:clickToChange')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('当选中文件时应显示预览图片', () => {
|
||||||
|
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
|
||||||
|
render(
|
||||||
|
<ImageUploader {...defaultProps} selectedFile={mockFile} previewUrl="blob:test-url" />,
|
||||||
|
);
|
||||||
|
const img = screen.getByAltText('QR Code Preview');
|
||||||
|
expect(img).toBeInTheDocument();
|
||||||
|
expect(img).toHaveAttribute('src', 'blob:test-url');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('当选中文件时应显示清除按钮', () => {
|
||||||
|
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
|
||||||
|
render(
|
||||||
|
<ImageUploader {...defaultProps} selectedFile={mockFile} previewUrl="blob:test-url" />,
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId('ClearIcon')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应包含隐藏的文件输入框', () => {
|
||||||
|
render(<ImageUploader {...defaultProps} />);
|
||||||
|
const input = document.getElementById('qr-code-upload') as HTMLInputElement;
|
||||||
|
expect(input).toBeInTheDocument();
|
||||||
|
expect(input).toHaveAttribute('type', 'file');
|
||||||
|
expect(input).toHaveAttribute('accept', 'image/*');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('文件选择交互', () => {
|
||||||
|
it('选择文件时应调用 onFileChange 和 onPreviewUrlChange', async () => {
|
||||||
|
render(<ImageUploader {...defaultProps} />);
|
||||||
|
const input = document.getElementById('qr-code-upload') as HTMLInputElement;
|
||||||
|
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.change(input, { target: { files: [mockFile] } });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockOnFileChange).toHaveBeenCalledWith(mockFile);
|
||||||
|
expect(mockCreateObjectURL).toHaveBeenCalledWith(mockFile);
|
||||||
|
expect(mockOnPreviewUrlChange).toHaveBeenCalledWith('blob:test-url');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('拖拽交互', () => {
|
||||||
|
it('拖拽进入时应调用 onDraggingChange(true)', () => {
|
||||||
|
const { container } = render(<ImageUploader {...defaultProps} />);
|
||||||
|
const dropzone = container.firstChild as HTMLElement;
|
||||||
|
|
||||||
|
fireEvent.dragOver(dropzone);
|
||||||
|
expect(mockOnDraggingChange).toHaveBeenCalledWith(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('拖拽离开时应调用 onDraggingChange(false)', () => {
|
||||||
|
const { container } = render(<ImageUploader {...defaultProps} />);
|
||||||
|
const dropzone = container.firstChild as HTMLElement;
|
||||||
|
|
||||||
|
fireEvent.dragLeave(dropzone);
|
||||||
|
expect(mockOnDraggingChange).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('放置文件时应调用 onFileChange 和 onPreviewUrlChange', () => {
|
||||||
|
const { container } = render(<ImageUploader {...defaultProps} />);
|
||||||
|
const dropzone = container.firstChild as HTMLElement;
|
||||||
|
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
|
||||||
|
|
||||||
|
const dropEvent = new Event('drop', { bubbles: true });
|
||||||
|
Object.defineProperty(dropEvent, 'dataTransfer', {
|
||||||
|
value: {
|
||||||
|
files: [mockFile],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(dropEvent, 'preventDefault', {
|
||||||
|
value: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent(dropzone, dropEvent);
|
||||||
|
|
||||||
|
expect(mockOnDraggingChange).toHaveBeenCalledWith(false);
|
||||||
|
expect(mockOnFileChange).toHaveBeenCalledWith(mockFile);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('清除文件功能', () => {
|
||||||
|
it('点击清除按钮时应调用 onClearFile', () => {
|
||||||
|
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
|
||||||
|
render(
|
||||||
|
<ImageUploader {...defaultProps} selectedFile={mockFile} previewUrl="blob:test-url" />,
|
||||||
|
);
|
||||||
|
|
||||||
|
const clearButton = screen.getByTestId('ClearIcon').closest('button')!;
|
||||||
|
fireEvent.click(clearButton);
|
||||||
|
|
||||||
|
expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:test-url');
|
||||||
|
expect(mockOnClearFile).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('清除文件时应撤销预览 URL', () => {
|
||||||
|
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
|
||||||
|
render(
|
||||||
|
<ImageUploader {...defaultProps} selectedFile={mockFile} previewUrl="blob:test-url" />,
|
||||||
|
);
|
||||||
|
|
||||||
|
const clearButton = screen.getByTestId('ClearIcon').closest('button')!;
|
||||||
|
fireEvent.click(clearButton);
|
||||||
|
|
||||||
|
expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:test-url');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('粘贴功能', () => {
|
||||||
|
it('监听粘贴事件', () => {
|
||||||
|
const addEventListenerSpy = vi.spyOn(document, 'addEventListener');
|
||||||
|
render(<ImageUploader {...defaultProps} />);
|
||||||
|
|
||||||
|
expect(addEventListenerSpy).toHaveBeenCalledWith('paste', expect.any(Function));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('组件卸载时应移除粘贴事件监听', () => {
|
||||||
|
const removeEventListenerSpy = vi.spyOn(document, 'removeEventListener');
|
||||||
|
const { unmount } = render(<ImageUploader {...defaultProps} />);
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
expect(removeEventListenerSpy).toHaveBeenCalledWith('paste', expect.any(Function));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('样式测试', () => {
|
||||||
|
it('拖拽状态时应应用拖拽样式', () => {
|
||||||
|
const { container } = render(<ImageUploader {...defaultProps} dragging={true} />);
|
||||||
|
const dropzone = container.firstChild as HTMLElement;
|
||||||
|
expect(dropzone).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('有文件时应应用有文件样式', () => {
|
||||||
|
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
|
||||||
|
const { container } = render(
|
||||||
|
<ImageUploader {...defaultProps} selectedFile={mockFile} previewUrl="blob:test-url" />,
|
||||||
|
);
|
||||||
|
const dropzone = container.firstChild as HTMLElement;
|
||||||
|
expect(dropzone).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,8 +2,19 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|||||||
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
||||||
import CloseIcon from '@mui/icons-material/Close';
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { ThemeProvider, createTheme } from '@mui/material/styles';
|
||||||
import PageHeader, { type PageHeaderProps } from '@/components/PageHeader';
|
import PageHeader, { type PageHeaderProps } from '@/components/PageHeader';
|
||||||
|
|
||||||
|
vi.mock('@/config/features', () => ({
|
||||||
|
getEntryPointType: vi.fn(() => 'sidepanel'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const theme = createTheme();
|
||||||
|
|
||||||
|
function renderWithTheme(ui: React.ReactElement) {
|
||||||
|
return render(<ThemeProvider theme={theme}>{ui}</ThemeProvider>);
|
||||||
|
}
|
||||||
|
|
||||||
describe('PageHeader 组件系统', () => {
|
describe('PageHeader 组件系统', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -22,32 +33,37 @@ describe('PageHeader 组件系统', () => {
|
|||||||
|
|
||||||
describe('PageHeader UI 渲染', () => {
|
describe('PageHeader UI 渲染', () => {
|
||||||
it('应渲染页面标题栏&副标题', () => {
|
it('应渲染页面标题栏&副标题', () => {
|
||||||
render(<PageHeader {...defaultProps} />);
|
renderWithTheme(<PageHeader {...defaultProps} />);
|
||||||
expect(screen.getByText('时间戳转换')).toBeInTheDocument();
|
expect(screen.getByText('时间戳转换')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Unix 毫秒数转换与格式化')).toBeInTheDocument();
|
expect(screen.getByText('Unix 毫秒数转换与格式化')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('应渲染图标', () => {
|
it('应渲染图标', () => {
|
||||||
render(<PageHeader {...defaultProps} />);
|
renderWithTheme(<PageHeader {...defaultProps} />);
|
||||||
expect(screen.getByTestId('AccessTimeIcon')).toBeInTheDocument();
|
expect(screen.getByTestId('AccessTimeIcon')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('应渲染自定义图标&图标颜色', () => {
|
it('应渲染自定义图标&图标颜色', () => {
|
||||||
render(<PageHeader {...defaultProps} icon={<CloseIcon />} iconColor="#FF0000" />);
|
renderWithTheme(<PageHeader {...defaultProps} icon={<CloseIcon />} iconColor="#FF0000" />);
|
||||||
expect(screen.getByTestId('CloseIcon')).toBeInTheDocument();
|
expect(screen.getByTestId('CloseIcon')).toBeInTheDocument();
|
||||||
expect(screen.getByTestId('CloseIcon')).toHaveStyle('color: #FF0000;');
|
expect(screen.getByTestId('CloseIcon')).toHaveStyle('color: #FF0000;');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('应默认使用主题 primary 色', () => {
|
||||||
|
renderWithTheme(<PageHeader {...defaultProps} icon={<CloseIcon />} />);
|
||||||
|
expect(screen.getByTestId('CloseIcon')).toHaveStyle(`color: ${theme.palette.primary.main};`);
|
||||||
|
});
|
||||||
|
|
||||||
it('应渲染 badge 组件', () => {
|
it('应渲染 badge 组件', () => {
|
||||||
const badge = <span data-testid="test-badge">New</span>;
|
const badge = <span data-testid="test-badge">New</span>;
|
||||||
render(<PageHeader {...defaultProps} badge={badge} />);
|
renderWithTheme(<PageHeader {...defaultProps} badge={badge} />);
|
||||||
expect(screen.getByTestId('test-badge')).toBeInTheDocument();
|
expect(screen.getByTestId('test-badge')).toBeInTheDocument();
|
||||||
expect(screen.getByText('New')).toBeInTheDocument();
|
expect(screen.getByText('New')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('应渲染 badge 与 title 并排布局', () => {
|
it('应渲染 badge 与 title 并排布局', () => {
|
||||||
const badge = <span data-testid="side-badge">v1.0</span>;
|
const badge = <span data-testid="side-badge">v1.0</span>;
|
||||||
render(<PageHeader {...defaultProps} badge={badge} />);
|
renderWithTheme(<PageHeader {...defaultProps} badge={badge} />);
|
||||||
const title = screen.getByText('时间戳转换');
|
const title = screen.getByText('时间戳转换');
|
||||||
const badgeEl = screen.getByTestId('side-badge');
|
const badgeEl = screen.getByTestId('side-badge');
|
||||||
expect(title).toBeInTheDocument();
|
expect(title).toBeInTheDocument();
|
||||||
@@ -57,13 +73,15 @@ describe('PageHeader 组件系统', () => {
|
|||||||
|
|
||||||
describe('PageHeader 条件渲染', () => {
|
describe('PageHeader 条件渲染', () => {
|
||||||
it('subtitle 为 undefined 时不应渲染副标题', () => {
|
it('subtitle 为 undefined 时不应渲染副标题', () => {
|
||||||
const { container } = render(<PageHeader icon={<AccessTimeIcon />} title="仅标题" />);
|
const { container } = renderWithTheme(
|
||||||
|
<PageHeader icon={<AccessTimeIcon />} title="仅标题" />,
|
||||||
|
);
|
||||||
const captionElements = container.querySelectorAll('p');
|
const captionElements = container.querySelectorAll('p');
|
||||||
expect(captionElements.length).toBe(0);
|
expect(captionElements.length).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('subtitle 为空字符串时不应渲染副标题', () => {
|
it('subtitle 为空字符串时不应渲染副标题', () => {
|
||||||
const { container } = render(
|
const { container } = renderWithTheme(
|
||||||
<PageHeader icon={<AccessTimeIcon />} title="标题" subtitle="" />,
|
<PageHeader icon={<AccessTimeIcon />} title="标题" subtitle="" />,
|
||||||
);
|
);
|
||||||
const captionElements = container.querySelectorAll('p');
|
const captionElements = container.querySelectorAll('p');
|
||||||
@@ -71,14 +89,14 @@ describe('PageHeader 组件系统', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('badge 为 undefined 时不应渲染 badge 区域', () => {
|
it('badge 为 undefined 时不应渲染 badge 区域', () => {
|
||||||
render(<PageHeader {...defaultProps} />);
|
renderWithTheme(<PageHeader {...defaultProps} />);
|
||||||
expect(screen.queryByText('v1.0')).not.toBeInTheDocument();
|
expect(screen.queryByText('v1.0')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('PageHeader 样式扩展', () => {
|
describe('PageHeader 样式扩展', () => {
|
||||||
it('iconSx 应作为属性传递给图标容器', () => {
|
it('iconSx 应作为属性传递给图标容器', () => {
|
||||||
const { container } = render(
|
const { container } = renderWithTheme(
|
||||||
<PageHeader {...defaultProps} iconSx={{ border: '2px solid red' }} />,
|
<PageHeader {...defaultProps} iconSx={{ border: '2px solid red' }} />,
|
||||||
);
|
);
|
||||||
const iconContainer = container.querySelector('div');
|
const iconContainer = container.querySelector('div');
|
||||||
@@ -86,21 +104,31 @@ describe('PageHeader 组件系统', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('titleSx 应作为属性传递给标题', () => {
|
it('titleSx 应作为属性传递给标题', () => {
|
||||||
render(<PageHeader {...defaultProps} titleSx={{ fontWeight: 'bold' }} />);
|
renderWithTheme(<PageHeader {...defaultProps} titleSx={{ fontWeight: 'bold' }} />);
|
||||||
const titleEl = screen.getByText('时间戳转换');
|
const titleEl = screen.getByText('时间戳转换');
|
||||||
expect(titleEl).toBeInTheDocument();
|
expect(titleEl).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('subtitleSx 应作为属性传递给副标题', () => {
|
it('subtitleSx 应作为属性传递给副标题', () => {
|
||||||
render(<PageHeader {...defaultProps} subtitleSx={{ color: 'red' }} />);
|
renderWithTheme(<PageHeader {...defaultProps} subtitleSx={{ color: 'red' }} />);
|
||||||
const subtitleEl = screen.getByText('Unix 毫秒数转换与格式化');
|
const subtitleEl = screen.getByText('Unix 毫秒数转换与格式化');
|
||||||
expect(subtitleEl).toBeInTheDocument();
|
expect(subtitleEl).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sx 应作为属性传递给外层容器', () => {
|
it('sx 应作为属性传递给外层容器', () => {
|
||||||
const { container } = render(<PageHeader {...defaultProps} sx={{ mt: 3 }} />);
|
const { container } = renderWithTheme(<PageHeader {...defaultProps} sx={{ mt: 3 }} />);
|
||||||
const outerElement = container.firstChild;
|
const outerElement = container.firstChild;
|
||||||
expect(outerElement).toBeTruthy();
|
expect(outerElement).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('PageHeader 入口点隐藏', () => {
|
||||||
|
it('popup 模式下应返回 null', async () => {
|
||||||
|
const { getEntryPointType } = await import('@/config/features');
|
||||||
|
vi.mocked(getEntryPointType).mockReturnValue('popup');
|
||||||
|
|
||||||
|
const { container } = renderWithTheme(<PageHeader {...defaultProps} />);
|
||||||
|
expect(container.innerHTML).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import QrCodePreview from '@/components/QrCodePreview';
|
||||||
|
|
||||||
|
describe('QrCodePreview 组件', () => {
|
||||||
|
const mockOnDownload = vi.fn();
|
||||||
|
const mockOnCopy = vi.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('渲染测试', () => {
|
||||||
|
it('当 qrCodeDataUrl 为空时应显示占位文本', () => {
|
||||||
|
render(<QrCodePreview qrCodeDataUrl="" onDownload={mockOnDownload} onCopy={mockOnCopy} />);
|
||||||
|
expect(screen.getByText('qrCode:qrCodeWillShow')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('当 qrCodeDataUrl 有值时应显示二维码图片', () => {
|
||||||
|
const testDataUrl = 'data:image/png;base64,test123';
|
||||||
|
render(
|
||||||
|
<QrCodePreview
|
||||||
|
qrCodeDataUrl={testDataUrl}
|
||||||
|
onDownload={mockOnDownload}
|
||||||
|
onCopy={mockOnCopy}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const img = screen.getByAltText('QR Code');
|
||||||
|
expect(img).toBeInTheDocument();
|
||||||
|
expect(img).toHaveAttribute('src', testDataUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('当 qrCodeDataUrl 有值时应显示下载按钮', () => {
|
||||||
|
render(
|
||||||
|
<QrCodePreview
|
||||||
|
qrCodeDataUrl="data:image/png;base64,test"
|
||||||
|
onDownload={mockOnDownload}
|
||||||
|
onCopy={mockOnCopy}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('qrCode:downloadButton')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('当 qrCodeDataUrl 有值时应显示复制按钮', () => {
|
||||||
|
render(
|
||||||
|
<QrCodePreview
|
||||||
|
qrCodeDataUrl="data:image/png;base64,test"
|
||||||
|
onDownload={mockOnDownload}
|
||||||
|
onCopy={mockOnCopy}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('qrCode:copyQrButton')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('当 qrCodeDataUrl 为空时不应显示操作按钮', () => {
|
||||||
|
render(<QrCodePreview qrCodeDataUrl="" onDownload={mockOnDownload} onCopy={mockOnCopy} />);
|
||||||
|
expect(screen.queryByText('qrCode:downloadButton')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('qrCode:copyQrButton')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('交互测试', () => {
|
||||||
|
it('点击下载按钮时应调用 onDownload 回调', () => {
|
||||||
|
render(
|
||||||
|
<QrCodePreview
|
||||||
|
qrCodeDataUrl="data:image/png;base64,test"
|
||||||
|
onDownload={mockOnDownload}
|
||||||
|
onCopy={mockOnCopy}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByText('qrCode:downloadButton'));
|
||||||
|
expect(mockOnDownload).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('点击复制按钮时应调用 onCopy 回调', () => {
|
||||||
|
render(
|
||||||
|
<QrCodePreview
|
||||||
|
qrCodeDataUrl="data:image/png;base64,test"
|
||||||
|
onDownload={mockOnDownload}
|
||||||
|
onCopy={mockOnCopy}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByText('qrCode:copyQrButton'));
|
||||||
|
expect(mockOnCopy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+3
-3
@@ -685,8 +685,8 @@ export const qrCodePageStyles = {
|
|||||||
width: '100%',
|
width: '100%',
|
||||||
} as const,
|
} as const,
|
||||||
QR_PREVIEW_IMAGE: {
|
QR_PREVIEW_IMAGE: {
|
||||||
maxWidth: '100%',
|
width: 250,
|
||||||
height: 'auto',
|
height: 250,
|
||||||
display: 'block',
|
display: 'block',
|
||||||
} as const,
|
} as const,
|
||||||
QR_PREVIEW_ACTIONS: {
|
QR_PREVIEW_ACTIONS: {
|
||||||
@@ -719,7 +719,7 @@ export const qrCodePageStyles = {
|
|||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
minHeight: 200,
|
height: 250,
|
||||||
border: '2px dashed',
|
border: '2px dashed',
|
||||||
borderColor: dragging || hasFile ? 'success.main' : 'divider',
|
borderColor: dragging || hasFile ? 'success.main' : 'divider',
|
||||||
borderRadius: 3,
|
borderRadius: 3,
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||||
|
import {
|
||||||
|
createAllContextMenus,
|
||||||
|
parseContextMenuClick,
|
||||||
|
CONTEXT_MENU_CONFIGS,
|
||||||
|
MAX_PAYLOAD_LENGTH,
|
||||||
|
} from '@/utils/contextMenu';
|
||||||
|
import { MessageAction } from '@/utils/messages';
|
||||||
|
|
||||||
|
describe('background 菜单注册与分流', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('菜单注册', () => {
|
||||||
|
it('应该调用 createAllContextMenus 创建所有菜单项', () => {
|
||||||
|
createAllContextMenus();
|
||||||
|
|
||||||
|
expect(chrome.contextMenus.create).toHaveBeenCalledTimes(CONTEXT_MENU_CONFIGS.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该创建父级菜单 Testing Tools', () => {
|
||||||
|
createAllContextMenus();
|
||||||
|
|
||||||
|
expect(chrome.contextMenus.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
id: 'testing-tools-parent',
|
||||||
|
title: 'Testing Tools',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该创建 JWT 解析子菜单', () => {
|
||||||
|
createAllContextMenus();
|
||||||
|
|
||||||
|
expect(chrome.contextMenus.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
id: 'jwt',
|
||||||
|
title: '🔑 解析 JWT',
|
||||||
|
parentId: 'testing-tools-parent',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该创建网页链接转二维码子菜单', () => {
|
||||||
|
createAllContextMenus();
|
||||||
|
|
||||||
|
expect(chrome.contextMenus.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
id: 'qrCode-page',
|
||||||
|
title: '🔗 网页链接转二维码',
|
||||||
|
contexts: ['page'],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('菜单点击解析', () => {
|
||||||
|
const createMockOnClickData = (
|
||||||
|
overrides: Partial<chrome.contextMenus.OnClickData> = {},
|
||||||
|
): chrome.contextMenus.OnClickData => ({
|
||||||
|
menuItemId: 'test',
|
||||||
|
editable: false,
|
||||||
|
pageUrl: 'https://example.com',
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
it('当有 selectionText 时应返回对应的 featureKey 和 payload', () => {
|
||||||
|
const info = createMockOnClickData({
|
||||||
|
menuItemId: 'jwt',
|
||||||
|
selectionText: 'test-token',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = parseContextMenuClick('jwt', info);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
success: true,
|
||||||
|
data: { featureKey: 'jwt', payload: 'test-token' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('当没有 selectionText 和 srcUrl 时应返回错误', () => {
|
||||||
|
const info = createMockOnClickData({
|
||||||
|
menuItemId: 'unknown',
|
||||||
|
pageUrl: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = parseContextMenuClick('unknown', info);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
success: false,
|
||||||
|
error: '无法获取有效数据',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('当文本超过最大长度限制时应截断', () => {
|
||||||
|
const longText = 'a'.repeat(MAX_PAYLOAD_LENGTH + 1000);
|
||||||
|
const info = createMockOnClickData({
|
||||||
|
menuItemId: 'textStatistics',
|
||||||
|
selectionText: longText,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = parseContextMenuClick('textStatistics', info);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.data?.payload.length).toBe(MAX_PAYLOAD_LENGTH);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('当文本未超过最大长度限制时应保持原样', () => {
|
||||||
|
const shortText = 'short text';
|
||||||
|
const info = createMockOnClickData({
|
||||||
|
menuItemId: 'textStatistics',
|
||||||
|
selectionText: shortText,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = parseContextMenuClick('textStatistics', info);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
success: true,
|
||||||
|
data: { featureKey: 'textStatistics', payload: 'short text' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该正确处理页面 URL 菜单点击', () => {
|
||||||
|
const info = createMockOnClickData({
|
||||||
|
menuItemId: 'storageCleaner',
|
||||||
|
pageUrl: 'https://example.com/page',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = parseContextMenuClick('storageCleaner', info);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
success: true,
|
||||||
|
data: { featureKey: 'storageCleaner', payload: 'https://example.com/page' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('消息类型定义', () => {
|
||||||
|
it('CONTEXT_MENU_CLICKED 消息类型应正确定义', () => {
|
||||||
|
expect(MessageAction.CONTEXT_MENU_CLICKED).toBe('contextMenuClicked');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,8 +1,52 @@
|
|||||||
import '../.wxt/types/imports.d.ts';
|
import '../.wxt/types/imports.d.ts';
|
||||||
import { browser } from 'wxt/browser';
|
import { browser } from 'wxt/browser';
|
||||||
import { MessageAction, onMessage } from '@/utils/messages';
|
import { MessageAction, onMessage, sendMessage } from '@/utils/messages';
|
||||||
|
import { createAllContextMenus, parseContextMenuClick } from '@/utils/contextMenu';
|
||||||
|
import { saveContextMenuData } from '@/utils/useContextMenuData';
|
||||||
|
|
||||||
export default defineBackground(() => {
|
export default defineBackground(() => {
|
||||||
|
browser.runtime.onInstalled.addListener(() => {
|
||||||
|
createAllContextMenus();
|
||||||
|
});
|
||||||
|
|
||||||
|
browser.contextMenus.onClicked.addListener(async (info, _tab) => {
|
||||||
|
const result = parseContextMenuClick(info.menuItemId as string, info);
|
||||||
|
|
||||||
|
if (!result.success || !result.data) {
|
||||||
|
if (result.error) {
|
||||||
|
console.warn('[Context Menu]', result.error);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { featureKey, payload } = result.data;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sidePanelState = await browser.storage.local.get('sidePanelOpen');
|
||||||
|
const isSidePanelOpen = sidePanelState.sidePanelOpen === true;
|
||||||
|
|
||||||
|
if (isSidePanelOpen) {
|
||||||
|
await sendMessage(MessageAction.CONTEXT_MENU_CLICKED, { featureKey, payload });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// sidepanel 未打开或无法通信,继续执行其他方案
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存数据到 storage,popup 打开后会读取
|
||||||
|
await saveContextMenuData({ featureKey, payload });
|
||||||
|
|
||||||
|
// 打开 popup 弹窗
|
||||||
|
try {
|
||||||
|
await browser.action.openPopup();
|
||||||
|
} catch (err) {
|
||||||
|
// openPopup 在无活动窗口时会失败(如窗口失焦、特殊页面等)
|
||||||
|
// 数据已保存到 storage,用户手动打开 popup 仍可正常使用
|
||||||
|
console.warn('[Context Menu] 自动打开 popup 失败,请手动点击扩展图标:', err);
|
||||||
|
await chrome.storage.local.remove('contextMenu/pendingData');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// 监听扩展图标点击事件,打开侧边栏
|
// 监听扩展图标点击事件,打开侧边栏
|
||||||
browser.action.onClicked.addListener(async (tab) => {
|
browser.action.onClicked.addListener(async (tab) => {
|
||||||
if (tab.id) {
|
if (tab.id) {
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { MessageAction, onMessage } from '@/utils/messages';
|
||||||
|
import { getTextStats } from '@/utils/textStatistics';
|
||||||
|
import { showTimestampResult, showTextStatsResult, hidePopover } from './uiPopover';
|
||||||
|
import type { ContextMenuClickedPayload } from '@/utils/messages';
|
||||||
|
|
||||||
|
function convertTimestamp(input: string): string {
|
||||||
|
const num = Number(input.trim());
|
||||||
|
if (isNaN(num)) {
|
||||||
|
return '无效时间戳';
|
||||||
|
}
|
||||||
|
|
||||||
|
const d = num > 1e12 ? new Date(num) : new Date(num * 1000);
|
||||||
|
|
||||||
|
if (isNaN(d.getTime())) {
|
||||||
|
return '无效时间戳';
|
||||||
|
}
|
||||||
|
|
||||||
|
const year = d.getFullYear();
|
||||||
|
const month = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(d.getDate()).padStart(2, '0');
|
||||||
|
const hours = String(d.getHours()).padStart(2, '0');
|
||||||
|
const minutes = String(d.getMinutes()).padStart(2, '0');
|
||||||
|
const seconds = String(d.getSeconds()).padStart(2, '0');
|
||||||
|
|
||||||
|
return `${year}/${month}/${day} ${hours}:${minutes}:${seconds}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastClickX = 0;
|
||||||
|
let lastClickY = 0;
|
||||||
|
|
||||||
|
document.addEventListener(
|
||||||
|
'contextmenu',
|
||||||
|
(e) => {
|
||||||
|
lastClickX = e.clientX;
|
||||||
|
lastClickY = e.clientY;
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
export function initContextMenuHandler(): void {
|
||||||
|
onMessage(MessageAction.CONTEXT_MENU_CLICKED, (message) => {
|
||||||
|
const { featureKey, payload } = message.data as ContextMenuClickedPayload;
|
||||||
|
|
||||||
|
switch (featureKey) {
|
||||||
|
case 'timestamp': {
|
||||||
|
const result = convertTimestamp(payload);
|
||||||
|
showTimestampResult(lastClickX, lastClickY, payload, result);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'textStatistics': {
|
||||||
|
const stats = getTextStats(payload);
|
||||||
|
showTextStatsResult(lastClickX, lastClickY, payload, stats);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
hidePopover();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1 +1,5 @@
|
|||||||
export function initMessageHandler() {}
|
import { initContextMenuHandler } from './contextMenuHandler';
|
||||||
|
|
||||||
|
export function initMessageHandler(): void {
|
||||||
|
initContextMenuHandler();
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
const POPOVER_ID = 'testing-tools-popover';
|
||||||
|
const POPOVER_STYLE_ID = 'testing-tools-popover-style';
|
||||||
|
|
||||||
|
function injectStyles(): void {
|
||||||
|
if (document.getElementById(POPOVER_STYLE_ID)) return;
|
||||||
|
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.id = POPOVER_STYLE_ID;
|
||||||
|
style.textContent = `
|
||||||
|
#${POPOVER_ID} {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 2147483647;
|
||||||
|
max-width: 400px;
|
||||||
|
min-width: 200px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: #1a1a2e;
|
||||||
|
color: #e0e0e0;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-8px);
|
||||||
|
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#${POPOVER_ID}.visible {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#${POPOVER_ID} .popover-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#${POPOVER_ID} .popover-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #a0a0b0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#${POPOVER_ID} .popover-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #808090;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#${POPOVER_ID} .popover-close:hover {
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#${POPOVER_ID} .popover-content {
|
||||||
|
word-break: break-all;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
#${POPOVER_ID} .popover-label {
|
||||||
|
color: #808090;
|
||||||
|
font-size: 11px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#${POPOVER_ID} .popover-value {
|
||||||
|
color: #ffffff;
|
||||||
|
font-family: 'SF Mono', 'Consolas', 'Monaco', monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#${POPOVER_ID} .popover-value:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#${POPOVER_ID} .stat-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#${POPOVER_ID} .stat-item {
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#${POPOVER_ID} .stat-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #808090;
|
||||||
|
}
|
||||||
|
|
||||||
|
#${POPOVER_ID} .stat-value {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOrCreatePopover(): HTMLElement {
|
||||||
|
let popover = document.getElementById(POPOVER_ID);
|
||||||
|
if (!popover) {
|
||||||
|
injectStyles();
|
||||||
|
popover = document.createElement('div');
|
||||||
|
popover.id = POPOVER_ID;
|
||||||
|
document.body.appendChild(popover);
|
||||||
|
}
|
||||||
|
return popover;
|
||||||
|
}
|
||||||
|
|
||||||
|
function positionPopover(popover: HTMLElement, x: number, y: number): void {
|
||||||
|
const rect = popover.getBoundingClientRect();
|
||||||
|
const viewportWidth = window.innerWidth;
|
||||||
|
const viewportHeight = window.innerHeight;
|
||||||
|
|
||||||
|
let left = x;
|
||||||
|
let top = y;
|
||||||
|
|
||||||
|
if (left + rect.width > viewportWidth - 16) {
|
||||||
|
left = viewportWidth - rect.width - 16;
|
||||||
|
}
|
||||||
|
if (left < 16) {
|
||||||
|
left = 16;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (top + rect.height > viewportHeight - 16) {
|
||||||
|
top = y - rect.height - 8;
|
||||||
|
}
|
||||||
|
if (top < 16) {
|
||||||
|
top = 16;
|
||||||
|
}
|
||||||
|
|
||||||
|
popover.style.left = `${left}px`;
|
||||||
|
popover.style.top = `${top}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let hideTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
export function showPopover(
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
content: string,
|
||||||
|
title?: string,
|
||||||
|
duration: number = 5000,
|
||||||
|
): void {
|
||||||
|
const popover = getOrCreatePopover();
|
||||||
|
|
||||||
|
const titleHtml = title
|
||||||
|
? `<div class="popover-header">
|
||||||
|
<span class="popover-title">${title}</span>
|
||||||
|
<button class="popover-close" onclick="this.closest('#${POPOVER_ID}').classList.remove('visible')">×</button>
|
||||||
|
</div>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
popover.innerHTML = `
|
||||||
|
${titleHtml}
|
||||||
|
<div class="popover-content">${content}</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
popover.classList.remove('visible');
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
positionPopover(popover, x, y);
|
||||||
|
popover.classList.add('visible');
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hideTimeout) {
|
||||||
|
clearTimeout(hideTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (duration > 0) {
|
||||||
|
hideTimeout = setTimeout(() => {
|
||||||
|
hidePopover();
|
||||||
|
}, duration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hidePopover(): void {
|
||||||
|
const popover = document.getElementById(POPOVER_ID);
|
||||||
|
if (popover) {
|
||||||
|
popover.classList.remove('visible');
|
||||||
|
}
|
||||||
|
if (hideTimeout) {
|
||||||
|
clearTimeout(hideTimeout);
|
||||||
|
hideTimeout = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showTimestampResult(x: number, y: number, timestamp: string, result: string): void {
|
||||||
|
const content = `
|
||||||
|
<div class="popover-label">输入时间戳</div>
|
||||||
|
<div class="popover-value">${timestamp}</div>
|
||||||
|
<div class="popover-label">转换结果</div>
|
||||||
|
<div class="popover-value">${result}</div>
|
||||||
|
`;
|
||||||
|
showPopover(x, y, content, '⏰ 时间戳转换');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showTextStatsResult(
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
text: string,
|
||||||
|
stats: { characters: number; words: number; lines: number; bytes: number },
|
||||||
|
): void {
|
||||||
|
const truncatedText = text.length > 50 ? text.substring(0, 50) + '...' : text;
|
||||||
|
const content = `
|
||||||
|
<div class="popover-label">选中文本</div>
|
||||||
|
<div class="popover-value">${truncatedText}</div>
|
||||||
|
<div class="stat-grid">
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-label">字符</div>
|
||||||
|
<div class="stat-value">${stats.characters}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-label">单词</div>
|
||||||
|
<div class="stat-value">${stats.words}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-label">行数</div>
|
||||||
|
<div class="stat-value">${stats.lines}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-label">字节</div>
|
||||||
|
<div class="stat-value">${stats.bytes}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
showPopover(x, y, content, '📊 文本统计');
|
||||||
|
}
|
||||||
@@ -1,31 +1,48 @@
|
|||||||
{
|
{
|
||||||
"pageTitle": "QR Code Tools",
|
"pageTitle": "QR Code Tools",
|
||||||
"pageSubtitle": "Generate and parse QR codes",
|
"pageSubtitle": "Generate and parse QR codes",
|
||||||
"urlToQr": "URL to QR Code",
|
|
||||||
"qrToUrl": "QR Code to URL",
|
"generateMode": "Generate QR Code",
|
||||||
"urlInputLabel": "Enter URL",
|
"parseMode": "Parse QR Code",
|
||||||
"urlInputPlaceholder": "https://example.com",
|
|
||||||
|
"urlToQr": "Text to QR Code",
|
||||||
|
"qrToUrl": "QR Code to Text",
|
||||||
|
|
||||||
|
"urlInputLabel": "Enter URL or Text",
|
||||||
|
"urlInputPlaceholder": "Enter URL or text content, QR code will be generated automatically",
|
||||||
"generateButton": "Generate QR Code",
|
"generateButton": "Generate QR Code",
|
||||||
"generating": "Generating...",
|
"generating": "Generating...",
|
||||||
|
|
||||||
"qrCodeWillShow": "QR code will be shown here",
|
"qrCodeWillShow": "QR code will be shown here",
|
||||||
"downloadButton": "Download QR Code",
|
"downloadButton": "Download QR Code",
|
||||||
"copyQrButton": "Copy QR Code Image",
|
"copyQrButton": "Copy QR Code Image",
|
||||||
|
|
||||||
"qrCodeSuccess": "QR code generated successfully",
|
"qrCodeSuccess": "QR code generated successfully",
|
||||||
"qrCodeDownloadSuccess": "QR code downloaded successfully",
|
"qrCodeDownloadSuccess": "QR code downloaded successfully",
|
||||||
"qrCodeCopySuccess": "QR code copied to clipboard",
|
"qrCodeCopySuccess": "QR code copied to clipboard",
|
||||||
|
|
||||||
"selectImage": "Please select a QR code image",
|
"selectImage": "Please select a QR code image",
|
||||||
"parseSuccess": "QR code parsed successfully",
|
"parseSuccess": "QR code parsed successfully",
|
||||||
"noQrDetected": "No QR code detected",
|
"noQrDetected": "No QR code detected, please ensure the image is clear and contains a QR code",
|
||||||
"parseError": "Failed to parse QR code, please try again",
|
"parseError": "Failed to parse QR code, please try again",
|
||||||
"imagePasted": "Image pasted successfully",
|
"generateError": "Failed to generate QR code, please check the input",
|
||||||
|
"copyError": "Copy failed, please try again",
|
||||||
|
|
||||||
|
"imagePasted": "Image pasted, parsing...",
|
||||||
"imagePasteError": "Failed to paste image, please try again",
|
"imagePasteError": "Failed to paste image, please try again",
|
||||||
"imageCleared": "Image cleared",
|
"imageCleared": "Image cleared",
|
||||||
"clickToUpload": "Click, drag, or paste to upload QR code image",
|
"clickToUpload": "Click, drag, or paste to upload QR code image",
|
||||||
"supportFormats": "Supports PNG, JPG, WEBP formats",
|
"supportFormats": "Supports PNG, JPG, WEBP, Base64 formats",
|
||||||
|
|
||||||
"parseButton": "Parse QR Code",
|
"parseButton": "Parse QR Code",
|
||||||
"parsing": "Parsing...",
|
"parsing": "Parsing...",
|
||||||
"resultLabel": "Parsing Result",
|
"resultLabel": "Parsing Result",
|
||||||
"copyTooltip": "Copy",
|
"copyTooltip": "Copy",
|
||||||
"enterUrlError": "Please enter a URL",
|
|
||||||
"clickToChange": "Click to change image"
|
"enterUrlError": "Please enter URL or text",
|
||||||
|
"clickToChange": "Click to change image",
|
||||||
|
|
||||||
|
"pasteHint": "Supports Ctrl+V to paste images or Base64 strings",
|
||||||
|
"autoGenerateHint": "QR code will be generated automatically after input",
|
||||||
|
"autoParseHint": "QR code will be parsed automatically after upload"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,48 @@
|
|||||||
{
|
{
|
||||||
"pageTitle": "二维码工具",
|
"pageTitle": "二维码工具",
|
||||||
"pageSubtitle": "生成和解析二维码",
|
"pageSubtitle": "生成和解析二维码",
|
||||||
"urlToQr": "URL 转二维码",
|
|
||||||
"qrToUrl": "二维码转 URL",
|
"generateMode": "生成二维码",
|
||||||
"urlInputLabel": "输入 URL",
|
"parseMode": "解析二维码",
|
||||||
"urlInputPlaceholder": "https://example.com",
|
|
||||||
|
"urlToQr": "文本转二维码",
|
||||||
|
"qrToUrl": "二维码转文本",
|
||||||
|
|
||||||
|
"urlInputLabel": "输入 URL 或文本",
|
||||||
|
"urlInputPlaceholder": "请输入 URL 或文本内容,将自动生成二维码",
|
||||||
"generateButton": "生成二维码",
|
"generateButton": "生成二维码",
|
||||||
"generating": "生成中...",
|
"generating": "生成中...",
|
||||||
|
|
||||||
"qrCodeWillShow": "二维码将显示在这里",
|
"qrCodeWillShow": "二维码将显示在这里",
|
||||||
"downloadButton": "下载二维码",
|
"downloadButton": "下载二维码",
|
||||||
"copyQrButton": "复制二维码",
|
"copyQrButton": "复制二维码",
|
||||||
|
|
||||||
"qrCodeSuccess": "二维码生成成功",
|
"qrCodeSuccess": "二维码生成成功",
|
||||||
"qrCodeDownloadSuccess": "二维码下载成功",
|
"qrCodeDownloadSuccess": "二维码下载成功",
|
||||||
"qrCodeCopySuccess": "二维码已复制到剪贴板",
|
"qrCodeCopySuccess": "二维码已复制到剪贴板",
|
||||||
|
|
||||||
"selectImage": "请选择二维码图片",
|
"selectImage": "请选择二维码图片",
|
||||||
"parseSuccess": "二维码解析成功",
|
"parseSuccess": "二维码解析成功",
|
||||||
"noQrDetected": "未检测到二维码",
|
"noQrDetected": "未检测到二维码,请确保图片清晰且包含二维码",
|
||||||
"parseError": "解析二维码失败,请重试",
|
"parseError": "解析二维码失败,请重试",
|
||||||
"imagePasted": "图片粘贴成功",
|
"generateError": "生成二维码失败,请检查输入内容",
|
||||||
|
"copyError": "复制失败,请重试",
|
||||||
|
|
||||||
|
"imagePasted": "图片粘贴成功,正在解析...",
|
||||||
"imagePasteError": "粘贴图片失败,请重试",
|
"imagePasteError": "粘贴图片失败,请重试",
|
||||||
"imageCleared": "图片已清除",
|
"imageCleared": "图片已清除",
|
||||||
"clickToUpload": "点击、拖拽或粘贴上传二维码图片",
|
"clickToUpload": "点击、拖拽或粘贴上传二维码图片",
|
||||||
"supportFormats": "支持 PNG、JPG、WEBP 格式",
|
"supportFormats": "支持 PNG、JPG、WEBP、Base64 格式",
|
||||||
|
|
||||||
"parseButton": "解析二维码",
|
"parseButton": "解析二维码",
|
||||||
"parsing": "解析中...",
|
"parsing": "解析中...",
|
||||||
"resultLabel": "解析结果",
|
"resultLabel": "解析结果",
|
||||||
"copyTooltip": "复制",
|
"copyTooltip": "复制",
|
||||||
"enterUrlError": "请输入 URL",
|
|
||||||
"clickToChange": "点击更换图片"
|
"enterUrlError": "请输入 URL 或文本",
|
||||||
|
"clickToChange": "点击更换图片",
|
||||||
|
|
||||||
|
"pasteHint": "支持 Ctrl+V 粘贴图片或 Base64 字符串",
|
||||||
|
"autoGenerateHint": "输入内容后将自动生成二维码",
|
||||||
|
"autoParseHint": "上传图片后将自动解析二维码"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import CopyButton from '@/components/CopyButton';
|
import CopyButton from '@/components/CopyButton';
|
||||||
import { textToBase64, base64ToText } from '@/utils/base64Converter';
|
import { textToBase64, base64ToText } from '@/utils/base64Converter';
|
||||||
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
|
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
|
||||||
|
import { useContextMenuData } from '@/utils/useContextMenuData';
|
||||||
|
|
||||||
const IMAGE_DATA_URI_PATTERN = /^\s*data:image\//i;
|
const IMAGE_DATA_URI_PATTERN = /^\s*data:image\//i;
|
||||||
|
|
||||||
@@ -26,6 +27,25 @@ export default function TextMode({ onSwitchToImageMode }: TextModeProps = {}) {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [direction, setDirection] = useState<'encode' | 'decode'>('encode');
|
const [direction, setDirection] = useState<'encode' | 'decode'>('encode');
|
||||||
|
|
||||||
|
const handleContextMenuData = useCallback(
|
||||||
|
(payload: string) => {
|
||||||
|
setInput(payload);
|
||||||
|
setDirection('decode');
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const decoded = base64ToText(payload);
|
||||||
|
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'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[t],
|
||||||
|
);
|
||||||
|
|
||||||
|
useContextMenuData({ featureKey: 'base64Converter', onData: handleContextMenuData });
|
||||||
|
|
||||||
const actionLabel = direction === 'encode' ? t('encode') : t('decode');
|
const actionLabel = direction === 'encode' ? t('encode') : t('decode');
|
||||||
const placeholder =
|
const placeholder =
|
||||||
direction === 'encode' ? t('textInputPlaceholder') : t('base64InputPlaceholder');
|
direction === 'encode' ? t('textInputPlaceholder') : t('base64InputPlaceholder');
|
||||||
|
|||||||
@@ -2,6 +2,24 @@ import { describe, it, expect, vi } from 'vitest';
|
|||||||
import { render, screen, fireEvent } from '@testing-library/react';
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
import Base64ConverterPage from '../index';
|
import Base64ConverterPage from '../index';
|
||||||
|
|
||||||
|
// Mock useLazyTranslation
|
||||||
|
vi.mock('@/utils/useLazyTranslation', () => ({
|
||||||
|
useLazyTranslation: () => ({
|
||||||
|
t: (key: string) => key,
|
||||||
|
i18n: { changeLanguage: vi.fn(), language: 'zh-CN' },
|
||||||
|
isLoaded: true,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock getEntryPointType
|
||||||
|
vi.mock('@/config/features', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('@/config/features')>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
getEntryPointType: () => 'sidepanel',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// Mock 子组件
|
// Mock 子组件
|
||||||
vi.mock('../TextMode', () => ({
|
vi.mock('../TextMode', () => ({
|
||||||
default: () => <div data-testid="text-mode">TextMode</div>,
|
default: () => <div data-testid="text-mode">TextMode</div>,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Box, IconButton, Typography } from '@mui/material';
|
import { Box, IconButton, Typography } from '@mui/material';
|
||||||
import NavigateBeforeIcon from '@mui/icons-material/NavigateBefore';
|
import NavigateBeforeIcon from '@mui/icons-material/NavigateBefore';
|
||||||
import NavigateNextIcon from '@mui/icons-material/NavigateNext';
|
import NavigateNextIcon from '@mui/icons-material/NavigateNext';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
||||||
import { jsonDiffPageStyles } from '@/config/pageTheme';
|
import { jsonDiffPageStyles } from '@/config/pageTheme';
|
||||||
|
|
||||||
interface DiffNavigatorProps {
|
interface DiffNavigatorProps {
|
||||||
@@ -13,7 +13,7 @@ interface DiffNavigatorProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function DiffNavigator({ total, currentIndex, onPrev, onNext }: DiffNavigatorProps) {
|
export default function DiffNavigator({ total, currentIndex, onPrev, onNext }: DiffNavigatorProps) {
|
||||||
const { t } = useTranslation(['jsonDiff']);
|
const { t } = useLazyTranslation('jsonDiff');
|
||||||
|
|
||||||
if (total === 0) {
|
if (total === 0) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Box, Stack, Typography, useTheme } from '@mui/material';
|
import { Box, Stack, Typography, useTheme } from '@mui/material';
|
||||||
import type { Theme } from '@mui/material/styles';
|
import type { Theme } from '@mui/material/styles';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
||||||
import { jsonDiffPageStyles, surfaceTint } from '@/config/pageTheme';
|
import { jsonDiffPageStyles, surfaceTint } from '@/config/pageTheme';
|
||||||
import JsonTree from './JsonTree';
|
import JsonTree from './JsonTree';
|
||||||
import type { DiffNode, DiffResult as DiffResultType, DiffType, ViewMode } from './types';
|
import type { DiffNode, DiffResult as DiffResultType, DiffType, ViewMode } from './types';
|
||||||
@@ -12,7 +12,7 @@ interface DiffResultProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function DiffResult({ result, viewMode, activePath }: DiffResultProps) {
|
export default function DiffResult({ result, viewMode, activePath }: DiffResultProps) {
|
||||||
const { t } = useTranslation(['jsonDiff']);
|
const { t } = useLazyTranslation('jsonDiff');
|
||||||
|
|
||||||
if (viewMode === 'sideBySide') {
|
if (viewMode === 'sideBySide') {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Box, Button, Stack, Typography } from '@mui/material';
|
import { Box, Button, Stack, Typography } from '@mui/material';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
||||||
import { formatByteSize } from '@/utils/textStatistics';
|
import { formatByteSize } from '@/utils/textStatistics';
|
||||||
import { useSnackbar } from '@/components/GlobalSnackbar';
|
import { useSnackbar } from '@/components/GlobalSnackbar';
|
||||||
import CopyButton from '@/components/CopyButton';
|
import CopyButton from '@/components/CopyButton';
|
||||||
@@ -43,7 +43,7 @@ export default function JsonConvertSection({
|
|||||||
convertFunction,
|
convertFunction,
|
||||||
convertButtonKey = 'convertButton',
|
convertButtonKey = 'convertButton',
|
||||||
}: JsonConvertSectionProps) {
|
}: JsonConvertSectionProps) {
|
||||||
const { t } = useTranslation(['jsonFormat']);
|
const { t } = useLazyTranslation('jsonFormat');
|
||||||
const { showMessage } = useSnackbar();
|
const { showMessage } = useSnackbar();
|
||||||
|
|
||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
TextField,
|
TextField,
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
||||||
import {
|
import {
|
||||||
formatJson,
|
formatJson,
|
||||||
validateJson,
|
validateJson,
|
||||||
@@ -32,7 +32,7 @@ const INDENT_OPTIONS = [2, 4, 6, 8] as const;
|
|||||||
* 支持一键复制格式化后的 JSON。
|
* 支持一键复制格式化后的 JSON。
|
||||||
*/
|
*/
|
||||||
export default function JsonFormatSection() {
|
export default function JsonFormatSection() {
|
||||||
const { t } = useTranslation(['jsonFormat']);
|
const { t } = useLazyTranslation('jsonFormat');
|
||||||
const { showMessage } = useSnackbar();
|
const { showMessage } = useSnackbar();
|
||||||
|
|
||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
|
|||||||
+9
-1
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
import { Box, Container, Paper, Stack, Typography } from '@mui/material';
|
import { Box, Container, Paper, Stack, Typography } from '@mui/material';
|
||||||
import { useSnackbar } from '@/components/GlobalSnackbar';
|
import { useSnackbar } from '@/components/GlobalSnackbar';
|
||||||
import VpnKeyIcon from '@mui/icons-material/VpnKey';
|
import VpnKeyIcon from '@mui/icons-material/VpnKey';
|
||||||
@@ -7,6 +7,7 @@ import { stringifyJson, parseJwt } from '@/utils/jwt';
|
|||||||
import CopyButton from '@/components/CopyButton';
|
import CopyButton from '@/components/CopyButton';
|
||||||
import TextInputArea from '@/components/TextInputArea';
|
import TextInputArea from '@/components/TextInputArea';
|
||||||
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
||||||
|
import { useContextMenuData } from '@/utils/useContextMenuData';
|
||||||
|
|
||||||
interface SectionProps {
|
interface SectionProps {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -61,6 +62,13 @@ export default function Index() {
|
|||||||
const { t } = useLazyTranslation('jwt');
|
const { t } = useLazyTranslation('jwt');
|
||||||
const [jwtInput, setJwtInput] = useState('');
|
const [jwtInput, setJwtInput] = useState('');
|
||||||
|
|
||||||
|
const handleContextMenuData = useCallback((payload: string) => {
|
||||||
|
const cleaned = payload.replace(/^Bearer\s*/i, '').trim();
|
||||||
|
setJwtInput(cleaned);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useContextMenuData({ featureKey: 'jwt', onData: handleContextMenuData });
|
||||||
|
|
||||||
const result = useMemo(() => {
|
const result = useMemo(() => {
|
||||||
if (!jwtInput.trim()) {
|
if (!jwtInput.trim()) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -1,292 +0,0 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
||||||
import {
|
|
||||||
Accordion,
|
|
||||||
AccordionDetails,
|
|
||||||
AccordionSummary,
|
|
||||||
Alert,
|
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
CircularProgress,
|
|
||||||
IconButton,
|
|
||||||
InputAdornment,
|
|
||||||
Stack,
|
|
||||||
TextField,
|
|
||||||
Typography,
|
|
||||||
} from '@mui/material';
|
|
||||||
import LinkIcon from '@mui/icons-material/Link';
|
|
||||||
import ImageIcon from '@mui/icons-material/Image';
|
|
||||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
|
||||||
import ClearIcon from '@mui/icons-material/Clear';
|
|
||||||
import CopyButton from '@/components/CopyButton';
|
|
||||||
import { qrCodePageStyles } from '@/config/pageTheme';
|
|
||||||
import { useSnackbar } from '@/components/GlobalSnackbar';
|
|
||||||
import { parseQrCodeFromFile } from '@/utils/qrCodeParser';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
interface QrCodeToUrlSectionProps {
|
|
||||||
expanded: boolean;
|
|
||||||
onExpandedChange: (expanded: boolean) => void;
|
|
||||||
/** 桌面端强制展开(隐藏折叠交互) */
|
|
||||||
forceExpanded?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const QrCodeToUrlSection = ({
|
|
||||||
expanded,
|
|
||||||
onExpandedChange,
|
|
||||||
forceExpanded = false,
|
|
||||||
}: QrCodeToUrlSectionProps) => {
|
|
||||||
const { t } = useTranslation(['qrCode']);
|
|
||||||
const { showMessage } = useSnackbar();
|
|
||||||
const [qrCodeFile, setQrCodeFile] = useState<File | null>(null);
|
|
||||||
const [previewUrl, setPreviewUrl] = useState<string>('');
|
|
||||||
const [parsedUrl, setParsedUrl] = useState('');
|
|
||||||
const [parseError, setParseError] = useState('');
|
|
||||||
const [parsing, setParsing] = useState(false);
|
|
||||||
const [dragging, setDragging] = useState(false);
|
|
||||||
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
// 清理预览 URL,防止内存泄漏
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (previewUrl) {
|
|
||||||
URL.revokeObjectURL(previewUrl);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [previewUrl]);
|
|
||||||
|
|
||||||
const handleFileChange = useCallback((file: File) => {
|
|
||||||
setQrCodeFile(file);
|
|
||||||
setPreviewUrl(URL.createObjectURL(file));
|
|
||||||
setParseError('');
|
|
||||||
setParsedUrl('');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleClearFile = () => {
|
|
||||||
setQrCodeFile(null);
|
|
||||||
if (previewUrl) {
|
|
||||||
URL.revokeObjectURL(previewUrl);
|
|
||||||
}
|
|
||||||
setPreviewUrl('');
|
|
||||||
setParsedUrl('');
|
|
||||||
setParseError('');
|
|
||||||
showMessage(t('qrCode:imageCleared'), {
|
|
||||||
severity: 'success',
|
|
||||||
autoHideDuration: 1000,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
if (e.target.files && e.target.files.length > 0) {
|
|
||||||
handleFileChange(e.target.files[0]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setDragging(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragLeave = () => {
|
|
||||||
setDragging(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setDragging(false);
|
|
||||||
const droppedFile = e.dataTransfer.files?.[0];
|
|
||||||
if (droppedFile) {
|
|
||||||
handleFileChange(droppedFile);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const parseQrCode = async () => {
|
|
||||||
if (!qrCodeFile) {
|
|
||||||
showMessage(t('qrCode:selectImage'), { severity: 'error', autoHideDuration: 300 });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setParsing(true);
|
|
||||||
setParseError('');
|
|
||||||
setParsedUrl('');
|
|
||||||
|
|
||||||
const result = await parseQrCodeFromFile(qrCodeFile);
|
|
||||||
|
|
||||||
if (result.success && result.data) {
|
|
||||||
setParsedUrl(result.data);
|
|
||||||
showMessage(t('qrCode:parseSuccess'), { severity: 'success', autoHideDuration: 1000 });
|
|
||||||
} else {
|
|
||||||
showMessage(result.error || t('qrCode:noQrDetected'), {
|
|
||||||
severity: 'error',
|
|
||||||
autoHideDuration: 1000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('解析二维码失败:', error);
|
|
||||||
showMessage(t('qrCode:parseError'), { severity: 'error', autoHideDuration: 300 });
|
|
||||||
} finally {
|
|
||||||
setParsing(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 监听粘贴事件
|
|
||||||
useEffect(() => {
|
|
||||||
const handlePaste = async (e: ClipboardEvent) => {
|
|
||||||
if (!expanded) return;
|
|
||||||
|
|
||||||
const items = e.clipboardData?.items;
|
|
||||||
if (!items) return;
|
|
||||||
|
|
||||||
for (let i = 0; i < items.length; i++) {
|
|
||||||
if (items[i].type.startsWith('image/')) {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const file = items[i].getAsFile();
|
|
||||||
if (file) {
|
|
||||||
try {
|
|
||||||
handleFileChange(file);
|
|
||||||
showMessage(t('qrCode:imagePasted'), { severity: 'success', autoHideDuration: 1000 });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('处理粘贴图片失败:', error);
|
|
||||||
showMessage(t('qrCode:imagePasteError'), {
|
|
||||||
severity: 'error',
|
|
||||||
autoHideDuration: 3000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener('paste', handlePaste);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('paste', handlePaste);
|
|
||||||
};
|
|
||||||
}, [expanded, showMessage, handleFileChange, t]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Accordion
|
|
||||||
expanded={forceExpanded ? true : expanded}
|
|
||||||
onChange={forceExpanded ? undefined : (_, isExpanded) => onExpandedChange(isExpanded)}
|
|
||||||
sx={forceExpanded ? qrCodePageStyles.ACCORDION_DESKTOP : qrCodePageStyles.ACCORDION}
|
|
||||||
>
|
|
||||||
<AccordionSummary expandIcon={<ExpandMoreIcon />} sx={qrCodePageStyles.ACCORDION_SUMMARY}>
|
|
||||||
<Box sx={qrCodePageStyles.ACCORDION_TITLE_ICON}>
|
|
||||||
<LinkIcon color="success" />
|
|
||||||
<Typography variant="subtitle1" sx={qrCodePageStyles.ACCORDION_TITLE_TEXT}>
|
|
||||||
{t('qrCode:qrToUrl')}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</AccordionSummary>
|
|
||||||
<AccordionDetails>
|
|
||||||
<Stack spacing={3}>
|
|
||||||
<Box
|
|
||||||
className="qr-flex-grow"
|
|
||||||
sx={qrCodePageStyles.DROPZONE(dragging, !!qrCodeFile)}
|
|
||||||
onDragOver={handleDragOver}
|
|
||||||
onDragLeave={handleDragLeave}
|
|
||||||
onDrop={handleDrop}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
onChange={handleInputChange}
|
|
||||||
style={{ display: 'none' }}
|
|
||||||
id="qr-code-upload"
|
|
||||||
/>
|
|
||||||
<label
|
|
||||||
htmlFor="qr-code-upload"
|
|
||||||
style={{ cursor: 'pointer', textAlign: 'center', width: '100%' }}
|
|
||||||
>
|
|
||||||
{qrCodeFile ? (
|
|
||||||
<Box sx={qrCodePageStyles.IMAGE_PREVIEW_WRAPPER}>
|
|
||||||
<Box sx={qrCodePageStyles.IMAGE_PREVIEW_BOX}>
|
|
||||||
<img
|
|
||||||
src={previewUrl}
|
|
||||||
alt="QR Code Preview"
|
|
||||||
style={qrCodePageStyles.IMAGE_PREVIEW_IMG}
|
|
||||||
/>
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleClearFile();
|
|
||||||
}}
|
|
||||||
sx={qrCodePageStyles.CLEAR_BUTTON}
|
|
||||||
>
|
|
||||||
<ClearIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</Box>
|
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
|
|
||||||
{qrCodeFile.name}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
{t('qrCode:clickToChange')}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<ImageIcon sx={{ fontSize: 48, color: 'text.disabled', mb: 2 }} />
|
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
|
||||||
{t('qrCode:clickToUpload')}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
{t('qrCode:supportFormats')}
|
|
||||||
</Typography>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
startIcon={parsing ? <CircularProgress size={16} color="inherit" /> : <LinkIcon />}
|
|
||||||
onClick={parseQrCode}
|
|
||||||
disabled={parsing}
|
|
||||||
sx={qrCodePageStyles.PRIMARY_BUTTON}
|
|
||||||
>
|
|
||||||
{parsing ? t('qrCode:parsing') : t('qrCode:parseButton')}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Box sx={qrCodePageStyles.RESULT_INPUT}>
|
|
||||||
<TextField
|
|
||||||
label={t('qrCode:resultLabel')}
|
|
||||||
value={parsedUrl}
|
|
||||||
fullWidth
|
|
||||||
variant="outlined"
|
|
||||||
slotProps={{
|
|
||||||
input: {
|
|
||||||
readOnly: true,
|
|
||||||
endAdornment: (
|
|
||||||
<InputAdornment position="end">
|
|
||||||
<CopyButton
|
|
||||||
text={parsedUrl}
|
|
||||||
tooltip={t('qrCode:copyTooltip')}
|
|
||||||
size="small"
|
|
||||||
color={qrCodePageStyles.primaryColor}
|
|
||||||
showMessage={showMessage}
|
|
||||||
/>
|
|
||||||
</InputAdornment>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
sx={qrCodePageStyles.INPUT_STYLE}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{parseError && (
|
|
||||||
<Alert severity="error" sx={{ borderRadius: 3 }}>
|
|
||||||
{parseError}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</AccordionDetails>
|
|
||||||
</Accordion>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default QrCodeToUrlSection;
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import {
|
|
||||||
Accordion,
|
|
||||||
AccordionDetails,
|
|
||||||
AccordionSummary,
|
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
CircularProgress,
|
|
||||||
Stack,
|
|
||||||
TextField,
|
|
||||||
Typography,
|
|
||||||
} from '@mui/material';
|
|
||||||
import QrCodeIcon from '@mui/icons-material/QrCode';
|
|
||||||
import DownloadIcon from '@mui/icons-material/Download';
|
|
||||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
|
||||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
|
||||||
import QRious from 'qrious';
|
|
||||||
import { qrCodePageStyles } from '@/config/pageTheme';
|
|
||||||
import { useSnackbar } from '@/components/GlobalSnackbar';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
interface UrlToQrCodeSectionProps {
|
|
||||||
expanded: boolean;
|
|
||||||
onExpandedChange: (expanded: boolean) => void;
|
|
||||||
/** 桌面端强制展开(隐藏折叠交互) */
|
|
||||||
forceExpanded?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const UrlToQrCodeSection = ({
|
|
||||||
expanded,
|
|
||||||
onExpandedChange,
|
|
||||||
forceExpanded = false,
|
|
||||||
}: UrlToQrCodeSectionProps) => {
|
|
||||||
const { t } = useTranslation(['qrCode']);
|
|
||||||
const { showMessage } = useSnackbar();
|
|
||||||
const [urlInput, setUrlInput] = useState('');
|
|
||||||
const [urlError, setUrlError] = useState('');
|
|
||||||
const [qrCodeDataUrl, setQrCodeDataUrl] = useState('');
|
|
||||||
const [generating, setGenerating] = useState(false);
|
|
||||||
|
|
||||||
const handleUrlInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setUrlInput(e.target.value);
|
|
||||||
setUrlError('');
|
|
||||||
};
|
|
||||||
|
|
||||||
const generateQrCode = async () => {
|
|
||||||
if (!urlInput) {
|
|
||||||
setUrlError(t('qrCode:enterUrlError'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setGenerating(true);
|
|
||||||
setUrlError('');
|
|
||||||
|
|
||||||
let url = urlInput;
|
|
||||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
|
||||||
url = 'https://' + url;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 使用 QRious 替代 qrcode 库,体积更小
|
|
||||||
const qr = new QRious({
|
|
||||||
value: url,
|
|
||||||
size: 250,
|
|
||||||
level: 'H',
|
|
||||||
foreground: '#000000',
|
|
||||||
background: '#FFFFFF',
|
|
||||||
});
|
|
||||||
|
|
||||||
setQrCodeDataUrl(qr.toDataURL());
|
|
||||||
showMessage(t('qrCode:qrCodeSuccess'), { severity: 'success', autoHideDuration: 1000 });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('生成二维码失败:', error);
|
|
||||||
showMessage(t('qrCode:parseError'), { severity: 'error', autoHideDuration: 300 });
|
|
||||||
} finally {
|
|
||||||
setGenerating(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const downloadQrCode = () => {
|
|
||||||
if (!qrCodeDataUrl) return;
|
|
||||||
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = qrCodeDataUrl;
|
|
||||||
link.download = 'qrcode.png';
|
|
||||||
link.click();
|
|
||||||
showMessage(t('qrCode:qrCodeDownloadSuccess'), { severity: 'success', autoHideDuration: 300 });
|
|
||||||
};
|
|
||||||
|
|
||||||
const copyQrCode = async () => {
|
|
||||||
if (!qrCodeDataUrl) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(qrCodeDataUrl);
|
|
||||||
const blob = await response.blob();
|
|
||||||
|
|
||||||
await navigator.clipboard.write([
|
|
||||||
new ClipboardItem({
|
|
||||||
'image/png': blob,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
showMessage(t('qrCode:qrCodeCopySuccess'), { severity: 'success', autoHideDuration: 1000 });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('复制二维码失败:', error);
|
|
||||||
showMessage(t('qrCode:parseError'), { severity: 'error', autoHideDuration: 300 });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Accordion
|
|
||||||
expanded={forceExpanded ? true : expanded}
|
|
||||||
onChange={forceExpanded ? undefined : (_, isExpanded) => onExpandedChange(isExpanded)}
|
|
||||||
sx={forceExpanded ? qrCodePageStyles.ACCORDION_DESKTOP : qrCodePageStyles.ACCORDION}
|
|
||||||
>
|
|
||||||
<AccordionSummary expandIcon={<ExpandMoreIcon />} sx={qrCodePageStyles.ACCORDION_SUMMARY}>
|
|
||||||
<Box sx={qrCodePageStyles.ACCORDION_TITLE_ICON}>
|
|
||||||
<QrCodeIcon color="primary" />
|
|
||||||
<Typography variant="subtitle1" sx={qrCodePageStyles.ACCORDION_TITLE_TEXT}>
|
|
||||||
{t('qrCode:urlToQr')}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</AccordionSummary>
|
|
||||||
<AccordionDetails>
|
|
||||||
<Stack spacing={3}>
|
|
||||||
<TextField
|
|
||||||
label={t('qrCode:urlInputLabel')}
|
|
||||||
placeholder={t('qrCode:urlInputPlaceholder')}
|
|
||||||
value={urlInput}
|
|
||||||
onChange={handleUrlInputChange}
|
|
||||||
fullWidth
|
|
||||||
variant="outlined"
|
|
||||||
error={!!urlError}
|
|
||||||
helperText={urlError}
|
|
||||||
sx={qrCodePageStyles.INPUT_STYLE}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
startIcon={generating ? <CircularProgress size={16} color="inherit" /> : <QrCodeIcon />}
|
|
||||||
onClick={generateQrCode}
|
|
||||||
disabled={generating}
|
|
||||||
sx={qrCodePageStyles.PRIMARY_BUTTON}
|
|
||||||
>
|
|
||||||
{generating ? t('qrCode:generating') : t('qrCode:generateButton')}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Box className="qr-flex-grow" sx={qrCodePageStyles.QR_PREVIEW_CONTAINER}>
|
|
||||||
{qrCodeDataUrl ? (
|
|
||||||
<Box sx={qrCodePageStyles.QR_PREVIEW_INNER}>
|
|
||||||
<img src={qrCodeDataUrl} alt="QR Code" style={qrCodePageStyles.QR_PREVIEW_IMAGE} />
|
|
||||||
<Box sx={qrCodePageStyles.QR_PREVIEW_ACTIONS}>
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
startIcon={<DownloadIcon />}
|
|
||||||
onClick={downloadQrCode}
|
|
||||||
sx={qrCodePageStyles.DOWNLOAD_BUTTON}
|
|
||||||
>
|
|
||||||
{t('qrCode:downloadButton')}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
startIcon={<ContentCopyIcon />}
|
|
||||||
onClick={copyQrCode}
|
|
||||||
sx={qrCodePageStyles.COPY_BUTTON}
|
|
||||||
>
|
|
||||||
{t('qrCode:copyQrButton')}
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
) : (
|
|
||||||
<Typography
|
|
||||||
variant="body2"
|
|
||||||
color="text.secondary"
|
|
||||||
sx={qrCodePageStyles.PLACEHOLDER_TEXT}
|
|
||||||
>
|
|
||||||
{t('qrCode:qrCodeWillShow')}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Stack>
|
|
||||||
</AccordionDetails>
|
|
||||||
</Accordion>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default UrlToQrCodeSection;
|
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import QrCodePage from '../index';
|
||||||
|
|
||||||
|
// Mock useLazyTranslation
|
||||||
|
vi.mock('@/utils/useLazyTranslation', () => ({
|
||||||
|
useLazyTranslation: () => ({
|
||||||
|
t: (key: string) => key,
|
||||||
|
i18n: { changeLanguage: vi.fn(), language: 'zh-CN' },
|
||||||
|
isLoaded: true,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock getEntryPointType
|
||||||
|
vi.mock('@/config/features', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('@/config/features')>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
getEntryPointType: () => 'sidepanel',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock 子组件
|
||||||
|
vi.mock('@/components/QrCodePreview', () => ({
|
||||||
|
default: () => <div data-testid="qr-code-preview">QrCodePreview</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/components/ImageUploader', () => ({
|
||||||
|
default: () => <div data-testid="image-uploader">ImageUploader</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock QRious
|
||||||
|
vi.mock('qrious', () => ({
|
||||||
|
default: vi.fn().mockImplementation(() => ({
|
||||||
|
toDataURL: () => 'data:image/png;base64,mock',
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock useSnackbar
|
||||||
|
vi.mock('@/components/GlobalSnackbar', () => ({
|
||||||
|
useSnackbar: () => ({
|
||||||
|
showMessage: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('QrCodePage', () => {
|
||||||
|
it('应该默认渲染生成模式', () => {
|
||||||
|
render(<QrCodePage />);
|
||||||
|
expect(screen.getByTestId('qr-code-preview')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该渲染模式切换按钮', () => {
|
||||||
|
render(<QrCodePage />);
|
||||||
|
expect(screen.getByText('qrCode:urlToQr')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('qrCode:qrToUrl')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该渲染页面标题', () => {
|
||||||
|
render(<QrCodePage />);
|
||||||
|
expect(screen.getByText('qrCode:pageTitle')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('qrCode:pageSubtitle')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('切换到解析模式应该渲染 ImageUploader', () => {
|
||||||
|
render(<QrCodePage />);
|
||||||
|
fireEvent.click(screen.getByText('qrCode:qrToUrl'));
|
||||||
|
expect(screen.getByTestId('image-uploader')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId('qr-code-preview')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('切换回生成模式应该渲染 QrCodePreview', () => {
|
||||||
|
render(<QrCodePage />);
|
||||||
|
// 先切换到解析模式
|
||||||
|
fireEvent.click(screen.getByText('qrCode:qrToUrl'));
|
||||||
|
expect(screen.getByTestId('image-uploader')).toBeInTheDocument();
|
||||||
|
// 再切换回生成模式
|
||||||
|
fireEvent.click(screen.getByText('qrCode:urlToQr'));
|
||||||
|
expect(screen.getByTestId('qr-code-preview')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该渲染输入区域', () => {
|
||||||
|
render(<QrCodePage />);
|
||||||
|
expect(screen.getByText('qrCode:urlInputLabel')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该渲染双栏布局容器', () => {
|
||||||
|
const { container } = render(<QrCodePage />);
|
||||||
|
const gridContainer = container.querySelector('.MuiGrid-container');
|
||||||
|
expect(gridContainer).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { Grid } from '@mui/material';
|
||||||
|
import TextInputArea from '@/components/TextInputArea';
|
||||||
|
import QrCodePreview from '@/components/QrCodePreview';
|
||||||
|
import { useSnackbar } from '@/components/GlobalSnackbar';
|
||||||
|
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
||||||
|
import { useQrCodeContext } from '../contexts/QrCodeContext';
|
||||||
|
|
||||||
|
export default function GeneratePanel() {
|
||||||
|
const { t } = useLazyTranslation('qrCode');
|
||||||
|
const { showMessage } = useSnackbar();
|
||||||
|
const { generatorState, setTextToEncode, downloadQrCode, copyQrCode } = useQrCodeContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
<Grid size={{ xs: 12, md: 6 }}>
|
||||||
|
<TextInputArea
|
||||||
|
title={t('qrCode:urlInputLabel')}
|
||||||
|
value={generatorState.textToEncode}
|
||||||
|
onChange={setTextToEncode}
|
||||||
|
placeholder={t('qrCode:urlInputPlaceholder')}
|
||||||
|
showCount
|
||||||
|
showClear
|
||||||
|
allowCopy
|
||||||
|
externalError={generatorState.inputError}
|
||||||
|
showMessage={showMessage}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, md: 6 }}>
|
||||||
|
<QrCodePreview
|
||||||
|
qrCodeDataUrl={generatorState.qrCodeDataUrl}
|
||||||
|
onDownload={downloadQrCode}
|
||||||
|
onCopy={copyQrCode}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { useEffect, useCallback } from 'react';
|
||||||
|
import { Grid } from '@mui/material';
|
||||||
|
import TextInputArea from '@/components/TextInputArea';
|
||||||
|
import ImageUploader from '@/components/ImageUploader';
|
||||||
|
import { useSnackbar } from '@/components/GlobalSnackbar';
|
||||||
|
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
||||||
|
import { useQrCodeContext } from '../contexts/QrCodeContext';
|
||||||
|
|
||||||
|
export default function ParsePanel() {
|
||||||
|
const { t } = useLazyTranslation('qrCode');
|
||||||
|
const { showMessage } = useSnackbar();
|
||||||
|
const { parserState, setParserState, handleFileChange, handleClearFile } = useQrCodeContext();
|
||||||
|
|
||||||
|
// 全局粘贴事件监听
|
||||||
|
const handlePaste = useCallback(
|
||||||
|
async (e: ClipboardEvent) => {
|
||||||
|
const items = e.clipboardData?.items;
|
||||||
|
if (!items) return;
|
||||||
|
|
||||||
|
// 检查是否有图片
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
if (items[i].type.startsWith('image/')) {
|
||||||
|
e.preventDefault();
|
||||||
|
const file = items[i].getAsFile();
|
||||||
|
if (file) {
|
||||||
|
handleFileChange(file);
|
||||||
|
showMessage(t('qrCode:imagePasted'), { severity: 'success', autoHideDuration: 1000 });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否有 Base64 字符串
|
||||||
|
const text = e.clipboardData?.getData('text/plain');
|
||||||
|
if (text && text.startsWith('data:image/')) {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
const response = await fetch(text);
|
||||||
|
const blob = await response.blob();
|
||||||
|
const file = new File([blob], 'pasted-image.png', { type: blob.type });
|
||||||
|
handleFileChange(file);
|
||||||
|
showMessage(t('qrCode:imagePasted'), { severity: 'success', autoHideDuration: 1000 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('处理 Base64 图片失败:', error);
|
||||||
|
showMessage(t('qrCode:imagePasteError'), { severity: 'error', autoHideDuration: 3000 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleFileChange, showMessage, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.addEventListener('paste', handlePaste);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('paste', handlePaste);
|
||||||
|
};
|
||||||
|
}, [handlePaste]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
<Grid size={{ xs: 12, md: 6 }}>
|
||||||
|
<ImageUploader
|
||||||
|
selectedFile={parserState.selectedFile}
|
||||||
|
onFileChange={handleFileChange}
|
||||||
|
onClearFile={handleClearFile}
|
||||||
|
previewUrl={parserState.previewUrl}
|
||||||
|
onPreviewUrlChange={(url) => setParserState((prev) => ({ ...prev, previewUrl: url }))}
|
||||||
|
dragging={parserState.dragging}
|
||||||
|
onDraggingChange={(dragging) => setParserState((prev) => ({ ...prev, dragging }))}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, md: 6 }}>
|
||||||
|
<TextInputArea
|
||||||
|
title={t('qrCode:resultLabel')}
|
||||||
|
value={parserState.decodedResult}
|
||||||
|
readOnly
|
||||||
|
showClear={false}
|
||||||
|
allowCopy
|
||||||
|
placeholder=""
|
||||||
|
externalError={parserState.parseError}
|
||||||
|
showMessage={showMessage}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { createContext, useContext } from 'react';
|
||||||
|
import type { QrCodeMode, QrCodeGeneratorState, QrCodeParserState } from '../types';
|
||||||
|
|
||||||
|
export interface QrCodeContextValue {
|
||||||
|
// 模式
|
||||||
|
mode: QrCodeMode;
|
||||||
|
setMode: (mode: QrCodeMode) => void;
|
||||||
|
|
||||||
|
// 生成器状态
|
||||||
|
generatorState: QrCodeGeneratorState;
|
||||||
|
setTextToEncode: (text: string) => void;
|
||||||
|
generateQrCode: (text: string) => Promise<void>;
|
||||||
|
downloadQrCode: () => void;
|
||||||
|
copyQrCode: () => Promise<void>;
|
||||||
|
|
||||||
|
// 解析器状态
|
||||||
|
parserState: QrCodeParserState;
|
||||||
|
setParserState: React.Dispatch<React.SetStateAction<QrCodeParserState>>;
|
||||||
|
parseQrCode: (file: File) => Promise<void>;
|
||||||
|
handleFileChange: (file: File) => void;
|
||||||
|
handleClearFile: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const QrCodeContext = createContext<QrCodeContextValue | null>(null);
|
||||||
|
|
||||||
|
export function useQrCodeContext() {
|
||||||
|
const context = useContext(QrCodeContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useQrCodeContext must be used within QrCodeProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||||
|
import QRious from 'qrious';
|
||||||
|
import { useSnackbar } from '@/components/GlobalSnackbar';
|
||||||
|
import { parseQrCodeFromFile } from '@/utils/qrCodeParser';
|
||||||
|
import { useContextMenuData } from '@/utils/useContextMenuData';
|
||||||
|
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
||||||
|
import { useDebounce } from '@/utils/useDebounce';
|
||||||
|
import type { QrCodeContextValue } from '../contexts/QrCodeContext';
|
||||||
|
import type { QrCodeMode, QrCodeGeneratorState, QrCodeParserState } from '../types';
|
||||||
|
|
||||||
|
export function useQrCode(): QrCodeContextValue {
|
||||||
|
const { t } = useLazyTranslation('qrCode');
|
||||||
|
const { showMessage } = useSnackbar();
|
||||||
|
|
||||||
|
// 当前模式
|
||||||
|
const [mode, setMode] = useState<QrCodeMode>('generate');
|
||||||
|
|
||||||
|
// 二维码生成器状态
|
||||||
|
const [generatorState, setGeneratorState] = useState<QrCodeGeneratorState>({
|
||||||
|
textToEncode: '',
|
||||||
|
qrCodeDataUrl: '',
|
||||||
|
generating: false,
|
||||||
|
inputError: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 二维码解析器状态
|
||||||
|
const [parserState, setParserState] = useState<QrCodeParserState>({
|
||||||
|
decodedResult: '',
|
||||||
|
parsing: false,
|
||||||
|
parseError: '',
|
||||||
|
selectedFile: null,
|
||||||
|
previewUrl: '',
|
||||||
|
dragging: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 生成二维码
|
||||||
|
const generateQrCode = useCallback(
|
||||||
|
async (text: string) => {
|
||||||
|
if (!text) {
|
||||||
|
setGeneratorState((prev) => ({ ...prev, qrCodeDataUrl: '' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setGeneratorState((prev) => ({ ...prev, generating: true, inputError: '' }));
|
||||||
|
|
||||||
|
let url = text;
|
||||||
|
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||||
|
url = 'https://' + url;
|
||||||
|
}
|
||||||
|
|
||||||
|
const qr = new QRious({
|
||||||
|
value: url,
|
||||||
|
size: 250,
|
||||||
|
level: 'H',
|
||||||
|
foreground: '#000000',
|
||||||
|
background: '#FFFFFF',
|
||||||
|
});
|
||||||
|
|
||||||
|
setGeneratorState((prev) => ({ ...prev, qrCodeDataUrl: qr.toDataURL() }));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('生成二维码失败:', error);
|
||||||
|
setGeneratorState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
inputError: t('qrCode:generateError'),
|
||||||
|
}));
|
||||||
|
showMessage(t('qrCode:generateError'), { severity: 'error', autoHideDuration: 3000 });
|
||||||
|
} finally {
|
||||||
|
setGeneratorState((prev) => ({ ...prev, generating: false }));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[t, showMessage],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 使用 useRef 存储 generateQrCode 的最新引用,避免无限循环
|
||||||
|
const generateQrCodeRef = useRef(generateQrCode);
|
||||||
|
generateQrCodeRef.current = generateQrCode;
|
||||||
|
|
||||||
|
// 防抖处理输入文本(200ms)
|
||||||
|
const debouncedTextToEncode = useDebounce(generatorState.textToEncode, 200);
|
||||||
|
|
||||||
|
// 当防抖后的文本变化时,自动生成二维码
|
||||||
|
useEffect(() => {
|
||||||
|
if (debouncedTextToEncode && mode === 'generate') {
|
||||||
|
generateQrCodeRef.current(debouncedTextToEncode);
|
||||||
|
}
|
||||||
|
}, [debouncedTextToEncode, mode]);
|
||||||
|
|
||||||
|
// 设置输入文本
|
||||||
|
const setTextToEncode = useCallback((text: string) => {
|
||||||
|
setGeneratorState((prev) => ({ ...prev, textToEncode: text }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 处理右键菜单数据
|
||||||
|
const handleContextMenuData = useCallback((payload: string) => {
|
||||||
|
setMode('generate');
|
||||||
|
setGeneratorState((prev) => ({ ...prev, textToEncode: payload }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useContextMenuData({ featureKey: 'qrCode', onData: handleContextMenuData });
|
||||||
|
|
||||||
|
// 解析二维码
|
||||||
|
const parseQrCode = useCallback(
|
||||||
|
async (file: File) => {
|
||||||
|
try {
|
||||||
|
setParserState((prev) => ({ ...prev, parsing: true, parseError: '', decodedResult: '' }));
|
||||||
|
|
||||||
|
const result = await parseQrCodeFromFile(file);
|
||||||
|
|
||||||
|
if (result.success && result.data) {
|
||||||
|
setParserState((prev) => ({ ...prev, decodedResult: result.data! }));
|
||||||
|
showMessage(t('qrCode:parseSuccess'), { severity: 'success', autoHideDuration: 1000 });
|
||||||
|
} else {
|
||||||
|
const errorMsg = result.error || t('qrCode:noQrDetected');
|
||||||
|
setParserState((prev) => ({ ...prev, parseError: errorMsg }));
|
||||||
|
showMessage(errorMsg, { severity: 'error', autoHideDuration: 3000 });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('解析二维码失败:', error);
|
||||||
|
const errorMsg = error instanceof Error ? error.message : t('qrCode:parseError');
|
||||||
|
setParserState((prev) => ({ ...prev, parseError: errorMsg }));
|
||||||
|
showMessage(errorMsg, { severity: 'error', autoHideDuration: 3000 });
|
||||||
|
} finally {
|
||||||
|
setParserState((prev) => ({ ...prev, parsing: false }));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[t, showMessage],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 下载二维码
|
||||||
|
const downloadQrCode = useCallback(() => {
|
||||||
|
if (!generatorState.qrCodeDataUrl) return;
|
||||||
|
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = generatorState.qrCodeDataUrl;
|
||||||
|
link.download = 'qrcode.png';
|
||||||
|
link.click();
|
||||||
|
showMessage(t('qrCode:qrCodeDownloadSuccess'), { severity: 'success', autoHideDuration: 1000 });
|
||||||
|
}, [generatorState.qrCodeDataUrl, showMessage, t]);
|
||||||
|
|
||||||
|
// 复制二维码
|
||||||
|
const copyQrCode = useCallback(async () => {
|
||||||
|
if (!generatorState.qrCodeDataUrl) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(generatorState.qrCodeDataUrl);
|
||||||
|
const blob = await response.blob();
|
||||||
|
|
||||||
|
await navigator.clipboard.write([
|
||||||
|
new ClipboardItem({
|
||||||
|
'image/png': blob,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
showMessage(t('qrCode:qrCodeCopySuccess'), { severity: 'success', autoHideDuration: 1000 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('复制二维码失败:', error);
|
||||||
|
showMessage(t('qrCode:copyError'), { severity: 'error', autoHideDuration: 3000 });
|
||||||
|
}
|
||||||
|
}, [generatorState.qrCodeDataUrl, showMessage, t]);
|
||||||
|
|
||||||
|
// 处理文件选择
|
||||||
|
const handleFileChange = useCallback(
|
||||||
|
(file: File) => {
|
||||||
|
setParserState((prev) => {
|
||||||
|
// 释放旧的预览 URL
|
||||||
|
if (prev.previewUrl) {
|
||||||
|
URL.revokeObjectURL(prev.previewUrl);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
selectedFile: file,
|
||||||
|
previewUrl: URL.createObjectURL(file),
|
||||||
|
decodedResult: '',
|
||||||
|
parseError: '',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
// 自动解析
|
||||||
|
parseQrCode(file);
|
||||||
|
},
|
||||||
|
[parseQrCode],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 清除文件
|
||||||
|
const handleClearFile = useCallback(() => {
|
||||||
|
if (parserState.previewUrl) {
|
||||||
|
URL.revokeObjectURL(parserState.previewUrl);
|
||||||
|
}
|
||||||
|
setParserState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
selectedFile: null,
|
||||||
|
previewUrl: '',
|
||||||
|
decodedResult: '',
|
||||||
|
parseError: '',
|
||||||
|
}));
|
||||||
|
}, [parserState.previewUrl]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
mode,
|
||||||
|
setMode,
|
||||||
|
generatorState,
|
||||||
|
setTextToEncode,
|
||||||
|
generateQrCode,
|
||||||
|
downloadQrCode,
|
||||||
|
copyQrCode,
|
||||||
|
parserState,
|
||||||
|
setParserState,
|
||||||
|
parseQrCode,
|
||||||
|
handleFileChange,
|
||||||
|
handleClearFile,
|
||||||
|
};
|
||||||
|
}
|
||||||
+38
-60
@@ -1,74 +1,52 @@
|
|||||||
import { Box, CircularProgress, Container, Stack, useMediaQuery, useTheme } from '@mui/material';
|
import { Box, Container, useMediaQuery, useTheme } from '@mui/material';
|
||||||
import QrCodeIcon from '@mui/icons-material/QrCode';
|
import QrCodeIcon from '@mui/icons-material/QrCode';
|
||||||
import UrlToQrCodeSection from '@/pages/QrCode/UrlToQrCodeSection';
|
|
||||||
import QrCodeToUrlSection from '@/pages/QrCode/QrCodeToUrlSection';
|
|
||||||
import { useStorageState } from '@/utils/useStorageState';
|
|
||||||
import { qrCodePageStyles } from '@/config/pageTheme';
|
|
||||||
import PageHeader from '@/components/PageHeader';
|
import PageHeader from '@/components/PageHeader';
|
||||||
|
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
|
||||||
|
import { qrCodePageStyles } from '@/config/pageTheme';
|
||||||
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
||||||
|
import { QrCodeContext } from './contexts/QrCodeContext';
|
||||||
|
import { useQrCode } from './hooks/useQrCode';
|
||||||
|
import GeneratePanel from './components/GeneratePanel';
|
||||||
|
import ParsePanel from './components/ParsePanel';
|
||||||
|
import type { QrCodeMode } from './types';
|
||||||
|
|
||||||
export default function Index() {
|
export default function Index() {
|
||||||
const { t } = useLazyTranslation('qrCode');
|
const { t } = useLazyTranslation('qrCode');
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const isDesktop = useMediaQuery(theme.breakpoints.up('md'));
|
const isDesktop = useMediaQuery(theme.breakpoints.up('md'));
|
||||||
|
const qrCode = useQrCode();
|
||||||
|
|
||||||
// 使用自定义钩子管理展开状态(移动端使用)
|
// 模式选项
|
||||||
const [urlExpanded, setUrlExpanded, urlInitialized] = useStorageState('qrCode/urlExpanded', true);
|
const modeOptions = [
|
||||||
const [qrExpanded, setQrExpanded, qrInitialized] = useStorageState('qrCode/qrExpanded', false);
|
{ value: 'generate' as QrCodeMode, label: t('qrCode:urlToQr') },
|
||||||
|
{ value: 'parse' as QrCodeMode, label: t('qrCode:qrToUrl') },
|
||||||
// 初始化未完成时显示加载状态
|
];
|
||||||
if (!urlInitialized || !qrInitialized) {
|
|
||||||
return (
|
|
||||||
<Container sx={qrCodePageStyles.LOADING_CONTAINER}>
|
|
||||||
<CircularProgress />
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sections = isDesktop ? (
|
|
||||||
<>
|
|
||||||
<Box sx={qrCodePageStyles.GRID_CELL}>
|
|
||||||
<UrlToQrCodeSection
|
|
||||||
expanded={urlExpanded}
|
|
||||||
onExpandedChange={setUrlExpanded}
|
|
||||||
forceExpanded={isDesktop}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
<Box sx={qrCodePageStyles.GRID_CELL}>
|
|
||||||
<QrCodeToUrlSection
|
|
||||||
expanded={qrExpanded}
|
|
||||||
onExpandedChange={setQrExpanded}
|
|
||||||
forceExpanded={isDesktop}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<UrlToQrCodeSection expanded={urlExpanded} onExpandedChange={setUrlExpanded} />
|
|
||||||
<QrCodeToUrlSection expanded={qrExpanded} onExpandedChange={setQrExpanded} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<QrCodeContext.Provider value={qrCode}>
|
||||||
<Container
|
<Box>
|
||||||
maxWidth={isDesktop ? 'lg' : false}
|
<Container
|
||||||
sx={{ py: 2, maxWidth: isDesktop ? undefined : 400 }}
|
maxWidth={isDesktop ? 'lg' : false}
|
||||||
>
|
sx={{ py: 2, maxWidth: isDesktop ? undefined : 400 }}
|
||||||
<PageHeader
|
>
|
||||||
title={t('qrCode:pageTitle')}
|
<PageHeader
|
||||||
subtitle={t('qrCode:pageSubtitle')}
|
title={t('qrCode:pageTitle')}
|
||||||
icon={<QrCodeIcon />}
|
subtitle={t('qrCode:pageSubtitle')}
|
||||||
iconColor={qrCodePageStyles.primaryColor}
|
icon={<QrCodeIcon />}
|
||||||
sx={{ mb: 2.5 }}
|
iconColor={qrCodePageStyles.primaryColor}
|
||||||
/>
|
sx={{ mb: 2.5 }}
|
||||||
|
/>
|
||||||
|
|
||||||
{isDesktop ? (
|
<SwitchButtonGroup
|
||||||
<Box sx={qrCodePageStyles.LAYOUT_GRID}>{sections}</Box>
|
value={qrCode.mode}
|
||||||
) : (
|
options={modeOptions}
|
||||||
<Stack spacing={3}>{sections}</Stack>
|
onChange={qrCode.setMode}
|
||||||
)}
|
sx={{ mb: 3 }}
|
||||||
</Container>
|
/>
|
||||||
</Box>
|
|
||||||
|
{qrCode.mode === 'generate' ? <GeneratePanel /> : <ParsePanel />}
|
||||||
|
</Container>
|
||||||
|
</Box>
|
||||||
|
</QrCodeContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* 二维码工具页面的状态类型定义
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 二维码生成模式 */
|
||||||
|
export type QrCodeMode = 'generate' | 'parse';
|
||||||
|
|
||||||
|
/** 二维码生成器的状态 */
|
||||||
|
export interface QrCodeGeneratorState {
|
||||||
|
/** 输入文本(URL 或任意文本) */
|
||||||
|
textToEncode: string;
|
||||||
|
/** 生成的二维码 Data URL */
|
||||||
|
qrCodeDataUrl: string;
|
||||||
|
/** 是否正在生成 */
|
||||||
|
generating: boolean;
|
||||||
|
/** 输入错误信息 */
|
||||||
|
inputError: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 二维码解析器的状态 */
|
||||||
|
export interface QrCodeParserState {
|
||||||
|
/** 解析结果文本 */
|
||||||
|
decodedResult: string;
|
||||||
|
/** 是否正在解析 */
|
||||||
|
parsing: boolean;
|
||||||
|
/** 解析错误信息 */
|
||||||
|
parseError: string;
|
||||||
|
/** 当前选中的文件 */
|
||||||
|
selectedFile: File | null;
|
||||||
|
/** 文件预览 URL */
|
||||||
|
previewUrl: string;
|
||||||
|
/** 是否正在拖拽 */
|
||||||
|
dragging: boolean;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Box, Switch, Typography } from '@mui/material';
|
import { Box, Switch, Typography } from '@mui/material';
|
||||||
import { storageCleanerPageStyles } from '@/config/pageTheme';
|
import { storageCleanerPageStyles } from '@/config/pageTheme';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
||||||
|
|
||||||
interface AutoRefreshToggleProps {
|
interface AutoRefreshToggleProps {
|
||||||
autoRefresh: boolean;
|
autoRefresh: boolean;
|
||||||
@@ -8,7 +8,7 @@ interface AutoRefreshToggleProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function AutoRefreshToggle({ autoRefresh, onChange }: AutoRefreshToggleProps) {
|
export default function AutoRefreshToggle({ autoRefresh, onChange }: AutoRefreshToggleProps) {
|
||||||
const { t } = useTranslation(['storageCleaner']);
|
const { t } = useLazyTranslation('storageCleaner');
|
||||||
return (
|
return (
|
||||||
<Box sx={storageCleanerPageStyles.AUTO_REFRESH_CONTAINER}>
|
<Box sx={storageCleanerPageStyles.AUTO_REFRESH_CONTAINER}>
|
||||||
<Typography variant="body2" fontWeight={700} sx={{ fontSize: '0.8rem', px: 1.2 }}>
|
<Typography variant="body2" fontWeight={700} sx={{ fontSize: '0.8rem', px: 1.2 }}>
|
||||||
|
|||||||
@@ -2,14 +2,14 @@ import { Alert, Box } from '@mui/material';
|
|||||||
import type { CleaningResult } from '@/types/storage';
|
import type { CleaningResult } from '@/types/storage';
|
||||||
import { formatCleaningResult } from '@/utils/storageCleaner';
|
import { formatCleaningResult } from '@/utils/storageCleaner';
|
||||||
import { storageCleanerPageStyles } from '@/config/pageTheme';
|
import { storageCleanerPageStyles } from '@/config/pageTheme';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
||||||
|
|
||||||
interface CleaningResultProps {
|
interface CleaningResultProps {
|
||||||
result: CleaningResult | null;
|
result: CleaningResult | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CleaningResult({ result }: CleaningResultProps) {
|
export default function CleaningResult({ result }: CleaningResultProps) {
|
||||||
const { t } = useTranslation(['storageCleaner']);
|
const { t } = useLazyTranslation('storageCleaner');
|
||||||
if (!result) return null;
|
if (!result) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import StorageIcon from '@mui/icons-material/Storage';
|
|||||||
import PageHeader from '@/components/PageHeader';
|
import PageHeader from '@/components/PageHeader';
|
||||||
import { formatSize } from '@/utils/storageCleaner';
|
import { formatSize } from '@/utils/storageCleaner';
|
||||||
import { storageCleanerPageStyles } from '@/config/pageTheme';
|
import { storageCleanerPageStyles } from '@/config/pageTheme';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DomainHeader 组件属性接口
|
* DomainHeader 组件属性接口
|
||||||
@@ -29,7 +29,7 @@ interface DomainHeaderProps {
|
|||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export default function DomainHeader({ domain, totalSize }: DomainHeaderProps) {
|
export default function DomainHeader({ domain, totalSize }: DomainHeaderProps) {
|
||||||
const { t } = useTranslation(['storageCleaner']);
|
const { t } = useLazyTranslation('storageCleaner');
|
||||||
return (
|
return (
|
||||||
<PageHeader
|
<PageHeader
|
||||||
icon={<StorageIcon sx={{ fontSize: 22 }} />}
|
icon={<StorageIcon sx={{ fontSize: 22 }} />}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { Box, Container, Typography } from '@mui/material';
|
import { Box, Container, Typography } from '@mui/material';
|
||||||
import WarningIcon from '@mui/icons-material/Warning';
|
import WarningIcon from '@mui/icons-material/Warning';
|
||||||
import { storageCleanerPageStyles } from '@/config/pageTheme';
|
import { storageCleanerPageStyles } from '@/config/pageTheme';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
||||||
|
|
||||||
interface ErrorDisplayProps {
|
interface ErrorDisplayProps {
|
||||||
error: string;
|
error: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ErrorDisplay({ error }: ErrorDisplayProps) {
|
export default function ErrorDisplay({ error }: ErrorDisplayProps) {
|
||||||
const { t } = useTranslation(['storageCleaner']);
|
const { t } = useLazyTranslation('storageCleaner');
|
||||||
return (
|
return (
|
||||||
<Container sx={storageCleanerPageStyles.ERROR_DISPLAY_CONTAINER}>
|
<Container sx={storageCleanerPageStyles.ERROR_DISPLAY_CONTAINER}>
|
||||||
<Box sx={{ width: '100%', maxWidth: 320 }}>
|
<Box sx={{ width: '100%', maxWidth: 320 }}>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Box, Checkbox, Typography } from '@mui/material';
|
import { Box, Checkbox, Typography } from '@mui/material';
|
||||||
import { formatSize } from '@/utils/storageCleaner';
|
import { formatSize } from '@/utils/storageCleaner';
|
||||||
import { storageCleanerPageStyles } from '@/config/pageTheme';
|
import { storageCleanerPageStyles } from '@/config/pageTheme';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
||||||
|
|
||||||
interface OptionItemProps {
|
interface OptionItemProps {
|
||||||
labelKey: string;
|
labelKey: string;
|
||||||
@@ -18,7 +18,7 @@ export default function OptionItem({
|
|||||||
isCount = false,
|
isCount = false,
|
||||||
onChange,
|
onChange,
|
||||||
}: OptionItemProps) {
|
}: OptionItemProps) {
|
||||||
const { t } = useTranslation(['storageCleaner']);
|
const { t } = useLazyTranslation('storageCleaner');
|
||||||
return (
|
return (
|
||||||
<Box sx={storageCleanerPageStyles.OPTION_ITEM(checked)}>
|
<Box sx={storageCleanerPageStyles.OPTION_ITEM(checked)}>
|
||||||
<Box sx={{ flex: 1, minWidth: 0, mr: 1.5 }}>
|
<Box sx={{ flex: 1, minWidth: 0, mr: 1.5 }}>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
import type { StorageCleanerOptions } from '@/types/storage';
|
import type { StorageCleanerOptions } from '@/types/storage';
|
||||||
import Button from '@/components/Button';
|
import Button from '@/components/Button';
|
||||||
import { storageCleanerPageStyles } from '@/config/pageTheme';
|
import { storageCleanerPageStyles } from '@/config/pageTheme';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
||||||
|
|
||||||
export interface StorageCleanerConfirmProps {
|
export interface StorageCleanerConfirmProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -25,7 +25,7 @@ export function StorageCleanerConfirm({
|
|||||||
onConfirm,
|
onConfirm,
|
||||||
options,
|
options,
|
||||||
}: StorageCleanerConfirmProps) {
|
}: StorageCleanerConfirmProps) {
|
||||||
const { t } = useTranslation(['storageCleaner']);
|
const { t } = useLazyTranslation('storageCleaner');
|
||||||
|
|
||||||
const selectedOptions = Object.entries(options)
|
const selectedOptions = Object.entries(options)
|
||||||
.filter(([_, value]) => value)
|
.filter(([_, value]) => value)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Box, Checkbox, Divider, Grid, Typography } from '@mui/material';
|
|||||||
import type { StorageCleanerOptions } from '@/types/storage';
|
import type { StorageCleanerOptions } from '@/types/storage';
|
||||||
import OptionItem from './OptionItem';
|
import OptionItem from './OptionItem';
|
||||||
import { storageCleanerPageStyles } from '@/config/pageTheme';
|
import { storageCleanerPageStyles } from '@/config/pageTheme';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
||||||
|
|
||||||
interface StorageOptionsGridProps {
|
interface StorageOptionsGridProps {
|
||||||
options: StorageCleanerOptions;
|
options: StorageCleanerOptions;
|
||||||
@@ -21,7 +21,7 @@ export default function StorageOptionsGrid({
|
|||||||
onOptionChange,
|
onOptionChange,
|
||||||
onSelectAll,
|
onSelectAll,
|
||||||
}: StorageOptionsGridProps) {
|
}: StorageOptionsGridProps) {
|
||||||
const { t } = useTranslation(['storageCleaner']);
|
const { t } = useLazyTranslation('storageCleaner');
|
||||||
|
|
||||||
const optionKeys: { key: keyof StorageCleanerOptions; isCount?: boolean }[] = [
|
const optionKeys: { key: keyof StorageCleanerOptions; isCount?: boolean }[] = [
|
||||||
{ key: 'localStorage' },
|
{ key: 'localStorage' },
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
isRestrictedUrl,
|
isRestrictedUrl,
|
||||||
} from '@/utils/storageCleaner';
|
} from '@/utils/storageCleaner';
|
||||||
import { MessageAction, sendMessage } from '@/utils/messages';
|
import { MessageAction, sendMessage } from '@/utils/messages';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
||||||
|
|
||||||
const DEFAULT_OPTIONS: StorageCleanerOptions = {
|
const DEFAULT_OPTIONS: StorageCleanerOptions = {
|
||||||
localStorage: true,
|
localStorage: true,
|
||||||
@@ -66,7 +66,7 @@ export interface UseStorageCleanerOptions {
|
|||||||
export function useStorageCleaner({
|
export function useStorageCleaner({
|
||||||
showMessage,
|
showMessage,
|
||||||
}: UseStorageCleanerOptions): UseStorageCleanerReturn {
|
}: UseStorageCleanerOptions): UseStorageCleanerReturn {
|
||||||
const { t } = useTranslation(['storageCleaner', 'common']);
|
const { t } = useLazyTranslation(['storageCleaner', 'common']);
|
||||||
const [domain, setDomain] = useState<string>('');
|
const [domain, setDomain] = useState<string>('');
|
||||||
const [error, setError] = useState<string>('');
|
const [error, setError] = useState<string>('');
|
||||||
const [isInitializing, setIsInitializing] = useState<boolean>(true);
|
const [isInitializing, setIsInitializing] = useState<boolean>(true);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
import { alpha, Box, Container, Grid, Paper, Typography } from '@mui/material';
|
import { alpha, Box, Container, Grid, Paper, Typography } from '@mui/material';
|
||||||
import PageHeader from '@/components/PageHeader';
|
import PageHeader from '@/components/PageHeader';
|
||||||
import TextInputArea from '@/components/TextInputArea';
|
import TextInputArea from '@/components/TextInputArea';
|
||||||
@@ -6,6 +6,7 @@ import DescriptionIcon from '@mui/icons-material/Description';
|
|||||||
import { formatByteSize, getTextStats } from '@/utils/textStatistics';
|
import { formatByteSize, getTextStats } from '@/utils/textStatistics';
|
||||||
import { textStatisticsPageStyles } from '@/config/pageTheme';
|
import { textStatisticsPageStyles } from '@/config/pageTheme';
|
||||||
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
||||||
|
import { useContextMenuData } from '@/utils/useContextMenuData';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 文本统计页面组件
|
* 文本统计页面组件
|
||||||
@@ -16,6 +17,12 @@ export default function Index() {
|
|||||||
const { t } = useLazyTranslation('textStatistics');
|
const { t } = useLazyTranslation('textStatistics');
|
||||||
const [text, setText] = useState('');
|
const [text, setText] = useState('');
|
||||||
|
|
||||||
|
const handleContextMenuData = useCallback((payload: string) => {
|
||||||
|
setText(payload);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useContextMenuData({ featureKey: 'textStatistics', onData: handleContextMenuData });
|
||||||
|
|
||||||
// 实时计算统计信息,使用 useMemo 优化性能
|
// 实时计算统计信息,使用 useMemo 优化性能
|
||||||
// 对于 10,000 字符以上的文本,Intl.Segmenter 也能保持良好的性能
|
// 对于 10,000 字符以上的文本,Intl.Segmenter 也能保持良好的性能
|
||||||
const stats = useMemo(() => getTextStats(text), [text]);
|
const stats = useMemo(() => getTextStats(text), [text]);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import CopyButton from '@/components/CopyButton';
|
|||||||
import { useSnackbar } from '@/components/GlobalSnackbar';
|
import { useSnackbar } from '@/components/GlobalSnackbar';
|
||||||
import type { UnitType } from '@/config/pageTheme';
|
import type { UnitType } from '@/config/pageTheme';
|
||||||
import { timestampPageStyles } from '@/config/pageTheme';
|
import { timestampPageStyles } from '@/config/pageTheme';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
||||||
|
|
||||||
interface LiveClockProps {
|
interface LiveClockProps {
|
||||||
unit: UnitType;
|
unit: UnitType;
|
||||||
@@ -14,7 +14,7 @@ interface LiveClockProps {
|
|||||||
|
|
||||||
const LiveClock = React.memo(({ unit, onUseNow }: LiveClockProps) => {
|
const LiveClock = React.memo(({ unit, onUseNow }: LiveClockProps) => {
|
||||||
const [now, setNow] = useState(() => Date.now());
|
const [now, setNow] = useState(() => Date.now());
|
||||||
const { t } = useTranslation(['timestamp']);
|
const { t } = useLazyTranslation('timestamp');
|
||||||
const { showMessage } = useSnackbar();
|
const { showMessage } = useSnackbar();
|
||||||
const onUseNowRef = useRef(onUseNow);
|
const onUseNowRef = useRef(onUseNow);
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import dayjs from '@/utils/dayjs';
|
|||||||
import CopyButton from '@/components/CopyButton';
|
import CopyButton from '@/components/CopyButton';
|
||||||
import type { UnitType } from '@/config/pageTheme';
|
import type { UnitType } from '@/config/pageTheme';
|
||||||
import { DATE_FORMAT, timestampPageStyles } from '@/config/pageTheme';
|
import { DATE_FORMAT, timestampPageStyles } from '@/config/pageTheme';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
||||||
|
|
||||||
interface ResultViewProps {
|
interface ResultViewProps {
|
||||||
result: string;
|
result: string;
|
||||||
@@ -17,7 +17,7 @@ interface ResultViewProps {
|
|||||||
|
|
||||||
const ResultView = React.memo(
|
const ResultView = React.memo(
|
||||||
({ result, mode, unit, zone, showEmptyPlaceholder = false }: ResultViewProps) => {
|
({ result, mode, unit, zone, showEmptyPlaceholder = false }: ResultViewProps) => {
|
||||||
const { t } = useTranslation(['timestamp']);
|
const { t } = useLazyTranslation('timestamp');
|
||||||
|
|
||||||
const extraInfo = useMemo(() => {
|
const extraInfo = useMemo(() => {
|
||||||
if (!result) return null;
|
if (!result) return null;
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { useCallback, useState } from 'react';
|
|||||||
import dayjs from '@/utils/dayjs';
|
import dayjs from '@/utils/dayjs';
|
||||||
import type { UnitType, ZoneType } from '@/config/pageTheme';
|
import type { UnitType, ZoneType } from '@/config/pageTheme';
|
||||||
import { DATE_FORMAT } from '@/config/pageTheme';
|
import { DATE_FORMAT } from '@/config/pageTheme';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
||||||
|
import { useContextMenuData } from '@/utils/useContextMenuData';
|
||||||
|
|
||||||
export interface UseTimestampConverterReturn {
|
export interface UseTimestampConverterReturn {
|
||||||
// State
|
// State
|
||||||
@@ -23,8 +24,17 @@ export interface UseTimestampConverterReturn {
|
|||||||
convert: () => void;
|
convert: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断输入是否为时间戳(纯数字或长度 >= 10 的数字字符串)
|
||||||
|
*/
|
||||||
|
function isTimestampLike(input: string): boolean {
|
||||||
|
const trimmed = input.trim();
|
||||||
|
if (!/^\d+$/.test(trimmed)) return false;
|
||||||
|
return trimmed.length >= 10;
|
||||||
|
}
|
||||||
|
|
||||||
export function useTimestampConverter(): UseTimestampConverterReturn {
|
export function useTimestampConverter(): UseTimestampConverterReturn {
|
||||||
const { t } = useTranslation(['timestamp']);
|
const { t } = useLazyTranslation('timestamp');
|
||||||
const [mode, setMode] = useState<'ts2dt' | 'dt2ts'>('ts2dt');
|
const [mode, setMode] = useState<'ts2dt' | 'dt2ts'>('ts2dt');
|
||||||
const [tsInput, setTsInput] = useState(() => String(Date.now()));
|
const [tsInput, setTsInput] = useState(() => String(Date.now()));
|
||||||
const [dtInput, setDtInput] = useState(() => dayjs().format(DATE_FORMAT));
|
const [dtInput, setDtInput] = useState(() => dayjs().format(DATE_FORMAT));
|
||||||
@@ -63,6 +73,48 @@ export function useTimestampConverter(): UseTimestampConverterReturn {
|
|||||||
}
|
}
|
||||||
}, [mode, tsInput, dtInput, unit, zone, t]);
|
}, [mode, tsInput, dtInput, unit, zone, t]);
|
||||||
|
|
||||||
|
// 处理右键菜单传递的数据
|
||||||
|
const handleContextMenuData = useCallback(
|
||||||
|
(payload: string) => {
|
||||||
|
const trimmed = payload.trim();
|
||||||
|
if (isTimestampLike(trimmed)) {
|
||||||
|
// 看起来是时间戳,切换到 ts2dt 模式
|
||||||
|
setMode('ts2dt');
|
||||||
|
setTsInput(trimmed);
|
||||||
|
// 如果是 13 位毫秒级时间戳,自动选择 ms 单位
|
||||||
|
const detectedUnit: UnitType = trimmed.length >= 13 ? 'ms' : 's';
|
||||||
|
setUnit(detectedUnit);
|
||||||
|
// 直接执行转换
|
||||||
|
const num = Number(trimmed);
|
||||||
|
if (!isNaN(num)) {
|
||||||
|
const d = detectedUnit === 'ms' ? dayjs(num) : dayjs.unix(num);
|
||||||
|
if (d.isValid()) {
|
||||||
|
setError('');
|
||||||
|
setResult(d.tz(zone).format(DATE_FORMAT));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 尝试作为日期时间解析
|
||||||
|
const d = dayjs(trimmed);
|
||||||
|
if (d.isValid()) {
|
||||||
|
setMode('dt2ts');
|
||||||
|
setDtInput(d.format(DATE_FORMAT));
|
||||||
|
// 直接执行转换
|
||||||
|
const ms = d.valueOf();
|
||||||
|
setError('');
|
||||||
|
setResult(String(ms));
|
||||||
|
} else {
|
||||||
|
// 无法识别,作为时间戳处理
|
||||||
|
setMode('ts2dt');
|
||||||
|
setTsInput(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[zone],
|
||||||
|
);
|
||||||
|
|
||||||
|
useContextMenuData({ featureKey: 'timestamp', onData: handleContextMenuData });
|
||||||
|
|
||||||
const handleUseNow = useCallback(
|
const handleUseNow = useCallback(
|
||||||
(now: number) => {
|
(now: number) => {
|
||||||
if (mode === 'ts2dt') {
|
if (mode === 'ts2dt') {
|
||||||
|
|||||||
@@ -7,13 +7,14 @@ import {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import type { PageType, StorageSchema } from '@/types/storage';
|
import type { PageType, StorageSchema, ContextMenuPendingData } from '@/types/storage';
|
||||||
import { storageUtil } from '@/utils/chromeStorage';
|
import { storageUtil } from '@/utils/chromeStorage';
|
||||||
import {
|
import {
|
||||||
getAllFeatureKeys,
|
getAllFeatureKeys,
|
||||||
getDefaultPageOrder,
|
getDefaultPageOrder,
|
||||||
getDefaultVisibleFeatureKeys,
|
getDefaultVisibleFeatureKeys,
|
||||||
} from '@/config/features';
|
} from '@/config/features';
|
||||||
|
import { saveContextMenuData, CONTEXT_MENU_DATA_EXPIRY_MS } from '@/utils/useContextMenuData';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 校验是否为合法的页面类型
|
* 校验是否为合法的页面类型
|
||||||
@@ -187,6 +188,40 @@ export function RouterProvider({
|
|||||||
.then(() => {
|
.then(() => {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setIsLoaded(true);
|
setIsLoaded(true);
|
||||||
|
|
||||||
|
// 检查 URL 参数中的右键菜单数据
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const feature = params.get('feature') as PageType | null;
|
||||||
|
const payload = params.get('payload');
|
||||||
|
|
||||||
|
if (feature && payload && isValidPage(feature)) {
|
||||||
|
saveContextMenuData({ featureKey: feature, payload }).catch(console.error);
|
||||||
|
navigateTo(feature);
|
||||||
|
|
||||||
|
// 清理 URL 参数
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.delete('feature');
|
||||||
|
url.searchParams.delete('payload');
|
||||||
|
window.history.replaceState({}, '', url.toString());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查 storage 中的右键菜单待处理数据(用于 openPopup 场景)
|
||||||
|
storageUtil
|
||||||
|
.get('contextMenu/pendingData', undefined)
|
||||||
|
.then((pendingData) => {
|
||||||
|
if (
|
||||||
|
pendingData &&
|
||||||
|
isValidPage(pendingData.featureKey) &&
|
||||||
|
Date.now() - pendingData.timestamp < CONTEXT_MENU_DATA_EXPIRY_MS
|
||||||
|
) {
|
||||||
|
navigateTo(pendingData.featureKey as PageType);
|
||||||
|
// 不在这里清除数据,让目标页面的 useContextMenuData 来消费和清除
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(console.error);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(console.error);
|
.catch(console.error);
|
||||||
@@ -253,6 +288,18 @@ export function RouterProvider({
|
|||||||
setPageOrder(newOrder);
|
setPageOrder(newOrder);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 监听右键菜单数据变化,自动跳转到对应页面(用于 popup 已打开的场景)
|
||||||
|
if (changes['contextMenu/pendingData']) {
|
||||||
|
const newData = changes['contextMenu/pendingData']
|
||||||
|
.newValue as ContextMenuPendingData | null;
|
||||||
|
if (
|
||||||
|
newData &&
|
||||||
|
isValidPage(newData.featureKey) &&
|
||||||
|
Date.now() - newData.timestamp < CONTEXT_MENU_DATA_EXPIRY_MS
|
||||||
|
) {
|
||||||
|
setCurrentPage(newData.featureKey as PageType);
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
chrome.storage.onChanged.addListener(handleStorageChange);
|
chrome.storage.onChanged.addListener(handleStorageChange);
|
||||||
|
|||||||
Vendored
+14
@@ -126,6 +126,20 @@ export interface StorageSchema {
|
|||||||
'htmlToMarkdown/previewMode': HtmlToMarkdownPreviewMode;
|
'htmlToMarkdown/previewMode': HtmlToMarkdownPreviewMode;
|
||||||
/** 语言偏好设置 */
|
/** 语言偏好设置 */
|
||||||
'app/language': string;
|
'app/language': string;
|
||||||
|
/** 右键菜单待处理数据 */
|
||||||
|
'contextMenu/pendingData': ContextMenuPendingData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 右键菜单待处理数据
|
||||||
|
*/
|
||||||
|
export interface ContextMenuPendingData {
|
||||||
|
/** 功能标识 */
|
||||||
|
featureKey: PageType;
|
||||||
|
/** 捕获的文本或图片 URL */
|
||||||
|
payload: string;
|
||||||
|
/** 数据创建时间戳 */
|
||||||
|
timestamp: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||||
|
import {
|
||||||
|
CONTEXT_MENU_CONFIGS,
|
||||||
|
createAllContextMenus,
|
||||||
|
parseContextMenuClick,
|
||||||
|
MAX_PAYLOAD_LENGTH,
|
||||||
|
} from '@/utils/contextMenu';
|
||||||
|
|
||||||
|
describe('contextMenu', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CONTEXT_MENU_CONFIGS', () => {
|
||||||
|
it('应该包含 7 个菜单项配置', () => {
|
||||||
|
expect(CONTEXT_MENU_CONFIGS).toHaveLength(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该有一个父级菜单项 Testing Tools', () => {
|
||||||
|
const parentMenu = CONTEXT_MENU_CONFIGS.find((c) => c.id === 'testing-tools-parent');
|
||||||
|
expect(parentMenu).toBeDefined();
|
||||||
|
expect(parentMenu?.title).toBe('Testing Tools');
|
||||||
|
expect(parentMenu?.parentId).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该有 4 个 selection 上下文的子菜单', () => {
|
||||||
|
const selectionMenus = CONTEXT_MENU_CONFIGS.filter(
|
||||||
|
(c) => c.contexts[0] === 'selection' && c.parentId === 'testing-tools-parent',
|
||||||
|
);
|
||||||
|
expect(selectionMenus).toHaveLength(4);
|
||||||
|
expect(selectionMenus.map((m) => m.id)).toEqual([
|
||||||
|
'jwt',
|
||||||
|
'base64Converter',
|
||||||
|
'textStatistics',
|
||||||
|
'timestamp',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该有 2 个 page 上下文的子菜单', () => {
|
||||||
|
const pageMenus = CONTEXT_MENU_CONFIGS.filter(
|
||||||
|
(c) => c.contexts[0] === 'page' && c.parentId === 'testing-tools-parent',
|
||||||
|
);
|
||||||
|
expect(pageMenus).toHaveLength(2);
|
||||||
|
expect(pageMenus.map((m) => m.id)).toEqual(['storageCleaner', 'qrCode-page']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createAllContextMenus', () => {
|
||||||
|
it('应该为每个配置调用 chrome.contextMenus.create', () => {
|
||||||
|
createAllContextMenus();
|
||||||
|
|
||||||
|
expect(chrome.contextMenus.create).toHaveBeenCalledTimes(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该使用正确的参数创建菜单项', () => {
|
||||||
|
createAllContextMenus();
|
||||||
|
|
||||||
|
expect(chrome.contextMenus.create).toHaveBeenCalledWith({
|
||||||
|
id: 'testing-tools-parent',
|
||||||
|
title: 'Testing Tools',
|
||||||
|
contexts: ['all'],
|
||||||
|
parentId: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(chrome.contextMenus.create).toHaveBeenCalledWith({
|
||||||
|
id: 'jwt',
|
||||||
|
title: '🔑 解析 JWT',
|
||||||
|
contexts: ['selection'],
|
||||||
|
parentId: 'testing-tools-parent',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseContextMenuClick', () => {
|
||||||
|
const createMockOnClickData = (
|
||||||
|
overrides: Partial<chrome.contextMenus.OnClickData> = {},
|
||||||
|
): chrome.contextMenus.OnClickData => ({
|
||||||
|
menuItemId: 'test',
|
||||||
|
editable: false,
|
||||||
|
pageUrl: 'https://example.com',
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
it('当点击 qrCode-page 菜单时应返回 qrCode 功能和 pageUrl', () => {
|
||||||
|
const info = createMockOnClickData({
|
||||||
|
pageUrl: 'https://example.com/page',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = parseContextMenuClick('qrCode-page', info);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
success: true,
|
||||||
|
data: { featureKey: 'qrCode', payload: 'https://example.com/page' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('当点击有 selectionText 的菜单时应返回对应功能和选中文本', () => {
|
||||||
|
const info = createMockOnClickData({
|
||||||
|
selectionText: 'selected text',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = parseContextMenuClick('jwt', info);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
success: true,
|
||||||
|
data: { featureKey: 'jwt', payload: 'selected text' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('当点击 timestamp 菜单时应正确映射功能键', () => {
|
||||||
|
const info = createMockOnClickData({
|
||||||
|
selectionText: '1234567890',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = parseContextMenuClick('timestamp', info);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
success: true,
|
||||||
|
data: { featureKey: 'timestamp', payload: '1234567890' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('当点击 storageCleaner 菜单时应返回 pageUrl', () => {
|
||||||
|
const info = createMockOnClickData({
|
||||||
|
pageUrl: 'https://example.com',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = parseContextMenuClick('storageCleaner', info);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
success: true,
|
||||||
|
data: { featureKey: 'storageCleaner', payload: 'https://example.com' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('当没有 selectionText 和 pageUrl 时应返回错误', () => {
|
||||||
|
const info = createMockOnClickData({
|
||||||
|
pageUrl: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = parseContextMenuClick('someMenu', info);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
success: false,
|
||||||
|
error: '无法获取有效数据',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('selectionText 优先于 pageUrl', () => {
|
||||||
|
const info = createMockOnClickData({
|
||||||
|
selectionText: 'selected text',
|
||||||
|
pageUrl: 'https://example.com',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = parseContextMenuClick('jwt', info);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
success: true,
|
||||||
|
data: { featureKey: 'jwt', payload: 'selected text' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('当文本超过最大长度限制时应截断', () => {
|
||||||
|
const longText = 'a'.repeat(MAX_PAYLOAD_LENGTH + 1000);
|
||||||
|
const info = createMockOnClickData({
|
||||||
|
selectionText: longText,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = parseContextMenuClick('textStatistics', info);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.data?.payload.length).toBe(MAX_PAYLOAD_LENGTH);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('当文本未超过最大长度限制时应保持原样', () => {
|
||||||
|
const shortText = 'short text';
|
||||||
|
const info = createMockOnClickData({
|
||||||
|
selectionText: shortText,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = parseContextMenuClick('textStatistics', info);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
success: true,
|
||||||
|
data: { featureKey: 'textStatistics', payload: 'short text' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { renderHook, act } from '@testing-library/react';
|
||||||
|
import {
|
||||||
|
useContextMenuData,
|
||||||
|
saveContextMenuData,
|
||||||
|
clearContextMenuData,
|
||||||
|
} from '@/utils/useContextMenuData';
|
||||||
|
|
||||||
|
describe('useContextMenuData', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('saveContextMenuData', () => {
|
||||||
|
it('应该保存数据到 storage 并添加时间戳', async () => {
|
||||||
|
const data = { featureKey: 'jwt' as const, payload: 'test-token' };
|
||||||
|
vi.setSystemTime(new Date('2024-01-01T12:00:00Z'));
|
||||||
|
|
||||||
|
await saveContextMenuData(data);
|
||||||
|
|
||||||
|
expect(chrome.storage.local.set).toHaveBeenCalledWith({
|
||||||
|
'contextMenu/pendingData': {
|
||||||
|
featureKey: 'jwt',
|
||||||
|
payload: 'test-token',
|
||||||
|
timestamp: 1704110400000,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该正确处理不同的 featureKey', async () => {
|
||||||
|
const data = { featureKey: 'timestamp' as const, payload: '1234567890' };
|
||||||
|
|
||||||
|
await saveContextMenuData(data);
|
||||||
|
|
||||||
|
expect(chrome.storage.local.set).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
'contextMenu/pendingData': expect.objectContaining({
|
||||||
|
featureKey: 'timestamp',
|
||||||
|
payload: '1234567890',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('clearContextMenuData', () => {
|
||||||
|
it('应该从 storage 中删除数据', async () => {
|
||||||
|
await clearContextMenuData();
|
||||||
|
|
||||||
|
expect(chrome.storage.local.remove).toHaveBeenCalledWith(['contextMenu/pendingData']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useContextMenuData Hook', () => {
|
||||||
|
it('当 storage 中有匹配数据时应调用 onData 回调', async () => {
|
||||||
|
const mockData = {
|
||||||
|
featureKey: 'jwt',
|
||||||
|
payload: 'test-token',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
(chrome.storage.local.get as any).mockResolvedValue({
|
||||||
|
'contextMenu/pendingData': mockData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const onData = vi.fn();
|
||||||
|
renderHook(() => useContextMenuData({ featureKey: 'jwt', onData }));
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(onData).toHaveBeenCalledWith('test-token');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('当 storage 中没有数据时不应调用 onData 回调', async () => {
|
||||||
|
(chrome.storage.local.get as any).mockResolvedValue({});
|
||||||
|
|
||||||
|
const onData = vi.fn();
|
||||||
|
renderHook(() => useContextMenuData({ featureKey: 'jwt', onData }));
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(onData).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('当 featureKey 不匹配时不应调用 onData 回调', async () => {
|
||||||
|
const mockData = {
|
||||||
|
featureKey: 'timestamp',
|
||||||
|
payload: '1234567890',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
(chrome.storage.local.get as any).mockResolvedValue({
|
||||||
|
'contextMenu/pendingData': mockData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const onData = vi.fn();
|
||||||
|
renderHook(() => useContextMenuData({ featureKey: 'jwt', onData }));
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(onData).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('当数据过期时不应调用 onData 回调并删除数据', async () => {
|
||||||
|
const now = Date.now();
|
||||||
|
vi.setSystemTime(now);
|
||||||
|
|
||||||
|
const mockData = {
|
||||||
|
featureKey: 'jwt',
|
||||||
|
payload: 'test-token',
|
||||||
|
timestamp: now - 6000,
|
||||||
|
};
|
||||||
|
(chrome.storage.local.get as any).mockResolvedValue({
|
||||||
|
'contextMenu/pendingData': mockData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const onData = vi.fn();
|
||||||
|
renderHook(() => useContextMenuData({ featureKey: 'jwt', onData }));
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(onData).not.toHaveBeenCalled();
|
||||||
|
expect(chrome.storage.local.remove).toHaveBeenCalledWith(['contextMenu/pendingData']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('消费数据后应删除 storage 中的数据', async () => {
|
||||||
|
const mockData = {
|
||||||
|
featureKey: 'jwt',
|
||||||
|
payload: 'test-token',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
(chrome.storage.local.get as any).mockResolvedValue({
|
||||||
|
'contextMenu/pendingData': mockData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const onData = vi.fn();
|
||||||
|
renderHook(() => useContextMenuData({ featureKey: 'jwt', onData }));
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(chrome.storage.local.remove).toHaveBeenCalledWith(['contextMenu/pendingData']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('当 storage 变化且 featureKey 匹配时应调用 onData 回调', async () => {
|
||||||
|
(chrome.storage.local.get as any).mockResolvedValue({});
|
||||||
|
|
||||||
|
const onData = vi.fn();
|
||||||
|
renderHook(() => useContextMenuData({ featureKey: 'jwt', onData }));
|
||||||
|
|
||||||
|
const storageChangeHandler = (chrome.storage.onChanged.addListener as any).mock.calls[0][0];
|
||||||
|
|
||||||
|
const mockData = {
|
||||||
|
featureKey: 'jwt',
|
||||||
|
payload: 'new-token',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
(chrome.storage.local.get as any).mockResolvedValue({
|
||||||
|
'contextMenu/pendingData': mockData,
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
storageChangeHandler({
|
||||||
|
'contextMenu/pendingData': { newValue: mockData },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(onData).toHaveBeenCalledWith('new-token');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('当 storage 变化但 featureKey 不匹配时不应调用 onData 回调', async () => {
|
||||||
|
(chrome.storage.local.get as any).mockResolvedValue({});
|
||||||
|
|
||||||
|
const onData = vi.fn();
|
||||||
|
renderHook(() => useContextMenuData({ featureKey: 'jwt', onData }));
|
||||||
|
|
||||||
|
const storageChangeHandler = (chrome.storage.onChanged.addListener as any).mock.calls[0][0];
|
||||||
|
|
||||||
|
const mockData = {
|
||||||
|
featureKey: 'timestamp',
|
||||||
|
payload: '1234567890',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
storageChangeHandler({
|
||||||
|
'contextMenu/pendingData': { newValue: mockData },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onData).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('当 storage 变化但数据被删除时不应调用 onData 回调', async () => {
|
||||||
|
(chrome.storage.local.get as any).mockResolvedValue({});
|
||||||
|
|
||||||
|
const onData = vi.fn();
|
||||||
|
renderHook(() => useContextMenuData({ featureKey: 'jwt', onData }));
|
||||||
|
|
||||||
|
const storageChangeHandler = (chrome.storage.onChanged.addListener as any).mock.calls[0][0];
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
storageChangeHandler({
|
||||||
|
'contextMenu/pendingData': { newValue: null },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onData).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('组件卸载时应移除 storage 变化监听器', () => {
|
||||||
|
const { unmount } = renderHook(() =>
|
||||||
|
useContextMenuData({ featureKey: 'jwt', onData: vi.fn() }),
|
||||||
|
);
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
expect(chrome.storage.onChanged.removeListener).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import type { PageType } from '@/types/storage';
|
||||||
|
|
||||||
|
export interface ContextMenuItemConfig {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
contexts: [`${chrome.contextMenus.ContextType}`, ...`${chrome.contextMenus.ContextType}`[]];
|
||||||
|
parentId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContextMenuClickedInfo {
|
||||||
|
featureKey: PageType;
|
||||||
|
payload: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParseResult {
|
||||||
|
success: boolean;
|
||||||
|
data?: ContextMenuClickedInfo;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PARENT_MENU_ID = 'testing-tools-parent';
|
||||||
|
|
||||||
|
export const MAX_PAYLOAD_LENGTH = 10000;
|
||||||
|
|
||||||
|
/** 菜单项 ID 到 PageType 的映射(仅处理非常规映射) */
|
||||||
|
const MENU_ID_TO_PAGE_TYPE: Record<string, PageType> = {
|
||||||
|
'qrCode-page': 'qrCode',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将菜单项 ID 转换为 PageType
|
||||||
|
* 如果存在显式映射则使用映射,否则直接使用 menuItemId
|
||||||
|
*/
|
||||||
|
function getMenuPageType(menuItemId: string): PageType {
|
||||||
|
return MENU_ID_TO_PAGE_TYPE[menuItemId] ?? (menuItemId as PageType);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CONTEXT_MENU_CONFIGS: ContextMenuItemConfig[] = [
|
||||||
|
{
|
||||||
|
id: PARENT_MENU_ID,
|
||||||
|
title: 'Testing Tools',
|
||||||
|
contexts: [chrome.contextMenus.ContextType.ALL],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'jwt',
|
||||||
|
title: '🔑 解析 JWT',
|
||||||
|
contexts: [chrome.contextMenus.ContextType.SELECTION],
|
||||||
|
parentId: PARENT_MENU_ID,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'base64Converter',
|
||||||
|
title: '🔄 Base64 解码',
|
||||||
|
contexts: [chrome.contextMenus.ContextType.SELECTION],
|
||||||
|
parentId: PARENT_MENU_ID,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'textStatistics',
|
||||||
|
title: '📊 统计选中文本',
|
||||||
|
contexts: [chrome.contextMenus.ContextType.SELECTION],
|
||||||
|
parentId: PARENT_MENU_ID,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'timestamp',
|
||||||
|
title: '⏰ 转换时间戳',
|
||||||
|
contexts: [chrome.contextMenus.ContextType.SELECTION],
|
||||||
|
parentId: PARENT_MENU_ID,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'storageCleaner',
|
||||||
|
title: '🧹 清理当前网站存储',
|
||||||
|
contexts: [chrome.contextMenus.ContextType.PAGE],
|
||||||
|
parentId: PARENT_MENU_ID,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'qrCode-page',
|
||||||
|
title: '🔗 网页链接转二维码',
|
||||||
|
contexts: [chrome.contextMenus.ContextType.PAGE],
|
||||||
|
parentId: PARENT_MENU_ID,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function createAllContextMenus(): void {
|
||||||
|
for (const config of CONTEXT_MENU_CONFIGS) {
|
||||||
|
chrome.contextMenus.create({
|
||||||
|
id: config.id,
|
||||||
|
title: config.title,
|
||||||
|
contexts: config.contexts,
|
||||||
|
parentId: config.parentId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseContextMenuClick(
|
||||||
|
menuItemId: string,
|
||||||
|
info: chrome.contextMenus.OnClickData,
|
||||||
|
): ParseResult {
|
||||||
|
const featureKey = getMenuPageType(menuItemId);
|
||||||
|
|
||||||
|
if (info.selectionText) {
|
||||||
|
const text = info.selectionText;
|
||||||
|
|
||||||
|
if (text.length > MAX_PAYLOAD_LENGTH) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: { featureKey, payload: text.substring(0, MAX_PAYLOAD_LENGTH) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: { featureKey, payload: text },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (info.pageUrl) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: { featureKey, payload: info.pageUrl },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: false, error: '无法获取有效数据' };
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { defineExtensionMessaging } from '@webext-core/messaging';
|
|||||||
export enum MessageAction {
|
export enum MessageAction {
|
||||||
RELOAD_TAB = 'reloadTab',
|
RELOAD_TAB = 'reloadTab',
|
||||||
SIDE_PANEL_STATE_CHANGED = 'sidePanelStateChanged',
|
SIDE_PANEL_STATE_CHANGED = 'sidePanelStateChanged',
|
||||||
|
CONTEXT_MENU_CLICKED = 'contextMenuClicked',
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MessageResponse {
|
export interface MessageResponse {
|
||||||
@@ -11,9 +12,15 @@ export interface MessageResponse {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ContextMenuClickedPayload {
|
||||||
|
featureKey: string;
|
||||||
|
payload: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ProtocolMap {
|
export interface ProtocolMap {
|
||||||
[MessageAction.RELOAD_TAB](data: { tabId: number; delay?: number }): MessageResponse;
|
[MessageAction.RELOAD_TAB](data: { tabId: number; delay?: number }): MessageResponse;
|
||||||
[MessageAction.SIDE_PANEL_STATE_CHANGED](data: { isOpen: boolean }): void;
|
[MessageAction.SIDE_PANEL_STATE_CHANGED](data: { isOpen: boolean }): void;
|
||||||
|
[MessageAction.CONTEXT_MENU_CLICKED](data: ContextMenuClickedPayload): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const { sendMessage, onMessage } = defineExtensionMessaging<ProtocolMap>();
|
export const { sendMessage, onMessage } = defineExtensionMessaging<ProtocolMap>();
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { useCallback, useEffect } from 'react';
|
||||||
|
import { storageUtil } from '@/utils/chromeStorage';
|
||||||
|
import type { ContextMenuPendingData, PageType } from '@/types/storage';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'contextMenu/pendingData' as const;
|
||||||
|
|
||||||
|
/** 右键菜单数据过期时间(毫秒) */
|
||||||
|
export const CONTEXT_MENU_DATA_EXPIRY_MS = 5000;
|
||||||
|
|
||||||
|
export interface UseContextMenuDataOptions {
|
||||||
|
/** 当前页面的功能标识 */
|
||||||
|
featureKey: PageType;
|
||||||
|
/** 收到数据时的回调函数 */
|
||||||
|
onData: (payload: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自定义 Hook:处理右键菜单传递的数据
|
||||||
|
*
|
||||||
|
* 使用方式:
|
||||||
|
* 1. 在页面组件中调用此 Hook
|
||||||
|
* 2. 传入当前页面的 featureKey 和数据处理回调
|
||||||
|
* 3. Hook 会自动从 storage 中读取并消费匹配的数据
|
||||||
|
*/
|
||||||
|
export function useContextMenuData({ featureKey, onData }: UseContextMenuDataOptions): void {
|
||||||
|
const checkAndConsumeData = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await storageUtil.get(STORAGE_KEY, undefined);
|
||||||
|
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
if (data.featureKey !== featureKey) return;
|
||||||
|
|
||||||
|
if (Date.now() - data.timestamp > CONTEXT_MENU_DATA_EXPIRY_MS) {
|
||||||
|
await storageUtil.remove(STORAGE_KEY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await storageUtil.remove(STORAGE_KEY);
|
||||||
|
|
||||||
|
onData(data.payload);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[useContextMenuData] 处理右键菜单数据失败:', error);
|
||||||
|
}
|
||||||
|
}, [featureKey, onData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkAndConsumeData();
|
||||||
|
}, [checkAndConsumeData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleStorageChange = (changes: { [key: string]: chrome.storage.StorageChange }) => {
|
||||||
|
if (changes[STORAGE_KEY]) {
|
||||||
|
const newData = changes[STORAGE_KEY].newValue as ContextMenuPendingData | null;
|
||||||
|
if (newData && newData.featureKey === featureKey) {
|
||||||
|
checkAndConsumeData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
chrome.storage.onChanged.addListener(handleStorageChange);
|
||||||
|
return () => chrome.storage.onChanged.removeListener(handleStorageChange);
|
||||||
|
}, [featureKey, checkAndConsumeData]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存右键菜单数据到 storage
|
||||||
|
* 由 RouterProvider 或入口组件调用
|
||||||
|
*/
|
||||||
|
export async function saveContextMenuData(
|
||||||
|
data: Omit<ContextMenuPendingData, 'timestamp'>,
|
||||||
|
): Promise<void> {
|
||||||
|
const pendingData: ContextMenuPendingData = {
|
||||||
|
...data,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
await storageUtil.set(STORAGE_KEY, pendingData);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除右键菜单待处理数据
|
||||||
|
*/
|
||||||
|
export async function clearContextMenuData(): Promise<void> {
|
||||||
|
await storageUtil.remove(STORAGE_KEY);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useDebounce Hook - 防抖值
|
||||||
|
*
|
||||||
|
* @param value - 需要防抖的值
|
||||||
|
* @param delay - 延迟时间(毫秒)
|
||||||
|
* @returns 防抖后的值
|
||||||
|
*/
|
||||||
|
export function useDebounce<T>(value: T, delay: number): T {
|
||||||
|
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setDebouncedValue(value);
|
||||||
|
}, delay);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [value, delay]);
|
||||||
|
|
||||||
|
return debouncedValue;
|
||||||
|
}
|
||||||
@@ -70,6 +70,28 @@ Object.defineProperty(global, 'chrome', {
|
|||||||
getAll: vi.fn().mockResolvedValue([]),
|
getAll: vi.fn().mockResolvedValue([]),
|
||||||
remove: vi.fn().mockResolvedValue(undefined),
|
remove: vi.fn().mockResolvedValue(undefined),
|
||||||
},
|
},
|
||||||
|
contextMenus: {
|
||||||
|
create: vi.fn(),
|
||||||
|
onClicked: {
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
},
|
||||||
|
ContextType: {
|
||||||
|
ALL: 'all',
|
||||||
|
PAGE: 'page',
|
||||||
|
FRAME: 'frame',
|
||||||
|
SELECTION: 'selection',
|
||||||
|
LINK: 'link',
|
||||||
|
EDITABLE: 'editable',
|
||||||
|
IMAGE: 'image',
|
||||||
|
VIDEO: 'video',
|
||||||
|
AUDIO: 'audio',
|
||||||
|
LAUNCHER: 'launcher',
|
||||||
|
BROWSER_ACTION: 'browser_action',
|
||||||
|
PAGE_ACTION: 'page_action',
|
||||||
|
ACTION: 'action',
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
writable: true,
|
writable: true,
|
||||||
});
|
});
|
||||||
|
|||||||
+58
-9
@@ -1,4 +1,59 @@
|
|||||||
import { defineConfig } from 'wxt';
|
import { defineConfig } from 'wxt';
|
||||||
|
import type { Plugin } from 'vite';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仅在 HTML 多入口构建(popup / options / sidepanel)中启用 manualChunks 拆分 vendor。
|
||||||
|
* WXT 对 background / content-script 使用 lib 模式(IIFE),该模式不支持 manualChunks。
|
||||||
|
*/
|
||||||
|
function manualChunksForHtmlOnly(): Plugin {
|
||||||
|
return {
|
||||||
|
name: 'manual-chunks-html-only',
|
||||||
|
outputOptions(rawOptions) {
|
||||||
|
// lib 模式(IIFE)不支持 manualChunks,仅对 ES/CJS 格式启用
|
||||||
|
if (rawOptions.format === 'iife' || rawOptions.format === 'umd') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rawOptions.manualChunks = (id: string) => {
|
||||||
|
if (!id.includes('node_modules')) return;
|
||||||
|
|
||||||
|
// React 核心
|
||||||
|
if (id.includes('/react-dom/') || (id.includes('/react/') && !id.includes('/react-dom/'))) {
|
||||||
|
return 'vendor-react';
|
||||||
|
}
|
||||||
|
// MUI + Emotion
|
||||||
|
if (
|
||||||
|
id.includes('@mui/') ||
|
||||||
|
id.includes('@emotion/') ||
|
||||||
|
id.includes('hoist-non-react-statics') ||
|
||||||
|
id.includes('csstype') ||
|
||||||
|
id.includes('@popperjs/')
|
||||||
|
) {
|
||||||
|
return 'vendor-mui';
|
||||||
|
}
|
||||||
|
// 国际化
|
||||||
|
if (
|
||||||
|
id.includes('i18next') ||
|
||||||
|
id.includes('react-i18next') ||
|
||||||
|
id.includes('intl-messageformat')
|
||||||
|
) {
|
||||||
|
return 'vendor-i18n';
|
||||||
|
}
|
||||||
|
// QR 相关
|
||||||
|
if (id.includes('qr-scanner') || id.includes('qrious')) {
|
||||||
|
return 'vendor-qr';
|
||||||
|
}
|
||||||
|
// 拖拽
|
||||||
|
if (id.includes('@dnd-kit')) {
|
||||||
|
return 'vendor-dnd';
|
||||||
|
}
|
||||||
|
// Markdown
|
||||||
|
if (id.includes('marked')) {
|
||||||
|
return 'vendor-markdown';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// See https://wxt.dev/api/config.html
|
// See https://wxt.dev/api/config.html
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
@@ -16,6 +71,7 @@ export default defineConfig({
|
|||||||
'tabs',
|
'tabs',
|
||||||
'cookies',
|
'cookies',
|
||||||
'sidePanel',
|
'sidePanel',
|
||||||
|
'contextMenus',
|
||||||
],
|
],
|
||||||
host_permissions: ['<all_urls>'],
|
host_permissions: ['<all_urls>'],
|
||||||
action: {
|
action: {
|
||||||
@@ -30,19 +86,12 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
vite: () => ({
|
vite: () => ({
|
||||||
|
plugins: [manualChunksForHtmlOnly()],
|
||||||
build: {
|
build: {
|
||||||
// 1. 切换压缩器为 terser
|
|
||||||
minify: 'terser',
|
minify: 'terser',
|
||||||
|
|
||||||
// 2. 配置 terser 强制转义所有非 ASCII 字符
|
|
||||||
terserOptions: {
|
terserOptions: {
|
||||||
format: {
|
format: { ascii_only: true, comments: false },
|
||||||
ascii_only: true,
|
|
||||||
comments: false,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// 3. 调整 chunk 大小警告阈值
|
|
||||||
chunkSizeWarningLimit: 600,
|
chunkSizeWarningLimit: 600,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
Reference in New Issue
Block a user