新功能 (Features)
智能表单引擎: 新增智能表单填充功能,内置模糊匹配引擎、Mock数据生成器与视觉反馈渲染器。

表单映射与导出: 实现表单映射页面(包含扫描器和高亮器),并支持将配置导出为 JSON 文件,附带 Snackbar 状态提示。

表单识别增强: 增加按域名保存字段类型偏好的功能;添加字段定位闪烁以辅助查找;优化填充逻辑(支持单字段覆盖默认模式);重构 FieldList 组件以提升操作体验。

♻️ 代码重构 (Refactor)
通用组件提取: 提取并统一应用通用的 PageHeader 组件,移除独立的侧边栏页面及未使用的组件文件。

状态与逻辑优化: 改进 useStorageState 钩子(增加加载状态管理与防抖处理);将二维码解析功能重构为独立模块。

类型与依赖简化: 统一使用 SnackbarOptions 类型;简化假数据生成器中 faker 的导入与使用逻辑。

💄 样式与界面 (Style)
UI 细节打磨: 统一各页面头部图标颜色,调整表单输入框与按钮交互样式;优化时间戳页面、结果视图布局(增加圆角、调整内边距/对齐方式);重构存储选项网格及自动刷新开关样式。

代码格式: 优化项目中导入语句的顺序与格式。

👷 持续集成 (CI)
流程提效: 移除 Firefox 测试步骤以减少资源消耗;收紧工作流触发条件,移除 develop 及其变体分支,仅保留 main 分支触发。

