* 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运行时间和资源消耗
This commit is contained in:
LingandRX
2026-04-28 08:56:47 +08:00
committed by GitHub
parent fb5f98dd3b
commit c039475119
29 changed files with 908 additions and 1317 deletions
+25 -3
View File
@@ -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: '未知操作' });
}
+354 -161
View File
@@ -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<FieldData[]>([]);
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<DataTemplate[]>([]);
const [showTemplates, setShowTemplates] = useState(false);
const [templateLoading, setTemplateLoading] = 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 () => {
@@ -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 (
<Box sx={{ bgcolor: dashboardPageStyles.backgroundColor, minHeight: '100%', pb: 4 }}>
<Container maxWidth="sm" sx={{ py: 3, px: 2, bgcolor: '#f5f5f5' }}>
<Box sx={{ mb: 4 }}>
<Typography variant="h6" component="h1" sx={{ fontWeight: 700, mb: 1 }}>
Dummy Data Generator
</Typography>
<Typography variant="body2" color="text.secondary">
</Typography>
</Box>
{/* Header */}
<PageHeader
title="表单测试数据填充器"
subtitle="一键填充表单测试数据,提升开发和测试效率"
icon={<InputIcon />}
sx={{ mb: 2.5 }}
/>
<Button
size="small"
startIcon={<OpenInNewIcon />}
onClick={handleOpenSidePanel}
sx={{
textTransform: 'none',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
opacity: sidePanelOpen ? 0 : 1,
transform: sidePanelOpen ? 'scale(0.8)' : 'scale(1)',
pointerEvents: sidePanelOpen ? 'none' : 'auto',
visibility: sidePanelOpen ? 'hidden' : 'visible',
position: 'relative',
}}
>
</Button>
{/* 扫描按钮 */}
<Button
@@ -252,34 +384,95 @@ const FormRecognizerPage = () => {
fields={fields}
showFields={showFields}
onToggleShowFields={() => setShowFields(!showFields)}
onFieldTypeChange={handleFieldTypeChange}
onLocateField={handleLocateField}
onHoverField={handleHoverField}
onToggleFieldSelection={handleToggleFieldSelection}
onToggleAllFields={handleToggleAllFields}
hoveredFieldId={hoveredFieldId}
/>
<MainActions
loading={loading}
onFillValidData={handleFillValidData}
onFillInvalidData={handleFillInvalidData}
onClearAllFields={handleClearAllFields}
/>
{/* 操作按钮 */}
{fields.length > 0 && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Button
disableElevation
disableRipple
variant="contained"
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>
<OptionsPanel includeHidden={includeHidden} onIncludeHiddenChange={setIncludeHidden} />
<Button
disableElevation
disableRipple
variant="outlined"
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>
</Box>
)}
<OperationHistory
history={operationHistory}
showHistory={showHistory}
onToggleShowHistory={() => setShowHistory(!showHistory)}
/>
<TemplateManager
templates={templates}
showTemplates={showTemplates}
templateLoading={templateLoading}
onToggleShowTemplates={() => setShowTemplates(!showTemplates)}
onLoadTemplates={loadTemplates}
onExportTemplates={handleExportTemplates}
onImportTemplates={handleImportTemplates}
/>
<FeatureDescription />
<Box sx={{ mt: 3 }}>
<FormControlLabel
control={
<Switch
checked={includeHidden}
onChange={(e) => setIncludeHidden(e.target.checked)}
/>
}
label="包含隐藏字段"
/>
</Box>
<GlobalSnackbar {...snackbarProps} />
</Container>
+9 -29
View File
@@ -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() {
<Box sx={{ bgcolor: dashboardPageStyles.backgroundColor, minHeight: '100%', pb: 3 }}>
<Container sx={{ py: 2 }}>
{/* Header */}
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ mb: 2.5 }}>
<Box
sx={{
p: 1,
borderRadius: 2.5,
bgcolor: alpha(THEME_COLOR, 0.1),
color: THEME_COLOR,
display: 'flex',
}}
>
<LanguageIcon sx={{ fontSize: 20 }} />
</Box>
<Box sx={{ flex: 1 }}>
<Typography
variant="subtitle1"
fontWeight={900}
sx={{ letterSpacing: '-0.5px', lineHeight: 1.2 }}
>
URL
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 600 }}>
URL
</Typography>
</Box>
</Stack>
<PageHeader
title="URL 工具"
subtitle="快速打开 URL 或复制链接"
icon={<LanguageIcon />}
sx={{ mb: 2.5 }}
/>
{/* Form Section */}
<UrlEntryForm onAddEntry={handleAddEntry} showMessage={showMessage} />
+9 -29
View File
@@ -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 (
<Box sx={{ minHeight: '100%', pb: 3, bgcolor: dashboardPageStyles.backgroundColor }}>
<Container sx={{ py: 2, maxWidth: 400, bgcolor: dashboardPageStyles.backgroundColor }}>
{/* Header */}
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ mb: 2.5 }}>
<Box
sx={{
p: 1,
borderRadius: 2.5,
bgcolor: alpha(qrCodePageStyles.successColor, 0.1),
color: qrCodePageStyles.successColor,
display: 'flex',
}}
>
<QrCodeIcon sx={{ fontSize: 20 }} />
</Box>
<Box sx={{ flex: 1 }}>
<Typography
variant="subtitle1"
fontWeight={900}
sx={{ letterSpacing: '-0.5px', lineHeight: 1.2 }}
>
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 600 }}>
</Typography>
</Box>
</Stack>
<PageHeader
title="二维码工具"
subtitle="生成和解析二维码"
icon={<QrCodeIcon />}
sx={{ mb: 2.5 }}
/>
<Stack spacing={3}>
<UrlToQrCodeSection
+10 -37
View File
@@ -1,16 +1,8 @@
import {
TextField,
Select,
MenuItem,
Stack,
Typography,
Box,
Container,
alpha,
} from '@mui/material';
import { TextField, Select, MenuItem, Stack, Box, Container, alpha } from '@mui/material';
import GlobalSnackbar, { useSnackbar } from '@/components/GlobalSnackbar';
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 LiveClock from './components/LiveClock';
import ResultView from './components/ResultView';
@@ -38,32 +30,13 @@ export default function TimestampPage() {
return (
<Box sx={{ bgcolor: globalStyles.backgroundColor, minHeight: '100%', pb: 3 }}>
<Container sx={{ py: 2, bgcolor: globalStyles.backgroundColor }}>
{/* Header with Icon */}
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ mb: 2.5 }}>
<Box
sx={{
p: 1,
borderRadius: 2.5,
bgcolor: alpha(timestampPageStyles.primaryColor, 0.1),
color: timestampPageStyles.primaryColor,
display: 'flex',
}}
>
<AccessTimeIcon sx={{ fontSize: 20 }} />
</Box>
<Box sx={{ flex: 1 }}>
<Typography
variant="subtitle1"
fontWeight={900}
sx={{ letterSpacing: '-0.5px', lineHeight: 1.2 }}
>
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 600 }}>
Unix
</Typography>
</Box>
</Stack>
{/* Header */}
<PageHeader
title="时间戳转换"
subtitle="Unix 毫秒数转换与格式化"
icon={<AccessTimeIcon />}
sx={{ mb: 2.5 }}
/>
{/* Live Clock Card */}
<LiveClock
@@ -181,7 +154,7 @@ export default function TimestampPage() {
fullWidth
value={zone}
onChange={(e) => 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)' },
@@ -25,7 +25,7 @@ export default function AutoRefreshToggle({ autoRefresh, onChange }: AutoRefresh
},
}}
>
<Typography variant="body2" fontWeight={700} sx={{ fontSize: '0.8rem' }}>
<Typography variant="body2" fontWeight={700} sx={{ fontSize: '0.8rem', px: 1.2 }}>
</Typography>
<Switch
@@ -1,85 +1,84 @@
import { Box, Stack, Typography } from '@mui/material';
import { Box } from '@mui/material';
import StorageIcon from '@mui/icons-material/Storage';
import PageHeader from '@/components/PageHeader';
import { formatSize } from '@/utils/storageCleaner';
import { storageCleanerPageStyles } from '@/config/pageTheme';
/**
* DomainHeader 组件属性接口
*/
interface DomainHeaderProps {
/** 当前域名 */
domain: string;
/** 已占用的存储大小(字节) */
totalSize: number;
}
/**
* DomainHeader - 存储清理页面标题栏组件
*
* 使用 PageHeader 组件构建,显示域名和已占用存储空间大小
*
* @example
* ```tsx
* <DomainHeader
* domain="example.com"
* totalSize={1048576}
* />
* ```
*/
export default function DomainHeader({ domain, totalSize }: DomainHeaderProps) {
return (
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ mb: 3 }}>
<Box
sx={{
p: 1.2,
borderRadius: 3,
bgcolor: 'rgba(255, 152, 0, 0.1)',
color: storageCleanerPageStyles.warningColor,
display: 'flex',
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)',
},
}}
>
<StorageIcon sx={{ fontSize: 22 }} />
</Box>
<Box sx={{ flex: 1 }}>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Typography
variant="h6"
fontWeight={900}
<PageHeader
icon={<StorageIcon sx={{ fontSize: 22 }} />}
iconColor={storageCleanerPageStyles.warningColor}
title="存储清理"
subtitle={domain || '加载中...'}
badge={
totalSize > 0 ? (
<Box
sx={{
letterSpacing: '-0.5px',
lineHeight: 1.2,
fontSize: '1rem',
color: 'text.primary',
bgcolor: 'rgba(255, 152, 0, 0.15)',
color: storageCleanerPageStyles.warningColor,
px: 1.5,
py: 0.3,
borderRadius: 2,
fontWeight: 800,
fontSize: '0.7rem',
boxShadow: '0 2px 4px rgba(255, 152, 0, 0.2)',
transition: 'all 0.2s',
'&:hover': {
bgcolor: 'rgba(255, 152, 0, 0.25)',
},
}}
>
</Typography>
{totalSize > 0 && (
<Box
sx={{
bgcolor: 'rgba(255, 152, 0, 0.15)',
color: storageCleanerPageStyles.warningColor,
px: 1.5,
py: 0.3,
borderRadius: 2,
fontWeight: 800,
fontSize: '0.7rem',
boxShadow: '0 2px 4px rgba(255, 152, 0, 0.2)',
transition: 'all 0.2s',
'&:hover': {
bgcolor: 'rgba(255, 152, 0, 0.25)',
},
}}
>
{formatSize(totalSize)}
</Box>
)}
</Stack>
<Typography
variant="body2"
color="text.secondary"
sx={{
fontWeight: 600,
display: 'block',
maxWidth: 240,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
mt: 0.3,
fontSize: '0.75rem',
}}
>
{domain || '加载中...'}
</Typography>
</Box>
</Stack>
{formatSize(totalSize)}
</Box>
) : 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 }}
/>
);
}
@@ -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) => {
@@ -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',
}}
>
<Typography
@@ -77,12 +81,6 @@ const ResultView = React.memo(({ result, mode, unit, zone, showMessage }: Result
tooltip="复制结果"
size="small"
color={timestampPageStyles.primaryColor}
style={{
position: 'absolute',
right: 8,
top: '50%',
transform: 'translateY(-50%)',
}}
showMessage={showMessage}
/>
</Box>
@@ -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) => (
<Box
key={item.label}
sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', px: 1 }}
sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}
>
<Typography
variant="caption"
sx={{ color: 'text.disabled', fontWeight: 700, fontSize: '0.65rem' }}
sx={{ color: 'text.disabled', fontWeight: 700, fontSize: '0.65rem', pr: 4 }}
>
{item.label}
</Typography>
@@ -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)',
},
}}
>
<Grid container spacing={1.5}>
<Grid size={6}>
<OptionItem
label="LocalStorage"
checked={options.localStorage}
size={sizes.localStorage}
onChange={() => onOptionChange('localStorage')}
/>
<Box sx={{ p: 1.2 }}>
<Grid container spacing={1.5}>
<Grid size={6}>
<OptionItem
label="LocalStorage"
checked={options.localStorage}
size={sizes.localStorage}
onChange={() => onOptionChange('localStorage')}
/>
</Grid>
<Grid size={6}>
<OptionItem
label="Session Storage"
checked={options.sessionStorage}
size={sizes.sessionStorage}
onChange={() => onOptionChange('sessionStorage')}
/>
</Grid>
<Grid size={6}>
<OptionItem
label="IndexedDB"
checked={options.indexedDB}
size={sizes.indexedDB}
onChange={() => onOptionChange('indexedDB')}
/>
</Grid>
<Grid size={6}>
<OptionItem
label="Cookies"
checked={options.cookies}
size={sizes.cookies}
onChange={() => onOptionChange('cookies')}
/>
</Grid>
<Grid size={6}>
<OptionItem
label="Cache Storage"
checked={options.cacheStorage}
size={sizes.cacheStorage}
isCount
onChange={() => onOptionChange('cacheStorage')}
/>
</Grid>
<Grid size={6}>
<OptionItem
label="Service Workers"
checked={options.serviceWorkers}
size={sizes.serviceWorkers}
isCount
onChange={() => onOptionChange('serviceWorkers')}
/>
</Grid>
</Grid>
<Grid size={6}>
<OptionItem
label="Session Storage"
checked={options.sessionStorage}
size={sizes.sessionStorage}
onChange={() => onOptionChange('sessionStorage')}
/>
</Grid>
<Grid size={6}>
<OptionItem
label="IndexedDB"
checked={options.indexedDB}
size={sizes.indexedDB}
onChange={() => onOptionChange('indexedDB')}
/>
</Grid>
<Grid size={6}>
<OptionItem
label="Cookies"
checked={options.cookies}
size={sizes.cookies}
onChange={() => onOptionChange('cookies')}
/>
</Grid>
<Grid size={6}>
<OptionItem
label="Cache Storage"
checked={options.cacheStorage}
size={sizes.cacheStorage}
isCount
onChange={() => onOptionChange('cacheStorage')}
/>
</Grid>
<Grid size={6}>
<OptionItem
label="Service Workers"
checked={options.serviceWorkers}
size={sizes.serviceWorkers}
isCount
onChange={() => onOptionChange('serviceWorkers')}
/>
</Grid>
</Grid>
<Divider sx={{ my: 1.2, borderColor: 'grey.100' }} />
</Box>
<Divider sx={{ mx: 0, borderColor: 'grey.100' }} />
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
px: 1.5,
py: 0.6,
bgcolor: 'rgba(0, 0, 0, 0.02)',
borderRadius: 2,
px: 2.7,
py: 0.8,
borderBottomLeftRadius: 4,
borderBottomRightRadius: 4,
transition: 'all 0.2s',
'&:hover': {
bgcolor: 'rgba(0, 0, 0, 0.04)',
@@ -106,7 +108,7 @@ export default function StorageOptionsGrid({
<Typography
variant="body2"
fontWeight={700}
sx={{ color: 'text.secondary', fontSize: '0.7rem' }}
sx={{ color: 'text.secondary', fontSize: '0.7rem', px: 0 }}
>
</Typography>
@@ -118,6 +120,7 @@ export default function StorageOptionsGrid({
color="warning"
sx={{
p: 0.6,
mr: 0,
'& .MuiSvgIcon-root': {
fontSize: 18,
transition: 'transform 0.2s',
+2 -4
View File
@@ -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({
@@ -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<FieldData[]>([]);
const [mode, setMode] = useState<'valid' | 'invalid'>('valid');
const [fillEmptyOnly, setFillEmptyOnly] = useState(false);
const [includeHidden] = useState(false);
const [hoveredFieldId, setHoveredFieldId] = useState<string | null>(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, string> = {
[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 (
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column', bgcolor: '#f5f5f5' }}>
<Box sx={{ p: 2, bgcolor: 'white', borderBottom: '1px solid #e0e0e0' }}>
<Typography variant="h6" sx={{ fontWeight: 700, mb: 1 }}>
Dummy Data Pro
</Typography>
<Typography variant="body2" color="text.secondary">
</Typography>
</Box>
<Box sx={{ p: 2, bgcolor: 'white', mb: 1 }}>
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
<Button
variant="contained"
size="small"
startIcon={scanning ? <CircularProgress size={16} color="inherit" /> : <RefreshIcon />}
onClick={handleScan}
disabled={scanning}
sx={{ flex: 1 }}
>
{scanning ? '扫描中...' : '扫描表单'}
</Button>
<Button
variant="outlined"
size="small"
startIcon={<AutoFixHighIcon />}
onClick={handleRefreshAll}
disabled={fields.length === 0}
>
</Button>
</Box>
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
<Button
variant="contained"
color="success"
size="small"
onClick={() => setMode('valid')}
sx={{
flex: 1,
bgcolor: mode === 'valid' ? '#4caf50' : '#e0e0e0',
color: mode === 'valid' ? 'white' : 'text.primary',
}}
>
</Button>
<Button
variant="contained"
color="warning"
size="small"
onClick={() => setMode('invalid')}
sx={{
flex: 1,
bgcolor: mode === 'invalid' ? '#ff9800' : '#e0e0e0',
color: mode === 'invalid' ? 'white' : 'text.primary',
}}
>
</Button>
</Box>
<FormControlLabel
control={
<Switch
checked={fillEmptyOnly}
onChange={(e) => setFillEmptyOnly(e.target.checked)}
size="small"
/>
}
label="仅填充空字段"
sx={{ mb: 1 }}
/>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
variant="contained"
color="primary"
size="small"
startIcon={loading ? <CircularProgress size={16} color="inherit" /> : null}
onClick={handleFill}
disabled={loading || selectedCount === 0}
sx={{ flex: 1 }}
>
{loading ? '填充中...' : `确认填充 (${selectedCount})`}
</Button>
<Button
variant="outlined"
color="error"
size="small"
startIcon={<DeleteOutlineIcon />}
onClick={handleClear}
disabled={loading}
>
</Button>
</Box>
</Box>
{fields.length > 0 && (
<Box sx={{ flex: 1, overflow: 'auto', px: 2, pb: 2 }}>
<Box
sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}
>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
({fields.length})
</Typography>
<Button size="small" onClick={handleSelectAll}>
{fields.every((f) => f.isSelected) ? '取消全选' : '全选'}
</Button>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{fields.map((field) => (
<Paper
key={field.id}
elevation={0}
sx={{
p: 1.5,
borderRadius: 2,
bgcolor: hoveredFieldId === field.id ? '#e3f2fd' : 'white',
border: '1px solid',
borderColor: hoveredFieldId === field.id ? '#2196f3' : '#e0e0e0',
transition: 'all 0.2s ease',
cursor: 'pointer',
}}
onMouseEnter={() => handleHoverField(field.id)}
onMouseLeave={() => handleHoverField(null)}
>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1 }}>
<Checkbox
size="small"
checked={field.isSelected}
onChange={() => handleToggleSelect(field.id)}
sx={{ p: 0, mt: 0.5 }}
/>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
<Typography
variant="body2"
sx={{
fontWeight: 600,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: '60%',
}}
>
{field.label || field.placeholder || field.name || '未命名字段'}
</Typography>
<Tooltip title="字段类型">
<Box
sx={{
px: 0.5,
py: 0.25,
bgcolor: '#e0e0e0',
borderRadius: 1,
fontSize: '0.7rem',
}}
>
{getFieldTypeLabel(field.fieldType)}
</Box>
</Tooltip>
<Tooltip title="高亮显示">
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
handleHoverField(field.id);
setTimeout(() => handleHoverField(null), 2000);
}}
sx={{ p: 0.5 }}
>
<HighlightAltIcon sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<TextField
size="small"
fullWidth
value={field.generatedValue}
onChange={(e) => handleEditValue(field.id, e.target.value)}
placeholder="生成的数据..."
sx={{
'& .MuiInputBase-input': {
fontSize: '0.8rem',
fontFamily: 'monospace',
},
}}
/>
<Tooltip title="刷新此项">
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
handleRefreshField(field.id);
}}
sx={{ p: 0.5 }}
>
<RefreshIcon sx={{ fontSize: 18 }} />
</IconButton>
</Tooltip>
</Box>
{field.value && (
<Typography
variant="caption"
color="text.secondary"
sx={{ mt: 0.5, display: 'block' }}
>
: {field.value}
</Typography>
)}
</Box>
</Box>
</Paper>
))}
</Box>
</Box>
)}
{fields.length === 0 && !scanning && (
<Box
sx={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', p: 3 }}
>
<Alert severity="info" sx={{ width: '100%' }}>
</Alert>
</Box>
)}
<GlobalSnackbar {...snackbarProps} />
</Box>
);
};
export default FormFillSidePanel;