diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1c31c00..244240f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -79,7 +79,7 @@ jobs: needs: [lint, typecheck, test] strategy: matrix: - browser: [chrome, firefox] + browser: [chrome] fail-fast: false steps: - name: Checkout @@ -97,7 +97,3 @@ jobs: - name: Build (Chrome) if: matrix.browser == 'chrome' run: npm run build - - - name: Build (Firefox) - if: matrix.browser == 'firefox' - run: npm run build:firefox diff --git a/components/Button.tsx b/components/Button.tsx index 26d601f..d74cc14 100644 --- a/components/Button.tsx +++ b/components/Button.tsx @@ -2,6 +2,12 @@ import { Button as MuiButton, ButtonProps as MuiButtonProps } from '@mui/materia export type ButtonProps = MuiButtonProps; +/** + * 按钮组件 + * @param sx 自定义样式 + * @param props 其他按钮属性 + * @returns 按钮组件 + */ export function Button({ sx = [], ...props }: ButtonProps) { return ( void; + showMessage?: (message: string, options?: SnackbarOptions) => void; } /** @@ -19,6 +29,8 @@ interface CopyButtonProps { * @param tooltip 提示信息 * @param size 按钮大小 * @param color 按钮颜色 + * @param style 自定义样式 + * @param showMessage 消息提示函数,用于显示复制成功或失败的消息 * @returns 复制按钮组件 */ export const CopyButton: React.FC = ({ @@ -56,8 +68,7 @@ export const CopyButton: React.FC = ({ '&:hover': { bgcolor: copied ? 'success.main' - : typeof color === 'string' && - !['primary', 'secondary', 'success', 'error', 'info', 'warning'].includes(color) + : !['primary', 'secondary', 'success', 'error', 'info', 'warning'].includes(color) ? color : `${color}.main`, color: '#fff', diff --git a/components/FeatureDescription.tsx b/components/FeatureDescription.tsx deleted file mode 100644 index a60a84b..0000000 --- a/components/FeatureDescription.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import React from 'react'; -import { Box, Typography, Paper } from '@mui/material'; - -const FeatureDescription: React.FC = () => { - return ( - - - - 功能说明 - - - - - 有效数据模式:生成符合格式要求的测试数据,适用于正常功能测试。 - - - 异常数据模式:生成边界值或格式错误的数据,适用于异常场景测试。 - - - 一键清空:快速清空当前页面所有表单字段的值。 - - - 支持的字段类型: - 文本、邮箱、手机号、数字、日期、文本域、密码、身份证号等。 - - - - ); -}; - -export default FeatureDescription; diff --git a/components/FieldList.tsx b/components/FieldList.tsx index 07974b6..4dcdcac 100644 --- a/components/FieldList.tsx +++ b/components/FieldList.tsx @@ -5,14 +5,19 @@ import { Paper, List, ListItem, - ListItemText, ListItemIcon, Collapse, - Chip, + FormControl, + InputLabel, + Select, + MenuItem, + SelectChangeEvent, + Checkbox, + Button, } from '@mui/material'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import ExpandLessIcon from '@mui/icons-material/ExpandLess'; -import InputIcon from '@mui/icons-material/Input'; +import { FieldType } from '@/utils/dummyDataGenerator'; // 字段数据接口 interface FieldData { @@ -24,51 +29,57 @@ interface FieldData { value: string; isSelected: boolean; generatedValue: string; + useInvalidData?: boolean; } // 字段类型显示名称映射 const FIELD_TYPE_NAMES: Record = { - text: '文本', - email: '邮箱', - phone: '手机号', - number: '数字', - date: '日期', - textarea: '文本域', - radio: '单选框', - checkbox: '复选框', - select: '下拉框', - password: '密码', - name: '姓名', - id_card: '身份证号', - unknown: '未知', -}; - -// 字段类型颜色映射 -const FIELD_TYPE_COLORS: Record< - string, - 'default' | 'primary' | 'secondary' | 'error' | 'success' | 'warning' -> = { - email: 'primary', - phone: 'success', - number: 'secondary', - date: 'warning', - password: 'error', - name: 'primary', - id_card: 'secondary', - text: 'default', - textarea: 'default', - unknown: 'default', + [FieldType.TEXT]: '文本', + [FieldType.EMAIL]: '邮箱', + [FieldType.PHONE]: '手机号', + [FieldType.NUMBER]: '数字', + [FieldType.DATE]: '日期', + [FieldType.TEXTarea]: '文本域', + [FieldType.RADIO]: '单选框', + [FieldType.CHECKBOX]: '复选框', + [FieldType.SELECT]: '下拉框', + [FieldType.PASSWORD]: '密码', + [FieldType.NAME]: '姓名', + [FieldType.ID_CARD]: '身份证号', + [FieldType.UNKNOWN]: '未知', }; interface FieldListProps { fields: FieldData[]; showFields: boolean; onToggleShowFields: () => void; + onFieldTypeChange: (fieldId: string, newType: string) => void; + onLocateField: (fieldId: string) => void; + onHoverField: (fieldId: string | null) => void; + onToggleFieldSelection: (fieldId: string) => void; + onToggleAllFields: () => void; + hoveredFieldId: string | null; } -const FieldList: React.FC = ({ fields, showFields, onToggleShowFields }) => { +const FieldList: React.FC = ({ + fields, + showFields, + onToggleShowFields, + onFieldTypeChange, + onHoverField, + onToggleFieldSelection, + onToggleAllFields, + hoveredFieldId, +}) => { if (fields.length === 0) return null; + const handleTypeChange = (fieldId: string, event: SelectChangeEvent) => { + onFieldTypeChange(fieldId, event.target.value); + }; + + const allSelected = fields.every((f) => f.isSelected); + const selectedCount = fields.filter((f) => f.isSelected).length; + return ( = ({ fields, showFields, onToggleShowF }} onClick={onToggleShowFields} > - - 已识别字段 ({fields.length}) - - {showFields ? : } + + + 已识别字段 ({fields.length}) + + 0 ? 'primary.main' : 'grey.300', + color: selectedCount > 0 ? 'white' : 'text.secondary', + px: 1, + py: 0.25, + borderRadius: 1, + }} + > + {selectedCount} 已选择 + + + + + {showFields ? : } + - + {fields.map((field, index) => ( - - - + onHoverField(field.id)} + onMouseLeave={() => onHoverField(null)} + > + + { + e.stopPropagation(); + onToggleFieldSelection(field.id); + }} + /> - - - {field.label || field.name || field.placeholder || `字段 ${index + 1}`} - - - - } - secondary={field.placeholder || field.name} - /> + + + + {field.label || field.name || field.placeholder || `字段 ${index + 1}`} + + + + + + 类型 + + + + + {field.placeholder && ( + + 占位符: {field.placeholder} + + )} + ))} diff --git a/components/MainActions.tsx b/components/MainActions.tsx deleted file mode 100644 index b915062..0000000 --- a/components/MainActions.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import React from 'react'; -import { Button, Stack, CircularProgress } from '@mui/material'; -import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome'; -import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'; -import ClearAllIcon from '@mui/icons-material/ClearAll'; -import { formRecognizerPageStyles } from '@/config/pageTheme'; - -interface MainActionsProps { - loading: boolean; - onFillValidData: () => void; - onFillInvalidData: () => void; - onClearAllFields: () => void; -} - -const MainActions: React.FC = ({ - loading, - onFillValidData, - onFillInvalidData, - onClearAllFields, -}) => { - return ( - - - - - - - - ); -}; - -export default MainActions; diff --git a/components/OperationHistory.tsx b/components/OperationHistory.tsx deleted file mode 100644 index afc36d8..0000000 --- a/components/OperationHistory.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import React from 'react'; -import { - Box, - Typography, - Paper, - List, - ListItem, - ListItemText, - Collapse, - Chip, - Divider, -} from '@mui/material'; -import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; -import ExpandLessIcon from '@mui/icons-material/ExpandLess'; - -interface OperationHistoryItem { - time: string; - type: string; - content: string; - result: string; -} - -interface OperationHistoryProps { - history: OperationHistoryItem[]; - showHistory: boolean; - onToggleShowHistory: () => void; -} - -const OperationHistory: React.FC = ({ - history, - showHistory, - onToggleShowHistory, -}) => { - if (history.length === 0) return null; - - return ( - - - - 操作历史 ({history.length}) - - {showHistory ? : } - - - - {history.map((item, index) => ( - - {index > 0 && } - - - - {item.content} - - } - secondary={`${item.time} · ${item.result}`} - /> - - - ))} - - - - ); -}; - -export default OperationHistory; diff --git a/components/OptionsPanel.tsx b/components/OptionsPanel.tsx deleted file mode 100644 index ccdc767..0000000 --- a/components/OptionsPanel.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import React from 'react'; -import { Box, Typography, Paper, FormControlLabel, Switch } from '@mui/material'; - -interface OptionsPanelProps { - includeHidden: boolean; - onIncludeHiddenChange: (checked: boolean) => void; -} - -const OptionsPanel: React.FC = ({ includeHidden, onIncludeHiddenChange }) => { - return ( - - - - 填充选项 - - - - onIncludeHiddenChange(e.target.checked)} - color="primary" - /> - } - label="包含隐藏字段" - sx={{ width: '100%' }} - /> - - - ); -}; - -export default OptionsPanel; diff --git a/components/PageHeader.tsx b/components/PageHeader.tsx new file mode 100644 index 0000000..9c4676c --- /dev/null +++ b/components/PageHeader.tsx @@ -0,0 +1,106 @@ +import { Stack, Typography, Box, alpha, SxProps, Theme } from '@mui/material'; +import { ReactNode } from 'react'; + +/** + * PageHeader 组件属性接口 + */ +export interface PageHeaderProps { + /** 要显示的图标组件 */ + icon: ReactNode; + /** 图标的颜色,默认为 '#1976d2'(蓝色) */ + iconColor?: string; + /** 主标题文本 */ + title: string; + /** 副标题文本(可选) */ + subtitle?: string; + /** 在标题右侧显示的徽章/标签组件(可选) */ + badge?: ReactNode; + /** 图标容器的自定义样式 */ + iconSx?: SxProps; + /** 标题文本的自定义样式 */ + titleSx?: SxProps; + /** 副标题文本的自定义样式 */ + subtitleSx?: SxProps; + /** 整个组件的自定义样式 */ + sx?: SxProps; +} + +/** + * PageHeader - 通用页面标题栏组件 + * + * 用于显示带图标的页面标题,支持自定义颜色、副标题、徽章等功能 + * + * @example + * ```tsx + * } + * iconColor="#1976d2" + * title="时间戳转换" + * subtitle="Unix 毫秒数转换与格式化" + * /> + * ``` + * + * @example + * ```tsx + * } + * iconColor={storageCleanerPageStyles.warningColor} + * title="存储清理" + * subtitle={domain} + * badge={已占用 {size}} + * /> + * ``` + */ +export default function PageHeader({ + icon, + iconColor = '#1976d2', + title, + subtitle, + badge, + iconSx, + titleSx, + subtitleSx, + sx, +}: PageHeaderProps) { + return ( + + {/* 图标容器 */} + + {icon} + + {/* 标题区域 */} + + {/* 标题行(含徽章) */} + + + {title} + + {badge} + + {/* 副标题 */} + {subtitle && ( + + {subtitle} + + )} + + + ); +} diff --git a/components/QrCodeUploader.tsx b/components/QrCodeUploader.tsx index b685894..d35f77a 100644 --- a/components/QrCodeUploader.tsx +++ b/components/QrCodeUploader.tsx @@ -1,11 +1,11 @@ -import React, { useState, useRef, useEffect, useCallback } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { - Box, - Typography, - Paper, - CircularProgress, Alert, + Box, + CircularProgress, IconButton, + Paper, + Typography, useMediaQuery, useTheme, } from '@mui/material'; diff --git a/components/TemplateManager.tsx b/components/TemplateManager.tsx deleted file mode 100644 index 9ffefda..0000000 --- a/components/TemplateManager.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import React from 'react'; -import { - Box, - Typography, - Paper, - List, - ListItem, - ListItemText, - ListItemIcon, - Collapse, - Button, - Stack, - CircularProgress, -} from '@mui/material'; -import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; -import ExpandLessIcon from '@mui/icons-material/ExpandLess'; -import FolderIcon from '@mui/icons-material/Folder'; -import DownloadIcon from '@mui/icons-material/Download'; -import UploadIcon from '@mui/icons-material/Upload'; -import { DataTemplate } from '@/utils/dataTemplate'; - -interface TemplateManagerProps { - templates: DataTemplate[]; - showTemplates: boolean; - templateLoading: boolean; - onToggleShowTemplates: () => void; - onLoadTemplates: () => void; - onExportTemplates: () => void; - onImportTemplates: () => void; -} - -const TemplateManager: React.FC = ({ - templates, - showTemplates, - templateLoading, - onToggleShowTemplates, - onLoadTemplates, - onExportTemplates, - onImportTemplates, -}) => { - const handleToggle = () => { - onToggleShowTemplates(); - if (!showTemplates) onLoadTemplates(); - }; - - return ( - - - - 模板管理 ({templates.length}) - - {showTemplates ? : } - - - - - - - - {templateLoading ? ( - - - - ) : templates.length === 0 ? ( - - 暂无模板,请先在其他页面创建模板 - - ) : ( - - {templates.map((template) => ( - - - - - - - ))} - - )} - - - - ); -}; - -export default TemplateManager; diff --git a/config/routes.ts b/config/routes.ts index aa63a47..f84c90e 100644 --- a/config/routes.ts +++ b/config/routes.ts @@ -6,7 +6,6 @@ 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 FormFillSidePanel from '@/entrypoints/sidepanel/pages/FormFillSidePanel'; export interface RouteConfig { key: PageType; @@ -76,7 +75,7 @@ export const ROUTES: RouteConfig[] = [ defaultVisible: true, components: { popup: FormRecognizerPage, - sidepanel: FormFillSidePanel, + sidepanel: FormRecognizerPage, detached: FormRecognizerPage, }, }, diff --git a/entrypoints/content.ts b/entrypoints/content.ts index 872609b..9018a3a 100644 --- a/entrypoints/content.ts +++ b/entrypoints/content.ts @@ -6,6 +6,7 @@ import { scanFormFields, highlightField, unhighlightField, + flashField, FillMode, type FormFieldInfo, } from '@/utils/dummyDataGenerator'; @@ -54,9 +55,19 @@ export default defineContentScript({ break; case MessageAction.FILL_SELECTED_FIELDS: { // 使用之前扫描时存储的字段,因为它们包含element属性 - const fieldIds = (message.fields || []).filter((f) => f.isSelected).map((f) => f.id); - const fieldsToFill = currentFields.filter((f) => fieldIds.includes(f.id)); - fieldsToFill.forEach((f) => (f.isSelected = true)); + 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; @@ -104,6 +115,17 @@ export default defineContentScript({ }); 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; + } default: sendResponse({ success: false, message: '未知操作' }); } diff --git a/entrypoints/popup/pages/FormRecognizerPage.tsx b/entrypoints/popup/pages/FormRecognizerPage.tsx index da047b5..ef78e5f 100644 --- a/entrypoints/popup/pages/FormRecognizerPage.tsx +++ b/entrypoints/popup/pages/FormRecognizerPage.tsx @@ -1,16 +1,23 @@ -import { useState, useRef } from 'react'; -import { Box, Typography, Container, Button, CircularProgress } from '@mui/material'; +import { useState, useRef, useEffect } from 'react'; +import { + Box, + Container, + Button, + CircularProgress, + FormControlLabel, + Switch, + alpha, +} from '@mui/material'; 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 { DataTemplateManager, type DataTemplate } from '@/utils/dataTemplate'; +import { FillMode } from '@/utils/dummyDataGenerator'; import FieldList from '@/components/FieldList'; -import OperationHistory from '@/components/OperationHistory'; -import TemplateManager from '@/components/TemplateManager'; -import MainActions from '@/components/MainActions'; -import OptionsPanel from '@/components/OptionsPanel'; -import FeatureDescription from '@/components/FeatureDescription'; +import { useStorageState } from '@/utils/useStorageState'; +import { FieldTypePreferences } from '@/types/storage'; +import PageHeader from '@/components/PageHeader'; // 字段数据接口 interface FieldData { @@ -22,28 +29,116 @@ interface FieldData { value: string; isSelected: boolean; generatedValue: string; + useInvalidData?: boolean; } +const DEFAULT_FIELD_TYPE_PREFERENCES: FieldTypePreferences = {}; + const FormRecognizerPage = () => { const { snackbarProps, showMessage } = useSnackbar({ autoHideDuration: 1500 }); - const [loading, setLoading] = useState(false); + const [fillLoading, setFillLoading] = useState(false); + const [clearLoading, setClearLoading] = useState(false); const [includeHidden, setIncludeHidden] = useState(false); const isProcessingRef = useRef(false); const [fields, setFields] = useState([]); const [scanning, setScanning] = useState(false); const [showFields, setShowFields] = useState(false); - const [operationHistory, setOperationHistory] = useState< - Array<{ - time: string; - type: string; - content: string; - result: string; - }> - >([]); - const [showHistory, setShowHistory] = useState(false); - const [templates, setTemplates] = useState([]); - const [showTemplates, setShowTemplates] = useState(false); - const [templateLoading, setTemplateLoading] = useState(false); + const [hoveredFieldId, setHoveredFieldId] = useState(null); + const [currentDomain, setCurrentDomain] = useState(''); + 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): 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 () => { @@ -60,9 +155,10 @@ const FormRecognizerPage = () => { } if (response.success && response.fields) { - setFields(response.fields as FieldData[]); + const fieldsWithPreferences = applySavedPreferences(response.fields as FieldData[]); + setFields(fieldsWithPreferences); + setShowFields(true); showMessage(`扫描完成,发现 ${response.totalCount} 个可填充字段`, { severity: 'success' }); - addOperationHistory('扫描', `扫描表单字段,发现 ${response.totalCount} 个字段`, '成功'); } else { showMessage(response.message || '扫描失败', { severity: 'error' }); } @@ -74,158 +170,194 @@ const FormRecognizerPage = () => { } }; - // 添加操作历史记录 - const addOperationHistory = (type: string, content: string, result: string) => { - const newEntry = { - time: new Date().toLocaleString('zh-CN'), - type, - content, - result, - }; - setOperationHistory((prev) => [newEntry, ...prev].slice(0, 50)); // 最多保留50条记录 - }; - - // 加载模板列表 - const loadTemplates = async () => { - setTemplateLoading(true); - try { - const allTemplates = await DataTemplateManager.getAllTemplates(); - setTemplates(allTemplates); - } catch (error) { - console.error('加载模板失败:', error); - showMessage('加载模板失败', { severity: 'error' }); - } finally { - setTemplateLoading(false); - } - }; - - // 导出模板 - const handleExportTemplates = async () => { - const allTemplates = await DataTemplateManager.getAllTemplates(); - if (allTemplates.length === 0) { - showMessage('没有可导出的模板', { severity: 'warning' }); - return; - } - const jsonStr = DataTemplateManager.exportTemplates(allTemplates); - const blob = new Blob([jsonStr], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `templates_${new Date().toISOString().split('T')[0]}.json`; - a.click(); - URL.revokeObjectURL(url); - showMessage(`已导出 ${allTemplates.length} 个模板`, { severity: 'success' }); - addOperationHistory('导出', `导出 ${allTemplates.length} 个模板`, '成功'); - }; - - // 导入模板 - const handleImportTemplates = () => { - const input = document.createElement('input'); - input.type = 'file'; - input.accept = '.json'; - input.onchange = async (e) => { - const file = (e.target as HTMLInputElement).files?.[0]; - if (!file) return; - const reader = new FileReader(); - reader.onload = async (event) => { - const content = event.target?.result as string; - const success = await DataTemplateManager.importTemplates(content); - if (success) { - showMessage('模板导入成功', { severity: 'success' }); - addOperationHistory('导入', '导入模板', '成功'); - loadTemplates(); - } else { - showMessage('模板导入失败,请检查文件格式', { severity: 'error' }); + // 更新字段类型 + 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; } - }; - reader.readAsText(file); - }; - input.click(); + return field; + }), + ); }; - const sendMessageWithHandler = async ( - action: MessageAction, - payload?: { includeHidden?: boolean }, - ) => { - // 防抖处理:防止快速点击导致多次请求 + // 切换单个字段的选中状态 + 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; } - setLoading(true); + const selectedCount = fields.filter((f) => f.isSelected).length; + if (selectedCount === 0) { + showMessage('请先选择要填充的字段', { severity: 'warning' }); + return; + } + + setFillLoading(true); isProcessingRef.current = true; try { - let response = await sendMessageToContent(action, payload); + // 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(action, payload); - } else { - showMessage('内容脚本注入失败,请刷新页面后重试', { severity: 'error' }); - return; + response = await sendMessageToContent(MessageAction.FILL_SELECTED_FIELDS, { + fields: messageFields, + mode: FillMode.VALID, + includeHidden, + }); } } if (response.success) { - showMessage(response.message || '操作成功', { severity: 'success' }); + showMessage(response.message || '填充成功', { severity: 'success' }); } else { - // 增强错误提示信息 - const errorMsg = response.message || '操作失败'; - const errorDetails = getErrorDetails(errorMsg); - showMessage(errorDetails, { severity: 'error' }); + showMessage(response.message || '填充失败', { severity: 'error' }); } } catch (error) { - console.error('发送消息失败:', error); + console.error('填充失败:', error); const errorMessage = error instanceof Error ? error.message : '未知错误'; - showMessage(`操作失败:${errorMessage},请确保当前页面已加载完成`, { severity: 'error' }); + showMessage(`填充失败:${errorMessage},请确保当前页面已加载完成`, { severity: 'error' }); } finally { - setLoading(false); + setFillLoading(false); isProcessingRef.current = false; } }; - // 获取详细的错误信息 - const getErrorDetails = (baseMsg: string): string => { - if (baseMsg.includes('标签页')) { - return `${baseMsg},请确保已打开网页页面`; + // 清空所有字段 + const handleClearAllFields = async () => { + if (isProcessingRef.current) { + showMessage('操作进行中,请稍候...', { severity: 'warning' }); + return; } - if (baseMsg.includes('注入')) { - return `${baseMsg},请检查页面是否支持内容脚本`; + + 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 baseMsg; }; - const handleFillValidData = () => { - sendMessageWithHandler(MessageAction.FILL_VALID_DATA, { includeHidden }); - addOperationHistory('填充', '填充有效数据', '成功'); + // 打开侧边栏 + 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 handleFillInvalidData = () => { - sendMessageWithHandler(MessageAction.FILL_INVALID_DATA, { includeHidden }); - addOperationHistory('填充', '填充异常数据', '成功'); - }; - - const handleClearAllFields = () => { - sendMessageWithHandler(MessageAction.CLEAR_ALL_FIELDS); - addOperationHistory('清空', '清空所有表单字段', '成功'); - }; + const selectedCount = fields.filter((f) => f.isSelected).length; return ( - - - Dummy Data Generator - - - 一键填充表单测试数据,提升开发和测试效率 - - + {/* Header */} + } + sx={{ mb: 2.5 }} + /> + + {/* 扫描按钮 */} - + + + )} - setShowHistory(!showHistory)} - /> - - setShowTemplates(!showTemplates)} - onLoadTemplates={loadTemplates} - onExportTemplates={handleExportTemplates} - onImportTemplates={handleImportTemplates} - /> - - + + setIncludeHidden(e.target.checked)} + /> + } + label="包含隐藏字段" + /> + diff --git a/entrypoints/popup/pages/OpenUrlPage.tsx b/entrypoints/popup/pages/OpenUrlPage.tsx index 59183e5..e3cbfe6 100644 --- a/entrypoints/popup/pages/OpenUrlPage.tsx +++ b/entrypoints/popup/pages/OpenUrlPage.tsx @@ -1,13 +1,12 @@ -import { Box, Typography, Container, Stack, alpha } from '@mui/material'; +import { Box, Typography, Container } from '@mui/material'; import LanguageIcon from '@mui/icons-material/Language'; import GlobalSnackbar, { useSnackbar } from '@/components/GlobalSnackbar'; import UrlEntryForm from '@/components/UrlEntryForm'; import UrlEntryList from '@/components/UrlEntryList'; import { useUrlPreferences } from '@/utils/useUrlPreferences'; import type { OpenUrlEntry } from '@/types/storage'; -import { openUrlPageStyles, dashboardPageStyles } from '@/config/pageTheme'; - -const THEME_COLOR = openUrlPageStyles.themeColor; +import { dashboardPageStyles } from '@/config/pageTheme'; +import PageHeader from '@/components/PageHeader'; export default function OpenUrlPage() { const { entries, setEntries, isLoaded } = useUrlPreferences(); @@ -38,31 +37,12 @@ export default function OpenUrlPage() { {/* Header */} - - - - - - - URL 工具 - - - 快速打开 URL 或复制链接 - - - + } + sx={{ mb: 2.5 }} + /> {/* Form Section */} diff --git a/entrypoints/popup/pages/QrCodePage.tsx b/entrypoints/popup/pages/QrCodePage.tsx index 1587d77..59364d2 100644 --- a/entrypoints/popup/pages/QrCodePage.tsx +++ b/entrypoints/popup/pages/QrCodePage.tsx @@ -1,11 +1,11 @@ -import { Box, Typography, Stack, Container, CircularProgress } from '@mui/material'; -import { alpha } from '@mui/system'; +import { Box, Stack, Container, CircularProgress } from '@mui/material'; import QrCodeIcon from '@mui/icons-material/QrCode'; import GlobalSnackbar, { useSnackbar } from '@/components/GlobalSnackbar'; import UrlToQrCodeSection from '@/components/UrlToQrCodeSection'; import QrCodeToUrlSection from '@/components/QrCodeToUrlSection'; import { useStorageState } from '@/utils/useStorageState'; -import { qrCodePageStyles, dashboardPageStyles } from '@/config/pageTheme'; +import { dashboardPageStyles } from '@/config/pageTheme'; +import PageHeader from '@/components/PageHeader'; const QrCodePage = () => { const { snackbarProps, showMessage } = useSnackbar({ autoHideDuration: 1500 }); @@ -35,32 +35,12 @@ const QrCodePage = () => { return ( - {/* Header */} - - - - - - - 二维码工具 - - - 生成和解析二维码 - - - + } + sx={{ mb: 2.5 }} + /> - {/* Header with Icon */} - - - - - - - 时间戳转换 - - - Unix 毫秒数转换与格式化 - - - + {/* Header */} + } + sx={{ mb: 2.5 }} + /> {/* Live Clock Card */} setZone(e.target.value as typeof zone)} - sx={{ ...timestampPageStyles.INPUT_STYLE, flex: 1 }} + sx={{ ...timestampPageStyles.INPUT_STYLE, flex: 1, borderRadius: 4 }} MenuProps={{ PaperProps: { sx: { borderRadius: 3, mt: 1, boxShadow: '0 12px 32px rgba(0,0,0,0.1)' }, diff --git a/entrypoints/popup/pages/components/AutoRefreshToggle.tsx b/entrypoints/popup/pages/components/AutoRefreshToggle.tsx index f55845e..37f68d6 100644 --- a/entrypoints/popup/pages/components/AutoRefreshToggle.tsx +++ b/entrypoints/popup/pages/components/AutoRefreshToggle.tsx @@ -25,7 +25,7 @@ export default function AutoRefreshToggle({ autoRefresh, onChange }: AutoRefresh }, }} > - + 清理后自动刷新页面 + * ``` + */ export default function DomainHeader({ domain, totalSize }: DomainHeaderProps) { return ( - - - - - - - } + iconColor={storageCleanerPageStyles.warningColor} + title="存储清理" + subtitle={domain || '加载中...'} + badge={ + totalSize > 0 ? ( + - 存储清理 - - {totalSize > 0 && ( - - 已占用 {formatSize(totalSize)} - - )} - - - {domain || '加载中...'} - - - + 已占用 {formatSize(totalSize)} + + ) : null + } + iconSx={{ + p: 1.2, + borderRadius: 3, + boxShadow: '0 2px 8px rgba(255, 152, 0, 0.15)', + transition: 'all 0.2s', + '&:hover': { + bgcolor: 'rgba(255, 152, 0, 0.15)', + transform: 'scale(1.05)', + }, + }} + titleSx={{ + fontSize: '1rem', + }} + subtitleSx={{ + display: 'block', + maxWidth: 240, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + mt: 0.3, + fontSize: '0.75rem', + }} + sx={{ mb: 3 }} + /> ); } diff --git a/entrypoints/popup/pages/components/LiveClock.tsx b/entrypoints/popup/pages/components/LiveClock.tsx index 3b61571..1167fa7 100644 --- a/entrypoints/popup/pages/components/LiveClock.tsx +++ b/entrypoints/popup/pages/components/LiveClock.tsx @@ -4,12 +4,13 @@ import AccessTimeIcon from '@mui/icons-material/AccessTime'; import CopyButton from '@/components/CopyButton'; import { timestampPageStyles } from '@/config/pageTheme'; import type { UnitType } from '@/config/pageTheme'; +import type { SnackbarOptions } from '@/components/GlobalSnackbar'; interface LiveClockProps { unit: UnitType; onUseNow: (val: number) => void; onUnitChange: (u: UnitType) => void; - showMessage?: (message: string, options?: { severity: 'success' | 'error' }) => void; + showMessage?: (message: string, options?: SnackbarOptions) => void; } const LiveClock = React.memo(({ unit, onUseNow, onUnitChange, showMessage }: LiveClockProps) => { diff --git a/entrypoints/popup/pages/components/ResultView.tsx b/entrypoints/popup/pages/components/ResultView.tsx index ea781fe..27bfe2f 100644 --- a/entrypoints/popup/pages/components/ResultView.tsx +++ b/entrypoints/popup/pages/components/ResultView.tsx @@ -4,13 +4,14 @@ import dayjs from '@/utils/dayjs'; import CopyButton from '@/components/CopyButton'; import { DATE_FORMAT, timestampPageStyles } from '@/config/pageTheme'; import type { UnitType } from '@/config/pageTheme'; +import type { SnackbarOptions } from '@/components/GlobalSnackbar'; interface ResultViewProps { result: string; mode: 'ts2dt' | 'dt2ts'; unit: UnitType; zone: string; - showMessage?: (message: string, options?: { severity: 'success' | 'error' }) => void; + showMessage?: (message: string, options?: SnackbarOptions) => void; } const ResultView = React.memo(({ result, mode, unit, zone, showMessage }: ResultViewProps) => { @@ -57,6 +58,9 @@ const ResultView = React.memo(({ result, mode, unit, zone, showMessage }: Result mb: 2.5, border: '1px solid', borderColor: alpha(timestampPageStyles.primaryColor, 0.1), + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', }} > @@ -95,7 +93,6 @@ const ResultView = React.memo(({ result, mode, unit, zone, showMessage }: Result borderRadius: 4, border: '1px solid', borderColor: alpha(timestampPageStyles.primaryColor, 0.1), - mt: 2, }} > {[ @@ -105,11 +102,11 @@ const ResultView = React.memo(({ result, mode, unit, zone, showMessage }: Result ].map((item) => ( {item.label} diff --git a/entrypoints/popup/pages/components/StorageOptionsGrid.tsx b/entrypoints/popup/pages/components/StorageOptionsGrid.tsx index 07ec2fc..16e5d4f 100644 --- a/entrypoints/popup/pages/components/StorageOptionsGrid.tsx +++ b/entrypoints/popup/pages/components/StorageOptionsGrid.tsx @@ -26,77 +26,79 @@ export default function StorageOptionsGrid({ border: '1px solid', borderColor: 'grey.100', borderRadius: 4, - p: 1.2, bgcolor: 'background.paper', boxShadow: '0 4px 12px rgba(0, 0, 0, 0.05)', transition: 'all 0.2s', + overflow: 'hidden', '&:hover': { boxShadow: '0 6px 16px rgba(0, 0, 0, 0.08)', }, }} > - - - onOptionChange('localStorage')} - /> + + + + onOptionChange('localStorage')} + /> + + + onOptionChange('sessionStorage')} + /> + + + onOptionChange('indexedDB')} + /> + + + onOptionChange('cookies')} + /> + + + onOptionChange('cacheStorage')} + /> + + + onOptionChange('serviceWorkers')} + /> + - - onOptionChange('sessionStorage')} - /> - - - onOptionChange('indexedDB')} - /> - - - onOptionChange('cookies')} - /> - - - onOptionChange('cacheStorage')} - /> - - - onOptionChange('serviceWorkers')} - /> - - - + + 全选所有项 @@ -118,6 +120,7 @@ export default function StorageOptionsGrid({ color="warning" sx={{ p: 0.6, + mr: 0, '& .MuiSvgIcon-root': { fontSize: 18, transition: 'transform 0.2s', diff --git a/entrypoints/popup/pages/useStorageCleaner.ts b/entrypoints/popup/pages/useStorageCleaner.ts index 86e70d2..52bd053 100644 --- a/entrypoints/popup/pages/useStorageCleaner.ts +++ b/entrypoints/popup/pages/useStorageCleaner.ts @@ -1,5 +1,6 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import { storageUtil } from '@/utils/chromeStorage'; +import type { SnackbarOptions } from '@/components/GlobalSnackbar'; import type { StorageCleanerOptions, CleaningResult, @@ -57,10 +58,7 @@ export interface UseStorageCleanerReturn { } export interface UseStorageCleanerOptions { - showMessage: ( - message: string, - options?: { severity?: 'success' | 'warning' | 'error' | 'info' }, - ) => void; + showMessage: (message: string, options?: SnackbarOptions) => void; } export function useStorageCleaner({ diff --git a/entrypoints/sidepanel/pages/FormFillSidePanel.tsx b/entrypoints/sidepanel/pages/FormFillSidePanel.tsx deleted file mode 100644 index ccf00e1..0000000 --- a/entrypoints/sidepanel/pages/FormFillSidePanel.tsx +++ /dev/null @@ -1,477 +0,0 @@ -import { useState } from 'react'; -import { - Box, - Typography, - Paper, - Button, - Checkbox, - TextField, - IconButton, - CircularProgress, - Switch, - FormControlLabel, - Tooltip, - Alert, -} from '@mui/material'; -import RefreshIcon from '@mui/icons-material/Refresh'; -import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh'; -import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; -import HighlightAltIcon from '@mui/icons-material/HighlightAlt'; -import GlobalSnackbar, { useSnackbar } from '@/components/GlobalSnackbar'; -import { FieldType, FillMode } from '@/utils/dummyDataGenerator'; -import { MessageAction, sendMessageToContent, injectContentScript } from '@/utils/messages'; - -interface FieldData { - id: string; - fieldType: FieldType; - label: string | null; - placeholder: string; - name: string; - value: string; - isSelected: boolean; - generatedValue: string; -} - -const FormFillSidePanel = () => { - const { snackbarProps, showMessage } = useSnackbar({ autoHideDuration: 1500 }); - const [loading, setLoading] = useState(false); - const [scanning, setScanning] = useState(false); - const [fields, setFields] = useState([]); - const [mode, setMode] = useState<'valid' | 'invalid'>('valid'); - const [fillEmptyOnly, setFillEmptyOnly] = useState(false); - const [includeHidden] = useState(false); - const [hoveredFieldId, setHoveredFieldId] = useState(null); - - const handleScan = 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) { - setFields(response.fields || []); - showMessage(`扫描完成,发现 ${response.totalCount} 个可填充字段`, { severity: 'success' }); - } else { - showMessage(response.message || '扫描失败', { severity: 'error' }); - } - } catch (error) { - console.error('扫描失败:', error); - showMessage('扫描失败,请确保页面已加载', { severity: 'error' }); - } finally { - setScanning(false); - } - }; - - const handleRefreshAll = async () => { - const updatedFields = fields.map((field) => ({ - ...field, - generatedValue: generateRandomValue(field.fieldType, mode), - })); - setFields(updatedFields); - showMessage('已刷新所有数据', { severity: 'success' }); - }; - - const handleRefreshField = (fieldId: string) => { - setFields((prevFields) => - prevFields.map((field) => - field.id === fieldId - ? { ...field, generatedValue: generateRandomValue(field.fieldType, mode) } - : field, - ), - ); - }; - - const handleToggleSelect = (fieldId: string) => { - setFields((prevFields) => - prevFields.map((field) => - field.id === fieldId ? { ...field, isSelected: !field.isSelected } : field, - ), - ); - }; - - const handleSelectAll = () => { - const allSelected = fields.every((f) => f.isSelected); - setFields((prevFields) => prevFields.map((field) => ({ ...field, isSelected: !allSelected }))); - }; - - const handleEditValue = (fieldId: string, newValue: string) => { - setFields((prevFields) => - prevFields.map((field) => - field.id === fieldId ? { ...field, generatedValue: newValue } : field, - ), - ); - }; - - const handleFill = async () => { - setLoading(true); - try { - const selectedFields = fields.filter((f) => f.isSelected); - const response = await sendMessageToContent(MessageAction.FILL_SELECTED_FIELDS, { - fields: selectedFields, - mode: mode === 'valid' ? FillMode.VALID : FillMode.INVALID, - includeHidden, - }); - if (response.success) { - showMessage(response.message || '填充成功', { severity: 'success' }); - } else { - showMessage(response.message || '填充失败', { severity: 'error' }); - } - } catch (error) { - console.error('填充失败:', error); - showMessage('填充失败', { severity: 'error' }); - } finally { - setLoading(false); - } - }; - - const handleClear = async () => { - setLoading(true); - try { - const 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); - showMessage('清空失败', { severity: 'error' }); - } finally { - setLoading(false); - } - }; - - const handleHoverField = async (fieldId: string | null) => { - setHoveredFieldId(fieldId); - if (fieldId) { - await sendMessageToContent(MessageAction.HIGHLIGHT_FIELD, { fieldId }); - } else { - await sendMessageToContent(MessageAction.UNHIGHLIGHT_ALL_FIELDS); - } - }; - - const generateRandomValue = (fieldType: FieldType, fillMode: 'valid' | 'invalid'): string => { - const chineseNames = ['张三', '李四', '王五', '赵六', '钱七', '孙八', '周九', '吴十']; - const englishNames = ['John Doe', 'Jane Smith', 'Bob Wilson', 'Alice Brown']; - const domains = ['example.com', 'test.com', 'demo.com']; - const specialChars = '!@#$%^&*()'; - - switch (fieldType) { - case FieldType.NAME: - return Math.random() > 0.5 - ? chineseNames[Math.floor(Math.random() * chineseNames.length)] - : englishNames[Math.floor(Math.random() * englishNames.length)]; - case FieldType.EMAIL: { - const emailPrefix = Math.random().toString(36).substr(2, 8); - const emailDomain = domains[Math.floor(Math.random() * domains.length)]; - return fillMode === 'valid' ? `${emailPrefix}@${emailDomain}` : `${emailPrefix}example.com`; - } - case FieldType.PHONE: { - const phonePrefixes = ['130', '131', '132', '135', '136', '137', '138', '139']; - const phonePrefix = phonePrefixes[Math.floor(Math.random() * phonePrefixes.length)]; - const phoneSuffix = Math.floor(Math.random() * 100000000) - .toString() - .padStart(8, '0'); - return fillMode === 'valid' - ? phonePrefix + phoneSuffix - : phonePrefix + Math.floor(Math.random() * 10000000).toString(); - } - case FieldType.NUMBER: - return fillMode === 'valid' - ? String(Math.floor(Math.random() * 10000)) - : String(-Math.floor(Math.random() * 10000)); - case FieldType.DATE: { - const days = Math.floor(Math.random() * 365); - const date = new Date(); - date.setDate(date.getDate() + days); - return date.toISOString().split('T')[0]; - } - case FieldType.TEXTarea: - return fillMode === 'valid' - ? '这是一段测试文本,用于填充表单输入区域。'.repeat(3) - : specialChars.repeat(100); - case FieldType.PASSWORD: - return 'Test@123'; - case FieldType.ID_CARD: { - const areaCode = '110101'; - const year = 1990 + Math.floor(Math.random() * 30); - 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; - } - default: - return fillMode === 'valid' ? '测试数据' : specialChars.repeat(50); - } - }; - - const getFieldTypeLabel = (fieldType: FieldType): string => { - const labels: Record = { - [FieldType.TEXT]: '文本', - [FieldType.EMAIL]: '邮箱', - [FieldType.PHONE]: '手机', - [FieldType.NUMBER]: '数字', - [FieldType.DATE]: '日期', - [FieldType.TEXTarea]: '文本域', - [FieldType.RADIO]: '单选', - [FieldType.CHECKBOX]: '多选', - [FieldType.SELECT]: '下拉', - [FieldType.PASSWORD]: '密码', - [FieldType.NAME]: '姓名', - [FieldType.ID_CARD]: '身份证', - [FieldType.UNKNOWN]: '未知', - }; - return labels[fieldType] || '未知'; - }; - - const selectedCount = fields.filter((f) => f.isSelected).length; - - return ( - - - - Dummy Data Pro - - - 智能表单填充助手 - - - - - - - - - - - - - - - setFillEmptyOnly(e.target.checked)} - size="small" - /> - } - label="仅填充空字段" - sx={{ mb: 1 }} - /> - - - - - - - - {fields.length > 0 && ( - - - - 字段列表 ({fields.length}) - - - - - - {fields.map((field) => ( - handleHoverField(field.id)} - onMouseLeave={() => handleHoverField(null)} - > - - handleToggleSelect(field.id)} - sx={{ p: 0, mt: 0.5 }} - /> - - - - {field.label || field.placeholder || field.name || '未命名字段'} - - - - {getFieldTypeLabel(field.fieldType)} - - - - { - e.stopPropagation(); - handleHoverField(field.id); - setTimeout(() => handleHoverField(null), 2000); - }} - sx={{ p: 0.5 }} - > - - - - - - handleEditValue(field.id, e.target.value)} - placeholder="生成的数据..." - sx={{ - '& .MuiInputBase-input': { - fontSize: '0.8rem', - fontFamily: 'monospace', - }, - }} - /> - - { - e.stopPropagation(); - handleRefreshField(field.id); - }} - sx={{ p: 0.5 }} - > - - - - - {field.value && ( - - 当前值: {field.value} - - )} - - - - ))} - - - )} - - {fields.length === 0 && !scanning && ( - - - 点击「扫描表单」按钮开始扫描当前页面的表单字段 - - - )} - - - - ); -}; - -export default FormFillSidePanel; diff --git a/types/storage.d.ts b/types/storage.d.ts index 816eca0..65299d6 100644 --- a/types/storage.d.ts +++ b/types/storage.d.ts @@ -20,6 +20,13 @@ export interface StorageSchema { 'openUrl/currentUrl': string; 'qrCode/qrExpanded': boolean; 'qrCode/urlExpanded': boolean; + 'formRecognizer/fieldTypePreferences': FieldTypePreferences; +} + +export interface FieldTypePreferences { + [domain: string]: { + [fieldIdentifier: string]: string; + }; } export interface StorageCleanerPreferences { diff --git a/utils/clipboard.ts b/utils/clipboard.ts index a6d5a55..e1d5ab8 100644 --- a/utils/clipboard.ts +++ b/utils/clipboard.ts @@ -1,3 +1,4 @@ +import type { SnackbarOptions } from '@/components/GlobalSnackbar'; import { useSnackbar } from '@/components/GlobalSnackbar'; /** @@ -8,7 +9,7 @@ import { useSnackbar } from '@/components/GlobalSnackbar'; */ export const copyToClipboard = async ( text: string, - showMessage?: (message: string, options?: { severity: 'success' | 'error' }) => void, + showMessage?: (message: string, options?: SnackbarOptions) => void, ): Promise => { try { await navigator.clipboard.writeText(text); diff --git a/utils/dummyDataGenerator.ts b/utils/dummyDataGenerator.ts index c8f3e91..d79e17a 100644 --- a/utils/dummyDataGenerator.ts +++ b/utils/dummyDataGenerator.ts @@ -1,4 +1,4 @@ -import { faker, fakerZH_CN } from '@faker-js/faker'; +import { fakerZH_CN as faker } from '@faker-js/faker'; /** * 表单字段信息接口 @@ -34,7 +34,7 @@ export class DummyDataGenerator { * 生成随机中文姓名 */ static generateChineseName(): string { - return fakerZH_CN.person.fullName(); + return faker.person.fullName(); } /** @@ -45,17 +45,20 @@ export class DummyDataGenerator { } /** - * 生成随机手机号 + * 生成随机手机号(中国格式) */ static generatePhoneNumber(): string { - return fakerZH_CN.phone.number(); + const prefix = + '1' + faker.string.numeric({ length: 1, allowLeadingZeros: false, exclude: ['0', '1', '2'] }); + const suffix = faker.string.numeric({ length: 9, allowLeadingZeros: true }); + return prefix + suffix; } /** * 生成有效邮箱 */ static generateValidEmail(): string { - return fakerZH_CN.internet.email(); + return faker.internet.email(); } /** @@ -77,14 +80,14 @@ export class DummyDataGenerator { * 生成短文本 */ static generateShortText(): string { - return fakerZH_CN.lorem.sentence({ min: 3, max: 6 }); + return faker.lorem.sentence({ min: 3, max: 6 }); } /** * 生成长文本 */ static generateLongText(): string { - return fakerZH_CN.lorem.paragraphs(5); + return faker.lorem.paragraphs(5); } /** @@ -106,42 +109,42 @@ export class DummyDataGenerator { * 生成随机数字 */ static generateNumber(): number { - return fakerZH_CN.number.int(10000); + return faker.number.int(10000); } /** * 生成随机浮点数 */ static generateFloat(): number { - return fakerZH_CN.number.float({ max: 10000 }); + return faker.number.float({ max: 10000 }); } /** * 生成随机负数 */ static generateNegativeNumber(): number { - return -fakerZH_CN.number.int(10000); + return -faker.number.int(10000); } /** * 生成随机日期 */ static generateDate(): string { - return fakerZH_CN.date.recent({ days: 365 }).toISOString().split('T')[0]; + return faker.date.recent({ days: 365 }).toISOString().split('T')[0]; } /** * 生成过去的日期 */ static generatePastDate(): string { - return fakerZH_CN.date.past({ years: 1 }).toISOString().split('T')[0]; + return faker.date.past({ years: 1 }).toISOString().split('T')[0]; } /** * 生成未来的日期 */ static generateFutureDate(): string { - return fakerZH_CN.date.future({ years: 1 }).toISOString().split('T')[0]; + return faker.date.future({ years: 1 }).toISOString().split('T')[0]; } /** @@ -779,14 +782,23 @@ export function fillFieldWithInjector( } /** - * 批量填充选中的字段 + * 批量填充选中的字段(支持单字段模式覆盖) */ -export function fillSelectedFields(fields: FormFieldInfo[], mode: FillMode): number { +export function fillSelectedFields( + fields: Array, + defaultMode: FillMode, +): number { let filledCount = 0; fields.forEach((field) => { if (field.isSelected) { - const value = field.generatedValue || generateValueByFieldType(field.fieldType, mode); + const mode = field.useInvalidData + ? FillMode.INVALID + : field.useInvalidData === false + ? FillMode.VALID + : defaultMode; + // 始终根据当前 fieldType 重新生成值,确保类型变更生效 + const value = generateValueByFieldType(field.fieldType, mode); fillFieldWithInjector(field.element, value); filledCount++; } @@ -795,6 +807,35 @@ export function fillSelectedFields(fields: FormFieldInfo[], mode: FillMode): num return filledCount; } +/** + * 闪烁字段(用于定位) + */ +export function flashField(element: HTMLElement): void { + let flashCount = 0; + const maxFlashes = 4; + const originalStyle = + element.getAttribute('data-original-style') || element.getAttribute('style') || ''; + element.setAttribute('data-original-style', originalStyle); + + const flash = () => { + if (flashCount >= maxFlashes) { + unhighlightField(element); + return; + } + if (flashCount % 2 === 0) { + element.style.outline = '3px solid #4caf50'; + element.style.outlineOffset = '2px'; + element.style.transition = 'outline 0.3s ease-in-out'; + } else { + element.style.outline = ''; + } + flashCount++; + setTimeout(flash, 300); + }; + + flash(); +} + /** * 高亮指定字段 */ diff --git a/utils/messages.ts b/utils/messages.ts index f9e275b..1985f18 100644 --- a/utils/messages.ts +++ b/utils/messages.ts @@ -1,5 +1,12 @@ import { FormFieldInfo, FillMode } from './dummyDataGenerator'; +/** + * 字段数据接口(用于消息传递) + */ +interface MessageFieldData extends Omit { + useInvalidData?: boolean; +} + /** * 消息动作类型 */ @@ -19,6 +26,9 @@ export enum MessageAction { UNHIGHLIGHT_FIELD = 'unhighlightField', HIGHLIGHT_ALL_FIELDS = 'highlightAllFields', UNHIGHLIGHT_ALL_FIELDS = 'unhighlightAllFields', + + // 字段定位/闪烁 + FLASH_FIELD = 'flashField', } /** @@ -28,7 +38,7 @@ export interface MessagePayload { action: MessageAction | string; tabId?: number; delay?: number; - fields?: Omit[]; + fields?: MessageFieldData[]; mode?: FillMode; includeHidden?: boolean; fieldId?: string; diff --git a/utils/useStorageState.ts b/utils/useStorageState.ts index 800dbbd..3277e08 100644 --- a/utils/useStorageState.ts +++ b/utils/useStorageState.ts @@ -1,28 +1,37 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { storageUtil } from '@/utils/chromeStorage'; +import type { StorageSchema } from '@/types/storage'; -export const useStorageState = ( - key: 'qrCode/urlExpanded' | 'qrCode/qrExpanded', - defaultValue: boolean, +export const useStorageState = ( + key: K, + defaultValue: StorageSchema[K], ) => { const [value, setValue] = useState(defaultValue); const [isInitialized, setIsInitialized] = useState(false); + const hasLoadedFromStorage = useRef(false); + // Only load from storage once on mount useEffect(() => { + if (hasLoadedFromStorage.current) return; + const loadState = async () => { try { const savedValue = await storageUtil.get(key, defaultValue); - setValue(savedValue ?? defaultValue); + if (savedValue !== undefined) { + setValue(savedValue); + } } catch (error) { console.error(`加载状态失败 (${key}):`, error); } finally { setIsInitialized(true); + hasLoadedFromStorage.current = true; } }; loadState(); - }, [key, defaultValue]); + }, [key]); // eslint-disable-line react-hooks/exhaustive-deps -- defaultValue intentionally excluded to prevent infinite loops + // Save to storage when value changes (after initial load) useEffect(() => { if (!isInitialized) return;