Files
leaudit-platform-frontend/app/components/rules/new/ExtractionSettings.tsx
T

1177 lines
48 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, KeyboardEvent, FormEvent, useContext, useEffect, useCallback, useRef } from 'react';
import { RuleContext } from '~/contexts/RuleContext';
import { processFieldName } from '~/utils';
/**
* ExtractionSettings 组件
*
* 功能:
* - 提供三种抽取设置方式:大模型抽取、多模态抽取和正则抽取
* - 允许在三个标签页中添加不同类型的字段
* - 统一的更新机制,确保点击"更新全部字段"按钮时,所有三种类型的字段都会被收集和更新
*
* 优化后的交互逻辑:
* 1. 用户可以在三个标签页之间切换,在每个标签页中添加对应类型的字段
* 2. 添加字段后,会自动标记为"有未保存更改"状态
* 3. 无论当前在哪个标签页,点击底部的"更新全部字段"按钮都会收集所有三种类型的字段
* 4. 更新成功后会显示详细的字段数量统计信息,包括每种类型的字段数
* 5. 系统会自动检查字段名重复,确保所有字段名唯一
*
* 注意:
* - 仅当点击"更新全部字段"按钮后,字段才会真正提交给父组件和规则上下文
* - 用户必须手动点击更新按钮,才能在评查设置中使用这些字段
*/
interface RegexField {
id: string;
fieldName: string;
regex: string;
}
interface PromptTemplate {
id: number;
template_name: string;
template_type: string;
template_content: string;
}
interface ExtractionSettingsProps {
onChange?: (data: Record<string, unknown>) => void;
initialData?: {
llm_ocr?: {
fields?: string[];
prompt_setting?: {
type?: string;
template?: string;
};
};
llm_vl?: {
fields?: string[];
prompt_setting?: {
type?: string;
template?: string;
};
};
ocr_regex?: {
fields?: RegexField[];
};
};
}
export function ExtractionSettings({ onChange, initialData }: ExtractionSettingsProps) {
const ruleContext = useContext(RuleContext);
const lastUpdateTimeRef = useRef(0); // 添加一个ref来记录上次更新时间
const lastEventFieldsRef = useRef<string[]>([]);
const ignoreEmptyFieldsRef = useRef(false);
const [currentTab, setCurrentTab] = useState('llm_ocr');
const [fields, setFields] = useState<{ [key: string]: string[] }>({
llm_ocr: [],
llm: [],
});
const [inputValue, setInputValue] = useState({
llm_ocr: '',
llm: '',
});
const [selectedFieldType, setSelectedFieldType] = useState('default');
const [regexFields, setRegexFields] = useState<RegexField[]>([{ id: '1', fieldName: '', regex: '' }]);
const [promptType, setPromptType] = useState({ llm_ocr: 'system', llm: 'system' });
const [promptContent, setPromptContent] = useState({ llm_ocr: '', llm: '' });
const [selectedTemplate, setSelectedTemplate] = useState({ llm_ocr: '', llm: '' });
// 添加状态记录每个字段的编辑状态
const [fieldEditStatus, setFieldEditStatus] = useState<{[id: string]: boolean}>({});
// 添加标记表示正在填写的字段ID
const activeFieldRef = useRef<string | null>(null);
// 添加定时器引用
const fieldEditResetTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// 添加状态提示显示
const [statusMessage, setStatusMessage] = useState<{id: string, message: string} | null>(null);
// 添加字段更新状态
const [updateStatus, setUpdateStatus] = useState<{success: boolean, message: string} | null>(null);
// 添加待更新状态
const [hasPendingChanges, setHasPendingChanges] = useState<boolean>(false);
// 加载初始数据
useEffect(() => {
if (initialData) {
const newFields = {
llm_ocr: initialData.llm_ocr?.fields || [],
llm: initialData.llm_vl?.fields || [],
};
setFields(newFields);
setPromptType({
llm_ocr: initialData.llm_ocr?.prompt_setting?.type || 'system',
llm: initialData.llm_vl?.prompt_setting?.type || 'system',
});
setPromptContent({
llm_ocr: initialData.llm_ocr?.prompt_setting?.template || '',
llm: initialData.llm_vl?.prompt_setting?.template || '',
});
if (initialData.ocr_regex?.fields?.length) {
setRegexFields(
initialData.ocr_regex.fields.map((field: RegexField, index: number) => ({
id: String(index + 1),
fieldName: field.fieldName || '',
regex: field.regex || '',
}))
);
}
}
}, [initialData]); // 只依赖 initialData,避免 ruleContext 导致频繁触发
// 自动保存字段变更状态
// 这个效果确保添加字段后自动保存到组件状态,但不自动提交更新
useEffect(() => {
// 仅标记有未保存的更改,不立即触发onChange
setHasPendingChanges(true);
// 这些字段变化不会立即反映到父组件,只在点击更新按钮时才会提交
}, [fields, regexFields]);
// 独立处理父组件传过来的初始数据
useEffect(() => {
if (!initialData && ruleContext?.extractionFields?.length > 0) {
setFields((prevFields) => ({
...prevFields,
[currentTab]: [...ruleContext.extractionFields],
}));
}
}, [ruleContext?.extractionFields, currentTab, initialData]); // 依赖具体属性而非整个 ruleContext
// 获取所有字段(使用 useCallback 稳定函数引用)
const getAllFields = useCallback(() => {
// 1. 收集大模型抽取字段
const llm_ocr_fields = fields.llm_ocr || [];
// 2. 收集多模态抽取字段(去掉类型后缀)
const llm_fields = (fields.llm || []).map((field) => {
// 从字段名_类型格式中提取字段名部分
return field.split('_')[0];
});
// 3. 收集正则抽取字段(仅保留有效字段)
const regex_fields = regexFields
.filter((field) => field.fieldName && field.fieldName.trim() !== '')
.map((field) => field.fieldName.trim());
// 4. 合并所有字段并确保唯一性(使用Set去重)
// 这样即使用户在不同标签页添加了同名字段,最终也只会保留一个
return [...new Set([...llm_ocr_fields, ...llm_fields, ...regex_fields])];
}, [fields, regexFields]);
// 检查字段名是否存在
const isFieldNameExists = useCallback(
(fieldName: string, excludeId?: string): boolean => {
if (!fieldName || !fieldName.trim()) return false;
const fieldNameTrimmed = fieldName.trim();
const fieldNameLower = fieldNameTrimmed.toLowerCase();
// 获取所有字段(不包括regexFields,这部分单独处理)
const llm_ocr_fields = fields.llm_ocr || [];
const llm_fields = (fields.llm || []).map(processFieldName);
// 检查是否在其他类型字段中存在
if (llm_ocr_fields.some(f => f.toLowerCase() === fieldNameLower) ||
llm_fields.some(f => f.toLowerCase() === fieldNameLower)) {
return true;
}
// 检查是否在其他正则字段中存在(排除当前正在编辑的字段)
const otherRegexFields = regexFields
.filter((f) => !excludeId || f.id !== excludeId)
.map((f) => f.fieldName ? f.fieldName.trim() : '');
return otherRegexFields.some(f => f.toLowerCase() === fieldNameLower);
},
[fields, regexFields]
);
// 验证并更新字段的函数
const validateAndUpdateFields = useCallback(() => {
try {
// 收集所有三种类型的字段,无论当前在哪个标签页
// 验证正则字段,只需要字段名有值即可,不要求正则表达式必须有值
const validRegexFields = regexFields.filter(field => field.fieldName && field.fieldName.trim() !== '');
// 检查字段名称是否重复
const fieldNames = new Map<string, number>();
let hasDuplicates = false;
const duplicateFields: string[] = [];
// 收集所有字段名 - 不受当前标签页影响,始终收集所有类型的字段
const allFieldNamesList = [
...fields.llm_ocr,
...fields.llm.map(f => processFieldName(f)),
...validRegexFields.map(f => f.fieldName.trim())
].filter(name => name); // 过滤空值
allFieldNamesList.forEach(name => {
const lowercaseName = name.toLowerCase();
fieldNames.set(lowercaseName, (fieldNames.get(lowercaseName) || 0) + 1);
if (fieldNames.get(lowercaseName)! > 1) {
hasDuplicates = true;
duplicateFields.push(name);
}
});
if (hasDuplicates) {
setUpdateStatus({
success: false,
message: `发现重复字段: ${[...new Set(duplicateFields)].join(', ')},请修正后再更新`
});
return false;
}
// 更新有效的字段列表 - 确保获取所有三种类型的字段
const allFields = getAllFields();
// 创建摘要信息,显示所有类型的字段数量
const llmOcrCount = fields.llm_ocr.length;
const llmVlCount = fields.llm.length;
const regexCount = validRegexFields.length;
const totalCount = allFields.length;
// 更新ruleContext
if (ruleContext?.updateFields) {
ruleContext.updateFields(allFields);
}
// 触发父组件的onChange回调 - 始终传递所有三种类型的字段数据
if (onChange) {
onChange({
fields: {
llm_ocr: fields.llm_ocr,
llm: fields.llm
},
regexFields: validRegexFields,
allFields,
pendingUpdate: false // 标记已完成更新
});
}
// 不再使用自定义事件,统一通过Context共享数据
// 更新上次发送的字段列表和时间
lastEventFieldsRef.current = [...allFields];
lastUpdateTimeRef.current = Date.now();
// 清除待更新状态
setHasPendingChanges(false);
// 生成更详细的成功消息,列出每种类型的字段数量
setUpdateStatus({
success: true,
message: `已成功更新${totalCount}个字段(大模型字段: ${llmOcrCount},多模态字段: ${llmVlCount},正则字段: ${regexCount}`
});
// 3秒后清除更新状态
setTimeout(() => {
setUpdateStatus(null);
}, 3000);
return true;
} catch (error) {
console.error('更新字段时出错:', error);
setUpdateStatus({
success: false,
message: `更新失败: ${error instanceof Error ? error.message : '未知错误'}`
});
return false;
}
}, [fields, regexFields, getAllFields, ruleContext, onChange]);
// 处理更新字段按钮点击
const handleUpdateFields = () => {
// 只有在点击更新按钮时,才执行验证和向父组件提交数据
if (validateAndUpdateFields()) {
// 当更新成功时,才传递字段数据到父组件
const validRegexFields = regexFields.filter(field => field.fieldName && field.fieldName.trim() !== '');
if (onChange) {
onChange({
fields: {
llm_ocr: fields.llm_ocr,
llm: fields.llm
},
regexFields: validRegexFields,
allFields: getAllFields(),
pendingUpdate: false, // 标记已完成更新
// 同时提交提示词设置
promptType,
promptContent,
promptSettings: {
llm_ocr: {
type: promptType.llm_ocr,
content: promptContent.llm_ocr,
template: selectedTemplate.llm_ocr
},
llm: {
type: promptType.llm,
content: promptContent.llm,
template: selectedTemplate.llm
}
}
});
}
}
};
const handleTabChange = (tab: string) => {
setCurrentTab(tab);
// 不触发父组件的onChange回调,只记录当前标签页,使界面切换
// onChange?.({ extractionMethod: tab });
};
const handleFieldInputChange = (e: FormEvent<HTMLInputElement>, type: 'llm_ocr' | 'llm') => {
setInputValue({ ...inputValue, [type]: e.currentTarget.value });
};
const handleFieldTypeChange = (e: FormEvent<HTMLSelectElement>) => {
setSelectedFieldType(e.currentTarget.value);
};
const addField = (type: 'llm_ocr' | 'llm') => {
const value = inputValue[type].trim();
if (!value) return;
if (type === 'llm_ocr') {
const fieldsToAdd = value
.split(/[\s、,]+/)
.map((f) => f.trim())
.filter((f) => f && !isFieldNameExists(f));
if (fieldsToAdd.length === 0) {
alert('所有字段名已存在,请确保字段名称唯一');
return;
}
setFields((prev) => ({ ...prev, [type]: [...prev[type], ...fieldsToAdd] }));
} else {
if (isFieldNameExists(value)) {
alert(`字段名 "${value}" 已存在,请确保字段名称唯一`);
return;
}
setFields((prev) => ({ ...prev, [type]: [...prev[type], `${value}_${selectedFieldType}`] }));
setSelectedFieldType('default');
}
setInputValue((prev) => ({ ...prev, [type]: '' }));
// 标记有未保存的更改
setHasPendingChanges(true);
};
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>, type: 'llm_ocr' | 'llm') => {
if (e.key === 'Enter') {
e.preventDefault();
addField(type);
}
};
const removeField = (type: 'llm_ocr' | 'llm', index: number) => {
setFields((prev) => {
const newFields = [...prev[type]];
newFields.splice(index, 1);
return { ...prev, [type]: newFields };
});
// 标记有未保存的更改
setHasPendingChanges(true);
};
const addRegexFieldRow = () => {
// 使用时间戳和随机数生成唯一ID
const newId = `regex_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
// 设置标记表示正在添加新字段,临时忽略空字段检查
ignoreEmptyFieldsRef.current = true;
// 记录正在添加的字段ID
activeFieldRef.current = newId;
// 标记新字段为编辑状态
setFieldEditStatus(prev => ({ ...prev, [newId]: true }));
// 添加空字段但不会立即触发验证和更新
setRegexFields(prev => {
const newFields = [...prev, { id: newId, fieldName: '', regex: '' }];
// 延迟聚焦到新添加的字段
setTimeout(() => {
const input = document.getElementById(`regex-field-name-${newId}`);
if (input) {
input.focus();
}
// 5秒后重置标记,恢复正常字段检查
setTimeout(() => {
ignoreEmptyFieldsRef.current = false;
}, 5000); // 设置为5秒,给用户足够的时间填写
}, 50);
return newFields;
});
};
const removeRegexFieldRow = (id: string) => {
if (regexFields.length <= 1) return;
setRegexFields((prev) => prev.filter((field) => field.id !== id));
// 标记有未保存的更改
setHasPendingChanges(true);
};
const updateRegexField = (id: string, key: 'fieldName' | 'regex', value: string) => {
// 标记此字段为正在编辑状态
setFieldEditStatus(prev => ({ ...prev, [id]: true }));
// 记录当前活动字段ID
activeFieldRef.current = id;
setRegexFields((prev) =>
prev.map((field) => (field.id === id ? { ...field, [key]: value } : field))
);
// 标记有未保存的更改
setHasPendingChanges(true);
// 如果状态消息是关于这个字段的,且该字段有内容了,则清除状态消息
if (statusMessage?.id === id && value.trim() !== '') {
setTimeout(() => {
setStatusMessage(null);
}, 1500);
}
// 如果用户正在输入,重置编辑状态计时器
if (fieldEditResetTimeoutRef.current) {
clearTimeout(fieldEditResetTimeoutRef.current);
}
// 设置一个更长的超时时间,给用户充分的编辑时间
fieldEditResetTimeoutRef.current = setTimeout(() => {
// 检查字段是否已填写完成
const currentField = regexFields.find(f => f.id === id);
if (currentField) {
// 只有当字段名有值时,才考虑将字段标记为完成状态
if (currentField.fieldName && currentField.fieldName.trim() !== '') {
// 即使正则为空,也不要自动删除字段,只是更新编辑状态
setFieldEditStatus(prev => ({ ...prev, [id]: false }));
if (activeFieldRef.current === id) {
activeFieldRef.current = null;
}
// 如果正则为空,提示用户填写
if (!currentField.regex || currentField.regex.trim() === '') {
setStatusMessage({
id,
message: "正则表达式为空,此字段会保留但不会执行抽取。"
});
// 5秒后自动隐藏提示
setTimeout(() => {
setStatusMessage(current => current?.id === id ? null : current);
}, 5000);
}
}
}
}, 180000); // 给用户3分钟的编辑时间,大幅延长以避免过早丢失编辑状态
};
const handleRegexFieldBlur = (id: string, key: 'fieldName' | 'regex') => {
// 如果用户从正则表达式字段离开并且字段名和正则都已填写,则标记字段编辑完成
const field = regexFields.find((f) => f.id === id);
if (!field) return;
if (key === 'fieldName') {
// 如果字段名为空,不进行任何操作,保留字段
if (!field.fieldName || field.fieldName.trim() === '') {
return;
}
// 检查重复字段
if (isFieldNameExists(field.fieldName, id)) {
alert(`字段名 "${field.fieldName.trim()}" 已存在,请确保字段名称唯一`);
setRegexFields((prev) =>
prev.map((f) => (f.id === id ? { ...f, fieldName: '' } : f))
);
}
} else if (key === 'regex') {
// 如果字段名和正则都已填写,标记为完成状态
if (field.fieldName && field.fieldName.trim() !== '') {
// 即使正则为空,也不要自动删除字段
setTimeout(() => {
// 正则有内容,标记为完成状态
if (field.regex && field.regex.trim() !== '') {
setFieldEditStatus(prev => ({ ...prev, [id]: false }));
if (activeFieldRef.current === id) {
activeFieldRef.current = null;
}
// 显示完成提示
setStatusMessage({
id,
message: "字段配置完成"
});
// 2秒后自动隐藏提示
setTimeout(() => {
setStatusMessage(current => current?.id === id ? null : current);
}, 2000);
}
}, 200);
}
}
};
const applyRegexTemplate = (regex: string) => {
const lastField = regexFields[regexFields.length - 1];
if (lastField) {
updateRegexField(lastField.id, 'regex', regex);
}
};
const getFieldInfo = (field: string) => {
const [fieldName, fieldType = 'default'] = field.split('_');
const typeName =
{
default: '默认',
seal: '印章',
'cross-seal': '骑缝章',
handwriting: '手写体',
print: '印刷体',
english: '英文',
number: '数字',
currency: '货币',
}[fieldType] || '默认';
const badgeClass =
{
default: 'bg-blue-100 text-blue-800',
seal: 'bg-red-100 text-red-800',
'cross-seal': 'bg-red-100 text-red-800',
handwriting: 'bg-yellow-100 text-yellow-800',
print: 'bg-purple-100 text-purple-800',
english: 'bg-indigo-100 text-indigo-800',
number: 'bg-gray-100 text-gray-800',
currency: 'bg-green-100 text-green-800',
}[fieldType] || 'bg-blue-100 text-blue-800';
return { fieldName, fieldType, typeName, badgeClass };
};
const handlePromptTypeChange = (e: FormEvent<HTMLInputElement>, type: 'llm_ocr' | 'llm') => {
const value = e.currentTarget.value;
setPromptType((prev) => ({ ...prev, [type]: value }));
// 标记有未保存的更改,但不触发onChange
setHasPendingChanges(true);
};
const handleTemplateChange = (e: FormEvent<HTMLSelectElement>, type: 'llm_ocr' | 'llm') => {
const value = e.currentTarget.value;
setSelectedTemplate((prev) => ({ ...prev, [type]: value }));
if (value) {
const templateData = getPromptTemplateById(Number(value));
if (templateData) {
let content = templateData.template_content;
if (content.includes('{fieldsList}') && fields[type].length > 0) {
const fieldListStr =
type === 'llm_ocr'
? fields[type].map((field, idx) => `${idx + 1}. ${field}`).join('\n')
: fields[type]
.map((field, idx) => {
const { fieldName, typeName } = getFieldInfo(field);
return `${idx + 1}. ${fieldName} (${typeName})`;
})
.join('\n');
content = content.replace('{fieldsList}', fieldListStr);
}
setPromptContent((prev) => ({ ...prev, [type]: content }));
// 标记有未保存的更改,但不触发onChange
setHasPendingChanges(true);
}
} else {
setPromptContent((prev) => ({ ...prev, [type]: '' }));
// 标记有未保存的更改,但不触发onChange
setHasPendingChanges(true);
}
};
const handlePromptContentChange = (e: FormEvent<HTMLTextAreaElement>, type: 'llm_ocr' | 'llm') => {
const value = e.currentTarget.value;
setPromptContent((prev) => ({ ...prev, [type]: value }));
// 标记有未保存的更改,但不触发onChange
setHasPendingChanges(true);
};
const applyVariableToPrompt = (variable: string, type: 'llm_ocr' | 'llm') => {
const textarea = document.getElementById(
type === 'llm_ocr' ? 'llm-prompt-content' : 'multimodal-prompt-content'
) as HTMLTextAreaElement;
if (textarea) {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const text = textarea.value;
const newText = text.substring(0, start) + `{${variable}}` + text.substring(end);
setPromptContent((prev) => ({ ...prev, [type]: newText }));
setTimeout(() => {
textarea.focus();
textarea.setSelectionRange(start + variable.length + 2, start + variable.length + 2);
}, 0);
// 标记有未保存的更改,但不触发onChange
setHasPendingChanges(true);
}
};
const getPromptTemplateById = (id: number): PromptTemplate | null => {
const templates: Record<number, PromptTemplate> = {
1: {
id: 1,
template_name: '行政处罚-抽取通用模板',
template_type: 'Extraction',
template_content: `你是一个专业的文档信息抽取助手。请从以下{docType}文档中抽取关键信息:\n{fieldsList}\n请将结果以JSON格式输出,包含以上字段。如果某个字段在文档中未找到,则该字段的值设为null。`,
},
4: {
id: 4,
template_name: '采购合同-乙方资质抽取',
template_type: 'Extraction',
template_content: `你是一个专业的合同信息抽取助手。请从以下{docType}中抽取乙方的资质信息:\n需要抽取的信息包括:\n{fieldsList}\n{companyName}要求所有供应商必须提供完整的资质信息。请将结果以JSON格式输出,包含以上字段。`,
},
5: {
id: 5,
template_name: '合同-关键条款抽取',
template_type: 'Extraction',
template_content: `请作为{industry}行业的专业合同审核员,从提供的{docType}中提取以下关键条款信息:\n{fieldsList}\n文档ID: {documentId}\n审核日期: {date}\n请以JSON格式输出结果,对于未明确指定的条款需标记为"未明确约定"。`,
},
6: {
id: 6,
template_name: '烟草许可证-信息抽取',
template_type: 'Extraction',
template_content: `请从下列烟草专卖许可证文件中抽取以下关键信息:\n{fieldsList}\n这些信息将用于{companyName}内部数据库更新。请确保许可证编号和有效期格式准确无误。`,
},
7: {
id: 7,
template_name: '多模态-印章识别模板',
template_type: 'Multimodal',
template_content: `请识别并提取文档中的所有印章信息,包括:\n{fieldsList}\n文档类型: {docType}\n页面范围: {pageRange}\n请注意区分公章、法人章和合同专用章,并分析印章的清晰度和完整性。`,
},
8: {
id: 8,
template_name: '多模态-表格抽取模板',
template_type: 'Multimodal',
template_content: `请从文档中的表格提取以下信息:\n{fieldsList}\n文档类型: {docType}\n表格可能跨页,请确保完整提取所有内容。表格中的数值需保留原始精度。`,
},
9: {
id: 9,
template_name: '多模态-手写内容识别模板',
template_type: 'Multimodal',
template_content: `请识别文档中的手写内容,特别关注:\n{fieldsList}\n文档类型: {docType}\n内容类型: {contentType}\n对于难以辨认的手写内容,请标注为"[难以辨认]"并尽可能给出可能的解读。`,
},
};
return templates[id] || null;
};
return (
<div className="ant-card">
<div className="ant-card-header">
<h3></h3>
</div>
<div className="ant-card-body">
<div className="mb-6">
<div className="tab-nav mb-4" id="extraction-method-tabs">
<button
className={`tab-nav-item ${currentTab === 'llm_ocr' ? 'active' : ''}`}
onClick={() => handleTabChange('llm_ocr')}
type="button"
>
<i className="ri-brain-line mr-1"></i>
</button>
<button
className={`tab-nav-item ${currentTab === 'llm' ? 'active' : ''}`}
onClick={() => handleTabChange('llm')}
type="button"
>
<i className="ri-scan-line mr-1"></i>
</button>
<button
className={`tab-nav-item ${currentTab === 'ocr_regex' ? 'active' : ''}`}
onClick={() => handleTabChange('ocr_regex')}
type="button"
>
<i className="ri-code-box-line mr-1"></i>
</button>
</div>
</div>
<div className={`extraction-config ${currentTab !== 'llm_ocr' ? 'hidden' : ''}`} id="llm-ocr-config">
<div className="grid grid-cols-1 gap-3">
<div className="col-span-1">
<label className="form-label mb-1" htmlFor="field-input-ocr">
</label>
<div className="flex mb-2">
<input
type="text"
className="form-input mr-2"
id="field-input-ocr"
placeholder="请输入字段名,多个字段可用、或,或空格分隔"
value={inputValue.llm_ocr}
onChange={(e) => handleFieldInputChange(e, 'llm_ocr')}
onKeyDown={(e) => handleKeyDown(e, 'llm_ocr')}
/>
<button
className="ant-btn ant-btn-default"
id="add-field-btn-ocr"
type="button"
onClick={() => addField('llm_ocr')}
>
</button>
</div>
<div className="chips-container" id="fields-container-ocr">
{fields.llm_ocr.map((field, index) => (
<div className="chip" key={`ocr-field-${index}`}>
{field}
<span
className="close-btn"
onClick={() => removeField('llm_ocr', index)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') removeField('llm_ocr', index);
}}
role="button"
tabIndex={0}
aria-label={`删除字段 ${field}`}
>
×
</span>
</div>
))}
</div>
<div className="form-tip mt-1 text-xs"></div>
</div>
</div>
<div className="grid grid-cols-1 gap-3 mt-3">
<div className="col-span-1">
<label className="form-label mb-1" htmlFor="llm-prompt-settings">
</label>
<div className="flex items-center mb-2" id="llm-prompt-settings">
<label className="inline-flex items-center mr-6">
<input
type="radio"
name="llm-prompt-type"
value="system"
checked={promptType.llm_ocr === 'system'}
onChange={(e) => handlePromptTypeChange(e, 'llm_ocr')}
className="form-radio"
/>
<span className="ml-2">使</span>
</label>
<label className="inline-flex items-center">
<input
type="radio"
name="llm-prompt-type"
value="custom"
checked={promptType.llm_ocr === 'custom'}
onChange={(e) => handlePromptTypeChange(e, 'llm_ocr')}
className="form-radio"
/>
<span className="ml-2">使</span>
</label>
</div>
<div
className="bg-gray-50 p-2 rounded text-xs text-gray-600 mb-2"
id="llm-system-prompt-info"
style={{ display: promptType.llm_ocr === 'system' ? 'block' : 'none' }}
>
</div>
<div
id="llm-custom-prompt-container"
style={{ display: promptType.llm_ocr === 'custom' ? 'block' : 'none' }}
className="border border-dashed border-gray-300 p-3 rounded-md"
>
<div className="mb-2">
<label className="form-label mb-1 text-sm" htmlFor="llm-prompt-template">
</label>
<select
className="form-select"
id="llm-prompt-template"
value={selectedTemplate.llm_ocr}
onChange={(e) => handleTemplateChange(e, 'llm_ocr')}
>
<option value=""></option>
<option value="1">-</option>
<option value="4">-</option>
<option value="5">-</option>
<option value="6">-</option>
</select>
</div>
<div className="mb-2">
<label className="form-label mb-1 text-sm" htmlFor="llm-prompt-content">
</label>
<textarea
className="form-textarea"
id="llm-prompt-content"
rows={4}
placeholder="选择模板后自动填充,您也可以进行修改..."
value={promptContent.llm_ocr}
onChange={(e) => handlePromptContentChange(e, 'llm_ocr')}
readOnly={!selectedTemplate.llm_ocr}
></textarea>
<div className="form-tip mt-1 bg-gray-50 p-2 rounded text-xs">
<p className="mb-1">
<strong></strong>
</p>
<div className="flex flex-wrap gap-1">
{[
'docType',
'fieldsList',
'companyName',
'documentId',
'date',
'industry',
'ocrText',
].map((variable) => (
<button
key={variable}
type="button"
className="var-tag"
onClick={() => applyVariableToPrompt(variable, 'llm_ocr')}
>
{variable}
</button>
))}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div className={`extraction-config ${currentTab !== 'llm' ? 'hidden' : ''}`} id="llm-config">
<div className="grid grid-cols-1 gap-3">
<div className="col-span-1">
<label className="form-label mb-1" htmlFor="field-input">
</label>
<div className="flex mb-2">
<input
type="text"
className="form-input mr-2"
id="field-input"
placeholder="请输入字段名"
value={inputValue.llm}
onChange={(e) => handleFieldInputChange(e, 'llm')}
onKeyDown={(e) => handleKeyDown(e, 'llm')}
/>
<select
className="form-select mr-2"
id="field-type"
value={selectedFieldType}
onChange={handleFieldTypeChange}
>
<option value="default"></option>
<option value="seal"></option>
<option value="cross-seal"></option>
<option value="handwriting"></option>
<option value="print"></option>
<option value="english"></option>
<option value="number"></option>
<option value="currency"></option>
</select>
<button
className="ant-btn ant-btn-default"
id="add-field-btn"
type="button"
onClick={() => addField('llm')}
>
</button>
</div>
<div className="chips-container" id="fields-container">
{fields.llm.map((field, index) => {
const { fieldName, fieldType, typeName, badgeClass } = getFieldInfo(field);
return (
<div className="chip" key={`llm-field-${index}`}>
{fieldName}
<span className={`badge ${badgeClass} text-xs ml-1`} data-type={fieldType}>
{typeName}
</span>
<span
className="close-btn"
onClick={() => removeField('llm', index)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') removeField('llm', index);
}}
role="button"
tabIndex={0}
aria-label={`删除字段 ${fieldName}`}
>
×
</span>
</div>
);
})}
</div>
<div className="form-tip mt-1 text-xs"></div>
</div>
</div>
<div className="grid grid-cols-1 gap-3 mt-3">
<div className="col-span-1">
<label className="form-label mb-1" htmlFor="multimodal-prompt-settings">
</label>
<div className="flex items-center mb-2" id="multimodal-prompt-settings">
<label className="inline-flex items-center mr-6">
<input
type="radio"
name="multimodal-prompt-type"
value="system"
checked={promptType.llm === 'system'}
onChange={(e) => handlePromptTypeChange(e, 'llm')}
className="form-radio"
/>
<span className="ml-2">使</span>
</label>
<label className="inline-flex items-center">
<input
type="radio"
name="multimodal-prompt-type"
value="custom"
checked={promptType.llm === 'custom'}
onChange={(e) => handlePromptTypeChange(e, 'llm')}
className="form-radio"
/>
<span className="ml-2">使</span>
</label>
</div>
<div
className="bg-gray-50 p-2 rounded text-xs text-gray-600 mb-2"
id="multimodal-system-prompt-info"
style={{ display: promptType.llm === 'system' ? 'block' : 'none' }}
>
</div>
<div
id="multimodal-custom-prompt-container"
style={{ display: promptType.llm === 'custom' ? 'block' : 'none' }}
className="border border-dashed border-gray-300 p-3 rounded-md"
>
<div className="mb-2">
<label className="form-label mb-1 text-sm" htmlFor="multimodal-prompt-template">
</label>
<select
className="form-select"
id="multimodal-prompt-template"
value={selectedTemplate.llm}
onChange={(e) => handleTemplateChange(e, 'llm')}
>
<option value=""></option>
<option value="7">-</option>
<option value="8">-</option>
<option value="9">-</option>
</select>
</div>
<div className="mb-2">
<label className="form-label mb-1 text-sm" htmlFor="multimodal-prompt-content">
</label>
<textarea
className="form-textarea"
id="multimodal-prompt-content"
rows={4}
placeholder="选择模板后自动填充,您也可以进行修改..."
value={promptContent.llm}
onChange={(e) => handlePromptContentChange(e, 'llm')}
readOnly={!selectedTemplate.llm}
></textarea>
<div className="form-tip mt-1 bg-gray-50 p-2 rounded text-xs">
<p className="mb-1">
<strong></strong>
</p>
<div className="flex flex-wrap gap-1">
{[
'docType',
'fieldsList',
'companyName',
'documentId',
'date',
'industry',
'contentType',
'pageRange',
'colorMode',
'ocrText',
].map((variable) => (
<button
key={variable}
type="button"
className="var-tag"
onClick={() => applyVariableToPrompt(variable, 'llm')}
>
{variable}
</button>
))}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div className={`extraction-config ${currentTab !== 'ocr_regex' ? 'hidden' : ''}`} id="ocr-regex-config">
<div className="grid grid-cols-1 gap-3">
<div className="col-span-1">
<div className="mb-2">
<div className="flex justify-between items-center mb-1">
<label className="form-label m-0" htmlFor="regex-fields-container">
</label>
<button
className="ant-btn ant-btn-default"
id="add-regex-field-row"
type="button"
onClick={addRegexFieldRow}
>
<i className="ri-add-line"></i>
</button>
</div>
<div className="mt-2" id="regex-fields-container">
{regexFields.map((field) => (
<div
className={`regex-field-row flex items-start mb-2 border rounded-md p-2 ${fieldEditStatus[field.id] ? 'border-blue-300 bg-blue-50' : 'border-gray-200 bg-gray-50'}`}
key={field.id}
>
<div className="w-3/10 mr-2">
<label
className="text-xs text-gray-600 mb-0 block"
htmlFor={`regex-field-name-${field.id}`}
>
</label>
<input
type="text"
className={`form-input regex-field-name ${!field.fieldName && !fieldEditStatus[field.id] ? 'border-yellow-300' : ''}`}
id={`regex-field-name-${field.id}`}
placeholder="如:合同编号"
value={field.fieldName || ''}
onChange={(e) => field.id && updateRegexField(field.id, 'fieldName', e.target.value)}
onBlur={() => field.id && handleRegexFieldBlur(field.id, 'fieldName')}
/>
</div>
<div className="w-7/10 mr-2">
<label
className="text-xs text-gray-600 mb-0 block"
htmlFor={`regex-expression-${field.id}`}
>
</label>
<input
type="text"
className={`form-input regex-expression ${field.fieldName && !field.regex && !fieldEditStatus[field.id] ? 'border-yellow-300' : ''}`}
id={`regex-expression-${field.id}`}
placeholder="如:\\d{4}[-/年](0?[1-9]|1[0-2])[-/月](0?[1-9]|[12][0-9]|3[01])[日]?"
value={field.regex || ''}
onChange={(e) => field.id && updateRegexField(field.id, 'regex', e.target.value)}
onBlur={() => field.id && handleRegexFieldBlur(field.id, 'regex')}
/>
{statusMessage && statusMessage.id === field.id && (
<div className="text-xs mt-1 text-blue-600 transition-opacity duration-300">
{statusMessage.message}
</div>
)}
{!statusMessage && field.fieldName && !field.regex && !fieldEditStatus[field.id] && (
<div className="text-xs mt-1 text-yellow-600">
</div>
)}
</div>
<div className="flex flex-col justify-end pt-3">
<button
className="text-red-500 hover:text-red-700 remove-regex-field-row"
type="button"
aria-label="删除"
onClick={() => field.id && removeRegexFieldRow(field.id)}
>
<i className="ri-delete-bin-line"></i>
</button>
</div>
</div>
))}
</div>
</div>
<div className="mt-2">
<label className="form-label mb-1" htmlFor="regex-template-container">
</label>
<div className="flex flex-wrap gap-1 mt-1" id="regex-template-container">
{[
{
label: '日期格式:yyyy-mm-dd',
regex: '\\d{4}[-/年](0?[1-9]|1[0-2])[-/月](0?[1-9]|[12][0-9]|3[01])[日]?',
},
{ label: '合同编号格式', regex: '[A-Z]{2,5}-\\d{4,10}' },
{
label: '金额格式',
regex: '(人民币|RMB)?\\s?(\\d{1,3}(,\\d{3})*(\\.\\d{2})?)\\s?[万元]?',
},
{ label: '座机号码格式', regex: '\\d{3}-\\d{8}|\\d{4}-\\d{7,8}' },
{ label: '手机号码格式', regex: '1[3-9]\\d{9}' },
].map(({ label, regex }) => (
<div
key={label}
className="chip cursor-pointer regex-template"
onClick={() => applyRegexTemplate(regex)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') applyRegexTemplate(regex);
}}
>
{label}
</div>
))}
</div>
</div>
</div>
</div>
</div>
{/* 在所有标签页外部添加统一的更新按钮和状态显示,这样在任何标签页都可见 */}
<div className="border-t border-gray-200 pt-5 mt-5">
<div className="flex flex-col items-center mb-2">
{hasPendingChanges && !updateStatus && (
<div className="text-center text-sm mb-3 p-2 rounded bg-yellow-100 text-yellow-700">
&ldquo;&rdquo;
</div>
)}
{!hasPendingChanges && !updateStatus && (
<div className="text-center text-xs mb-2 text-gray-600">
</div>
)}
<button
className={`ant-btn ${hasPendingChanges ? 'ant-btn-primary' : 'ant-btn-default'} text-base py-2 px-4 font-medium shadow-sm hover:shadow`}
type="button"
onClick={handleUpdateFields}
>
<i className="ri-refresh-line mr-1"></i>
{hasPendingChanges ? '更新全部字段 (有未保存更改)' : '更新全部字段'}
</button>
</div>
{updateStatus && (
<div className={`text-center text-sm mt-2 p-2 rounded ${updateStatus.success ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>
{updateStatus.message}
</div>
)}
</div>
</div>
</div>
);
}