Refactor and enhance form recognition, mapping, and UI components (#18)

* docs: 添加组件文档注释和类型导入

refactor: 统一使用 SnackbarOptions 类型
style: 优化导入语句顺序和格式

* refactor: 简化假数据生成器中的faker导入和使用

Co-authored-by: Copilot <copilot@github.com>

* feat(form-recognizer): 增强表单识别功能并优化UI交互

- 新增字段类型偏好设置功能,支持按域名保存字段类型
- 重构FieldList组件,改进字段选择和类型修改体验
- 添加字段定位闪烁功能,便于在页面上快速找到对应字段
- 优化表单填充逻辑,支持单个字段覆盖默认填充模式
- 移除独立的侧边栏页面,统一使用主页面组件
- 改进useStorageState钩子,增加加载状态管理和防抖处理

* refactor: 移除未使用的组件文件

* refactor(页面头部): 提取通用 PageHeader 组件并替换各页面头部实现

重构各页面头部为统一的 PageHeader 组件,提高代码复用性和维护性

* style(组件): 调整自动刷新开关和存储选项网格的样式

优化自动刷新开关的文本内边距,重构存储选项网格的布局结构,调整间距和边框样式

* style(ui): 调整时间戳页面和结果视图的样式

- 为时区选择器添加圆角
- 优化结果视图的布局和对齐方式
- 调整结果项的内边距和文本样式

* ci(workflow): 移除Firefox测试以简化CI流程

仅保留Chrome浏览器的构建步骤,减少CI运行时间和资源消耗

* refactor(qrcode): 重构二维码解析功能并提取为独立模块

* style(页面样式): 统一页面头部图标颜色并优化表单样式

更新各页面头部组件的图标颜色配置,移除冗余的表单标签属性,优化输入框样式和按钮悬停效果

* feat(formMapping): 添加表单映射功能

- 创建 FormMappingPage 页面组件
- 实现表单扫描器 scanner.ts
- 实现高亮器 highlighter.ts
- 更新路由配置 routes.ts
- 更新内容脚本 content.ts
- 更新类型定义 storage.d.ts
- 添加 formMappingPageStyles 样式配置

* feat(表单映射): 添加配置导出功能及状态提示

添加配置导出为JSON文件的功能,包含导出按钮和错误处理。新增Snackbar组件用于显示导出成功和错误状态。优化页面布局结构,将状态提示移至全局容器外。

* feat(表单填充): 新增智能表单填充功能

添加智能表单填充功能,包括:
1. 新增表单填充页面和路由配置
2. 实现模糊匹配引擎和智能注入引擎
3. 添加Mock数据生成器和视觉反馈渲染器
4. 扩展消息接口支持填充操作

* ci: 精简触发CI的工作流分支

移除对develop分支及其变体的触发,仅保留main分支的触发

* test: 更新路由测试以匹配新增的路由数量

* test: 将测试文件中的描述和断言翻译为中文

* test(PageHeader): 添加组件测试用例验证渲染逻辑和样式

* refactor(theme): 重构主题样式并优化仪表盘卡片组件

将页面样式配置集中管理,移除各页面中硬编码的背景色
新增 DashboardCard 组件封装通用卡片逻辑
添加 dashboardCards 配置文件统一管理卡片数据

* refactor(Button): 统一按钮样式并移除重复样式定义

将按钮样式统一封装到 Button 组件中,移除各页面重复的样式定义
更新组件文档说明,提供更清晰的用法示例

* docs: 更新 README 文件中的目录结构说明

添加新组件和配置文件的说明,保持文档与代码同步

* refactor: 优化代码类型声明和UI布局

修复类型声明从any改为never以提高类型安全性
调整TimestampPage页面布局间距
移除DashboardPage中不必要的实时时钟状态
更新ESLint配置以使用推荐配置
添加选项页面打开的错误处理

* refactor(消息通信): 使用 @webext-core/messaging 重构消息处理逻辑

将原有的 chrome.runtime.onMessage 和 chrome.tabs.sendMessage 替换为类型安全的 @webext-core/messaging 实现
添加 ProtocolMap 类型定义确保消息类型安全
更新相关页面和后台脚本使用新的消息通信方式

* fix: 修复依赖项缺失导致的潜在问题

修复 useStorageState 中缺少 defaultValue 依赖的问题
重构 RouterProvider 的初始化逻辑,使用 useCallback 优化性能

* refactor: 重构表单识别和消息处理逻辑

将表单映射UI逻辑提取到独立文件
将消息处理器提取到独立文件
将表单识别页面逻辑提取到自定义hook
优化代码结构和可维护性

* feat(侧边栏): 添加侧边栏状态变化通知功能

- 在消息类型中新增侧边栏状态变化枚举和字段
- 侧边栏打开和关闭时发送状态通知
- 替换轮询检查方式为消息监听机制

* refactor: 优化高亮组件渲染逻辑并添加防抖处理

重构高亮组件渲染逻辑,使用 requestAnimationFrame 进行节流优化
在 useStorageCleaner 中添加防抖处理以避免频繁加载

* refactor: 统一组件导出方式为默认导出

* refactor: 重构样式系统并迁移至 MUI 主题

删除冗余的 CSS 文件,统一使用 MUI 主题管理样式
新增 useActiveTabDomain 和 useSidePanelState 自定义 Hook
优化各入口点的主题集成和布局处理

* docs: 更新项目文档以反映新增功能和技术栈细节

更新 AGENTS.md 文档,详细描述新增的功能模块(存储管理、URL 管理、二维码生成、表单工具套件等)和更新的技术栈信息

* feat: 添加错误边界组件以捕获子组件错误

在 popup、sidepanel 和 options 入口点添加 ErrorBoundary 组件,用于捕获并处理子组件中的 JavaScript 错误。当错误发生时,显示友好的错误界面并提供刷新功能。

* refactor(ui): 使用全局 snackbar 替换本地通知组件

重构表单页面中的通知系统,移除本地 Snackbar 和 Alert 组件,改用全局 GlobalSnackbar 组件统一管理通知
标准化主题配置中的 CSS 属性命名,移除带前缀的样式属性

* refactor(snackbar): 重构全局消息提示为 SnackbarProvider 组件

将原本分散在各页面的 GlobalSnackbar 组件重构为集中管理的 SnackbarProvider,通过 Context 提供统一的消息提示功能。主要变更包括:

1. 创建新的 SnackbarProvider 组件作为全局消息提示容器
2. 提供 useSnackbar hook 供子组件调用
3. 移除各页面中独立的 GlobalSnackbar 实例
4. 在 App 根组件中统一集成 SnackbarProvider
5. 优化消息提示样式和交互行为

* refactor(Snackbar): 重构消息提示组件并优化样式

重构 Snackbar 组件,将状态管理逻辑提取到 GlobalSnackbar 中复用
优化消息提示样式,调整阴影效果和最小尺寸
统一各页面使用 Snackbar 的方式,移除冗余配置

* refactor: 重构路由和功能配置系统

- 将路由配置和仪表盘卡片配置合并为统一的 features 配置
- 重构 RouterProvider 增加数据校验和本地快照功能
- 优化表单字段识别和填充逻辑,提取公共方法
- 更新相关组件和文档以适配新的配置系统
- 删除废弃的 dashboardCards 和 routes 配置文件
This commit is contained in:
LingandRX
2026-05-01 18:00:06 +08:00
committed by GitHub
parent 60537f6e2e
commit 8d36f21f1b
64 changed files with 2901 additions and 2197 deletions
+15 -61
View File
@@ -1,6 +1,6 @@
import '../.wxt/types/imports.d.ts';
import { browser } from 'wxt/browser';
import { type MessagePayload, type MessageResponse } from '@/utils/messages';
import { MessageAction, onMessage } from '@/utils/messages';
export default defineBackground(() => {
// 监听扩展图标点击事件,打开侧边栏
@@ -14,66 +14,20 @@ export default defineBackground(() => {
}
});
// 监听扩展安装或更新事件
browser.runtime.onInstalled.addListener(async ({ reason }) => {
if (reason === 'install') {
console.log('Extension installed for the first time');
} else if (reason === 'update') {
console.log('Extension updated to a new version');
// 使用 @webext-core/messaging 处理消息
onMessage(MessageAction.RELOAD_TAB, async (message) => {
const { tabId, delay = 0 } = message.data;
const executeReload = () => {
browser.tabs.reload(tabId);
};
if (delay > 0) {
setTimeout(executeReload, delay);
} else {
executeReload();
}
return { success: true, message: '刷新请求已接收' };
});
chrome.tabs.onUpdated.addListener((tabId, changeInfo) => {
if (changeInfo.status === 'complete') {
console.log('加载完成的 Tab ID:', tabId);
}
});
// 监听来自 popup/sidepanel 的消息
chrome.runtime.onMessage.addListener(
(message: MessagePayload, sender, sendResponse: (response: MessageResponse) => void) => {
try {
// 处理跨标签页的消息转发
if (sender.tab) {
// 从内容脚本或弹出页面发送的消息
console.log('收到来自标签页的消息:', message.action);
sendResponse({ success: true, message: '消息已收到' });
} else {
// 从扩展其他部分发送的消息
console.log('收到来自扩展的消息:', message.action);
// 处理刷新标签页请求
if (message.action === 'reloadTab' && message.tabId !== undefined) {
const tabId = message.tabId;
const delay = message.delay || 0;
const executeReload = () => {
browser.tabs
.reload(tabId)
.then(() => {
console.log('标签页刷新成功:', tabId);
})
.catch((err) => {
console.error('刷新标签页失败:', err.message);
});
};
if (delay > 0) {
setTimeout(executeReload, delay);
} else {
executeReload();
}
sendResponse({ success: true, message: '刷新请求已接收' });
return;
}
sendResponse({ success: true, message: '消息已收到' });
}
} catch (error) {
console.error('处理消息失败:', error);
sendResponse({ success: false, message: '处理消息失败' });
}
},
);
});
+6 -219
View File
@@ -1,228 +1,15 @@
import '../.wxt/types/imports.d.ts';
import {
fillAllFields,
clearAllFields,
fillSelectedFields,
scanFormFields,
highlightField,
unhighlightField,
flashField,
FillMode,
type FormFieldInfo,
} 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[] = [];
import { initFormMappingHelper } from '@/utils/formMapping/ui';
import { initMessageHandler } from './content/messageHandler';
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();
}
});
// 初始化表单映射助手逻辑 (UI, Picker, Highlighter)
initFormMappingHelper();
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) => {
try {
switch (message.action) {
case MessageAction.SCAN_FORM_FIELDS: {
const result = scanFormFields();
currentFields = result.fields;
sendResponse({
success: true,
fields: result.fields.map((f) => ({
id: f.id,
fieldType: f.fieldType,
label: f.label,
placeholder: f.placeholder,
name: f.name,
value: f.value,
isSelected: f.isSelected,
generatedValue: f.generatedValue,
})),
totalCount: result.totalCount,
validCount: result.validCount,
hasModal: !!result.modalContainer,
});
break;
}
case MessageAction.FILL_VALID_DATA:
fillAllFields(FillMode.VALID, message.includeHidden || false);
sendResponse({ success: true, message: '已填充有效数据' });
break;
case MessageAction.FILL_INVALID_DATA:
fillAllFields(FillMode.INVALID, message.includeHidden || false);
sendResponse({ success: true, message: '已填充无效数据' });
break;
case MessageAction.FILL_SELECTED_FIELDS: {
// 使用之前扫描时存储的字段,因为它们包含element属性
const incomingFields = message.fields || [];
const fieldsToFill = currentFields.map((field) => {
const incomingField = incomingFields.find((f) => f.id === field.id);
if (incomingField) {
return {
...field,
fieldType: incomingField.fieldType,
isSelected: incomingField.isSelected,
useInvalidData: (incomingField as { useInvalidData?: boolean }).useInvalidData,
};
}
return field;
});
const count = fillSelectedFields(fieldsToFill, message.mode || FillMode.VALID);
sendResponse({ success: true, message: `已填充 ${count} 个字段` });
break;
}
case MessageAction.CLEAR_ALL_FIELDS:
clearAllFields();
sendResponse({ success: true, message: '已清空所有字段' });
break;
case MessageAction.HIGHLIGHT_FIELD: {
const fieldId = message.fieldId;
const field = currentFields.find((f) => f.id === fieldId);
if (field) {
highlightField(field.element);
sendResponse({ success: true });
} else {
sendResponse({ success: false, message: '未找到字段' });
}
break;
}
case MessageAction.UNHIGHLIGHT_FIELD: {
const fieldId = message.fieldId;
const field = currentFields.find((f) => f.id === fieldId);
if (field) {
unhighlightField(field.element);
sendResponse({ success: true });
} else {
sendResponse({ success: false, message: '未找到字段' });
}
break;
}
case MessageAction.HIGHLIGHT_ALL_FIELDS: {
const fieldIds = message.fieldIds || [];
fieldIds.forEach((id) => {
const field = currentFields.find((f) => f.id === id);
if (field) {
highlightField(field.element);
}
});
sendResponse({ success: true });
break;
}
case MessageAction.UNHIGHLIGHT_ALL_FIELDS:
currentFields.forEach((field) => {
unhighlightField(field.element);
});
sendResponse({ success: true });
break;
case MessageAction.FLASH_FIELD: {
const fieldId = message.fieldId;
const field = currentFields.find((f) => f.id === fieldId);
if (field) {
flashField(field.element);
sendResponse({ success: true });
} else {
sendResponse({ success: false, message: '未找到字段' });
}
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: '未知操作' });
}
} catch (error) {
console.error('执行操作失败:', error);
sendResponse({ success: false, message: '执行操作失败' });
}
},
);
// 初始化消息处理器 (Scan, Fill, Clear, Highlight, Flash, Inject)
initMessageHandler();
},
});
+160
View File
@@ -0,0 +1,160 @@
import {
fillAllFields,
clearAllFields,
fillSelectedFields,
scanFormFields,
highlightField,
unhighlightField,
flashField,
FillMode,
type FormFieldInfo,
} from '@/utils/dummyDataGenerator';
import { MessageAction, onMessage } from '@/utils/messages';
import {
FuzzyMatcher,
SmartInjectionEngine,
FeedbackRenderer,
} from '@/utils/formMapping/smartInjector';
import { FormMapEntry } from '@/types/storage';
// 存储当前扫描到的字段列表,用于高亮联动
let currentFields: FormFieldInfo[] = [];
/**
* 初始化消息处理器
*/
export function initMessageHandler() {
onMessage(MessageAction.SCAN_FORM_FIELDS, async () => {
const result = scanFormFields();
currentFields = result.fields;
return {
success: true,
fields: result.fields.map((f) => ({
id: f.id,
fieldType: f.fieldType,
label: f.label,
placeholder: f.placeholder,
name: f.name,
value: f.value,
isSelected: f.isSelected,
generatedValue: f.generatedValue,
})),
totalCount: result.totalCount,
validCount: result.validCount,
hasModal: !!result.modalContainer,
};
});
onMessage(MessageAction.FILL_VALID_DATA, async (message) => {
fillAllFields(FillMode.VALID, message.data.includeHidden || false);
return { success: true, message: '已填充有效数据' };
});
onMessage(MessageAction.FILL_INVALID_DATA, async (message) => {
fillAllFields(FillMode.INVALID, message.data.includeHidden || false);
return { success: true, message: '已填充无效数据' };
});
onMessage(MessageAction.FILL_SELECTED_FIELDS, async (message) => {
const { fields: incomingFields, mode } = message.data;
const fieldsToFill = currentFields.map((field) => {
const incomingField = incomingFields.find((f) => f.id === field.id);
if (incomingField) {
return {
...field,
fieldType: incomingField.fieldType,
isSelected: incomingField.isSelected,
useInvalidData: incomingField.useInvalidData,
};
}
return field;
});
const count = fillSelectedFields(fieldsToFill, mode || FillMode.VALID);
return { success: true, message: `已填充 ${count} 个字段` };
});
onMessage(MessageAction.CLEAR_ALL_FIELDS, async () => {
clearAllFields();
return { success: true, message: '已清空所有字段' };
});
onMessage(MessageAction.HIGHLIGHT_FIELD, async (message) => {
const { fieldId } = message.data;
const field = currentFields.find((f) => f.id === fieldId);
if (field) {
highlightField(field.element);
return { success: true };
}
return { success: false, message: '未找到字段' };
});
onMessage(MessageAction.UNHIGHLIGHT_FIELD, async (message) => {
const { fieldId } = message.data;
const field = currentFields.find((f) => f.id === fieldId);
if (field) {
unhighlightField(field.element);
return { success: true };
}
return { success: false, message: '未找到字段' };
});
onMessage(MessageAction.HIGHLIGHT_ALL_FIELDS, async (message) => {
const { fieldIds } = message.data;
fieldIds.forEach((id) => {
const field = currentFields.find((f) => f.id === id);
if (field) {
highlightField(field.element);
}
});
return { success: true };
});
onMessage(MessageAction.UNHIGHLIGHT_ALL_FIELDS, async () => {
currentFields.forEach((field) => {
unhighlightField(field.element);
});
return { success: true };
});
onMessage(MessageAction.FLASH_FIELD, async (message) => {
const { fieldId } = message.data;
const field = currentFields.find((f) => f.id === fieldId);
if (field) {
flashField(field.element);
return { success: true };
}
return { success: false, message: '未找到字段' };
});
onMessage(MessageAction.FORM_INJECT, async (message) => {
try {
const injectData =
(message.data.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 };
}
});
return { success: true, results };
} catch (error) {
console.error('智能注入失败:', error);
return {
success: false,
error: error instanceof Error ? error.message : '注入失败',
};
}
});
}
-9
View File
@@ -1,9 +0,0 @@
.app {
min-height: 100vh;
background-color: #f5f5f5;
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell',
'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
+106 -91
View File
@@ -14,30 +14,35 @@ import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import type { PageType } from '@/types/storage';
import { storageUtil } from '@/utils/chromeStorage';
import { getRouteByKey, getDefaultPageOrder, getDefaultVisibleRoutes } from '@/config/routes';
import GlobalSnackbar, { useSnackbar } from '@/components/GlobalSnackbar';
import {
getFeatureByKey,
getDefaultPageOrder,
getDefaultVisibleFeatureKeys,
} from '@/config/features';
import GlobalSnackbar, { useSnackbarState } from '@/components/GlobalSnackbar';
import ErrorBoundary from '@/components/ErrorBoundary';
function App() {
export default function App() {
const [visiblePages, setVisiblePages] = useState<PageType[]>([]);
const [pageOrder, setPageOrder] = useState<PageType[]>([]);
const [isLoaded, setIsLoaded] = useState(false);
const { snackbarProps, showMessage } = useSnackbar();
const { snackbarProps, showMessage } = useSnackbarState();
useEffect(() => {
loadConfig();
loadConfig().catch(console.error);
}, []);
const loadConfig = async () => {
try {
const [savedVisible, savedOrder] = await Promise.all([
storageUtil.get('app/visiblePages', getDefaultVisibleRoutes()),
storageUtil.get('app/visiblePages', getDefaultVisibleFeatureKeys()),
storageUtil.get('app/pageOrder', getDefaultPageOrder()),
]);
setVisiblePages(savedVisible ?? getDefaultVisibleRoutes());
setVisiblePages(savedVisible ?? getDefaultVisibleFeatureKeys());
setPageOrder(savedOrder && savedOrder.length > 0 ? savedOrder : getDefaultPageOrder());
} catch (error) {
console.error('Failed to load config:', error);
setVisiblePages(getDefaultVisibleRoutes());
setVisiblePages(getDefaultVisibleFeatureKeys());
setPageOrder(getDefaultPageOrder());
} finally {
setIsLoaded(true);
@@ -61,8 +66,8 @@ function App() {
try {
await storageUtil.set('app/visiblePages', newPages);
setVisiblePages(newPages);
const route = getRouteByKey(page);
showToast(`${isCurrentlyVisible ? '隐藏' : '显示'} ${route?.label || page}`, 'success');
const feature = getFeatureByKey(page);
showToast(`${isCurrentlyVisible ? '隐藏' : '显示'} ${feature?.label || page}`, 'success');
} catch (error) {
console.error('Failed to save config:', error);
showToast('保存失败', 'warning');
@@ -88,8 +93,8 @@ function App() {
const handleRestoreDefaults = async () => {
try {
const { getDefaultVisibleRoutes } = await import('@/config/routes');
const defaults = getDefaultVisibleRoutes();
const { getDefaultVisibleFeatureKeys } = await import('@/config/features');
const defaults = getDefaultVisibleFeatureKeys();
const defaultOrder = getDefaultPageOrder();
await Promise.all([
@@ -121,92 +126,102 @@ function App() {
}
return (
<Box sx={{ p: 4, maxWidth: 600, mx: 'auto', minHeight: '100vh', bgcolor: 'grey.50' }}>
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" sx={{ mb: 4 }}>
<Button
variant="text"
size="small"
onClick={handleRestoreDefaults}
startIcon={<RefreshIcon sx={{ fontSize: 16 }} />}
sx={{ color: 'text.secondary', fontWeight: 600 }}
>
</Button>
</Stack>
<Box
className="app"
sx={{ p: 4, minHeight: '100vh', bgcolor: 'grey.50', display: 'block', overflowY: 'auto' }}
>
<ErrorBoundary>
<Box sx={{ maxWidth: 600, mx: 'auto' }}>
<Stack
direction="row"
justifyContent="space-between"
alignItems="flex-start"
sx={{ mb: 4 }}
>
<Button
variant="text"
size="small"
onClick={handleRestoreDefaults}
startIcon={<RefreshIcon sx={{ fontSize: 16 }} />}
sx={{ color: 'text.secondary', fontWeight: 600 }}
>
</Button>
</Stack>
<Paper
elevation={0}
sx={{
borderRadius: 4,
border: '1px solid',
borderColor: 'grey.200',
overflow: 'hidden',
bgcolor: 'background.paper',
}}
>
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
{pageOrder.map((key, index, array) => {
const route = getRouteByKey(key);
if (!route) return null;
<Paper
elevation={0}
sx={{
borderRadius: 4,
border: '1px solid',
borderColor: 'grey.200',
overflow: 'hidden',
bgcolor: 'background.paper',
}}
>
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
{pageOrder.map((key, index, array) => {
const feature = getFeatureByKey(key);
if (!feature) return null;
const isChecked = visiblePages.includes(key);
const isDisabled = isChecked && visiblePages.length === 1;
const isChecked = visiblePages.includes(key);
const isDisabled = isChecked && visiblePages.length === 1;
return (
<Box
key={key}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
p: 2.5,
borderBottom: index === array.length - 1 ? 'none' : '1px solid',
borderColor: 'grey.100',
transition: 'all 0.2s',
'&:hover': { bgcolor: 'grey.50' },
}}
>
<Box>
<Typography variant="body1" sx={{ fontWeight: 700, color: 'text.primary' }}>
{route.label}
</Typography>
<Typography variant="caption" color="text.secondary">
{isChecked ? '已在 Dashboard 启用' : '已在 Dashboard 隐藏'}
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<IconButton
size="small"
onClick={() => handleMove(index, 'up')}
disabled={index === 0}
sx={{ color: 'text.secondary' }}
return (
<Box
key={key}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
p: 2.5,
borderBottom: index === array.length - 1 ? 'none' : '1px solid',
borderColor: 'grey.100',
transition: 'all 0.2s',
'&:hover': { bgcolor: 'grey.50' },
}}
>
<KeyboardArrowUpIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
onClick={() => handleMove(index, 'down')}
disabled={index === array.length - 1}
sx={{ color: 'text.secondary' }}
>
<KeyboardArrowDownIcon fontSize="small" />
</IconButton>
<Switch
size="small"
checked={isChecked}
onChange={() => handlePageToggle(key)}
disabled={isDisabled}
/>
</Box>
</Box>
);
})}
<Box>
<Typography variant="body1" sx={{ fontWeight: 700, color: 'text.primary' }}>
{feature.label}
</Typography>
<Typography variant="caption" color="text.secondary">
{isChecked ? '已在 Dashboard 启用' : '已在 Dashboard 隐藏'}
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<IconButton
size="small"
onClick={() => handleMove(index, 'up')}
disabled={index === 0}
sx={{ color: 'text.secondary' }}
>
<KeyboardArrowUpIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
onClick={() => handleMove(index, 'down')}
disabled={index === array.length - 1}
sx={{ color: 'text.secondary' }}
>
<KeyboardArrowDownIcon fontSize="small" />
</IconButton>
<Switch
size="small"
checked={isChecked}
onChange={() => handlePageToggle(key)}
disabled={isDisabled}
/>
</Box>
</Box>
);
})}
</Box>
</Paper>
</Box>
</Paper>
</ErrorBoundary>
<GlobalSnackbar {...snackbarProps} />
</Box>
);
}
export default App;
+7 -3
View File
@@ -1,11 +1,15 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { ThemeProvider } from '@mui/material/styles';
import CssBaseline from '@mui/material/CssBaseline';
import theme from '@/config/theme';
import App from './App';
import './App.css';
import '@mui/material/styles';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
<ThemeProvider theme={theme}>
<CssBaseline />
<App />
</ThemeProvider>
</React.StrictMode>,
);
-103
View File
@@ -1,103 +0,0 @@
/* 极简滚动条变量 */
:root {
/* 统一圆角变量 */
--radius: 8px;
/* 统一背景颜色变量 */
--bg-color: #fafafa;
/* 统一文字颜色变量 */
--text-color: #333333;
/* 统一按钮颜色变量 */
--btn-bg: #e0e0e0;
/* 统一按钮文字颜色变量 */
--btn-text: #333333;
--border: 1px solid #e0e0e0;
/* 滚动条变量 */
--sb-width: 6px;
--sb-thumb-color: rgba(0, 0, 0, 0.1);
--sb-thumb-hover: rgba(0, 0, 0, 0.2);
--sb-track-color: transparent;
}
/* 默认 Popup 尺寸 */
html,
body,
#root {
margin: 0;
padding: 0;
width: 400px;
height: 600px;
overflow: hidden; /* 禁用外层滚动 */
}
/* 独立窗口全屏自适应 */
@media screen and (min-width: 401px), screen and (min-height: 601px) {
html,
body,
#root {
width: 100vw;
height: 100vh;
min-width: 400px;
min-height: 600px;
}
}
/* 全局极简滚动条定制 */
*::-webkit-scrollbar {
width: var(--sb-width);
}
*::-webkit-scrollbar-track {
background: var(--sb-track-color);
}
*::-webkit-scrollbar-thumb {
background: var(--sb-thumb-color);
border-radius: 10px;
background-clip: content-box;
border: 1px solid transparent;
}
*::-webkit-scrollbar-thumb:hover {
background: var(--sb-thumb-hover);
}
.app {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
box-sizing: border-box;
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
overflow: hidden; /* 内部由 flex 子项控制滚动 */
}
/* Animations */
@keyframes slideInRight {
from {
transform: translateX(30px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.page-transition-enter {
animation: slideInRight 0.3s cubic-bezier(0.25, 0.8, 0.25, 1) forwards;
}
.page-transition-dashboard {
animation: fadeIn 0.3s ease-out forwards;
}
+32 -13
View File
@@ -1,24 +1,43 @@
import './App.css';
import RouterProvider from '@/providers/RouterProvider';
import TopBar from '@/components/TopBar';
import RouterContainer from '@/components/RouterContainer';
import ErrorBoundary from '@/components/ErrorBoundary';
import { globalStyles } from '@/config/pageTheme';
import { SnackbarProvider } from '@/components/GlobalSnackbar';
import { Box } from '@mui/material';
function App() {
export default function App() {
// 打开Chrome扩展选项页面,需确保manifest中已配置options_page或options_ui
const handleOpenOptions = () => {
chrome.runtime.openOptionsPage();
chrome.runtime.openOptionsPage().catch((r) => console.error(r));
};
return (
<RouterProvider defaultRoute="dashboard" syncRoute={true} syncKey="app/popupRoute">
<div
className="app"
style={{ display: 'flex', flexDirection: 'column', height: '100vh', overflow: 'hidden' }}
>
<TopBar onOpenOptions={handleOpenOptions} />
<RouterContainer />
</div>
<RouterProvider syncKey="app/popupRoute">
<SnackbarProvider initialOptions={{ autoHideDuration: 1500000 }}>
<Box
className="app"
sx={{
display: 'flex',
flexDirection: 'column',
width: '400px',
height: '600px',
overflow: 'hidden',
backgroundColor: globalStyles.backgroundColor,
'@media screen and (min-width: 401px), screen and (min-height: 601px)': {
width: '100vw',
height: '100vh',
minWidth: '400px',
minHeight: '600px',
},
}}
>
<TopBar onOpenOptions={handleOpenOptions} />
<ErrorBoundary>
<RouterContainer />
</ErrorBoundary>
</Box>
</SnackbarProvider>
</RouterProvider>
);
}
export default App;
+7 -2
View File
@@ -1,10 +1,15 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { ThemeProvider } from '@mui/material/styles';
import CssBaseline from '@mui/material/CssBaseline';
import theme from '@/config/theme';
import App from './App.tsx';
import './style.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
<ThemeProvider theme={theme}>
<CssBaseline />
<App />
</ThemeProvider>
</React.StrictMode>,
);
+35 -129
View File
@@ -1,143 +1,49 @@
import { Box, Typography, Container } from '@mui/material';
import { Box } from '@mui/material';
import { useRouter } from '@/providers/RouterProvider';
import ToolCard from '@/components/ToolCard';
import AccessTimeIcon from '@mui/icons-material/AccessTime';
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 DashboardCard from '@/components/DashboardCard';
import { getFeatureByKey } from '@/config/features';
import type { PageType } from '@/types/storage';
import { useEffect, useState } from 'react';
import dayjs from '@/utils/dayjs';
import { useCallback } from 'react';
import { dashboardPageStyles } from '@/config/pageTheme';
export default function DashboardPage() {
const { navigateTo, visiblePages, pageOrder } = useRouter();
const [now, setNow] = useState(dayjs());
useEffect(() => {
const timer = setInterval(() => setNow(dayjs()), 1000);
return () => clearInterval(timer);
}, []);
const isVisible = (key: string) => visiblePages.includes(key as PageType);
const renderCard = (key: PageType) => {
switch (key) {
case 'timestamp':
return (
<ToolCard
key={key}
title="时间戳"
description="Unix 毫秒数转换与格式化"
colorCode="#2196f3"
icon={<AccessTimeIcon sx={{ fontSize: 20 }} />}
onClick={() => navigateTo('timestamp')}
cardBackgroundColor={dashboardPageStyles.cardBackgroundColor}
snapshot={
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography
sx={{
fontFamily: 'monospace',
fontWeight: 600,
color: dashboardPageStyles.primaryColor,
fontSize: '0.85rem',
}}
>
{now.valueOf()}
</Typography>
<Typography sx={{ fontSize: '0.7rem', color: 'text.secondary' }}>
{now.format('HH:mm:ss')}
</Typography>
</Box>
}
/>
);
case 'storageCleaner':
return (
<ToolCard
key={key}
title="存储管理"
description="清理缓存、Cookies 及本地存储"
colorCode="#ff9800"
icon={<StorageIcon sx={{ fontSize: 20 }} />}
onClick={() => navigateTo('storageCleaner')}
cardBackgroundColor={dashboardPageStyles.cardBackgroundColor}
/>
);
case 'openUrl':
return (
<ToolCard
key={key}
title="URL 工具"
description="快速打开 URL 或复制链接"
colorCode="#9c27b0"
icon={<LanguageIcon sx={{ fontSize: 20 }} />}
onClick={() => navigateTo('openUrl')}
cardBackgroundColor={dashboardPageStyles.cardBackgroundColor}
/>
);
case 'qrCode':
return (
<ToolCard
key={key}
title="二维码工具"
description="URL 转二维码与二维码解析"
colorCode="#4caf50"
icon={<QrCodeIcon sx={{ fontSize: 20 }} />}
onClick={() => navigateTo('qrCode')}
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
key={key}
title="表单识别"
description="识别标签页中的表单内容"
colorCode="#ff5722"
icon={<DescriptionIcon sx={{ fontSize: 20 }} />}
onClick={() => navigateTo('formRecognizer')}
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;
}
};
const handleCardClick = useCallback(
(page: PageType) => {
navigateTo(page);
},
[navigateTo],
);
return (
<Box sx={{ bgcolor: dashboardPageStyles.backgroundColor, minHeight: '100%', pb: 4 }}>
<Container maxWidth="sm" sx={{ py: 3, px: 2 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{pageOrder.map((key) => (isVisible(key) ? renderCard(key) : null))}
</Box>
</Container>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, p: 2 }}>
{pageOrder.map((key) => {
if (!isVisible(key)) return null;
const feature = getFeatureByKey(key);
if (!feature || !feature.icon || !feature.themeColor) return null;
// 适配 DashboardCard 组件,将 themeColor 映射到 colorCode
const cardConfig = {
title: feature.label,
description: feature.description,
colorCode: feature.themeColor,
icon: feature.icon,
};
return (
<DashboardCard
key={key}
config={cardConfig}
onClick={() => handleCardClick(key)}
cardBackgroundColor={dashboardPageStyles.cardBackgroundColor}
/>
);
})}
</Box>
);
}
+27 -47
View File
@@ -9,11 +9,7 @@ import {
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';
@@ -24,17 +20,18 @@ 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 { formMappingPageStyles } from '@/config/pageTheme.ts';
import { MockDataGenerator } from '@/utils/formMapping/smartInjector';
import { Button } from '@/components/Button';
import { FormInjectResult, MessageAction, sendMessage } from '@/utils/messages';
import { useSnackbar as useGlobalSnackbar } from '@/components/SnackbarProvider';
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 { showMessage } = useGlobalSnackbar({ autoHideDuration: 3000 });
const generatePreviewData = useCallback((items: FormMapEntry[]) => {
const preview = new Map<string, string>();
@@ -53,10 +50,10 @@ export default function FormFillPage() {
generatePreviewData(data || []);
};
loadEntries();
loadEntries().catch((r) => console.error(r));
const listener = (changes: { [key: string]: chrome.storage.StorageChange }, area: string) => {
if (area === 'local' && changes['active_form_map']) {
loadEntries();
loadEntries().catch((r) => console.error(r));
}
};
chrome.storage.onChanged.addListener(listener);
@@ -69,8 +66,7 @@ export default function FormFillPage() {
const injectAllFields = async () => {
if (entries.length === 0) {
setErrorMessage('没有可填充的字段');
setShowError(true);
showMessage('没有可填充的字段', { severity: 'error' });
return;
}
@@ -95,27 +91,24 @@ export default function FormFillPage() {
mockValue: previewData.get(entry.id) || '',
}));
// 执行注入
const result = await chrome.tabs.sendMessage(tabId, {
type: 'FORM_INJECT',
data: injectData,
});
// 执行注入 (使用 type-safe sendMessage)
const result = await sendMessage(MessageAction.FORM_INJECT, { data: injectData }, tabId);
if (result && result.success) {
result.results.forEach((r: { id: string; success: boolean }) => {
result.results?.forEach((r: FormInjectResult) => {
results.set(r.id, r.success);
});
setInjectResults(results);
setShowSuccess(true);
showMessage('填充成功!', { severity: 'success' });
} else {
throw new Error(result?.error || '注入失败');
throw new Error(result?.error || result?.message || '注入失败');
}
} catch (error) {
console.error('注入失败:', error);
setErrorMessage(
error instanceof Error ? error.message : '注入失败,请确保已在网页中打开表单',
);
setShowError(true);
showMessage(error instanceof Error ? error.message : '注入失败,请确保已在网页中打开表单');
showMessage(error instanceof Error ? error.message : '注入失败,请确保已在网页中打开表单', {
severity: 'error',
});
} finally {
setIsInjecting(false);
}
@@ -141,8 +134,8 @@ export default function FormFillPage() {
};
return (
<Box sx={{ bgcolor: globalStyles.backgroundColor, minHeight: '100%', pb: 3 }}>
<Container sx={{ py: 2, bgcolor: globalStyles.backgroundColor }}>
<Box>
<Container sx={{ py: 2 }}>
<PageHeader
title="智能表单填充"
subtitle="基于指纹识别的精准数据注入"
@@ -177,7 +170,6 @@ export default function FormFillPage() {
onClick={refreshPreview}
size="small"
startIcon={<RefreshIcon />}
sx={{ borderRadius: 3 }}
>
</Button>
@@ -188,9 +180,7 @@ export default function FormFillPage() {
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 ? '注入中...' : '开始填充'}
@@ -261,6 +251,14 @@ export default function FormFillPage() {
sx={{ mr: 2 }}
/>
<ListItemText
slotProps={{
primary: {
component: 'div',
},
secondary: {
component: 'div',
},
}}
primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<span style={{ fontWeight: 500 }}>{entry.label_display}</span>
@@ -361,24 +359,6 @@ export default function FormFillPage() {
)}
</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>
);
}
+11 -50
View File
@@ -9,9 +9,6 @@ import {
Switch,
Divider,
Paper,
alpha,
Snackbar,
Alert,
} from '@mui/material';
import DeleteIcon from '@mui/icons-material/Delete';
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
@@ -22,13 +19,12 @@ 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';
import { useSnackbar as useGlobalSnackbar } from '@/components/SnackbarProvider';
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);
const { showMessage } = useGlobalSnackbar({ autoHideDuration: 3000 });
useEffect(() => {
const loadData = async () => {
@@ -38,7 +34,7 @@ export default function FormMappingPage() {
setIsPicking(picking || false);
};
loadData();
loadData().catch((r) => console.error(r));
const listener = (changes: { [key: string]: chrome.storage.StorageChange }, area: string) => {
if (area === 'local') {
@@ -79,7 +75,7 @@ export default function FormMappingPage() {
const exportConfig = () => {
try {
if (entries.length === 0) {
setExportError('没有可导出的配置数据');
showMessage('没有可导出的配置数据', { severity: 'warning' });
return;
}
@@ -99,17 +95,19 @@ export default function FormMappingPage() {
document.body.removeChild(link);
URL.revokeObjectURL(url);
setShowExportSuccess(true);
showMessage('配置导出成功!', { severity: 'success' });
} catch (error) {
console.error('导出配置失败:', error);
setExportError(error instanceof Error ? error.message : '导出失败,请重试');
showMessage(error instanceof Error ? error.message : '导出失败,请重试', {
severity: 'error',
});
}
};
return (
<Box>
<Box sx={{ bgcolor: globalStyles.backgroundColor, minHeight: '100%', pb: 3 }}>
<Container sx={{ py: 2, bgcolor: globalStyles.backgroundColor }}>
<Box>
<Container sx={{ py: 2 }}>
<PageHeader
title="通用表单映射助手"
subtitle="智能识别表单指纹,自定义填充逻辑"
@@ -142,17 +140,6 @@ export default function FormMappingPage() {
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>
@@ -174,12 +161,7 @@ export default function FormMappingPage() {
<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 size="small" color="error" onClick={clearAll}>
</Button>
</Box>
@@ -273,7 +255,6 @@ export default function FormMappingPage() {
variant="outlined"
onClick={exportConfig}
startIcon={<FileDownloadIcon />}
sx={{ fontWeight: 700, fontSize: '0.75rem', borderRadius: 3 }}
>
</Button>
@@ -299,26 +280,6 @@ export default function FormMappingPage() {
</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>
);
}
+30 -387
View File
@@ -1,339 +1,39 @@
import { useState, useRef, useEffect } from 'react';
import {
Box,
Container,
Button,
CircularProgress,
FormControlLabel,
Switch,
alpha,
} from '@mui/material';
import { Box, Container, CircularProgress, FormControlLabel, Switch } from '@mui/material';
import Button from '@/components/Button';
import InputIcon from '@mui/icons-material/Input';
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import GlobalSnackbar, { useSnackbar } from '@/components/GlobalSnackbar';
import { dashboardPageStyles, formRecognizerPageStyles } from '@/config/pageTheme';
import { MessageAction, sendMessageToContent, injectContentScript } from '@/utils/messages';
import { FillMode } from '@/utils/dummyDataGenerator';
import { formRecognizerPageStyles } from '@/config/pageTheme';
import FieldList from '@/components/FieldList';
import { useStorageState } from '@/utils/useStorageState';
import { FieldTypePreferences } from '@/types/storage';
import PageHeader from '@/components/PageHeader';
import { useFormRecognizer } from './hooks/useFormRecognizer';
// 字段数据接口
interface FieldData {
id: string;
fieldType: string;
label: string | null;
placeholder: string;
name: string;
value: string;
isSelected: boolean;
generatedValue: string;
useInvalidData?: boolean;
}
const DEFAULT_FIELD_TYPE_PREFERENCES: FieldTypePreferences = {};
const FormRecognizerPage = () => {
const { snackbarProps, showMessage } = useSnackbar({ autoHideDuration: 1500 });
const [fillLoading, setFillLoading] = useState(false);
const [clearLoading, setClearLoading] = useState(false);
const [includeHidden, setIncludeHidden] = useState(false);
const isProcessingRef = useRef(false);
const [fields, setFields] = useState<FieldData[]>([]);
const [scanning, setScanning] = useState(false);
const [showFields, setShowFields] = useState(false);
const [hoveredFieldId, setHoveredFieldId] = useState<string | null>(null);
const [currentDomain, setCurrentDomain] = useState<string>('');
const [sidePanelOpen, setSidePanelOpen] = useState(false);
const [fieldTypePreferences, setFieldTypePreferences] = useStorageState(
'formRecognizer/fieldTypePreferences',
DEFAULT_FIELD_TYPE_PREFERENCES,
);
// 获取当前域名
useEffect(() => {
const getActiveTab = async () => {
try {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (tab?.url) {
const url = new URL(tab.url);
setCurrentDomain(url.hostname);
}
} catch (error) {
console.error('获取标签页信息失败:', error);
}
};
getActiveTab();
}, []);
// 检测侧边栏状态
useEffect(() => {
console.log('开始检测侧边栏状态');
const checkSidePanelState = async () => {
try {
// 检查 chrome.runtime.getContexts 是否可用
if (typeof chrome.runtime.getContexts === 'function') {
const contexts = await chrome.runtime.getContexts({
contextTypes: ['SIDE_PANEL'],
});
console.log('侧边栏上下文:', contexts);
setSidePanelOpen(contexts.length > 0);
} else {
console.log('chrome.runtime.getContexts 不可用');
setSidePanelOpen(false);
}
} catch (error) {
console.error('检测侧边栏状态失败:', error);
setSidePanelOpen(false);
}
};
// 初始检查
checkSidePanelState();
// 定期检查侧边栏状态(每 500ms)
const interval = setInterval(checkSidePanelState, 500);
console.log('侧边栏状态检测已启动');
return () => {
console.log('清理侧边栏状态检测');
clearInterval(interval);
};
}, []);
// 生成字段标识符
const getFieldIdentifier = (field: Pick<FieldData, 'label' | 'name' | 'placeholder'>): string => {
return field.label || field.name || field.placeholder || 'unknown';
};
// 应用保存的类型偏好
const applySavedPreferences = (fields: FieldData[]): FieldData[] => {
if (!currentDomain || !(fieldTypePreferences as FieldTypePreferences)[currentDomain]) {
return fields;
}
const prefs = (fieldTypePreferences as FieldTypePreferences)[currentDomain];
return fields.map((field) => {
const identifier = getFieldIdentifier(field);
if (prefs && prefs[identifier]) {
return { ...field, fieldType: prefs[identifier] };
}
return field;
});
};
// 保存类型偏好
const saveTypePreference = (field: FieldData, newType: string) => {
if (!currentDomain) return;
const identifier = getFieldIdentifier(field);
setFieldTypePreferences((prev) => {
const prevPrefs = prev as FieldTypePreferences;
const currentDomainPrefs = prevPrefs[currentDomain] || {};
return {
...prevPrefs,
[currentDomain]: {
...currentDomainPrefs,
[identifier]: newType,
},
} as FieldTypePreferences;
});
};
// 扫描表单字段
const handleScanFields = async () => {
setScanning(true);
try {
let response = await sendMessageToContent(MessageAction.SCAN_FORM_FIELDS);
if (!response.success && response.message && response.message.includes('无法连接')) {
showMessage('正在注入内容脚本...', { severity: 'info' });
const injected = await injectContentScript();
if (injected) {
response = await sendMessageToContent(MessageAction.SCAN_FORM_FIELDS);
}
}
if (response.success && response.fields) {
const fieldsWithPreferences = applySavedPreferences(response.fields as FieldData[]);
setFields(fieldsWithPreferences);
setShowFields(true);
showMessage(`扫描完成,发现 ${response.totalCount} 个可填充字段`, { severity: 'success' });
} else {
showMessage(response.message || '扫描失败', { severity: 'error' });
}
} catch (error) {
console.error('扫描失败:', error);
showMessage('扫描失败,请确保页面已加载', { severity: 'error' });
} finally {
setScanning(false);
}
};
// 更新字段类型
const handleFieldTypeChange = (fieldId: string, newType: string) => {
setFields((prev) =>
prev.map((field) => {
if (field.id === fieldId) {
const updatedField = {
...field,
fieldType: newType,
// 清空旧的 generatedValue,保持界面一致性
generatedValue: '',
};
saveTypePreference(field, newType);
return updatedField;
}
return field;
}),
);
};
// 切换单个字段的选中状态
const handleToggleFieldSelection = (fieldId: string) => {
setFields((prev) =>
prev.map((field) =>
field.id === fieldId ? { ...field, isSelected: !field.isSelected } : field,
),
);
};
// 全选/取消全选
const handleToggleAllFields = () => {
setFields((prev) => {
const allSelected = prev.every((f) => f.isSelected);
return prev.map((f) => ({ ...f, isSelected: !allSelected }));
});
};
// 定位字段(闪烁)
const handleLocateField = async (fieldId: string) => {
try {
const response = await sendMessageToContent(MessageAction.FLASH_FIELD, { fieldId });
if (!response.success) {
showMessage(response.message || '定位字段失败', { severity: 'error' });
}
} catch (error) {
console.error('定位字段失败:', error);
}
};
// 悬停高亮
const handleHoverField = async (fieldId: string | null) => {
setHoveredFieldId(fieldId);
try {
if (fieldId) {
await sendMessageToContent(MessageAction.HIGHLIGHT_FIELD, { fieldId });
} else {
await sendMessageToContent(MessageAction.UNHIGHLIGHT_ALL_FIELDS);
}
} catch (error) {
console.error('高亮字段失败:', error);
}
};
// 填充选中字段
const handleFillSelectedFields = async () => {
if (isProcessingRef.current) {
showMessage('操作进行中,请稍候...', { severity: 'warning' });
return;
}
const selectedCount = fields.filter((f) => f.isSelected).length;
if (selectedCount === 0) {
showMessage('请先选择要填充的字段', { severity: 'warning' });
return;
}
setFillLoading(true);
isProcessingRef.current = true;
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const messageFields = fields as any;
let response = await sendMessageToContent(MessageAction.FILL_SELECTED_FIELDS, {
fields: messageFields,
mode: FillMode.VALID,
includeHidden,
});
if (!response.success && response.message && response.message.includes('无法连接')) {
showMessage('正在注入内容脚本...', { severity: 'info' });
const injected = await injectContentScript();
if (injected) {
response = await sendMessageToContent(MessageAction.FILL_SELECTED_FIELDS, {
fields: messageFields,
mode: FillMode.VALID,
includeHidden,
});
}
}
if (response.success) {
showMessage(response.message || '填充成功', { severity: 'success' });
} else {
showMessage(response.message || '填充失败', { severity: 'error' });
}
} catch (error) {
console.error('填充失败:', error);
const errorMessage = error instanceof Error ? error.message : '未知错误';
showMessage(`填充失败:${errorMessage},请确保当前页面已加载完成`, { severity: 'error' });
} finally {
setFillLoading(false);
isProcessingRef.current = false;
}
};
// 清空所有字段
const handleClearAllFields = async () => {
if (isProcessingRef.current) {
showMessage('操作进行中,请稍候...', { severity: 'warning' });
return;
}
setClearLoading(true);
isProcessingRef.current = true;
try {
let response = await sendMessageToContent(MessageAction.CLEAR_ALL_FIELDS);
if (!response.success && response.message && response.message.includes('无法连接')) {
showMessage('正在注入内容脚本...', { severity: 'info' });
const injected = await injectContentScript();
if (injected) {
response = await sendMessageToContent(MessageAction.CLEAR_ALL_FIELDS);
}
}
if (response.success) {
showMessage(response.message || '清空成功', { severity: 'success' });
} else {
showMessage(response.message || '清空失败', { severity: 'error' });
}
} catch (error) {
console.error('清空失败:', error);
const errorMessage = error instanceof Error ? error.message : '未知错误';
showMessage(`清空失败:${errorMessage},请确保当前页面已加载完成`, { severity: 'error' });
} finally {
setClearLoading(false);
isProcessingRef.current = false;
}
};
// 打开侧边栏
const handleOpenSidePanel = async () => {
try {
await chrome.sidePanel.open({ windowId: chrome.windows.WINDOW_ID_CURRENT });
showMessage('侧边栏已打开', { severity: 'success' });
} catch (error) {
console.error('打开侧边栏失败:', error);
showMessage('打开侧边栏失败', { severity: 'error' });
}
};
const selectedCount = fields.filter((f) => f.isSelected).length;
export default function FormRecognizerPage() {
const {
fillLoading,
clearLoading,
includeHidden,
setIncludeHidden,
fields,
scanning,
showFields,
setShowFields,
hoveredFieldId,
sidePanelOpen,
handleScanFields,
handleFieldTypeChange,
handleToggleFieldSelection,
handleToggleAllFields,
handleLocateField,
handleHoverField,
handleFillSelectedFields,
handleClearAllFields,
handleOpenSidePanel,
selectedCount,
} = useFormRecognizer();
return (
<Box sx={{ bgcolor: dashboardPageStyles.backgroundColor, minHeight: '100%', pb: 4 }}>
<Container maxWidth="sm" sx={{ py: 3, px: 2, bgcolor: '#f5f5f5' }}>
<Box>
<Container maxWidth="sm" sx={{ py: 3, px: 2 }}>
{/* Header */}
<PageHeader
title="表单测试数据填充器"
@@ -366,16 +66,6 @@ const FormRecognizerPage = () => {
onClick={handleScanFields}
disabled={scanning}
fullWidth
sx={{
...formRecognizerPageStyles.buttonStyle,
mb: 2,
borderColor: formRecognizerPageStyles.validColor,
color: formRecognizerPageStyles.validColor,
'&:hover': {
borderColor: formRecognizerPageStyles.validDark,
bgcolor: 'rgba(76, 175, 80, 0.05)',
},
}}
startIcon={scanning ? <CircularProgress size={16} color="inherit" /> : <InputIcon />}
>
{scanning ? '扫描中...' : '扫描表单字段'}
@@ -403,27 +93,6 @@ const FormRecognizerPage = () => {
onClick={handleFillSelectedFields}
disabled={fillLoading || selectedCount === 0}
fullWidth
sx={{
py: 1.6,
borderRadius: 4,
bgcolor: formRecognizerPageStyles.validColor,
fontWeight: 700,
fontSize: '1rem',
textTransform: 'none',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
'&:hover': {
bgcolor: formRecognizerPageStyles.validDark,
boxShadow: `0 8px 24px ${alpha(formRecognizerPageStyles.validColor, 0.3)}`,
transform: 'translateY(-1px)',
},
'&:disabled': {
bgcolor: '#e0e0e0',
color: '#9e9e9e',
},
'&:active': {
transform: 'translateY(0)',
},
}}
>
</Button>
@@ -435,28 +104,6 @@ const FormRecognizerPage = () => {
onClick={handleClearAllFields}
disabled={clearLoading}
fullWidth
sx={{
py: 1.6,
borderRadius: 4,
borderColor: formRecognizerPageStyles.clearColor,
color: formRecognizerPageStyles.clearColor,
fontWeight: 600,
fontSize: '1rem',
textTransform: 'none',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
'&:hover': {
borderColor: formRecognizerPageStyles.clearDark,
bgcolor: formRecognizerPageStyles.clearBg,
transform: 'translateY(-1px)',
},
'&:disabled': {
borderColor: '#e0e0e0',
color: '#9e9e9e',
},
'&:active': {
transform: 'translateY(0)',
},
}}
>
</Button>
@@ -474,11 +121,7 @@ const FormRecognizerPage = () => {
label="包含隐藏字段"
/>
</Box>
<GlobalSnackbar {...snackbarProps} />
</Container>
</Box>
);
};
export default FormRecognizerPage;
}
+5 -6
View File
@@ -1,16 +1,16 @@
import { Box, Typography, Container } from '@mui/material';
import LanguageIcon from '@mui/icons-material/Language';
import GlobalSnackbar, { useSnackbar } from '@/components/GlobalSnackbar';
import { useSnackbar as useGlobalSnackbar } from '@/components/SnackbarProvider';
import UrlEntryForm from '@/components/UrlEntryForm';
import UrlEntryList from '@/components/UrlEntryList';
import { useUrlPreferences } from '@/utils/useUrlPreferences';
import type { OpenUrlEntry } from '@/types/storage';
import { dashboardPageStyles, openUrlPageStyles } from '@/config/pageTheme';
import { openUrlPageStyles } from '@/config/pageTheme';
import PageHeader from '@/components/PageHeader';
export default function OpenUrlPage() {
const { entries, setEntries, isLoaded } = useUrlPreferences();
const { snackbarProps, showMessage } = useSnackbar();
const { showMessage } = useGlobalSnackbar();
const handleAddEntry = (entry: OpenUrlEntry) => {
setEntries([...entries, entry]);
@@ -25,7 +25,7 @@ export default function OpenUrlPage() {
if (!isLoaded) {
return (
<Box sx={{ bgcolor: dashboardPageStyles.backgroundColor, minHeight: '100%', pb: 3 }}>
<Box sx={{ minHeight: '100%', pb: 3 }}>
<Container sx={{ py: 2 }}>
<Typography>...</Typography>
</Container>
@@ -34,7 +34,7 @@ export default function OpenUrlPage() {
}
return (
<Box sx={{ bgcolor: dashboardPageStyles.backgroundColor, minHeight: '100%', pb: 3 }}>
<Box>
<Container sx={{ py: 2 }}>
{/* Header */}
<PageHeader
@@ -64,7 +64,6 @@ export default function OpenUrlPage() {
/>
</Box>
</Container>
<GlobalSnackbar {...snackbarProps} />
</Box>
);
}
@@ -1,7 +1,6 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { Box, Typography, CircularProgress, Alert } from '@mui/material';
import { storageUtil } from '@/utils/chromeStorage';
import { dashboardPageStyles } from '@/config/pageTheme';
// 只允许 HTTP/HTTPS 协议,阻止危险协议
const ALLOWED_PROTOCOLS = ['http:', 'https:'];
@@ -112,7 +111,6 @@ export default function OpenUrlViewerPage() {
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
bgcolor: dashboardPageStyles.backgroundColor,
}}
>
{/* 加载状态指示器 */}
+7 -10
View File
@@ -1,14 +1,14 @@
import { Box, Stack, Container, CircularProgress } from '@mui/material';
import QrCodeIcon from '@mui/icons-material/QrCode';
import GlobalSnackbar, { useSnackbar } from '@/components/GlobalSnackbar';
import { useSnackbar as useGlobalSnackbar } from '@/components/SnackbarProvider';
import UrlToQrCodeSection from '@/components/UrlToQrCodeSection';
import QrCodeToUrlSection from '@/components/QrCodeToUrlSection';
import { useStorageState } from '@/utils/useStorageState';
import { dashboardPageStyles, qrCodePageStyles } from '@/config/pageTheme';
import { qrCodePageStyles } from '@/config/pageTheme';
import PageHeader from '@/components/PageHeader';
const QrCodePage = () => {
const { snackbarProps, showMessage } = useSnackbar({ autoHideDuration: 1500 });
export default function QrCodePage() {
const { showMessage } = useGlobalSnackbar();
// 使用自定义钩子管理展开状态
const [urlExpanded, setUrlExpanded, urlInitialized] = useStorageState('qrCode/urlExpanded', true);
@@ -33,8 +33,8 @@ const QrCodePage = () => {
}
return (
<Box sx={{ minHeight: '100%', pb: 3 }}>
<Container sx={{ py: 2, maxWidth: 400, bgcolor: dashboardPageStyles.backgroundColor }}>
<Box>
<Container sx={{ py: 2, maxWidth: 400 }}>
<PageHeader
title="二维码工具"
subtitle="生成和解析二维码"
@@ -56,10 +56,7 @@ const QrCodePage = () => {
showMessage={showMessage}
/>
</Stack>
<GlobalSnackbar {...snackbarProps} />
</Container>
</Box>
);
};
export default QrCodePage;
}
+3 -15
View File
@@ -1,6 +1,6 @@
import { Box, Container, CircularProgress } from '@mui/material';
import Button from '@/components/Button';
import GlobalSnackbar, { useSnackbar } from '@/components/GlobalSnackbar';
import { useSnackbar as useGlobalSnackbar } from '@/components/SnackbarProvider';
import StorageCleanerConfirm from '@/components/StorageCleanerConfirm';
import { storageCleanerPageStyles } from '@/config/pageTheme';
import { useStorageCleaner } from './useStorageCleaner';
@@ -11,7 +11,7 @@ import ErrorDisplay from './components/ErrorDisplay';
import CleaningResult from './components/CleaningResult';
export default function StorageCleanerPage() {
const { snackbarProps, showMessage } = useSnackbar();
const { showMessage } = useGlobalSnackbar();
const {
domain,
error,
@@ -45,7 +45,7 @@ export default function StorageCleanerPage() {
}
return (
<Box sx={{ bgcolor: '#f5f5f5', minHeight: '100%', pb: 2 }}>
<Box>
<Container sx={{ py: 2 }}>
<DomainHeader domain={domain} totalSize={totalSize} />
@@ -64,20 +64,9 @@ export default function StorageCleanerPage() {
variant="contained"
onClick={() => setShowConfirm(true)}
sx={{
py: 1.3,
borderRadius: 4,
bgcolor: storageCleanerPageStyles.warningColor,
fontWeight: 800,
fontSize: '0.85rem',
boxShadow: '0 4px 12px rgba(255, 152, 0, 0.25)',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
'&:hover': {
bgcolor: storageCleanerPageStyles.warningDark,
boxShadow: '0 8px 20px rgba(255, 152, 0, 0.35)',
transform: 'translateY(-2px)',
},
'&:active': {
transform: 'translateY(0)',
},
}}
disabled={loading}
@@ -95,7 +84,6 @@ export default function StorageCleanerPage() {
onConfirm={handleClean}
options={options}
/>
<GlobalSnackbar {...snackbarProps} />
</Box>
);
}
+8 -27
View File
@@ -1,15 +1,15 @@
import { TextField, Select, MenuItem, Stack, Box, Container, alpha } from '@mui/material';
import GlobalSnackbar, { useSnackbar } from '@/components/GlobalSnackbar';
import { TextField, Select, MenuItem, Stack, Box, Container } from '@mui/material';
import { useSnackbar } from '@/components/SnackbarProvider';
import AccessTimeIcon from '@mui/icons-material/AccessTime';
import Button from '@/components/Button';
import PageHeader from '@/components/PageHeader';
import { ZONES, globalStyles, timestampPageStyles } from '@/config/pageTheme';
import { ZONES, timestampPageStyles } from '@/config/pageTheme';
import LiveClock from './components/LiveClock';
import ResultView from './components/ResultView';
import { useTimestampConverter } from './hooks/useTimestampConverter';
export default function TimestampPage() {
const { snackbarProps, showMessage } = useSnackbar({ autoHideDuration: 1500 });
const { showMessage } = useSnackbar();
const {
mode,
tsInput,
@@ -28,14 +28,13 @@ export default function TimestampPage() {
} = useTimestampConverter();
return (
<Box sx={{ bgcolor: globalStyles.backgroundColor, minHeight: '100%', pb: 3 }}>
<Container sx={{ py: 2, bgcolor: globalStyles.backgroundColor }}>
<Box>
<Container sx={{ p: 2 }}>
{/* Header */}
<PageHeader
title="时间戳转换"
subtitle="Unix 毫秒数转换与格式化"
icon={<AccessTimeIcon />}
sx={{ mb: 2.5 }}
/>
{/* Live Clock Card */}
@@ -66,7 +65,7 @@ export default function TimestampPage() {
width: 'calc(50% - 5px)',
bgcolor: '#fff',
borderRadius: 3.5,
boxShadow: '0 4px 12px rgba(0,0,0,0.08)',
boxShadow: '0 4px 12px rgba(0,0,0,0.05)',
transition: 'transform 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
transform: mode === 'ts2dt' ? 'translateX(0)' : 'translateX(100%)',
top: 5,
@@ -171,31 +170,13 @@ export default function TimestampPage() {
</Stack>
{/* Main Action */}
<Button
fullWidth
variant="contained"
onClick={convert}
sx={{
py: 1.4,
borderRadius: 4,
bgcolor: 'primary.main',
fontWeight: 800,
fontSize: '0.9rem',
boxShadow: 'none',
'&:hover': {
bgcolor: 'primary.dark',
boxShadow: `0 8px 24px ${alpha(timestampPageStyles.primaryColor, 0.2)}`,
},
}}
>
<Button fullWidth variant="contained" onClick={convert}>
</Button>
{/* Result View */}
<ResultView result={result} mode={mode} unit={unit} zone={zone} showMessage={showMessage} />
</Container>
<GlobalSnackbar {...snackbarProps} />
</Box>
);
}
@@ -0,0 +1,29 @@
import { useState, useEffect } from 'react';
import { getActiveTabDomain } from '@/utils/chromeTabs';
/**
* 自动获取并维护当前活动标签页域名的 Hook
*/
export function useActiveTabDomain() {
const [domain, setDomain] = useState<string>('');
useEffect(() => {
getActiveTabDomain().then(setDomain);
// 如果需要实时同步(如切换标签页),可以监听 chrome.tabs.onActivated
const handleActivated = () => getActiveTabDomain().then(setDomain);
const handleUpdated = (_: number, changeInfo: { url?: string }) => {
if (changeInfo.url) getActiveTabDomain().then(setDomain);
};
chrome.tabs.onActivated.addListener(handleActivated);
chrome.tabs.onUpdated.addListener(handleUpdated);
return () => {
chrome.tabs.onActivated.removeListener(handleActivated);
chrome.tabs.onUpdated.removeListener(handleUpdated);
};
}, []);
return domain;
}
@@ -0,0 +1,292 @@
import { useState, useRef, useCallback } from 'react';
import { useSnackbar as useGlobalSnackbar } from '@/components/SnackbarProvider';
import { MessageAction, sendMessageToContent, injectContentScript } from '@/utils/messages';
import { FillMode } from '@/utils/dummyDataGenerator';
import { useStorageState } from '@/utils/useStorageState';
import { FieldTypePreferences } from '@/types/storage';
import { useActiveTabDomain } from './useActiveTabDomain';
import { useSidePanelState } from './useSidePanelState';
// 字段数据接口
export interface FieldData {
id: string;
fieldType: string;
label: string | null;
placeholder: string;
name: string;
value: string;
isSelected: boolean;
generatedValue: string;
useInvalidData?: boolean;
}
const DEFAULT_FIELD_TYPE_PREFERENCES: FieldTypePreferences = {};
export function useFormRecognizer() {
const { showMessage } = useGlobalSnackbar({ autoHideDuration: 1500 });
const [fillLoading, setFillLoading] = useState(false);
const [clearLoading, setClearLoading] = useState(false);
const [includeHidden, setIncludeHidden] = useState(false);
const isProcessingRef = useRef(false);
const [fields, setFields] = useState<FieldData[]>([]);
const [scanning, setScanning] = useState(false);
const [showFields, setShowFields] = useState(false);
const [hoveredFieldId, setHoveredFieldId] = useState<string | null>(null);
const currentDomain = useActiveTabDomain();
const { sidePanelOpen, handleOpenSidePanel } = useSidePanelState();
const [fieldTypePreferences, setFieldTypePreferences] = useStorageState(
'formRecognizer/fieldTypePreferences',
DEFAULT_FIELD_TYPE_PREFERENCES,
);
// 生成字段标识符
const getFieldIdentifier = useCallback(
(field: Pick<FieldData, 'label' | 'name' | 'placeholder'>): string => {
return field.label || field.name || field.placeholder || 'unknown';
},
[],
);
// 应用保存的类型偏好
const applySavedPreferences = useCallback(
(fields: FieldData[], domain: string, preferences: FieldTypePreferences): FieldData[] => {
if (!domain || !preferences[domain]) {
return fields;
}
const prefs = preferences[domain];
return fields.map((field) => {
const identifier = getFieldIdentifier(field);
if (prefs && prefs[identifier]) {
return { ...field, fieldType: prefs[identifier] };
}
return field;
});
},
[getFieldIdentifier],
);
// 保存类型偏好
const saveTypePreference = useCallback(
(field: FieldData, newType: string) => {
if (!currentDomain) return;
const identifier = getFieldIdentifier(field);
setFieldTypePreferences((prev) => {
const prevPrefs = prev as FieldTypePreferences;
const currentDomainPrefs = prevPrefs[currentDomain] || {};
return {
...prevPrefs,
[currentDomain]: {
...currentDomainPrefs,
[identifier]: newType,
},
} as FieldTypePreferences;
});
},
[currentDomain, getFieldIdentifier, setFieldTypePreferences],
);
// 扫描表单字段
const handleScanFields = async () => {
setScanning(true);
try {
let response = await sendMessageToContent(MessageAction.SCAN_FORM_FIELDS);
if (!response.success && response.message && response.message.includes('无法连接')) {
showMessage('正在注入内容脚本...', { severity: 'info' });
const injected = await injectContentScript();
if (injected) {
response = await sendMessageToContent(MessageAction.SCAN_FORM_FIELDS);
}
}
if (response.success && response.fields) {
const fieldsWithPreferences = applySavedPreferences(
response.fields as FieldData[],
currentDomain,
fieldTypePreferences,
);
setFields(fieldsWithPreferences);
setShowFields(true);
showMessage(`扫描完成,发现 ${response.totalCount} 个可填充字段`, { severity: 'success' });
} else {
showMessage(response.message || '扫描失败', { severity: 'error' });
}
} catch (error) {
console.error('扫描失败:', error);
showMessage('扫描失败,请确保页面已加载', { severity: 'error' });
} finally {
setScanning(false);
}
};
// 更新字段类型
const handleFieldTypeChange = (fieldId: string, newType: string) => {
setFields((prev) =>
prev.map((field) => {
if (field.id === fieldId) {
const updatedField = {
...field,
fieldType: newType,
generatedValue: '',
};
saveTypePreference(field, newType);
return updatedField;
}
return field;
}),
);
};
// 切换单个字段的选中状态
const handleToggleFieldSelection = (fieldId: string) => {
setFields((prev) =>
prev.map((field) =>
field.id === fieldId ? { ...field, isSelected: !field.isSelected } : field,
),
);
};
// 全选/取消全选
const handleToggleAllFields = () => {
setFields((prev) => {
const allSelected = prev.every((f) => f.isSelected);
return prev.map((f) => ({ ...f, isSelected: !allSelected }));
});
};
// 定位字段(闪烁)
const handleLocateField = async (fieldId: string) => {
try {
const response = await sendMessageToContent(MessageAction.FLASH_FIELD, { fieldId });
if (!response.success) {
showMessage(response.message || '定位字段失败', { severity: 'error' });
}
} catch (error) {
console.error('定位字段失败:', error);
}
};
// 悬停高亮
const handleHoverField = async (fieldId: string | null) => {
setHoveredFieldId(fieldId);
try {
if (fieldId) {
await sendMessageToContent(MessageAction.HIGHLIGHT_FIELD, { fieldId });
} else {
await sendMessageToContent(MessageAction.UNHIGHLIGHT_ALL_FIELDS);
}
} catch (error) {
console.error('高亮字段失败:', error);
}
};
// 填充选中字段
const handleFillSelectedFields = async () => {
if (isProcessingRef.current) {
showMessage('操作进行中,请稍候...', { severity: 'warning' });
return;
}
const selectedCount = fields.filter((f) => f.isSelected).length;
if (selectedCount === 0) {
showMessage('请先选择要填充的字段', { severity: 'warning' });
return;
}
setFillLoading(true);
isProcessingRef.current = true;
try {
const messageFields = fields as MessageFieldData[];
let response = await sendMessageToContent(MessageAction.FILL_SELECTED_FIELDS, {
fields: messageFields,
mode: FillMode.VALID,
includeHidden,
});
if (!response.success && response.message && response.message.includes('无法连接')) {
showMessage('正在注入内容脚本...', { severity: 'info' });
const injected = await injectContentScript();
if (injected) {
response = await sendMessageToContent(MessageAction.FILL_SELECTED_FIELDS, {
fields: messageFields,
mode: FillMode.VALID,
includeHidden,
});
}
}
if (response.success) {
showMessage(response.message || '填充成功', { severity: 'success' });
} else {
showMessage(response.message || '填充失败', { severity: 'error' });
}
} catch (error) {
console.error('填充失败:', error);
const errorMessage = error instanceof Error ? error.message : '未知错误';
showMessage(`填充失败:${errorMessage},请确保当前页面已加载完成`, { severity: 'error' });
} finally {
setFillLoading(false);
isProcessingRef.current = false;
}
};
// 清空所有字段
const handleClearAllFields = async () => {
if (isProcessingRef.current) {
showMessage('操作进行中,请稍候...', { severity: 'warning' });
return;
}
setClearLoading(true);
isProcessingRef.current = true;
try {
let response = await sendMessageToContent(MessageAction.CLEAR_ALL_FIELDS);
if (!response.success && response.message && response.message.includes('无法连接')) {
showMessage('正在注入内容脚本...', { severity: 'info' });
const injected = await injectContentScript();
if (injected) {
response = await sendMessageToContent(MessageAction.CLEAR_ALL_FIELDS);
}
}
if (response.success) {
showMessage(response.message || '清空成功', { severity: 'success' });
} else {
showMessage(response.message || '清空失败', { severity: 'error' });
}
} catch (error) {
console.error('清空失败:', error);
const errorMessage = error instanceof Error ? error.message : '未知错误';
showMessage(`清空失败:${errorMessage},请确保当前页面已加载完成`, { severity: 'error' });
} finally {
setClearLoading(false);
isProcessingRef.current = false;
}
};
return {
fillLoading,
clearLoading,
includeHidden,
setIncludeHidden,
fields,
scanning,
showFields,
setShowFields,
hoveredFieldId,
sidePanelOpen,
handleScanFields,
handleFieldTypeChange,
handleToggleFieldSelection,
handleToggleAllFields,
handleLocateField,
handleHoverField,
handleFillSelectedFields,
handleClearAllFields,
handleOpenSidePanel,
selectedCount: fields.filter((f) => f.isSelected).length,
};
}
@@ -0,0 +1,60 @@
import { useState, useEffect, useCallback } from 'react';
import { MessageAction, onMessage } from '@/utils/messages';
import { useSnackbar } from '@/components/SnackbarProvider';
/**
* 管理侧边栏状态检测与开启逻辑的 Hook
*/
export function useSidePanelState() {
const [sidePanelOpen, setSidePanelOpen] = useState(false);
const { showMessage } = useSnackbar();
const checkSidePanelState = useCallback(async () => {
try {
if (typeof chrome.runtime.getContexts === 'function') {
const contexts = await chrome.runtime.getContexts({
contextTypes: ['SIDE_PANEL'],
});
setSidePanelOpen(contexts.length > 0);
} else {
setSidePanelOpen(false);
}
} catch (error) {
console.error('检测侧边栏状态失败:', error);
setSidePanelOpen(false);
}
}, []);
useEffect(() => {
// 使用 requestAnimationFrame 避免同步调用 setState
requestAnimationFrame(() => {
checkSidePanelState().catch(console.error);
});
// 监听侧边栏状态变化消息
const removeListener = onMessage(MessageAction.SIDE_PANEL_STATE_CHANGED, (message) => {
setSidePanelOpen(message.data.isOpen);
});
return () => {
removeListener();
};
}, [checkSidePanelState]);
const handleOpenSidePanel = useCallback(async () => {
try {
await chrome.sidePanel.open({ windowId: chrome.windows.WINDOW_ID_CURRENT });
setSidePanelOpen(true);
showMessage('侧边栏已打开', { severity: 'success' });
} catch (error) {
console.error('打开侧边栏失败:', error);
showMessage('打开侧边栏失败', { severity: 'error' });
}
}, [showMessage]);
return {
sidePanelOpen,
handleOpenSidePanel,
checkSidePanelState,
};
}
+22 -9
View File
@@ -17,6 +17,7 @@ import {
getCacheStorageSize,
getServiceWorkerCount,
} from '@/utils/storageCleaner';
import { MessageAction, sendMessage } from '@/utils/messages';
const DEFAULT_OPTIONS: StorageCleanerOptions = {
localStorage: true,
@@ -73,16 +74,15 @@ export function useStorageCleaner({
const [loading, setLoading] = useState<boolean>(false);
const [result, setResult] = useState<CleaningResult | null>(null);
const [showConfirm, setShowConfirm] = useState<boolean>(false);
const reloadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const resultTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const requestIdRef = useRef<number>(0);
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
const reloadTimeout = reloadTimeoutRef.current;
const resultTimeout = resultTimeoutRef.current;
return () => {
if (reloadTimeout) clearTimeout(reloadTimeout);
if (resultTimeout) clearTimeout(resultTimeout);
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
};
}, []);
@@ -141,13 +141,23 @@ export function useStorageCleaner({
const loadInfoRef = useRef(loadInfo);
loadInfoRef.current = loadInfo;
useEffect(() => {
loadInfoRef.current();
const debouncedLoadInfo = useCallback(() => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
debounceTimerRef.current = setTimeout(() => {
loadInfoRef.current().then((r) => console.info(r));
}, 300);
}, []);
const handleTabChange = () => loadInfoRef.current();
useEffect(() => {
// 首次加载不防抖
loadInfoRef.current().then((r) => console.info(r));
const handleTabChange = () => debouncedLoadInfo();
const handleTabUpdated = (_tabId: number, changeInfo: { status?: string; url?: string }) => {
if (changeInfo.status === 'complete' || changeInfo.url) {
loadInfoRef.current();
debouncedLoadInfo();
}
};
@@ -159,8 +169,11 @@ export function useStorageCleaner({
chrome.tabs.onActivated.removeListener(handleTabChange);
chrome.tabs.onUpdated.removeListener(handleTabUpdated);
chrome.windows.onFocusChanged.removeListener(handleTabChange);
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
};
}, []);
}, [debouncedLoadInfo]);
const handleAutoRefreshChange = useCallback(
async (checked: boolean) => {
@@ -219,7 +232,7 @@ export function useStorageCleaner({
if (autoRefresh && cleaningResult.success) {
showMessage('清理成功,即将刷新页面', { severity: 'success' });
await chrome.runtime.sendMessage({ action: 'reloadTab', tabId: tab.id, delay: 1000 });
await sendMessage(MessageAction.RELOAD_TAB, { tabId: tab.id, delay: 1000 });
} else {
await loadInfo();
}
-18
View File
@@ -1,18 +0,0 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
h1, h2, h3, h4, h5, h6 {
font-size: inherit; /* 先全部重置为继承大小 */
font-weight: inherit;
}
-91
View File
@@ -1,91 +0,0 @@
/* 极简滚动条变量 */
:root {
/* 统一圆角变量 */
--radius: 8px;
/* 统一背景颜色变量 */
--bg-color: #fafafa;
/* 统一文字颜色变量 */
--text-color: #333333;
/* 统一按钮颜色变量 */
--btn-bg: #e0e0e0;
/* 统一按钮文字颜色变量 */
--btn-text: #333333;
--border: 1px solid #e0e0e0;
/* 滚动条变量 */
--sb-width: 6px;
--sb-thumb-color: rgba(0, 0, 0, 0.1);
--sb-thumb-hover: rgba(0, 0, 0, 0.2);
--sb-track-color: transparent;
}
/* 适配侧边栏 - 使用百分比而非固定尺寸 */
html,
body,
#root {
margin: 0;
padding: 0;
width: 100%;
height: 100vh;
overflow: hidden; /* 禁用外层滚动 */
}
/* 全局极简滚动条定制 */
*::-webkit-scrollbar {
width: var(--sb-width);
}
*::-webkit-scrollbar-track {
background: var(--sb-track-color);
}
*::-webkit-scrollbar-thumb {
background: var(--sb-thumb-color);
border-radius: 10px;
background-clip: content-box;
border: 1px solid transparent;
}
*::-webkit-scrollbar-thumb:hover {
background: var(--sb-thumb-hover);
}
.app {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
box-sizing: border-box;
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
overflow: hidden; /* 内部由 flex 子项控制滚动 */
}
/* Animations */
@keyframes slideInRight {
from {
transform: translateX(30px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.page-transition-enter {
animation: slideInRight 0.3s cubic-bezier(0.25, 0.8, 0.25, 1) forwards;
}
.page-transition-dashboard {
animation: fadeIn 0.3s ease-out forwards;
}
+28 -7
View File
@@ -1,21 +1,42 @@
import './App.css';
import { useEffect } from 'react';
import RouterProvider from '@/providers/RouterProvider';
import TopBar from '@/components/TopBar';
import RouterContainer from '@/components/RouterContainer';
import ErrorBoundary from '@/components/ErrorBoundary';
import { MessageAction, sendMessage } from '@/utils/messages';
import { Box } from '@mui/material';
function App() {
export default function App() {
const handleOpenOptions = () => {
chrome.runtime.openOptionsPage();
};
// 通知侧边栏已打开
useEffect(() => {
sendMessage(MessageAction.SIDE_PANEL_STATE_CHANGED, { isOpen: true });
return () => {
// 尝试在关闭时通知,虽然在某些情况下可能无法成功发送
sendMessage(MessageAction.SIDE_PANEL_STATE_CHANGED, { isOpen: false });
};
}, []);
return (
<RouterProvider defaultRoute="dashboard" syncKey="app/sidepanelRoute">
<div className="app" style={{ display: 'flex', flexDirection: 'column', height: '100vh', overflow: 'hidden' }}>
<Box
className="app"
sx={{
display: 'flex',
flexDirection: 'column',
height: '100vh',
width: '100%',
overflow: 'hidden',
}}
>
<TopBar onOpenOptions={handleOpenOptions} />
<RouterContainer />
</div>
<ErrorBoundary>
<RouterContainer />
</ErrorBoundary>
</Box>
</RouterProvider>
);
}
export default App;
+7 -2
View File
@@ -1,10 +1,15 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { ThemeProvider } from '@mui/material/styles';
import CssBaseline from '@mui/material/CssBaseline';
import theme from '@/config/theme';
import App from './App.tsx';
import './style.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
<ThemeProvider theme={theme}>
<CssBaseline />
<App />
</ThemeProvider>
</React.StrictMode>,
);
-22
View File
@@ -1,22 +0,0 @@
body {
margin: 0;
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Ubuntu', 'Cantarell', 'Fira Sans',
'Droid Sans', 'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-size: inherit; /* 先全部重置为继承大小 */
font-weight: inherit;
}