📝 文档 (Docs)
代码维护: 补充组件的文档注释与类型导入。
This commit is contained in:
LingandRX
2026-04-30 19:56:32 +08:00
committed by GitHub
parent c039475119
commit 60537f6e2e
22 changed files with 2069 additions and 587 deletions
-3
View File
@@ -4,12 +4,9 @@ on:
push:
branches:
- main
- develop
- develop-*
pull_request:
branches:
- main
- develop
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
+83
View File
@@ -0,0 +1,83 @@
# Testing Tools Browser Extension - Gemini Instructions
This document provides essential context and instructions for AI agents working on the Testing Tools browser extension project.
## Project Overview
**Testing Tools** is a lightweight, feature-rich browser extension built with the [WXT (Web Extension Toolkit)](https://wxt.dev/) framework. It provides a suite of utilities for developers and testers, including timestamp conversion, storage management, URL shortcuts, and QR code tools.
### Tech Stack
- **Framework:** WXT (Web Extension Toolkit)
- **Frontend:** React 19 + TypeScript
- **UI Library:** Material UI (MUI) @7.x
- **Date Handling:** dayjs (with UTC and timezone plugins)
- **Messaging:** @webext-core/messaging
- **Storage:** Type-safe Chrome Storage API wrapper
- **Testing:** Vitest + Testing Library (jsdom)
### Architecture & Directory Structure
- `entrypoints/`: Extension entry points (popup, options, sidepanel, background, content).
- `popup/`: Main UI shown when clicking the extension icon.
- `options/`: Extension settings page.
- `sidepanel/`: Browser side panel integration.
- `background.ts`: Background script for lifecycle management and background tasks.
- `content.ts`: Content script injected into web pages.
- `components/`: Reusable React components.
- `config/`: Application configuration, including routes and themes.
- `providers/`: React Context providers (e.g., `RouterProvider`).
- `utils/`: Utility functions and service abstractions.
- `chromeStorage.ts`: Type-safe storage utility.
- `types/`: Global TypeScript type definitions.
- `public/`: Static assets (icons, etc.).
## Building and Running
### Development
- `npm run dev`: Start Chrome development mode with HMR.
- `npm run dev:firefox`: Start Firefox development mode.
- `npm run compile`: Run TypeScript type checking (`tsc --noEmit`).
### Production
- `npm run build`: Build production version for Chrome.
- `npm run build:firefox`: Build production version for Firefox.
- `npm run zip`: Package the extension for Chrome Web Store.
- `npm run zip:firefox`: Package the extension for Firefox Add-ons.
### Testing & Linting
- `npm run test`: Run all tests once.
- `npm run test:watch`: Run tests in watch mode.
- `npm run test:coverage`: Run tests and generate coverage report.
- `npm run lint`: Run ESLint checks.
## Development Conventions
### Coding Style
- **TypeScript:** Use strict typing. Prefer interfaces for object structures and types for unions/aliases.
- **Components:** Functional components with Hooks. Use MUI components for consistent UI.
- **Storage:** Always use `storageUtil` from `@/utils/chromeStorage.ts` for accessing `chrome.storage.local`. Ensure keys are defined in `StorageSchema` in `@/types/storage.d.ts`.
- **Messaging:** Use `@webext-core/messaging` for communication between entry points. Define message types in `@/utils/messages.ts`.
### Testing Practices
- **Framework:** Vitest with `jsdom` environment.
- **Location:** Place tests in `__tests__` directories adjacent to the files being tested.
- **Naming:** Follow `*.test.ts` or `*.test.tsx` naming convention.
- **Patterns:** Use `@testing-library/react` for component testing. Prefer `user-event` (v14+) for simulating interactions.
### CI/CD
- **GitHub Actions:** CI runs on push/PR to `main` and `develop` branches (lint, compile, test, build).
- **Releases:** Automatic release to GitHub on pushing a `v*` tag.
## Key Considerations for AI Agents
- **Manifest Permissions:** When adding features that require new browser APIs, update `wxt.config.ts`.
- **Browser Compatibility:** Ensure features work in both Chrome and Firefox.
- **React 19:** Be aware of React 19 specific features and deprecations.
- **WXT Modules:** The project uses `@wxt-dev/module-react`.
+58 -59
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import {
Box,
Typography,
@@ -11,14 +11,16 @@ import {
AccordionDetails,
CircularProgress,
InputAdornment,
IconButton,
} 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 jsQR from 'jsqr';
import ClearIcon from '@mui/icons-material/Clear';
import CopyButton from '@/components/CopyButton';
import { qrCodePageStyles } from '@/config/pageTheme';
import type { SnackbarOptions } from '@/components/GlobalSnackbar';
import { parseQrCodeFromFile } from '@/utils/qrCodeParser';
interface QrCodeToUrlSectionProps {
expanded: boolean;
@@ -35,13 +37,37 @@ const QrCodeToUrlSection = ({
const [parsedUrl, setParsedUrl] = useState('');
const [parseError, setParseError] = useState('');
const [parsing, setParsing] = useState(false);
const [dragging, setDragging] = useState(false);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
const file = e.target.files[0];
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileChange = useCallback((file: File) => {
setQrCodeFile(file);
setParseError('');
setParsedUrl('');
}, []);
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);
}
};
@@ -56,34 +82,16 @@ const QrCodeToUrlSection = ({
setParseError('');
setParsedUrl('');
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const result = await parseQrCodeFromFile(qrCodeFile);
if (!ctx) {
throw new Error('无法创建 canvas 上下文');
}
const image = new Image();
image.src = URL.createObjectURL(qrCodeFile);
await new Promise<void>((resolve, reject) => {
image.onload = () => {
canvas.width = image.width;
canvas.height = image.height;
ctx.drawImage(image, 0, 0);
resolve();
};
image.onerror = () => reject(new Error('图片加载失败'));
});
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const code = jsQR(imageData.data, imageData.width, imageData.height);
if (code) {
setParsedUrl(code.data);
if (result.success && result.data) {
setParsedUrl(result.data);
showMessage('二维码解析成功', { severity: 'success', autoHideDuration: 1000 });
} else {
showMessage('未检测到二维码', { severity: 'error', autoHideDuration: 1000 });
showMessage(result.error || '未检测到二维码', {
severity: 'error',
autoHideDuration: 1000,
});
}
} catch (error) {
console.error('解析二维码失败:', error);
@@ -108,9 +116,7 @@ const QrCodeToUrlSection = ({
const file = items[i].getAsFile();
if (file) {
try {
setQrCodeFile(file);
setParseError('');
setParsedUrl('');
handleFileChange(file);
showMessage('图片粘贴成功', { severity: 'success', autoHideDuration: 1000 });
} catch (error) {
console.error('处理粘贴图片失败:', error);
@@ -127,7 +133,7 @@ const QrCodeToUrlSection = ({
return () => {
document.removeEventListener('paste', handlePaste);
};
}, [expanded, showMessage]);
}, [expanded, showMessage, handleFileChange]);
return (
<Accordion
@@ -157,10 +163,18 @@ const QrCodeToUrlSection = ({
justifyContent: 'center',
minHeight: 200,
border: '2px dashed',
borderColor: qrCodeFile ? qrCodePageStyles.successColor : 'grey.200',
borderColor: dragging
? qrCodePageStyles.successColor
: qrCodeFile
? qrCodePageStyles.successColor
: 'grey.200',
borderRadius: 3,
p: 4,
bgcolor: qrCodeFile ? 'rgba(76, 175, 80, 0.05)' : 'grey.50',
bgcolor: dragging
? 'rgba(76, 175, 80, 0.1)'
: qrCodeFile
? 'rgba(76, 175, 80, 0.05)'
: 'grey.50',
cursor: 'pointer',
transition: 'all 0.2s',
'&:hover': {
@@ -168,11 +182,15 @@ const QrCodeToUrlSection = ({
bgcolor: 'rgba(76, 175, 80, 0.05)',
},
}}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleFileChange}
onChange={handleInputChange}
style={{
display: 'none',
}}
@@ -195,8 +213,7 @@ const QrCodeToUrlSection = ({
objectFit: 'contain',
}}
/>
<Button
variant="contained"
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
@@ -212,33 +229,15 @@ const QrCodeToUrlSection = ({
position: 'absolute',
top: -8,
right: -8,
minWidth: '32px',
width: '32px',
height: '32px',
borderRadius: '50%',
bgcolor: 'rgba(244, 67, 54, 0.9)',
color: 'white',
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.2)',
transition: 'all 0.2s ease-in-out',
'&:hover': {
bgcolor: 'rgba(211, 47, 47, 0.95)',
transform: 'scale(1.1)',
boxShadow: '0 4px 8px rgba(0, 0, 0, 0.3)',
},
'&:active': {
transform: 'scale(0.95)',
},
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontWeight: 700,
fontSize: '16px',
lineHeight: 1,
padding: 0,
}}
>
×
</Button>
<ClearIcon fontSize="small" />
</IconButton>
</Box>
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
{qrCodeFile.name}
+7 -40
View File
@@ -13,9 +13,9 @@ import ImageIcon from '@mui/icons-material/Image';
import ClearIcon from '@mui/icons-material/Clear';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import ErrorIcon from '@mui/icons-material/Error';
import jsQR from 'jsqr';
import GlobalSnackbar, { useSnackbar } from './GlobalSnackbar';
import CopyButton from './CopyButton';
import { parseQrCodeFromFile } from '@/utils/qrCodeParser';
interface QrCodeUploaderProps {
onQrCodeDetected?: (data: string) => void;
@@ -67,7 +67,6 @@ const QrCodeUploader: React.FC<QrCodeUploaderProps> = ({
setProgress(0);
try {
// 模拟上传进度
const progressInterval = setInterval(() => {
setProgress((prev) => {
if (prev >= 90) {
@@ -78,51 +77,20 @@ const QrCodeUploader: React.FC<QrCodeUploaderProps> = ({
});
}, 200);
// 读取文件并解析二维码
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('无法创建 canvas 上下文');
}
const image = new Image();
image.src = URL.createObjectURL(file);
await new Promise<void>((resolve, reject) => {
const timeoutId = setTimeout(() => {
reject(new Error('图片加载超时'));
}, timeout);
image.onload = () => {
clearTimeout(timeoutId);
canvas.width = image.width;
canvas.height = image.height;
ctx.drawImage(image, 0, 0);
resolve();
};
image.onerror = () => {
clearTimeout(timeoutId);
reject(new Error('图片加载失败'));
};
});
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const code = jsQR(imageData.data, imageData.width, imageData.height);
const result = await parseQrCodeFromFile(file, timeout);
clearInterval(progressInterval);
setProgress(100);
if (code) {
setResult(code.data);
if (result.success && result.data) {
setResult(result.data);
showMessage('二维码解析成功', { severity: 'success' });
if (onQrCodeDetected) {
onQrCodeDetected(code.data);
onQrCodeDetected(result.data);
}
} else {
setError('未检测到二维码');
showMessage('未检测到二维码', { severity: 'error' });
setError(result.error || '未检测到二维码');
showMessage(result.error || '未检测到二维码', { severity: 'error' });
}
} catch (err) {
setError(err instanceof Error ? err.message : '解析失败');
@@ -131,7 +99,6 @@ const QrCodeUploader: React.FC<QrCodeUploaderProps> = ({
});
} finally {
setUploading(false);
// 延迟清除进度,让用户看到完成状态
setTimeout(() => setProgress(0), 500);
}
},
+3 -13
View File
@@ -1,5 +1,5 @@
import { useState } from 'react';
import { Box, TextField, Alert, Stack } from '@mui/material';
import { Box, TextField, Alert, Stack, alpha } from '@mui/material';
import AddIcon from '@mui/icons-material/Add';
import Button from '@/components/Button';
import type { OpenUrlEntry } from '@/types/storage';
@@ -65,11 +65,6 @@ const UrlEntryForm = ({ onAddEntry, showMessage }: UrlEntryFormProps) => {
fullWidth
variant="outlined"
sx={openUrlPageStyles.INPUT_STYLE}
slotProps={{
inputLabel: {
shrink: true,
},
}}
/>
<TextField
label="目标 URL"
@@ -79,11 +74,6 @@ const UrlEntryForm = ({ onAddEntry, showMessage }: UrlEntryFormProps) => {
fullWidth
variant="outlined"
sx={openUrlPageStyles.INPUT_STYLE}
slotProps={{
inputLabel: {
shrink: true,
},
}}
/>
{showMixedContentWarning && (
@@ -111,8 +101,8 @@ const UrlEntryForm = ({ onAddEntry, showMessage }: UrlEntryFormProps) => {
fontWeight: 800,
boxShadow: 'none',
'&:hover': {
bgcolor: 'rgba(25, 118, 210, 0.85)',
boxShadow: '0 8px 24px rgba(25, 118, 210, 0.2)',
bgcolor: openUrlPageStyles.primaryDark,
boxShadow: `0 8px 24px ${alpha(openUrlPageStyles.primaryColor, 0.2)}`,
},
}}
>
+5 -5
View File
@@ -9,8 +9,8 @@ import {
describe('routes', () => {
describe('ROUTES', () => {
it('should have 7 routes defined', () => {
expect(ROUTES).toHaveLength(7);
it('should have 9 routes defined', () => {
expect(ROUTES).toHaveLength(9);
});
it('should have all required properties for each route', () => {
@@ -104,7 +104,7 @@ describe('routes', () => {
describe('getAllRouteKeys', () => {
it('should return all route keys', () => {
const allKeys = getAllRouteKeys();
expect(allKeys).toHaveLength(7);
expect(allKeys).toHaveLength(9);
expect(allKeys).toContain('dashboard');
expect(allKeys).toContain('timestamp');
expect(allKeys).toContain('storageCleaner');
@@ -135,9 +135,9 @@ describe('routes', () => {
expect(pageOrder).toContain('formRecognizer');
});
it('should have 5 items in page order', () => {
it('should have 7 items in page order', () => {
const pageOrder = getDefaultPageOrder();
expect(pageOrder).toHaveLength(5);
expect(pageOrder).toHaveLength(7);
});
});
});
+14 -27
View File
@@ -111,15 +111,13 @@ export const timestampPageStyles = {
* 打开 URL 页面样式
*/
export const openUrlPageStyles = {
primaryColor: THEME_COLORS.purple,
primaryDark: THEME_COLORS.purpleDark,
INPUT_STYLE: {
'& .MuiOutlinedInput-root': {
bgcolor: 'background.paper',
borderRadius: 3,
borderRadius: 4,
transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
'& fieldset': {
border: '1px solid',
borderColor: 'grey.100',
},
'&:hover fieldset': {
borderColor: 'grey.300',
},
@@ -129,14 +127,14 @@ export const openUrlPageStyles = {
},
'&.Mui-focused': {
bgcolor: '#fff',
boxShadow: (_theme: Theme) => `0 0 0 4px ${alpha(THEME_COLORS.purple, 0.1)}`,
},
},
'& .MuiInputBase-input': {
py: 1.2,
py: '14px',
px: 2,
fontSize: '0.85rem',
fontWeight: 600,
lineHeight: 1.4,
},
'& .MuiInputLabel-root': {
fontSize: '0.85rem',
@@ -176,26 +174,7 @@ export const qrCodePageStyles = {
successDark: THEME_COLORS.successDark,
white: THEME_COLORS.white,
black: THEME_COLORS.black,
INPUT_STYLE: {
'& .MuiOutlinedInput-root': {
borderRadius: 3,
'& fieldset': {
borderColor: THEME_COLORS.success,
},
'&:hover fieldset': {
borderColor: THEME_COLORS.success,
},
'&.Mui-focused fieldset': {
borderColor: THEME_COLORS.success,
},
},
'& .MuiInputLabel-root': {
fontSize: '0.85rem',
fontWeight: 700,
color: 'text.secondary',
'&.Mui-focused': { color: THEME_COLORS.success },
},
},
INPUT_STYLE: {},
} as const;
/**
@@ -212,6 +191,7 @@ export const dashboardPageStyles = {
* 使用语义化的颜色命名:valid(有效)、invalid(无效)、clear(清除)
*/
export const formRecognizerPageStyles = {
primaryColor: '#ff5722',
validColor: THEME_COLORS.success,
validDark: THEME_COLORS.successDark,
invalidColor: THEME_COLORS.warning,
@@ -225,3 +205,10 @@ export const formRecognizerPageStyles = {
fontWeight: 700,
},
} as const;
/**
* 表单映射页面样式
*/
export const formMappingPageStyles = {
secondaryColor: THEME_COLORS.purple,
} as const;
+22
View File
@@ -6,6 +6,8 @@ import OpenUrlPage from '@/entrypoints/popup/pages/OpenUrlPage';
import OpenUrlViewerPage from '@/entrypoints/popup/pages/OpenUrlViewerPage';
import QrCodePage from '@/entrypoints/popup/pages/QrCodePage';
import FormRecognizerPage from '@/entrypoints/popup/pages/FormRecognizerPage';
import FormMappingPage from '@/entrypoints/popup/pages/FormMappingPage';
import FormFillPage from '@/entrypoints/popup/pages/FormFillPage';
export interface RouteConfig {
key: PageType;
@@ -69,6 +71,26 @@ export const ROUTES: RouteConfig[] = [
detached: QrCodePage,
},
},
{
key: 'formMapping',
label: '表单映射',
defaultVisible: true,
components: {
popup: FormMappingPage,
sidepanel: FormMappingPage,
detached: FormMappingPage,
},
},
{
key: 'formFill',
label: '智能填充',
defaultVisible: true,
components: {
popup: FormFillPage,
sidepanel: FormFillPage,
detached: FormFillPage,
},
},
{
key: 'formRecognizer',
label: '表单识别',
+89
View File
@@ -12,6 +12,16 @@ import {
} from '@/utils/dummyDataGenerator';
import { MessageAction, type MessagePayload, type MessageResponse } from '@/utils/messages';
import { SmartDetector } from '@/utils/formMapping/scanner';
import { highlighter } from '@/utils/formMapping/highlighter';
import {
FuzzyMatcher,
SmartInjectionEngine,
FeedbackRenderer,
} from '@/utils/formMapping/smartInjector';
import { storageUtil } from '@/utils/chromeStorage';
import { FormMapEntry } from '@/types/storage';
// 存储当前扫描到的字段列表,用于高亮联动
let currentFields: FormFieldInfo[] = [];
@@ -19,6 +29,53 @@ export default defineContentScript({
matches: ['<all_urls>'],
runAt: 'document_end',
main() {
// === 通用表单映射助手逻辑 ===
chrome.storage.onChanged.addListener((changes, area) => {
if (
area === 'local' &&
(changes['active_form_map'] || changes['app/formMapping/isPicking'])
) {
updateMappingUI();
}
});
async function updateMappingUI() {
const entries = ((await storageUtil.get('active_form_map')) as FormMapEntry[]) || [];
const isPicking = ((await storageUtil.get('app/formMapping/isPicking')) as boolean) || false;
if (entries.length > 0 || isPicking) {
highlighter.show();
highlighter.draw(entries);
if (isPicking) {
highlighter.enablePicker(async (el) => {
const fingerprint = SmartDetector.generateFingerprint(el);
const label = SmartDetector.extractSemanticLabel(el);
const newEntry: FormMapEntry = {
id: Math.random().toString(36).substr(2, 9),
label_display: label,
fingerprint,
action_logic: { type: 'text', strategy: 'fixed', value: '' },
ui_state: { is_selected: true },
};
const currentMap = ((await storageUtil.get('active_form_map')) as FormMapEntry[]) || [];
await storageUtil.set('active_form_map', [...currentMap, newEntry]);
await storageUtil.set('app/formMapping/isPicking', false);
});
} else {
highlighter.disablePicker();
}
} else {
highlighter.hide();
}
}
// 初始加载映射 UI
updateMappingUI();
// === 原有表单识别逻辑 ===
// 监听来自 popup/sidepanel 的消息
chrome.runtime.onMessage.addListener(
(message: MessagePayload, _sender, sendResponse: (response: MessageResponse) => void) => {
@@ -126,6 +183,38 @@ export default defineContentScript({
}
break;
}
case 'FORM_INJECT': {
try {
const injectData =
(message.data as Array<{ entry: FormMapEntry; mockValue: string }>) || [];
const results = injectData.map((item) => {
const matchResult = FuzzyMatcher.findTargetElement(item.entry.fingerprint);
if (matchResult.element) {
const injectResult = SmartInjectionEngine.inject(
matchResult.element,
item.entry,
item.mockValue,
);
if (injectResult.success) {
FeedbackRenderer.renderSuccess(matchResult.element);
} else {
FeedbackRenderer.renderError(matchResult.element);
}
return { id: item.entry.id, success: injectResult.success };
} else {
return { id: item.entry.id, success: false };
}
});
sendResponse({ success: true, results });
} catch (error) {
console.error('智能注入失败:', error);
sendResponse({
success: false,
error: error instanceof Error ? error.message : '注入失败',
});
}
break;
}
default:
sendResponse({ success: false, message: '未知操作' });
}
+25
View File
@@ -6,6 +6,7 @@ import StorageIcon from '@mui/icons-material/Storage';
import LanguageIcon from '@mui/icons-material/Language';
import QrCodeIcon from '@mui/icons-material/QrCode';
import DescriptionIcon from '@mui/icons-material/Description';
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
import type { PageType } from '@/types/storage';
import { useEffect, useState } from 'react';
import dayjs from '@/utils/dayjs';
@@ -89,6 +90,18 @@ export default function DashboardPage() {
cardBackgroundColor={dashboardPageStyles.cardBackgroundColor}
/>
);
case 'formMapping':
return (
<ToolCard
key={key}
title="通用表单映射助手"
description="智能识别表单指纹,自定义填充逻辑"
colorCode="#3f51b5"
icon={<AutoFixHighIcon sx={{ fontSize: 20 }} />}
onClick={() => navigateTo('formMapping')}
cardBackgroundColor={dashboardPageStyles.cardBackgroundColor}
/>
);
case 'formRecognizer':
return (
<ToolCard
@@ -101,6 +114,18 @@ export default function DashboardPage() {
cardBackgroundColor={dashboardPageStyles.cardBackgroundColor}
/>
);
case 'formFill':
return (
<ToolCard
key={key}
title="智能填充"
description="根据表单指纹智能填充表单内容"
colorCode="#2196f3"
icon={<AutoFixHighIcon sx={{ fontSize: 20 }} />}
onClick={() => navigateTo('formFill')}
cardBackgroundColor={dashboardPageStyles.cardBackgroundColor}
/>
);
default:
return null;
}
+384
View File
@@ -0,0 +1,384 @@
import {
Box,
Typography,
Container,
List,
ListItem,
ListItemText,
IconButton,
Switch,
Divider,
Paper,
Button,
Chip,
Snackbar,
Alert,
alpha,
} from '@mui/material';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import RefreshIcon from '@mui/icons-material/Refresh';
import VisibilityIcon from '@mui/icons-material/Visibility';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import CancelIcon from '@mui/icons-material/Cancel';
import { useEffect, useState, useCallback } from 'react';
import { storageUtil } from '@/utils/chromeStorage';
import { FormMapEntry } from '@/types/storage';
import PageHeader from '@/components/PageHeader';
import { globalStyles, formMappingPageStyles } from '@/config/pageTheme.ts';
import { MockDataGenerator } from '@/utils/formMapping/smartInjector';
export default function FormFillPage() {
const [entries, setEntries] = useState<FormMapEntry[]>([]);
const [previewData, setPreviewData] = useState<Map<string, string>>(new Map());
const [injectResults, setInjectResults] = useState<Map<string, boolean>>(new Map());
const [isInjecting, setIsInjecting] = useState(false);
const [showSuccess, setShowSuccess] = useState(false);
const [showError, setShowError] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const generatePreviewData = useCallback((items: FormMapEntry[]) => {
const preview = new Map<string, string>();
items.forEach((entry) => {
const value = MockDataGenerator.generate(entry.action_logic, entry);
preview.set(entry.id, value);
});
setPreviewData(preview);
setInjectResults(new Map());
}, []);
useEffect(() => {
const loadEntries = async () => {
const data = (await storageUtil.get('active_form_map')) as FormMapEntry[];
setEntries(data || []);
generatePreviewData(data || []);
};
loadEntries();
const listener = (changes: { [key: string]: chrome.storage.StorageChange }, area: string) => {
if (area === 'local' && changes['active_form_map']) {
loadEntries();
}
};
chrome.storage.onChanged.addListener(listener);
return () => chrome.storage.onChanged.removeListener(listener);
}, [generatePreviewData]);
const refreshPreview = () => {
generatePreviewData(entries);
};
const injectAllFields = async () => {
if (entries.length === 0) {
setErrorMessage('没有可填充的字段');
setShowError(true);
return;
}
setIsInjecting(true);
const results = new Map<string, boolean>();
try {
// 发送消息到 content script 执行注入
const response = await chrome.tabs.query({ active: true, currentWindow: true });
if (response.length === 0) {
throw new Error('无法获取当前标签页');
}
const tabId = response[0].id;
if (!tabId) {
throw new Error('标签页ID无效');
}
// 准备注入数据
const injectData = entries.map((entry) => ({
entry,
mockValue: previewData.get(entry.id) || '',
}));
// 执行注入
const result = await chrome.tabs.sendMessage(tabId, {
type: 'FORM_INJECT',
data: injectData,
});
if (result && result.success) {
result.results.forEach((r: { id: string; success: boolean }) => {
results.set(r.id, r.success);
});
setInjectResults(results);
setShowSuccess(true);
} else {
throw new Error(result?.error || '注入失败');
}
} catch (error) {
console.error('注入失败:', error);
setErrorMessage(
error instanceof Error ? error.message : '注入失败,请确保已在网页中打开表单',
);
setShowError(true);
} finally {
setIsInjecting(false);
}
};
const getFieldTypeLabel = (type: string) => {
const labels: Record<string, string> = {
text: '文本',
select: '下拉框',
checkbox: '复选框',
radio: '单选框',
};
return labels[type] || type;
};
const getStrategyLabel = (strategy: string) => {
const labels: Record<string, string> = {
fixed: '固定值',
random: '随机',
sequence: '序列',
};
return labels[strategy] || strategy;
};
return (
<Box sx={{ bgcolor: globalStyles.backgroundColor, minHeight: '100%', pb: 3 }}>
<Container sx={{ py: 2, bgcolor: globalStyles.backgroundColor }}>
<PageHeader
title="智能表单填充"
subtitle="基于指纹识别的精准数据注入"
icon={<PlayArrowIcon />}
/>
<Container maxWidth="sm" sx={{ py: 2, px: 0 }}>
{/* 操作区域 */}
<Paper
sx={{
p: 2,
mb: 2.5,
bgcolor: 'background.paper',
borderRadius: 4,
border: '1px solid',
borderColor: 'grey.100',
}}
>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 1.5,
}}
>
<Typography variant="subtitle1" fontWeight={800} color="text.primary">
</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
variant="outlined"
onClick={refreshPreview}
size="small"
startIcon={<RefreshIcon />}
sx={{ borderRadius: 3 }}
>
</Button>
<Button
variant="contained"
onClick={injectAllFields}
size="small"
startIcon={<PlayArrowIcon />}
disabled={isInjecting || entries.length === 0}
sx={{
borderRadius: 3,
bgcolor: formMappingPageStyles.secondaryColor || '#9c27b0',
boxShadow: `0 4px 12px ${alpha(formMappingPageStyles.secondaryColor || '#9c27b0', 0.2)}`,
}}
>
{isInjecting ? '注入中...' : '开始填充'}
</Button>
</Box>
</Box>
<Typography variant="body2" color="text.secondary">
"开始填充"
</Typography>
</Paper>
{/* 字段列表 */}
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 1.5,
px: 0.5,
}}
>
<Typography variant="subtitle2" fontWeight={800} color="text.secondary">
({entries.length})
</Typography>
</Box>
<List
sx={{
bgcolor: 'background.paper',
borderRadius: 4,
overflow: 'hidden',
border: '1px solid',
borderColor: 'grey.100',
}}
>
{entries.length === 0 ? (
<ListItem>
<ListItemText
primary="暂无映射字段"
secondary="请先在表单映射页面配置字段"
slotProps={{
primary: {
align: 'center',
color: 'text.secondary',
},
secondary: {
align: 'center',
},
}}
/>
</ListItem>
) : (
entries.map((entry, index) => (
<Box key={entry.id}>
{index > 0 && <Divider />}
<ListItem
secondaryAction={
<IconButton edge="end" aria-label="preview">
<VisibilityIcon fontSize="small" />
</IconButton>
}
sx={{ py: 1.5 }}
>
<Switch
edge="start"
checked={entry.ui_state.is_selected}
disabled
sx={{ mr: 2 }}
/>
<ListItemText
primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<span style={{ fontWeight: 500 }}>{entry.label_display}</span>
<Chip
size="small"
label={getFieldTypeLabel(entry.action_logic.type)}
sx={{
fontSize: '0.65rem',
bgcolor: 'grey.100',
color: 'grey.700',
}}
/>
<Chip
size="small"
label={getStrategyLabel(entry.action_logic.strategy)}
sx={{
fontSize: '0.65rem',
bgcolor: formMappingPageStyles.secondaryColor + '20',
color: formMappingPageStyles.secondaryColor || '#9c27b0',
}}
/>
</Box>
}
secondary={
<Box>
<Typography
sx={{
fontFamily: 'monospace',
fontSize: '0.7rem',
color: 'text.secondary',
mb: 1,
}}
>
{entry.fingerprint.selector}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography
sx={{
fontSize: '0.75rem',
color: 'primary.main',
fontStyle: 'italic',
wordBreak: 'break-all',
maxWidth: '250px',
}}
>
: {previewData.get(entry.id) || '---'}
</Typography>
{injectResults.has(entry.id) &&
(injectResults.get(entry.id) ? (
<CheckCircleIcon sx={{ color: '#32CD32', fontSize: '1rem' }} />
) : (
<CancelIcon sx={{ color: '#FF4444', fontSize: '1rem' }} />
))}
</Box>
</Box>
}
/>
</ListItem>
</Box>
))
)}
</List>
{/* 统计信息 */}
{injectResults.size > 0 && (
<Box sx={{ mt: 4 }}>
<Paper
elevation={0}
sx={{
p: 2,
bgcolor: 'grey.50',
borderRadius: 3,
border: '1px solid',
borderColor: 'grey.200',
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-around' }}>
<Box textAlign="center">
<Typography variant="h5" fontWeight={800} color="primary.main">
{Array.from(injectResults.values()).filter(Boolean).length}
</Typography>
<Typography variant="body2" color="text.secondary">
</Typography>
</Box>
<Divider orientation="vertical" flexItem />
<Box textAlign="center">
<Typography variant="h5" fontWeight={800} color="error.main">
{Array.from(injectResults.values()).filter((v) => !v).length}
</Typography>
<Typography variant="body2" color="text.secondary">
</Typography>
</Box>
</Box>
</Paper>
</Box>
)}
</Container>
</Container>
{/* 提示消息 */}
<Snackbar
open={showSuccess}
autoHideDuration={3000}
onClose={() => setShowSuccess(false)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert severity="success"></Alert>
</Snackbar>
<Snackbar
open={showError}
autoHideDuration={4000}
onClose={() => setShowError(false)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert severity="error">{errorMessage}</Alert>
</Snackbar>
</Box>
);
}
+324
View File
@@ -0,0 +1,324 @@
import {
Box,
Typography,
Container,
List,
ListItem,
ListItemText,
IconButton,
Switch,
Divider,
Paper,
alpha,
Snackbar,
Alert,
} from '@mui/material';
import DeleteIcon from '@mui/icons-material/Delete';
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline';
import FileDownloadIcon from '@mui/icons-material/FileDownload';
import { useEffect, useState } from 'react';
import { storageUtil } from '@/utils/chromeStorage';
import { FormMapEntry } from '@/types/storage';
import PageHeader from '@/components/PageHeader';
import Button from '@/components/Button';
import { globalStyles, formMappingPageStyles } from '@/config/pageTheme.ts';
export default function FormMappingPage() {
const [entries, setEntries] = useState<FormMapEntry[]>([]);
const [isPicking, setIsPicking] = useState(false);
const [exportError, setExportError] = useState<string | null>(null);
const [showExportSuccess, setShowExportSuccess] = useState(false);
useEffect(() => {
const loadData = async () => {
const data = (await storageUtil.get('active_form_map')) as FormMapEntry[];
setEntries(data || []);
const picking = (await storageUtil.get('app/formMapping/isPicking')) as boolean;
setIsPicking(picking || false);
};
loadData();
const listener = (changes: { [key: string]: chrome.storage.StorageChange }, area: string) => {
if (area === 'local') {
if (changes['active_form_map']) {
setEntries((changes['active_form_map'].newValue as FormMapEntry[]) || []);
}
if (changes['app/formMapping/isPicking']) {
setIsPicking((changes['app/formMapping/isPicking'].newValue as boolean) || false);
}
}
};
chrome.storage.onChanged.addListener(listener);
return () => chrome.storage.onChanged.removeListener(listener);
}, []);
const togglePicking = async () => {
await storageUtil.set('app/formMapping/isPicking', !isPicking);
};
const deleteEntry = async (id: string) => {
const newEntries = entries.filter((e) => e.id !== id);
await storageUtil.set('active_form_map', newEntries);
};
const toggleSelection = async (id: string) => {
const newEntries = entries.map((e) =>
e.id === id ? { ...e, ui_state: { ...e.ui_state, is_selected: !e.ui_state.is_selected } } : e,
);
await storageUtil.set('active_form_map', newEntries);
};
const clearAll = async () => {
await storageUtil.set('active_form_map', []);
await storageUtil.set('app/formMapping/isPicking', false);
};
const exportConfig = () => {
try {
if (entries.length === 0) {
setExportError('没有可导出的配置数据');
return;
}
const jsonStr = JSON.stringify(entries, null, 2);
const blob = new Blob([jsonStr], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const date = new Date();
const dateStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
const filename = `form-mapping-config-${dateStr}.json`;
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
setShowExportSuccess(true);
} catch (error) {
console.error('导出配置失败:', error);
setExportError(error instanceof Error ? error.message : '导出失败,请重试');
}
};
return (
<Box>
<Box sx={{ bgcolor: globalStyles.backgroundColor, minHeight: '100%', pb: 3 }}>
<Container sx={{ py: 2, bgcolor: globalStyles.backgroundColor }}>
<PageHeader
title="通用表单映射助手"
subtitle="智能识别表单指纹,自定义填充逻辑"
icon={<AutoFixHighIcon />}
/>
<Container maxWidth="sm" sx={{ py: 2, px: 0 }}>
<Paper
sx={{
p: 2,
mb: 2.5,
bgcolor: 'background.paper',
borderRadius: 4,
border: '1px solid',
borderColor: 'grey.100',
}}
>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 1.5,
}}
>
<Typography variant="subtitle1" fontWeight={800} color="text.primary">
</Typography>
<Button
variant={isPicking ? 'contained' : 'outlined'}
onClick={togglePicking}
size="small"
startIcon={<AddCircleOutlineIcon />}
sx={{
borderRadius: 3,
px: 2,
fontWeight: 800,
...(isPicking
? {
bgcolor: 'secondary.main',
boxShadow: `0 4px 12px ${alpha(formMappingPageStyles.secondaryColor || '#9c27b0', 0.2)}`,
}
: {}),
}}
>
{isPicking ? '正在拾取...' : '开始拾取'}
</Button>
</Box>
<Typography variant="body2" color="text.secondary">
</Typography>
</Paper>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 1.5,
px: 0.5,
}}
>
<Typography variant="subtitle2" fontWeight={800} color="text.secondary">
({entries.length})
</Typography>
<Button
size="small"
color="error"
onClick={clearAll}
sx={{ fontWeight: 700, fontSize: '0.75rem' }}
>
</Button>
</Box>
<List
sx={{
bgcolor: 'background.paper',
borderRadius: 4,
overflow: 'hidden',
border: '1px solid',
borderColor: 'grey.100',
}}
>
{entries.length === 0 ? (
<ListItem>
<ListItemText
primary="暂无数据"
secondary="点击上方按钮开始探测网页表单"
slotProps={{
primary: {
align: 'center',
color: 'text.secondary',
},
secondary: {
align: 'center',
},
}}
/>
</ListItem>
) : (
entries.map((entry, index) => (
<Box key={entry.id}>
{index > 0 && <Divider />}
<ListItem
secondaryAction={
<IconButton
edge="end"
aria-label="delete"
onClick={() => deleteEntry(entry.id)}
sx={{ color: 'error.light' }}
>
<DeleteIcon fontSize="small" />
</IconButton>
}
sx={{ py: 1.5 }}
>
<Switch
edge="start"
checked={entry.ui_state.is_selected}
onChange={() => toggleSelection(entry.id)}
/>
<ListItemText
primary={entry.label_display}
secondary={entry.fingerprint.selector}
slotProps={{
primary: { fontWeight: 500 },
secondary: {
sx: {
fontFamily: 'monospace',
fontSize: '0.7rem',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: '200px',
},
},
}}
/>
</ListItem>
</Box>
))
)}
</List>
{entries.length > 0 && (
<Box sx={{ mt: 4 }}>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 1.5,
px: 0.5,
}}
>
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: 'text.secondary' }}>
(JSON)
</Typography>
<Button
size="small"
variant="outlined"
onClick={exportConfig}
startIcon={<FileDownloadIcon />}
sx={{ fontWeight: 700, fontSize: '0.75rem', borderRadius: 3 }}
>
</Button>
</Box>
<Paper
elevation={0}
sx={{
p: 2,
bgcolor: 'grey.50',
borderRadius: 3,
border: '1px solid',
borderColor: 'grey.200',
fontFamily: 'monospace',
fontSize: '0.7rem',
maxHeight: '180px',
overflow: 'auto',
}}
>
<pre style={{ margin: 0 }}>{JSON.stringify(entries, null, 2)}</pre>
</Paper>
</Box>
)}
</Container>
</Container>
</Box>
<Snackbar
open={!!exportError}
autoHideDuration={4000}
onClose={() => setExportError(null)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert severity="error" onClose={() => setExportError(null)}>
{exportError}
</Alert>
</Snackbar>
<Snackbar
open={showExportSuccess}
autoHideDuration={3000}
onClose={() => setShowExportSuccess(false)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert severity="success" onClose={() => setShowExportSuccess(false)}>
</Alert>
</Snackbar>
</Box>
);
}
@@ -339,6 +339,7 @@ const FormRecognizerPage = () => {
title="表单测试数据填充器"
subtitle="一键填充表单测试数据,提升开发和测试效率"
icon={<InputIcon />}
iconColor={formRecognizerPageStyles.primaryColor}
sx={{ mb: 2.5 }}
/>
+2 -1
View File
@@ -5,7 +5,7 @@ import UrlEntryForm from '@/components/UrlEntryForm';
import UrlEntryList from '@/components/UrlEntryList';
import { useUrlPreferences } from '@/utils/useUrlPreferences';
import type { OpenUrlEntry } from '@/types/storage';
import { dashboardPageStyles } from '@/config/pageTheme';
import { dashboardPageStyles, openUrlPageStyles } from '@/config/pageTheme';
import PageHeader from '@/components/PageHeader';
export default function OpenUrlPage() {
@@ -41,6 +41,7 @@ export default function OpenUrlPage() {
title="URL 工具"
subtitle="快速打开 URL 或复制链接"
icon={<LanguageIcon />}
iconColor={openUrlPageStyles.primaryColor}
sx={{ mb: 2.5 }}
/>
+3 -2
View File
@@ -4,7 +4,7 @@ import GlobalSnackbar, { useSnackbar } from '@/components/GlobalSnackbar';
import UrlToQrCodeSection from '@/components/UrlToQrCodeSection';
import QrCodeToUrlSection from '@/components/QrCodeToUrlSection';
import { useStorageState } from '@/utils/useStorageState';
import { dashboardPageStyles } from '@/config/pageTheme';
import { dashboardPageStyles, qrCodePageStyles } from '@/config/pageTheme';
import PageHeader from '@/components/PageHeader';
const QrCodePage = () => {
@@ -33,12 +33,13 @@ const QrCodePage = () => {
}
return (
<Box sx={{ minHeight: '100%', pb: 3, bgcolor: dashboardPageStyles.backgroundColor }}>
<Box sx={{ minHeight: '100%', pb: 3 }}>
<Container sx={{ py: 2, maxWidth: 400, bgcolor: dashboardPageStyles.backgroundColor }}>
<PageHeader
title="二维码工具"
subtitle="生成和解析二维码"
icon={<QrCodeIcon />}
iconColor={qrCodePageStyles.primaryColor}
sx={{ mb: 2.5 }}
/>
+150 -435
View File
@@ -1891,44 +1891,6 @@
"integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==",
"license": "MIT"
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmmirror.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "2.0.5",
"run-parallel": "^1.1.9"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/@nodelib/fs.stat": {
"version": "2.0.5",
"resolved": "https://registry.npmmirror.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/@nodelib/fs.walk": {
"version": "1.2.8",
"resolved": "https://registry.npmmirror.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.scandir": "2.1.5",
"fastq": "^1.6.0"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/@pnpm/config.env-replace": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz",
@@ -3039,9 +3001,9 @@
}
},
"node_modules/@wxt-dev/browser": {
"version": "0.1.32",
"resolved": "https://registry.npmmirror.com/@wxt-dev/browser/-/browser-0.1.32.tgz",
"integrity": "sha512-jvfSppeLzlH4sOkIvMBJoA1pKoI+U5gTkjDwMKdkTWh0P/fj+KDyze3lzo3S6372viCm8tXUKNez+VKyVz2ZDw==",
"version": "0.1.40",
"resolved": "https://mirrors.cloud.tencent.com/npm/@wxt-dev/browser/-/browser-0.1.40.tgz",
"integrity": "sha512-h2/v/Hpkj5sz//h84ProqBaAcTsDFRKp9b/JVHOK/r7LT0XLE+ZDs5YN1BnFLUEHdM7G3fUjTyBG84cayXQshQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3050,18 +3012,18 @@
}
},
"node_modules/@wxt-dev/module-react": {
"version": "1.1.5",
"resolved": "https://registry.npmmirror.com/@wxt-dev/module-react/-/module-react-1.1.5.tgz",
"integrity": "sha512-KgsUrsgH5rBT8MwiipnDEOHBXmLvTIdFICrI7KjngqSf9DpVRn92HsKmToxY0AYpkP19hHWta2oNYFTzmmm++g==",
"version": "1.2.2",
"resolved": "https://mirrors.cloud.tencent.com/npm/@wxt-dev/module-react/-/module-react-1.2.2.tgz",
"integrity": "sha512-+lRLi1r9dAXpLySWSIWHLJ1h/nFzR20iQnx3RNrKyA6oJg4+ClOluVXozHjfPg9Okfy/umtffiOopGayASrg6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitejs/plugin-react": "^4.4.1 || ^5.0.0"
"@vitejs/plugin-react": "^4.4.1 || ^5.0.0 || ^6.0.0"
},
"funding": {
"url": "https://github.com/sponsors/wxt-dev"
},
"peerDependencies": {
"vite": "^5.4.19 || ^6.3.4 || ^7.0.0 || ^8.0.0-0",
"wxt": ">=0.19.16"
}
},
@@ -3706,7 +3668,7 @@
},
"node_modules/bundle-name": {
"version": "4.1.0",
"resolved": "https://registry.npmmirror.com/bundle-name/-/bundle-name-4.1.0.tgz",
"resolved": "https://mirrors.cloud.tencent.com/npm/bundle-name/-/bundle-name-4.1.0.tgz",
"integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==",
"dev": true,
"license": "MIT",
@@ -3749,36 +3711,6 @@
}
}
},
"node_modules/c12/node_modules/chokidar": {
"version": "5.0.0",
"resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-5.0.0.tgz",
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
"dev": true,
"license": "MIT",
"dependencies": {
"readdirp": "^5.0.0"
},
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/c12/node_modules/readdirp": {
"version": "5.0.0",
"resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-5.0.0.tgz",
"integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/cac": {
"version": "6.7.14",
"resolved": "https://registry.npmmirror.com/cac/-/cac-6.7.14.tgz",
@@ -3923,16 +3855,16 @@
}
},
"node_modules/chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"version": "5.0.0",
"resolved": "https://mirrors.cloud.tencent.com/npm/chokidar/-/chokidar-5.0.0.tgz",
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
"dev": true,
"license": "MIT",
"dependencies": {
"readdirp": "^4.0.1"
"readdirp": "^5.0.0"
},
"engines": {
"node": ">= 14.16.0"
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
@@ -4000,9 +3932,9 @@
}
},
"node_modules/ci-info": {
"version": "4.3.1",
"resolved": "https://registry.npmmirror.com/ci-info/-/ci-info-4.3.1.tgz",
"integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==",
"version": "4.4.0",
"resolved": "https://mirrors.cloud.tencent.com/npm/ci-info/-/ci-info-4.4.0.tgz",
"integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==",
"dev": true,
"funding": [
{
@@ -4054,19 +3986,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/cli-spinners": {
"version": "2.9.2",
"resolved": "https://registry.npmmirror.com/cli-spinners/-/cli-spinners-2.9.2.tgz",
"integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/cli-truncate": {
"version": "4.0.0",
"resolved": "https://registry.npmmirror.com/cli-truncate/-/cli-truncate-4.0.0.tgz",
@@ -4642,9 +4561,9 @@
"license": "MIT"
},
"node_modules/default-browser": {
"version": "5.4.0",
"resolved": "https://registry.npmmirror.com/default-browser/-/default-browser-5.4.0.tgz",
"integrity": "sha512-XDuvSq38Hr1MdN47EDvYtx3U0MTqpCEn+F6ft8z2vYDzMrvQhVp0ui9oQdqW3MvK3vqUETglt1tVGgjLuJ5izg==",
"version": "5.5.0",
"resolved": "https://mirrors.cloud.tencent.com/npm/default-browser/-/default-browser-5.5.0.tgz",
"integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4660,7 +4579,7 @@
},
"node_modules/default-browser-id": {
"version": "5.0.1",
"resolved": "https://registry.npmmirror.com/default-browser-id/-/default-browser-id-5.0.1.tgz",
"resolved": "https://mirrors.cloud.tencent.com/npm/default-browser-id/-/default-browser-id-5.0.1.tgz",
"integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==",
"dev": true,
"license": "MIT",
@@ -4691,7 +4610,7 @@
},
"node_modules/define-lazy-prop": {
"version": "3.0.0",
"resolved": "https://registry.npmmirror.com/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz",
"resolved": "https://mirrors.cloud.tencent.com/npm/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz",
"integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==",
"dev": true,
"license": "MIT",
@@ -5623,23 +5542,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/fast-glob": {
"version": "3.3.3",
"resolved": "https://registry.npmmirror.com/fast-glob/-/fast-glob-3.3.3.tgz",
"integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "^2.0.2",
"@nodelib/fs.walk": "^1.2.3",
"glob-parent": "^5.1.2",
"merge2": "^1.3.0",
"micromatch": "^4.0.8"
},
"engines": {
"node": ">=8.6.0"
}
},
"node_modules/fast-json-stable-stringify": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
@@ -5664,16 +5566,6 @@
"node": ">=6"
}
},
"node_modules/fastq": {
"version": "1.20.1",
"resolved": "https://registry.npmmirror.com/fastq/-/fastq-1.20.1.tgz",
"integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
"dev": true,
"license": "ISC",
"dependencies": {
"reusify": "^1.0.4"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz",
@@ -5706,9 +5598,9 @@
}
},
"node_modules/filesize": {
"version": "11.0.13",
"resolved": "https://registry.npmmirror.com/filesize/-/filesize-11.0.13.tgz",
"integrity": "sha512-mYJ/qXKvREuO0uH8LTQJ6v7GsUvVOguqxg2VTwQUkyTPXXRRWPdjuUPVqdBrJQhvci48OHlNGRnux+Slr2Rnvw==",
"version": "11.0.17",
"resolved": "https://mirrors.cloud.tencent.com/npm/filesize/-/filesize-11.0.17.tgz",
"integrity": "sha512-oHLTvMLw6imZUl1se/RBQrFlyy50nXce4sU7yGR6Qc0JgCwqnfiFsAnEwotdGmfKLD7SArGUk2/5STU0k8LOBQ==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
@@ -6070,19 +5962,6 @@
"giget": "dist/cli.mjs"
}
},
"node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/glob-to-regexp": {
"version": "0.4.1",
"resolved": "https://registry.npmmirror.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz",
@@ -6306,9 +6185,9 @@
"license": "MIT"
},
"node_modules/hookable": {
"version": "5.5.3",
"resolved": "https://registry.npmmirror.com/hookable/-/hookable-5.5.3.tgz",
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==",
"version": "6.1.1",
"resolved": "https://mirrors.cloud.tencent.com/npm/hookable/-/hookable-6.1.1.tgz",
"integrity": "sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ==",
"dev": true,
"license": "MIT"
},
@@ -6673,7 +6552,7 @@
},
"node_modules/is-docker": {
"version": "3.0.0",
"resolved": "https://registry.npmmirror.com/is-docker/-/is-docker-3.0.0.tgz",
"resolved": "https://mirrors.cloud.tencent.com/npm/is-docker/-/is-docker-3.0.0.tgz",
"integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==",
"dev": true,
"license": "MIT",
@@ -6775,9 +6654,22 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-in-ssh": {
"version": "1.0.0",
"resolved": "https://mirrors.cloud.tencent.com/npm/is-in-ssh/-/is-in-ssh-1.0.0.tgz",
"integrity": "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-inside-container": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/is-inside-container/-/is-inside-container-1.0.0.tgz",
"resolved": "https://mirrors.cloud.tencent.com/npm/is-inside-container/-/is-inside-container-1.0.0.tgz",
"integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==",
"dev": true,
"license": "MIT",
@@ -6811,19 +6703,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-interactive": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/is-interactive/-/is-interactive-2.0.0.tgz",
"integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-map": {
"version": "2.0.3",
"resolved": "https://registry.npmmirror.com/is-map/-/is-map-2.0.3.tgz",
@@ -7041,19 +6920,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-unicode-supported": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz",
"integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-weakmap": {
"version": "2.0.2",
"resolved": "https://registry.npmmirror.com/is-weakmap/-/is-weakmap-2.0.2.tgz",
@@ -7101,9 +6967,9 @@
}
},
"node_modules/is-wsl": {
"version": "3.1.0",
"resolved": "https://registry.npmmirror.com/is-wsl/-/is-wsl-3.1.0.tgz",
"integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==",
"version": "3.1.1",
"resolved": "https://mirrors.cloud.tencent.com/npm/is-wsl/-/is-wsl-3.1.1.tgz",
"integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -7537,19 +7403,6 @@
"node": ">=20.0.0"
}
},
"node_modules/lint-staged/node_modules/nano-spawn": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/nano-spawn/-/nano-spawn-2.0.0.tgz",
"integrity": "sha512-tacvGzUY5o2D8CBh2rrwxyNojUsZNU2zjNTzKQrkgGJQTbGAfArVWXSKMBokBeeg6C7OLRGUEyoFlYbfeWQIqw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20.17"
},
"funding": {
"url": "https://github.com/sindresorhus/nano-spawn?sponsor=1"
}
},
"node_modules/lint-staged/node_modules/slice-ansi": {
"version": "7.1.2",
"resolved": "https://registry.npmmirror.com/slice-ansi/-/slice-ansi-7.1.2.tgz",
@@ -7643,36 +7496,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/log-symbols": {
"version": "6.0.0",
"resolved": "https://registry.npmmirror.com/log-symbols/-/log-symbols-6.0.0.tgz",
"integrity": "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==",
"dev": true,
"license": "MIT",
"dependencies": {
"chalk": "^5.3.0",
"is-unicode-supported": "^1.3.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/log-symbols/node_modules/is-unicode-supported": {
"version": "1.3.0",
"resolved": "https://registry.npmmirror.com/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz",
"integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/log-update": {
"version": "6.1.0",
"resolved": "https://registry.npmmirror.com/log-update/-/log-update-6.1.0.tgz",
@@ -7777,15 +7600,15 @@
}
},
"node_modules/magicast": {
"version": "0.3.5",
"resolved": "https://registry.npmmirror.com/magicast/-/magicast-0.3.5.tgz",
"integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==",
"version": "0.5.2",
"resolved": "https://mirrors.cloud.tencent.com/npm/magicast/-/magicast-0.5.2.tgz",
"integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.25.4",
"@babel/types": "^7.25.4",
"source-map-js": "^1.2.0"
"@babel/parser": "^7.29.0",
"@babel/types": "^7.29.0",
"source-map-js": "^1.2.1"
}
},
"node_modules/make-error": {
@@ -7822,16 +7645,6 @@
"node": ">= 0.4"
}
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmmirror.com/merge2/-/merge2-1.4.1.tgz",
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/micromatch": {
"version": "4.0.8",
"resolved": "https://registry.npmmirror.com/micromatch/-/micromatch-4.0.8.tgz",
@@ -7905,45 +7718,6 @@
"node": ">=4"
}
},
"node_modules/minimatch": {
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.5"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/minimatch/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/minimatch/node_modules/brace-expansion": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmmirror.com/minimist/-/minimist-1.2.8.tgz",
@@ -8025,9 +7799,9 @@
}
},
"node_modules/nano-spawn": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/nano-spawn/-/nano-spawn-1.0.3.tgz",
"integrity": "sha512-jtpsQDetTnvS2Ts1fiRdci5rx0VYws5jGyC+4IYOTnIQ/wwdf6JdomlHBwqC3bJYOvaKu0C2GSZ1A60anrYpaA==",
"version": "2.1.0",
"resolved": "https://mirrors.cloud.tencent.com/npm/nano-spawn/-/nano-spawn-2.1.0.tgz",
"integrity": "sha512-yTW+2okrElHiH4fsiz/+/zc0EDo9BDDoC3iKk8dpv1GeRc9nUWzUZHx6TofMWErchhUQR8hY9/Eu1Uja9x1nqA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -8056,6 +7830,16 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/nanospinner": {
"version": "1.2.2",
"resolved": "https://mirrors.cloud.tencent.com/npm/nanospinner/-/nanospinner-1.2.2.tgz",
"integrity": "sha512-Zt/AmG6qRU3e+WnzGGLuMCEAO/dAu45stNbHY223tUxldaDAeE+FxSPsd9Q+j+paejmm0ZbrNVs5Sraqy3dRxA==",
"dev": true,
"license": "MIT",
"dependencies": {
"picocolors": "^1.1.1"
}
},
"node_modules/natural-compare": {
"version": "1.4.0",
"resolved": "https://registry.npmmirror.com/natural-compare/-/natural-compare-1.4.0.tgz",
@@ -8198,15 +7982,15 @@
"license": "MIT"
},
"node_modules/nypm": {
"version": "0.6.4",
"resolved": "https://registry.npmmirror.com/nypm/-/nypm-0.6.4.tgz",
"integrity": "sha512-1TvCKjZyyklN+JJj2TS3P4uSQEInrM/HkkuSXsEzm1ApPgBffOn8gFguNnZf07r/1X6vlryfIqMUkJKQMzlZiw==",
"version": "0.6.6",
"resolved": "https://mirrors.cloud.tencent.com/npm/nypm/-/nypm-0.6.6.tgz",
"integrity": "sha512-vRyr0r4cbBapw07Xw8xrj9Teq3o7MUD35rSaTcanDbW+aK2XHDgJFiU6ZTj2GBw7Q12ysdsyFss+Vdz4hQ0Y6Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"citty": "^0.2.0",
"citty": "^0.2.2",
"pathe": "^2.0.3",
"tinyexec": "^1.0.2"
"tinyexec": "^1.1.1"
},
"bin": {
"nypm": "dist/cli.mjs"
@@ -8216,9 +8000,9 @@
}
},
"node_modules/nypm/node_modules/citty": {
"version": "0.2.0",
"resolved": "https://registry.npmmirror.com/citty/-/citty-0.2.0.tgz",
"integrity": "sha512-8csy5IBFI2ex2hTVpaHN2j+LNE199AgiI7y4dMintrr8i0lQiFn+0AWMZrWdHKIgMOer65f8IThysYhoReqjWA==",
"version": "0.2.2",
"resolved": "https://mirrors.cloud.tencent.com/npm/citty/-/citty-0.2.2.tgz",
"integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==",
"dev": true,
"license": "MIT"
},
@@ -8386,19 +8170,21 @@
}
},
"node_modules/open": {
"version": "10.2.0",
"resolved": "https://registry.npmmirror.com/open/-/open-10.2.0.tgz",
"integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==",
"version": "11.0.0",
"resolved": "https://mirrors.cloud.tencent.com/npm/open/-/open-11.0.0.tgz",
"integrity": "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==",
"dev": true,
"license": "MIT",
"dependencies": {
"default-browser": "^5.2.1",
"default-browser": "^5.4.0",
"define-lazy-prop": "^3.0.0",
"is-in-ssh": "^1.0.0",
"is-inside-container": "^1.0.0",
"wsl-utils": "^0.1.0"
"powershell-utils": "^0.1.0",
"wsl-utils": "^0.3.0"
},
"engines": {
"node": ">=18"
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
@@ -8422,30 +8208,6 @@
"node": ">= 0.8.0"
}
},
"node_modules/ora": {
"version": "8.2.0",
"resolved": "https://registry.npmmirror.com/ora/-/ora-8.2.0.tgz",
"integrity": "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==",
"dev": true,
"license": "MIT",
"dependencies": {
"chalk": "^5.3.0",
"cli-cursor": "^5.0.0",
"cli-spinners": "^2.9.2",
"is-interactive": "^2.0.0",
"is-unicode-supported": "^2.0.0",
"log-symbols": "^6.0.0",
"stdin-discarder": "^0.2.2",
"string-width": "^7.2.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/os-shim": {
"version": "0.1.3",
"resolved": "https://registry.npmmirror.com/os-shim/-/os-shim-0.1.3.tgz",
@@ -8773,9 +8535,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.8",
"resolved": "https://mirrors.cloud.tencent.com/npm/postcss/-/postcss-8.5.8.tgz",
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
"version": "8.5.12",
"resolved": "https://mirrors.cloud.tencent.com/npm/postcss/-/postcss-8.5.12.tgz",
"integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==",
"dev": true,
"funding": [
{
@@ -8791,7 +8553,6 @@
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -8801,6 +8562,19 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/powershell-utils": {
"version": "0.1.0",
"resolved": "https://mirrors.cloud.tencent.com/npm/powershell-utils/-/powershell-utils-0.1.0.tgz",
"integrity": "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmmirror.com/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -9212,27 +8986,6 @@
],
"license": "MIT"
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz",
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/quick-format-unescaped": {
"version": "4.0.4",
"resolved": "https://registry.npmmirror.com/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
@@ -9356,13 +9109,13 @@
}
},
"node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-4.1.2.tgz",
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"version": "5.0.0",
"resolved": "https://mirrors.cloud.tencent.com/npm/readdirp/-/readdirp-5.0.0.tgz",
"integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14.18.0"
"node": ">= 20.19.0"
},
"funding": {
"type": "individual",
@@ -9525,17 +9278,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/reusify": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/reusify/-/reusify-1.1.0.tgz",
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
"dev": true,
"license": "MIT",
"engines": {
"iojs": ">=1.0.0",
"node": ">=0.10.0"
}
},
"node_modules/rfdc": {
"version": "1.4.1",
"resolved": "https://registry.npmmirror.com/rfdc/-/rfdc-1.4.1.tgz",
@@ -9597,7 +9339,7 @@
},
"node_modules/run-applescript": {
"version": "7.1.0",
"resolved": "https://registry.npmmirror.com/run-applescript/-/run-applescript-7.1.0.tgz",
"resolved": "https://mirrors.cloud.tencent.com/npm/run-applescript/-/run-applescript-7.1.0.tgz",
"integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==",
"dev": true,
"license": "MIT",
@@ -9608,30 +9350,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/run-parallel/-/run-parallel-1.2.0.tgz",
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"queue-microtask": "^1.2.2"
}
},
"node_modules/safe-array-concat": {
"version": "1.1.3",
"resolved": "https://registry.npmmirror.com/safe-array-concat/-/safe-array-concat-1.1.3.tgz",
@@ -10136,19 +9854,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/stdin-discarder": {
"version": "0.2.2",
"resolved": "https://registry.npmmirror.com/stdin-discarder/-/stdin-discarder-0.2.2.tgz",
"integrity": "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/stop-iteration-iterator": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
@@ -10480,9 +10185,9 @@
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/tinyexec/-/tinyexec-1.0.2.tgz",
"integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==",
"version": "1.1.2",
"resolved": "https://mirrors.cloud.tencent.com/npm/tinyexec/-/tinyexec-1.1.2.tgz",
"integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -12057,80 +11762,90 @@
}
},
"node_modules/wsl-utils": {
"version": "0.1.0",
"resolved": "https://registry.npmmirror.com/wsl-utils/-/wsl-utils-0.1.0.tgz",
"integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==",
"version": "0.3.1",
"resolved": "https://mirrors.cloud.tencent.com/npm/wsl-utils/-/wsl-utils-0.3.1.tgz",
"integrity": "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-wsl": "^3.1.0"
"is-wsl": "^3.1.0",
"powershell-utils": "^0.1.0"
},
"engines": {
"node": ">=18"
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/wxt": {
"version": "0.20.13",
"resolved": "https://registry.npmmirror.com/wxt/-/wxt-0.20.13.tgz",
"integrity": "sha512-FwQEk+0a4/pYha6rTKGl5iicU6kRYDBDiElJf55CFEfoJKqvGzBTZpphafurQfqU1X0hvAm9w5GEWC0thXI6wQ==",
"version": "0.20.25",
"resolved": "https://mirrors.cloud.tencent.com/npm/wxt/-/wxt-0.20.25.tgz",
"integrity": "sha512-ca+8Yt0Auzn9tX0ZW2Kzocb9yM8F/RoOjcYQ0fHkwcSc7/IUkqV2+1JUNn1SMSNAS4Gr3YQHAn/pi3q+jIGRqw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@1natsu/wait-element": "^4.1.2",
"@aklinker1/rollup-plugin-visualizer": "5.12.0",
"@webext-core/fake-browser": "^1.3.2",
"@webext-core/isolated-element": "^1.1.2",
"@webext-core/fake-browser": "^1.3.4",
"@webext-core/isolated-element": "^1.1.3",
"@webext-core/match-patterns": "^1.0.3",
"@wxt-dev/browser": "^0.1.32",
"@wxt-dev/browser": "^0.1.40",
"@wxt-dev/storage": "^1.0.0",
"async-mutex": "^0.5.0",
"c12": "^3.3.2",
"cac": "^6.7.14",
"chokidar": "^4.0.3",
"ci-info": "^4.3.1",
"c12": "^3.3.3",
"cac": "^6.7.14 || ^7.0.0",
"chokidar": "^5.0.0",
"ci-info": "^4.4.0",
"consola": "^3.4.2",
"defu": "^6.1.4",
"dotenv": "^17.2.3",
"dotenv-expand": "^12.0.3",
"esbuild": "^0.27.1",
"fast-glob": "^3.3.3",
"filesize": "^11.0.13",
"fs-extra": "^11.3.2",
"filesize": "^11.0.15",
"get-port-please": "^3.2.0",
"giget": "^1.2.3 || ^2.0.0",
"hookable": "^5.5.3",
"giget": "^1.2.3 || ^2.0.0 || ^3.0.0",
"hookable": "^6.1.0",
"import-meta-resolve": "^4.2.0",
"is-wsl": "^3.1.0",
"is-wsl": "^3.1.1",
"json5": "^2.2.3",
"jszip": "^3.10.1",
"linkedom": "^0.18.12",
"magicast": "^0.3.5",
"minimatch": "^10.1.1",
"nano-spawn": "^1.0.3",
"magicast": "^0.5.2",
"nano-spawn": "^2.0.0",
"nanospinner": "^1.2.2",
"normalize-path": "^3.0.0",
"nypm": "^0.6.2",
"nypm": "^0.6.5",
"ohash": "^2.0.11",
"open": "^10.2.0",
"ora": "^8.2.0",
"perfect-debounce": "^2.0.0",
"picocolors": "^1.1.1",
"open": "^11.0.0",
"perfect-debounce": "^2.1.0",
"picomatch": "^4.0.3",
"prompts": "^2.4.2",
"publish-browser-extension": "^2.3.0 || ^3.0.2",
"publish-browser-extension": "^2.3.0 || ^3.0.2 || ^4.0.4",
"scule": "^1.3.0",
"unimport": "^3.13.1 || ^4.0.0 || ^5.0.0",
"vite": "^5.4.19 || ^6.3.4 || ^7.0.0",
"vite-node": "^3.2.4 || ^5.0.0",
"tinyglobby": "^0.2.15",
"unimport": "^3.13.1 || ^4.0.0 || ^5.0.0 || ^6.0.0",
"vite": "^5.4.19 || ^6.3.4 || ^7.0.0 || ^8.0.0-0",
"vite-node": "^3.2.4 || ^5.0.0 || ^6.0.0",
"web-ext-run": "^0.2.4"
},
"bin": {
"wxt": "bin/wxt.mjs",
"wxt-publish-extension": "bin/wxt-publish-extension.cjs"
"wxt-publish-extension": "bin/wxt-publish-extension.mjs"
},
"engines": {
"bun": ">=1.2.0",
"node": ">=20.12.0"
},
"funding": {
"url": "https://github.com/sponsors/wxt-dev"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0"
},
"peerDependenciesMeta": {
"eslint": {
"optional": true
}
}
},
"node_modules/xdg-basedir": {
+22
View File
@@ -5,8 +5,28 @@ export type PageType =
| 'openUrl'
| 'qrCode'
| 'formRecognizer'
| 'formMapping'
| 'formFill'
| 'openUrlViewer';
export interface FormMapEntry {
id: string;
label_display: string;
fingerprint: {
selector: string;
name_attr: string;
placeholder: string;
};
action_logic: {
type: 'text' | 'select' | 'checkbox';
strategy: 'fixed' | 'random' | 'sequence';
value: string;
};
ui_state: {
is_selected: boolean;
};
}
export interface StorageSchema {
'app/currentRoute': PageType;
'app/popupRoute': PageType;
@@ -15,6 +35,8 @@ export interface StorageSchema {
'app/pageOrder': PageType[];
'app/lastRoute': string;
'app/theme': string;
'app/formMapping/isPicking': boolean;
active_form_map: FormMapEntry[];
'storageCleaner/preferences': StorageCleanerPreferences;
'openUrl/preferences': OpenUrlPreferences;
'openUrl/currentUrl': string;
+139
View File
@@ -0,0 +1,139 @@
import { FormMapEntry } from '@/types/storage';
/**
* 可视化交互模块:负责在网页上绘制非破坏性的高亮遮罩
*/
export class VisualHighlighter {
private canvas: HTMLCanvasElement | null = null;
private ctx: CanvasRenderingContext2D | null = null;
private isVisible = false;
constructor() {
this.handleResize = this.handleResize.bind(this);
}
public init() {
if (this.canvas) return;
this.canvas = document.createElement('canvas');
this.canvas.id = 'form-mapping-highlighter';
Object.assign(this.canvas.style, {
position: 'fixed',
top: '0',
left: '0',
width: '100vw',
height: '100vh',
pointerEvents: 'none',
zIndex: '2147483647',
display: 'none',
});
document.body.appendChild(this.canvas);
this.ctx = this.canvas.getContext('2d');
window.addEventListener('resize', this.handleResize);
window.addEventListener('scroll', this.handleResize);
}
public show() {
if (!this.canvas) this.init();
this.isVisible = true;
this.canvas!.style.display = 'block';
this.handleResize();
}
public hide() {
this.isVisible = false;
if (this.canvas) this.canvas.style.display = 'none';
}
private handleResize() {
if (!this.isVisible || !this.canvas || !this.ctx) return;
this.canvas.width = window.innerWidth;
this.canvas.height = window.innerHeight;
this.draw();
}
/**
* 核心渲染循环
*/
public draw(entries: FormMapEntry[] = []) {
if (!this.ctx || !this.isVisible) return;
this.ctx.clearRect(0, 0, this.canvas!.width, this.canvas!.height);
entries.forEach((entry) => {
const el = document.querySelector<HTMLElement>(entry.fingerprint.selector);
if (!el) return;
const rect = el.getBoundingClientRect();
// 检查元素是否在视口内
if (
rect.bottom < 0 ||
rect.top > window.innerHeight ||
rect.right < 0 ||
rect.left > window.innerWidth
) {
return;
}
// 设置样式
if (entry.ui_state.is_selected) {
// 选中状态:亮黄色边框
this.ctx!.strokeStyle = '#FFD700';
this.ctx!.lineWidth = 3;
this.ctx!.fillStyle = 'rgba(255, 215, 0, 0.2)';
} else {
// 未选中状态:浅蓝色半透明
this.ctx!.strokeStyle = 'rgba(173, 216, 230, 0.8)';
this.ctx!.lineWidth = 1;
this.ctx!.fillStyle = 'rgba(173, 216, 230, 0.4)';
}
// 绘制矩形
this.ctx!.beginPath();
this.ctx!.rect(rect.left, rect.top, rect.width, rect.height);
this.ctx!.fill();
this.ctx!.stroke();
// 如果被选中,绘制一个小标签
if (entry.ui_state.is_selected) {
this.ctx!.fillStyle = '#FFD700';
this.ctx!.font = '12px sans-serif';
this.ctx!.fillText(entry.label_display, rect.left, rect.top - 5);
}
});
}
/**
* 开启拾取模式:拦截点击事件
*/
public enablePicker(onPick: (element: HTMLElement) => void) {
if (!this.canvas) this.init();
this.canvas!.style.pointerEvents = 'auto';
this.canvas!.style.cursor = 'crosshair';
const handleClick = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
// 暂时禁用 canvas pointer-events 以便探测下方的真实元素
this.canvas!.style.pointerEvents = 'none';
const el = document.elementFromPoint(e.clientX, e.clientY) as HTMLElement;
this.canvas!.style.pointerEvents = 'auto';
if (el) {
onPick(el);
}
};
this.canvas!.addEventListener('click', handleClick, { capture: true, once: true });
}
public disablePicker() {
if (this.canvas) {
this.canvas.style.pointerEvents = 'none';
this.canvas.style.cursor = 'default';
}
}
}
export const highlighter = new VisualHighlighter();
+100
View File
@@ -0,0 +1,100 @@
import { FormMapEntry } from '@/types/storage';
/**
* 智能探测引擎:负责扫描 DOM 并生成唯一指纹
*/
export class SmartDetector {
/**
* 扫描页面中符合条件的表单元素
*/
public static scanFormElements(): HTMLElement[] {
const selector =
'input:not([type="hidden"]):not([type="submit"]):not([type="button"]), textarea, select, [contenteditable="true"]';
const elements = Array.from(document.querySelectorAll<HTMLElement>(selector));
return elements.filter((el) => {
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0 && window.getComputedStyle(el).display !== 'none';
});
}
/**
* 生成元素的唯一性指纹
*/
public static generateFingerprint(element: HTMLElement): FormMapEntry['fingerprint'] {
return {
selector: this.getUniqueSelector(element),
name_attr: element.getAttribute('name') || element.getAttribute('id') || '',
placeholder: element.getAttribute('placeholder') || '',
};
}
/**
* 提取元素的语义标签 (核心算法)
* 优先查找 label[for],其次在物理位置上方或左侧 50px 范围内寻找文本
*/
public static extractSemanticLabel(element: HTMLElement): string {
// 1. 尝试查找关联的 label 元素
if (element.id) {
const label = document.querySelector(`label[for="${element.id}"]`);
if (label?.textContent) return label.textContent.trim();
}
// 2. 尝试向上查找父级中的 label
const parentLabel = element.closest('label');
if (parentLabel?.textContent) return parentLabel.textContent.trim();
// 3. 物理位置探测算法 (getBoundingClientRect)
const rect = element.getBoundingClientRect();
// 探测左侧 50px
const leftText = this.getTextNearby(rect.left - 25, rect.top + rect.height / 2);
if (leftText) return leftText;
// 探测上方 50px
const topText = this.getTextNearby(rect.left + rect.width / 2, rect.top - 25);
if (topText) return topText;
// 4. 降级:使用 placeholder 或 name
return element.getAttribute('placeholder') || element.getAttribute('name') || '未知字段';
}
/**
* 在指定坐标附近寻找最可能的文本节点
*/
private static getTextNearby(x: number, y: number): string | null {
if (x < 0 || y < 0) return null;
const el = document.elementFromPoint(x, y);
if (!el) return null;
// 如果命中了文本容器
const text = el.textContent?.trim();
if (text && text.length < 30) return text; // 避免抓到太长的段落
return null;
}
/**
* 计算元素的相对短且唯一的 CSS 选择器
*/
private static getUniqueSelector(el: HTMLElement): string {
if (el.id) return `#${el.id}`;
let path = el.tagName.toLowerCase();
// 尝试添加类名以增加唯一性
if (el.classList.length > 0) {
path += `.${Array.from(el.classList).join('.')}`;
}
// 如果当前路径在文档中不是唯一的,则增加 nth-child
if (document.querySelectorAll(path).length > 1) {
const parent = el.parentElement;
if (parent) {
const index = Array.from(parent.children).indexOf(el) + 1;
path = `${this.getUniqueSelector(parent as HTMLElement)} > ${el.tagName.toLowerCase()}:nth-child(${index})`;
}
}
return path;
}
}
+579
View File
@@ -0,0 +1,579 @@
import { FormMapEntry } from '@/types/storage';
/**
* 模糊匹配引擎结果接口
*/
export interface MatchResult {
element: HTMLElement | null;
score: number;
}
/**
* 注入结果接口
*/
export interface InjectResult {
success: boolean;
entry: FormMapEntry;
error?: string;
}
/**
* 模糊匹配引擎
* 根据 JSON 指纹在页面中精准定位目标 DOM 元素
*/
export class FuzzyMatcher {
private static readonly MATCH_THRESHOLD = 75;
private static readonly SCORE_SELECTOR = 50;
private static readonly SCORE_NAME_ATTR = 25;
private static readonly SCORE_PLACEHOLDER = 15;
private static readonly SCORE_NEIGHBOR_TEXT = 10;
/**
* 根据指纹查找目标元素
* @param fingerprint - 表单字段指纹
* @returns 匹配结果(包含元素和得分)
*/
public static findTargetElement(fingerprint: FormMapEntry['fingerprint']): MatchResult {
const candidates: Array<{ element: HTMLElement; score: number }> = [];
// 1. 首先尝试精确选择器匹配
if (fingerprint.selector) {
const exactMatch = document.querySelector<HTMLElement>(fingerprint.selector);
if (exactMatch) {
const score = this.calculateScore(exactMatch, fingerprint);
candidates.push({ element: exactMatch, score });
}
}
// 2. 收集所有可能的候选元素
const potentialElements = this.collectPotentialElements(fingerprint);
for (const element of potentialElements) {
const score = this.calculateScore(element, fingerprint);
if (score > 0) {
candidates.push({ element, score });
}
}
// 3. 找到最高分的候选
if (candidates.length === 0) {
return { element: null, score: 0 };
}
const bestMatch = candidates.reduce((prev, curr) => (curr.score > prev.score ? curr : prev));
return bestMatch.score >= this.MATCH_THRESHOLD
? { element: bestMatch.element, score: bestMatch.score }
: { element: null, score: bestMatch.score };
}
/**
* 计算元素匹配得分
*/
private static calculateScore(
element: HTMLElement,
fingerprint: FormMapEntry['fingerprint'],
): number {
let score = 0;
// 选择器精确匹配
if (fingerprint.selector) {
const matched = document.querySelector(fingerprint.selector);
if (matched === element) {
score += this.SCORE_SELECTOR;
}
}
// name 或 id 属性匹配
if (fingerprint.name_attr) {
const elementName = element.getAttribute('name') || '';
const elementId = element.getAttribute('id') || '';
if (elementName === fingerprint.name_attr || elementId === fingerprint.name_attr) {
score += this.SCORE_NAME_ATTR;
}
}
// placeholder 匹配
if (fingerprint.placeholder) {
const elementPlaceholder =
'placeholder' in element && (element as HTMLInputElement).placeholder;
if (elementPlaceholder === fingerprint.placeholder) {
score += this.SCORE_PLACEHOLDER;
}
}
// 邻近文本(label)匹配
if (fingerprint.name_attr || fingerprint.placeholder) {
const neighborText = this.getNeighborText(element);
const searchText = fingerprint.name_attr || fingerprint.placeholder || '';
if (neighborText.includes(searchText)) {
score += this.SCORE_NEIGHBOR_TEXT;
}
}
return score;
}
/**
* 收集潜在的候选元素
*/
private static collectPotentialElements(
_fingerprint: FormMapEntry['fingerprint'],
): HTMLElement[] {
const elements: HTMLElement[] = [];
// 获取所有表单元素
const selectors = [
'input:not([type="hidden"])',
'textarea',
'select',
'[contenteditable="true"]',
];
for (const selector of selectors) {
const found = document.querySelectorAll<HTMLElement>(selector);
found.forEach((el) => {
if (this.isVisibleElement(el)) {
elements.push(el);
}
});
}
return elements;
}
/**
* 获取元素附近的文本内容
*/
private static getNeighborText(element: HTMLElement): string {
const texts: string[] = [];
// 查找关联的 label
const id = element.getAttribute('id');
if (id) {
const label = document.querySelector(`label[for="${id}"]`);
if (label) {
texts.push(label.textContent || '');
}
}
// 查找父级 label
const parentLabel = element.closest('label');
if (parentLabel) {
texts.push(parentLabel.textContent || '');
}
// 查找相邻元素的文本
const prevSibling = element.previousElementSibling;
const nextSibling = element.nextElementSibling;
if (prevSibling) {
texts.push(prevSibling.textContent || '');
}
if (nextSibling) {
texts.push(nextSibling.textContent || '');
}
// 查找父级内的文本节点
const parent = element.parentElement;
if (parent) {
const textNodes = parent.querySelectorAll('span, div, p');
textNodes.forEach((node) => {
texts.push(node.textContent || '');
});
}
return texts.join(' ').toLowerCase();
}
/**
* 检查元素是否可见
*/
private static isVisibleElement(element: HTMLElement): boolean {
const rect = element.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return false;
const style = window.getComputedStyle(element);
return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0';
}
}
/**
* 智能注入引擎
* 突破 React/Vue 等现代框架的表单状态绑定
*/
export class SmartInjectionEngine {
/**
* 注入数据到目标元素
* @param element - 目标 DOM 元素
* @param entry - 表单映射条目
* @returns 注入结果
*/
public static inject(element: HTMLElement, entry: FormMapEntry, mockValue: string): InjectResult {
try {
const { action_logic } = entry;
switch (action_logic.type) {
case 'text':
this.injectText(element as HTMLInputElement | HTMLTextAreaElement, mockValue);
break;
case 'select':
this.injectSelect(element as HTMLSelectElement, action_logic);
break;
case 'checkbox':
this.injectCheckbox(element as HTMLInputElement, action_logic);
break;
default:
this.injectText(element as HTMLInputElement | HTMLTextAreaElement, mockValue);
}
return { success: true, entry };
} catch (error) {
return {
success: false,
entry,
error: error instanceof Error ? error.message : '注入失败',
};
}
}
/**
* 注入文本类输入框
*/
private static injectText(element: HTMLInputElement | HTMLTextAreaElement, value: string): void {
// 获取原生 setter
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
element instanceof HTMLInputElement
? window.HTMLInputElement.prototype
: window.HTMLTextAreaElement.prototype,
'value',
)?.set;
if (nativeInputValueSetter) {
nativeInputValueSetter.call(element, value);
} else {
element.value = value;
}
// 连续触发事件
element.dispatchEvent(new Event('focus', { bubbles: true }));
element.dispatchEvent(new Event('input', { bubbles: true }));
element.dispatchEvent(new Event('change', { bubbles: true }));
element.dispatchEvent(new Event('blur', { bubbles: true }));
}
/**
* 注入下拉框
*/
private static injectSelect(
element: HTMLSelectElement,
actionLogic: FormMapEntry['action_logic'],
): void {
if (actionLogic.strategy === 'random') {
// 随机选择
const options = Array.from(element.options).filter((opt) => !opt.disabled);
if (options.length > 0) {
const randomIndex = Math.floor(Math.random() * options.length);
element.selectedIndex = randomIndex;
}
} else {
// 使用固定值
const value = actionLogic.value;
const matchingOption = Array.from(element.options).find(
(opt) => opt.value === value || opt.text === value,
);
if (matchingOption) {
element.value = matchingOption.value;
} else if (element.options.length > 0) {
element.selectedIndex = 0;
}
}
element.dispatchEvent(new Event('change', { bubbles: true }));
}
/**
* 注入复选框/单选框
*/
private static injectCheckbox(
element: HTMLInputElement,
actionLogic: FormMapEntry['action_logic'],
): void {
if (actionLogic.strategy === 'random') {
// 随机选择
const isChecked = Math.random() > 0.5;
if (element.type === 'checkbox') {
element.checked = isChecked;
} else if (element.type === 'radio') {
// 对于单选框,找到同 name 的所有选项并随机选择一个
const radioGroup = document.querySelectorAll<HTMLInputElement>(
`input[type="radio"][name="${element.name}"]`,
);
if (radioGroup.length > 0) {
const randomIndex = Math.floor(Math.random() * radioGroup.length);
radioGroup[randomIndex].click();
}
}
} else {
// 使用固定值
const shouldCheck = actionLogic.value === 'true' || actionLogic.value === '1';
element.checked = shouldCheck;
if (shouldCheck) {
element.click();
}
}
element.dispatchEvent(new Event('change', { bubbles: true }));
}
}
/**
* Mock 数据生成器
*/
export class MockDataGenerator {
/**
* 根据策略生成随机数据
*/
public static generate(actionLogic: FormMapEntry['action_logic'], entry: FormMapEntry): string {
const { strategy, value, type } = actionLogic;
if (strategy === 'fixed') {
return value;
}
// 根据字段类型和策略生成数据
switch (type) {
case 'text':
return this.generateText(entry);
case 'select':
return this.generateSelectValue();
case 'checkbox':
return this.generateBoolean();
default:
return this.generateText(entry);
}
}
/**
* 生成文本数据
*/
private static generateText(entry: FormMapEntry): string {
const { fingerprint, action_logic } = entry;
const { strategy, value: pattern } = action_logic;
// 根据模式生成数据
if (pattern) {
return this.generateByPattern(pattern);
}
// 根据指纹特征推断数据类型
const name = fingerprint.name_attr.toLowerCase();
const placeholder = fingerprint.placeholder.toLowerCase();
if (name.includes('phone') || placeholder.includes('phone')) {
return this.generatePhoneNumber();
}
if (name.includes('email') || placeholder.includes('email')) {
return this.generateEmail();
}
if (name.includes('name') || placeholder.includes('name')) {
return this.generateName();
}
if (name.includes('id') || name.includes('card')) {
return this.generateIdCard();
}
if (name.includes('date') || placeholder.includes('date')) {
return this.generateDate();
}
if (name.includes('number') || placeholder.includes('number')) {
return this.generateNumber();
}
// 默认生成随机文本
return strategy === 'random' ? this.generateRandomText() : '测试数据';
}
/**
* 根据模式生成数据
*/
private static generateByPattern(pattern: string): string {
if (pattern.includes('phone') || pattern.includes('mobile')) {
return this.generatePhoneNumber();
}
if (pattern.includes('email')) {
return this.generateEmail();
}
if (pattern.includes('name')) {
return this.generateName();
}
if (pattern.includes('date')) {
return this.generateDate();
}
if (pattern.includes('idcard') || pattern.includes('身份证')) {
return this.generateIdCard();
}
if (/^\d+$/.test(pattern)) {
return this.generateNumber(pattern.length);
}
return pattern;
}
/**
* 生成手机号
*/
private static generatePhoneNumber(): string {
const prefix = '1' + ['3', '4', '5', '6', '7', '8', '9'][Math.floor(Math.random() * 7)];
const suffix = Math.floor(Math.random() * 1000000000)
.toString()
.padStart(9, '0');
return prefix + suffix;
}
/**
* 生成邮箱
*/
private static generateEmail(): string {
const names = ['test', 'user', 'admin', 'guest', 'demo'];
const domains = ['example.com', 'test.com', 'gmail.com', 'outlook.com'];
const name = names[Math.floor(Math.random() * names.length)];
const domain = domains[Math.floor(Math.random() * domains.length)];
const num = Math.floor(Math.random() * 1000);
return `${name}${num}@${domain}`;
}
/**
* 生成姓名
*/
private static generateName(): string {
const surnames = ['张', '李', '王', '刘', '陈', '杨', '赵', '黄'];
const givenNames = ['伟', '芳', '强', '英', '华', '建', '明', '娜'];
return (
surnames[Math.floor(Math.random() * surnames.length)] +
givenNames[Math.floor(Math.random() * givenNames.length)]
);
}
/**
* 生成身份证号
*/
private static generateIdCard(): string {
const areaCodes = ['110101', '310101', '440101', '120101', '320101'];
const areaCode = areaCodes[Math.floor(Math.random() * areaCodes.length)];
const year = (1980 + Math.floor(Math.random() * 30)).toString();
const month = String(1 + Math.floor(Math.random() * 12)).padStart(2, '0');
const day = String(1 + Math.floor(Math.random() * 28)).padStart(2, '0');
const random = Math.floor(Math.random() * 10000)
.toString()
.padStart(4, '0');
return areaCode + year + month + day + random;
}
/**
* 生成日期
*/
private static generateDate(): string {
const date = new Date();
date.setDate(date.getDate() - Math.floor(Math.random() * 365));
return date.toISOString().split('T')[0];
}
/**
* 生成数字
*/
private static generateNumber(length: number = 6): string {
return Math.floor(Math.random() * Math.pow(10, length))
.toString()
.padStart(length, '0');
}
/**
* 生成随机文本
*/
private static generateRandomText(): string {
const texts = ['测试内容', '示例文本', 'Lorem ipsum', '随机数据', 'Sample Text'];
return texts[Math.floor(Math.random() * texts.length)];
}
/**
* 生成下拉框值
*/
private static generateSelectValue(): string {
return '选项' + (Math.floor(Math.random() * 5) + 1);
}
/**
* 生成布尔值
*/
private static generateBoolean(): string {
return Math.random() > 0.5 ? 'true' : 'false';
}
}
/**
* 视觉反馈渲染器
*/
export class FeedbackRenderer {
private static readonly SUCCESS_COLOR = '#32CD32';
private static readonly ERROR_COLOR = '#FF4444';
private static readonly HIGHLIGHT_DURATION = 3000;
/**
* 渲染成功反馈
*/
public static renderSuccess(element: HTMLElement): void {
this.applyHighlight(element, this.SUCCESS_COLOR);
}
/**
* 渲染失败反馈
*/
public static renderError(element: HTMLElement | null): void {
if (!element) return;
this.applyHighlight(element, this.ERROR_COLOR);
}
/**
* 应用高亮样式
*/
private static applyHighlight(element: HTMLElement, color: string): void {
// 保存原始样式
const originalStyle = element.getAttribute('style') || '';
element.setAttribute('data-original-style', originalStyle);
// 应用高亮
element.style.outline = `3px solid ${color}`;
element.style.outlineOffset = '2px';
element.style.transition = 'outline 0.3s ease';
// 自动移除高亮
setTimeout(() => {
const savedStyle = element.getAttribute('data-original-style');
if (savedStyle) {
element.setAttribute('style', savedStyle);
element.removeAttribute('data-original-style');
} else {
element.style.outline = '';
element.style.outlineOffset = '';
}
}, this.HIGHLIGHT_DURATION);
}
/**
* 清除所有高亮
*/
public static clearAllHighlights(): void {
const highlightedElements = document.querySelectorAll('[data-original-style]');
highlightedElements.forEach((element) => {
const savedStyle = element.getAttribute('data-original-style');
if (savedStyle) {
element.setAttribute('style', savedStyle);
element.removeAttribute('data-original-style');
}
});
}
}
+3
View File
@@ -43,6 +43,7 @@ export interface MessagePayload {
includeHidden?: boolean;
fieldId?: string;
fieldIds?: string[];
data?: unknown;
}
/**
@@ -55,6 +56,8 @@ export interface MessageResponse {
totalCount?: number;
validCount?: number;
hasModal?: boolean;
results?: unknown[];
error?: string;
}
/**
+54
View File
@@ -0,0 +1,54 @@
import jsQR from 'jsqr';
export interface QrCodeParseResult {
success: boolean;
data?: string;
error?: string;
}
export async function parseQrCodeFromFile(
file: File,
timeout: number = 10000,
): Promise<QrCodeParseResult> {
try {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) {
return { success: false, error: '无法创建 canvas 上下文' };
}
const image = new Image();
image.src = URL.createObjectURL(file);
await new Promise<void>((resolve, reject) => {
const timeoutId = setTimeout(() => {
reject(new Error('图片加载超时'));
}, timeout);
image.onload = () => {
clearTimeout(timeoutId);
canvas.width = image.width;
canvas.height = image.height;
ctx.drawImage(image, 0, 0);
resolve();
};
image.onerror = () => {
clearTimeout(timeoutId);
reject(new Error('图片加载失败'));
};
});
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const code = jsQR(imageData.data, imageData.width, imageData.height);
if (code) {
return { success: true, data: code.data };
} else {
return { success: false, error: '未检测到二维码' };
}
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : '解析失败' };
}
}