diff --git a/app/components/rules/new/ActionButtons.tsx b/app/components/rules/new/ActionButtons.tsx new file mode 100644 index 0000000..221d166 --- /dev/null +++ b/app/components/rules/new/ActionButtons.tsx @@ -0,0 +1,34 @@ +import React from 'react'; + +interface ActionButtonsProps { + onSave?: () => void; + onSaveDraft?: () => void; +} + +export function ActionButtons({ onSave, onSaveDraft }: ActionButtonsProps) { + return ( +
+ + + +
+ ); +} \ No newline at end of file diff --git a/app/components/rules/new/BasicInfo.tsx b/app/components/rules/new/BasicInfo.tsx new file mode 100644 index 0000000..3afd207 --- /dev/null +++ b/app/components/rules/new/BasicInfo.tsx @@ -0,0 +1,260 @@ +import { useState } from 'react'; + +interface BasicInfoProps { + onChange?: (data: Record) => void; +} + +export function BasicInfo({ onChange }: BasicInfoProps) { + const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false); + const [formData, setFormData] = useState({ + name: '', + code: '', + riskLevel: 'medium', + type: '', + group: 'contract-base', + enabled: true, + description: '', + lawName: '', + lawArticles: '', + lawContent: '' + }); + + const toggleDescription = () => { + setIsDescriptionExpanded(!isDescriptionExpanded); + }; + + const handleInputChange = (e: React.ChangeEvent) => { + const { id, value } = e.target; + let fieldName = id; + + // 映射id到表单字段名 + switch(id) { + case 'checkpoint-name': fieldName = 'name'; break; + case 'checkpoint-code': fieldName = 'code'; break; + case 'risk-level': fieldName = 'riskLevel'; break; + case 'checkpointType': fieldName = 'type'; break; + case 'rule-group': fieldName = 'group'; break; + case 'is-enabled': fieldName = 'enabled'; break; + case 'checkpoint-description': fieldName = 'description'; break; + case 'law-name': fieldName = 'lawName'; break; + case 'law-articles': fieldName = 'lawArticles'; break; + case 'law-content': fieldName = 'lawContent'; break; + } + + const newData = { + ...formData, + [fieldName]: id === 'is-enabled' ? value === 'true' : value + }; + + setFormData(newData); + + if (onChange) { + onChange(newData); + } + }; + + return ( +
+
+

基本信息

+
+
+
+
+ + +
请使用简洁明了的名称,不超过30个字符
+
+
+ + +
用于系统标识的唯一编码
+
+
+ + +
请定义评查点的风险等级
+
+
+ + +
评查点类型用于分类管理,便于规则统一调用
+
+
+ + +
+
+ + +
创建后是否立即启用此评查点
+
+
+
{ + if (e.key === 'Enter' || e.key === ' ') { + toggleDescription(); + } + }} + aria-expanded={isDescriptionExpanded} + aria-controls="description-section" + > +

评查点描述与法律依据

+ +
+ +
+
+ + +
详细描述有助于其他用户了解该评查点的用途
+
+ + {/* 引用法典输入区域 */} +
+ + +
+ + +
+ +
+ + +
多个条款用逗号分隔,将自动转换为数组格式
+
+ +
+ + +
+ +
+ 引用的法律条文将在评查结果中显示,帮助用户理解评查规则的法律依据 +
+ + {/* 预览区域 */} +
+
预览效果
+
+
+ {formData.lawName || '《中华人民共和国民法典》'} +
+
+ {formData.lawArticles ? formData.lawArticles.split(',').map((article, index) => ( + {article.trim()} + )) : ( + <> + 第五百八十五条 + 第五百八十六条 + + )} +
+
+ {formData.lawContent || '当事人应当按照约定全面履行自己的义务。'} +
+
+
+
+
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/app/components/rules/new/CodeEditor.tsx b/app/components/rules/new/CodeEditor.tsx new file mode 100644 index 0000000..4131729 --- /dev/null +++ b/app/components/rules/new/CodeEditor.tsx @@ -0,0 +1,128 @@ +import React, { useState, useEffect } from 'react'; +import CodeMirror from '@uiw/react-codemirror'; +import { javascript } from '@codemirror/lang-javascript'; +import { oneDark } from '@codemirror/theme-one-dark'; + +interface CodeEditorProps { + id: string; + initialValue?: string; + language?: 'javascript' | 'python'; + onChange?: (value: string) => void; +} + +export function CodeEditor({ + id, + initialValue = '', + language = 'javascript', + onChange +}: CodeEditorProps) { + const [code, setCode] = useState(initialValue); + const [copySuccess, setCopySuccess] = useState(false); + + // 当语言变化时更新编辑器 + const extensions = [language === 'javascript' ? javascript() : javascript()]; + + // 处理代码变化 + const handleChange = (value: string) => { + setCode(value); + if (onChange) { + onChange(value); + } + }; + + // 复制代码到剪贴板 + const copyToClipboard = () => { + navigator.clipboard.writeText(code).then(() => { + setCopySuccess(true); + setTimeout(() => setCopySuccess(false), 2000); + }); + }; + + // 初始示例代码 + const getDefaultCode = (lang: string) => { + if (lang === 'javascript') { + return `// 示例代码 +function checkRule(data) { + // data 包含抽取的字段 + try { + // 在此编写检查逻辑 + if (data.fieldName && condition) { + return { + pass: true, + message: "检查通过" + }; + } else { + return { + pass: false, + message: "检查不通过,原因:..." + }; + } + } catch (error) { + return { + pass: false, + message: "执行出错:" + error.message + }; + } +}`; + } else { + return `# 示例代码 +def check_rule(data): + # data 包含抽取的字段 + try: + # 在此编写检查逻辑 + if 'field_name' in data and condition: + return { + 'pass': True, + 'message': "检查通过" + } + else: + return { + 'pass': False, + 'message': "检查不通过,原因:..." + } + except Exception as error: + return { + 'pass': False, + 'message': f"执行出错:{str(error)}" + }`; + } + }; + + // 如果初始值为空,则使用默认示例代码 + useEffect(() => { + if (!initialValue) { + setCode(getDefaultCode(language)); + } + }, [language, initialValue]); + + return ( +
+
+
+
{language === 'javascript' ? 'script.js' : 'script.py'}
+
+ +
+
+ +
+ {copySuccess && ( +
+ 代码已复制到剪贴板 +
+ )} +
+ ); +} \ No newline at end of file diff --git a/app/components/rules/new/ExtractionSettings.tsx b/app/components/rules/new/ExtractionSettings.tsx new file mode 100644 index 0000000..8f6cef6 --- /dev/null +++ b/app/components/rules/new/ExtractionSettings.tsx @@ -0,0 +1,1010 @@ +import { useState, KeyboardEvent, FormEvent, useContext, useEffect } from 'react'; +import { RuleContext } from './ReviewSettings'; + +interface ExtractionSettingsProps { + onChange?: (data: Record) => void; +} + +interface RegexField { + id: string; + fieldName: string; + regex: string; +} + +interface PromptTemplate { + id: number; + template_name: string; + template_type: string; + template_content: string; +} + +export function ExtractionSettings({ onChange }: ExtractionSettingsProps) { + // 使用RuleContext获取全局状态 + const ruleContext = useContext(RuleContext); + + 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([ + { 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: '' + }); + + // 在组件初始化时,如果Context中已有字段数据,则使用Context数据初始化 + useEffect(() => { + if (ruleContext && ruleContext.extractionFields.length > 0) { + // 将Context中的字段数据添加到当前激活的抽取方式中 + setFields(prevFields => ({ + ...prevFields, + [currentTab]: [...ruleContext.extractionFields] + })); + } + }, []); + + const handleTabChange = (tab: string) => { + setCurrentTab(tab); + + // 当切换抽取方法时,更新全局Context中的字段 + if (ruleContext && fields[tab]) { + ruleContext.updateExtractionFields(fields[tab]); + } + + if (onChange) { + onChange({ extractionMethod: tab }); + } + }; + + const handleFieldInputChange = (e: FormEvent, type: 'llm_ocr' | 'llm') => { + setInputValue({ + ...inputValue, + [type]: e.currentTarget.value + }); + }; + + const handleFieldTypeChange = (e: FormEvent) => { + setSelectedFieldType(e.currentTarget.value); + }; + + const addField = (type: 'llm_ocr' | 'llm') => { + if (inputValue[type].trim()) { + let newFields: string[] = []; + + // OCR+LLM模式下,支持多个字段同时添加(用逗号、顿号或空格分隔) + if (type === 'llm_ocr') { + newFields = [ + ...fields[type], + ...inputValue[type].split(/[\s、,]+/).map(f => f.trim()).filter(f => f !== '') + ]; + } else { + // 多模态抽取模式下,一次只添加一个字段(带类型) + newFields = [...fields[type], `${inputValue[type].trim()}_${selectedFieldType}`]; + } + + setFields({ + ...fields, + [type]: newFields + }); + + setInputValue({ + ...inputValue, + [type]: '' + }); + + if (type === 'llm') { + setSelectedFieldType('default'); + } + + // 更新全局Context中的字段 + if (ruleContext) { + ruleContext.updateExtractionFields(newFields); + } + + if (onChange) { + onChange({ + extractionMethod: currentTab, + fields: { + ...fields, + [type]: newFields + } + }); + } + + // 触发自定义事件,通知字段已更新(兼容非Context的实现) + const event = new CustomEvent('extraction-fields-updated', { + detail: { fields: newFields } + }); + document.dispatchEvent(event); + } + }; + + const handleKeyDown = (e: KeyboardEvent, type: 'llm_ocr' | 'llm') => { + if (e.key === 'Enter') { + e.preventDefault(); + addField(type); + } + }; + + const removeField = (type: 'llm_ocr' | 'llm', index: number) => { + const newFields = [...fields[type]]; + newFields.splice(index, 1); + + setFields({ + ...fields, + [type]: newFields + }); + + // 更新全局Context中的字段 + if (ruleContext) { + ruleContext.updateExtractionFields(newFields); + } + + if (onChange) { + onChange({ + extractionMethod: currentTab, + fields: { + ...fields, + [type]: newFields + } + }); + } + + // 触发自定义事件,通知字段已更新(兼容非Context的实现) + const event = new CustomEvent('extraction-fields-updated', { + detail: { fields: newFields } + }); + document.dispatchEvent(event); + }; + + // 添加正则表达式字段行 + const addRegexFieldRow = () => { + const newId = `${regexFields.length + 1}`; + setRegexFields([...regexFields, { id: newId, fieldName: '', regex: '' }]); + + if (onChange) { + onChange({ + extractionMethod: currentTab, + regexFields: [...regexFields, { id: newId, fieldName: '', regex: '' }] + }); + } + + // 添加字段时不触发事件,因为此时字段名称尚未填写 + }; + + // 删除正则表达式字段行 + const removeRegexFieldRow = (id: string) => { + // 至少保留一行 + if (regexFields.length <= 1) { + return; + } + + const newRegexFields = regexFields.filter(field => field.id !== id); + setRegexFields(newRegexFields); + + if (onChange) { + onChange({ + extractionMethod: currentTab, + regexFields: newRegexFields + }); + } + }; + + // 更新正则表达式字段值 + const updateRegexField = (id: string, key: 'fieldName' | 'regex', value: string) => { + const newRegexFields = regexFields.map(field => + field.id === id ? { ...field, [key]: value } : field + ); + + setRegexFields(newRegexFields); + + // 如果是字段名更新且当前抽取方法是正则抽取,则更新Context + if (key === 'fieldName' && currentTab === 'ocr_regex' && ruleContext) { + const fieldNames = newRegexFields + .map(field => field.fieldName) + .filter(name => name.trim() !== ''); + + ruleContext.updateExtractionFields(fieldNames); + } + + if (onChange) { + onChange({ + extractionMethod: currentTab, + regexFields: newRegexFields + }); + } + + // 如果更新的是字段名,触发自定义事件通知字段已更新 + if (key === 'fieldName' && value.trim()) { + const allFieldNames = [...fields[currentTab], ...newRegexFields.map(f => f.fieldName).filter(f => f)]; + + // 触发自定义事件,通知字段已更新(兼容非Context的实现) + const event = new CustomEvent('extraction-fields-updated', { + detail: { fields: allFieldNames } + }); + document.dispatchEvent(event); + } + }; + + // 应用正则模板 + const applyRegexTemplate = (regex: string) => { + // 找到当前正在编辑的行,或者最后一行 + const lastField = regexFields[regexFields.length - 1]; + 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, type: 'llm_ocr' | 'llm') => { + const value = e.currentTarget.value; + setPromptType({ + ...promptType, + [type]: value + }); + + if (onChange) { + onChange({ + extractionMethod: currentTab, + promptSettings: { + ...promptType, + [type]: value + } + }); + } + }; + + // 处理提示词模板选择 + const handleTemplateChange = (e: FormEvent, type: 'llm_ocr' | 'llm') => { + const value = e.currentTarget.value; + setSelectedTemplate({ + ...selectedTemplate, + [type]: value + }); + + if (value) { + const templateData = getPromptTemplateById(Number(value)); + if (templateData) { + // 基础模板内容 + let content = templateData.template_content; + + // 替换字段列表变量 + if (content.includes('{fieldsList}') && fields[type].length > 0) { + let fieldListStr = ''; + + if (type === 'llm_ocr') { + // 普通字段列表 + fieldListStr = fields[type].map((field, idx) => `${idx+1}. ${field}`).join('\n'); + } else if (type === 'llm') { + // 带类型的字段列表 + fieldListStr = fields[type].map((field, idx) => { + const { fieldName, typeName } = getFieldInfo(field); + return `${idx+1}. ${fieldName} (${typeName})`; + }).join('\n'); + } + + content = content.replace('{fieldsList}', fieldListStr); + } + + setPromptContent({ + ...promptContent, + [type]: content + }); + + if (onChange) { + onChange({ + extractionMethod: currentTab, + promptSettings: { + type: promptType[type], + template: value, + content: content + } + }); + } + } + } else { + // 清空内容 + setPromptContent({ + ...promptContent, + [type]: '' + }); + } + }; + + // 处理提示词内容变更 + const handlePromptContentChange = (e: FormEvent, type: 'llm_ocr' | 'llm') => { + const value = e.currentTarget.value; + setPromptContent({ + ...promptContent, + [type]: value + }); + + if (onChange) { + onChange({ + extractionMethod: currentTab, + promptSettings: { + type: promptType[type], + template: selectedTemplate[type], + content: value + } + }); + } + }; + + // 应用变量标签到提示词 + 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({ + ...promptContent, + [type]: newText + }); + + // 使焦点回到文本框并设置光标位置 + setTimeout(() => { + textarea.focus(); + textarea.setSelectionRange(start + variable.length + 2, start + variable.length + 2); + }, 0); + + if (onChange) { + onChange({ + extractionMethod: currentTab, + promptSettings: { + type: promptType[type], + template: selectedTemplate[type], + content: newText + } + }); + } + } + }; + + // 模拟获取提示词模板 + const getPromptTemplateById = (id: number): PromptTemplate | null => { + // 模拟的模板数据,实际应用中应从服务器获取 + const templates: Record = { + 1: { + id: 1, + template_name: '行政处罚-抽取通用模板', + template_type: 'Extraction', + template_content: `你是一个专业的文档信息抽取助手。请从以下{docType}文档中抽取关键信息: + +{fieldsList} + +请将结果以JSON格式输出,包含以上字段。如果某个字段在文档中未找到,则该字段的值设为null。` + }, + 4: { + id: 4, + template_name: '采购合同-乙方资质抽取', + template_type: 'Extraction', + template_content: `你是一个专业的合同信息抽取助手。请从以下{docType}中抽取乙方的资质信息: + +需要抽取的信息包括: +{fieldsList} + +{companyName}要求所有供应商必须提供完整的资质信息。请将结果以JSON格式输出,包含以上字段。` + }, + 5: { + id: 5, + template_name: '合同-关键条款抽取', + template_type: 'Extraction', + template_content: `请作为{industry}行业的专业合同审核员,从提供的{docType}中提取以下关键条款信息: + +{fieldsList} + +文档ID: {documentId} +审核日期: {date} + +请以JSON格式输出结果,对于未明确指定的条款需标记为"未明确约定"。` + }, + 6: { + id: 6, + template_name: '烟草许可证-信息抽取', + template_type: 'Extraction', + template_content: `请从下列烟草专卖许可证文件中抽取以下关键信息: + +{fieldsList} + +这些信息将用于{companyName}内部数据库更新。请确保许可证编号和有效期格式准确无误。` + }, + 7: { + id: 7, + template_name: '多模态-印章识别模板', + template_type: 'Multimodal', + template_content: `请识别并提取文档中的所有印章信息,包括: + +{fieldsList} + +文档类型: {docType} +页面范围: {pageRange} + +请注意区分公章、法人章和合同专用章,并分析印章的清晰度和完整性。` + }, + 8: { + id: 8, + template_name: '多模态-表格抽取模板', + template_type: 'Multimodal', + template_content: `请从文档中的表格提取以下信息: + +{fieldsList} + +文档类型: {docType} +表格可能跨页,请确保完整提取所有内容。表格中的数值需保留原始精度。` + }, + 9: { + id: 9, + template_name: '多模态-手写内容识别模板', + template_type: 'Multimodal', + template_content: `请识别文档中的手写内容,特别关注: + +{fieldsList} + +文档类型: {docType} +内容类型: {contentType} + +对于难以辨认的手写内容,请标注为"[难以辨认]"并尽可能给出可能的解读。` + } + }; + + return templates[id] || null; + }; + + return ( +
+
+

抽取设置

+
+
+
+ {/* 切换按钮 */} +
+ + + +
+
+ + {/* 大模型抽取配置 */} +
+
+
+ +
+ handleFieldInputChange(e, 'llm_ocr')} + onKeyDown={(e) => handleKeyDown(e, 'llm_ocr')} + /> + +
+
+ {fields.llm_ocr.map((field, index) => ( +
+ {field} + removeField('llm_ocr', index)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + removeField('llm_ocr', index); + } + }} + role="button" + tabIndex={0} + aria-label={`删除字段 ${field}`} + >× +
+ ))} +
+
支持一次输入多个字段
+
+
+ +
+
+ +
+ + +
+ +
+ 系统将根据评查点类型和抽取目标自动生成适合的提示词,您无需额外配置。 +
+ +
+
+ + +
+
+ + +
+

支持的变量(点击变量将其添加到提示词中):

+
+ + + + + + + +
+
+
+
+
+
+
+ + {/* 多模态抽取配置 */} +
+
+
+ +
+ handleFieldInputChange(e, 'llm')} + onKeyDown={(e) => handleKeyDown(e, 'llm')} + /> + + +
+
+ {fields.llm.map((field, index) => { + const { fieldName, fieldType, typeName, badgeClass } = getFieldInfo(field); + return ( +
+ {fieldName} + {typeName} + removeField('llm', index)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + removeField('llm', index); + } + }} + role="button" + tabIndex={0} + aria-label={`删除字段 ${fieldName}`} + >× +
+ ); + })} +
+
请为每个字段选择适当的抽取类型,有助于提高识别准确率
+
+
+ +
+
+ +
+ + +
+
+ 系统将根据评查点类型和抽取目标自动生成适合的提示词,支持图表、印章等图像内容抽取。 +
+ +
+
+ + +
+
+ + +
+

支持的变量(点击变量将其添加到提示词中):

+
+ + + + + + + + + + +
+
+
+
+
+
+
+ + {/* 正则抽取配置 */} +
+
+
+
+
+ + +
+ +
+ {/* 字段-正则表达式配置行 */} + {regexFields.map((field) => ( +
+
+ + updateRegexField(field.id, 'fieldName', e.target.value)} + /> +
+
+ + updateRegexField(field.id, 'regex', e.target.value)} + /> +
+
+ +
+
+ ))} +
+
+
+ +
+
applyRegexTemplate("\\d{4}[-/年](0?[1-9]|1[0-2])[-/月](0?[1-9]|[12][0-9]|3[01])[日]?")} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + applyRegexTemplate("\\d{4}[-/年](0?[1-9]|1[0-2])[-/月](0?[1-9]|[12][0-9]|3[01])[日]?"); + } + }} + >日期格式:yyyy-mm-dd
+
applyRegexTemplate("[A-Z]{2,5}-\\d{4,10}")} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + applyRegexTemplate("[A-Z]{2,5}-\\d{4,10}"); + } + }} + >合同编号格式
+
applyRegexTemplate("(人民币|RMB)?\\s?(\\d{1,3}(,\\d{3})*(\\.\\d{2})?)\\s?[万元]?")} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + applyRegexTemplate("(人民币|RMB)?\\s?(\\d{1,3}(,\\d{3})*(\\.\\d{2})?)\\s?[万元]?"); + } + }} + >金额格式
+
applyRegexTemplate("\\d{3}-\\d{8}|\\d{4}-\\d{7,8}")} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + applyRegexTemplate("\\d{3}-\\d{8}|\\d{4}-\\d{7,8}"); + } + }} + >座机号码格式
+
applyRegexTemplate("1[3-9]\\d{9}")} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + applyRegexTemplate("1[3-9]\\d{9}"); + } + }} + >手机号码格式
+
+
+
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/app/components/rules/new/PageHeader.tsx b/app/components/rules/new/PageHeader.tsx new file mode 100644 index 0000000..f1b907e --- /dev/null +++ b/app/components/rules/new/PageHeader.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { Link } from '@remix-run/react'; + +interface PageHeaderProps { + title: string; + onSave?: () => void; +} + +export function PageHeader({ title, onSave }: PageHeaderProps) { + return ( +
+

{title}

+
+ +
+
+ ); +} \ No newline at end of file diff --git a/app/components/rules/new/ReviewSettings.tsx b/app/components/rules/new/ReviewSettings.tsx new file mode 100644 index 0000000..399c705 --- /dev/null +++ b/app/components/rules/new/ReviewSettings.tsx @@ -0,0 +1,1309 @@ +import React, { useState, useEffect, useContext, createContext } from 'react'; +import { SimpleCodeEditor } from './SimpleCodeEditor'; + +interface RuleType { + id: string; + type: string; + config: Record; +} + +// 为配置项添加类型定义 +interface ComparisonPair { + sourceField: string; + targetField: string; + compareMethod: string; +} + +interface ReviewSettingsProps { + onChange?: (data: Record) => void; +} + +// 创建全局上下文以便在不同组件间共享数据 +interface RuleContextType { + extractionFields: string[]; + updateExtractionFields: (fields: string[]) => void; +} + +// 创建全局Context对象 +export const RuleContext = createContext({ + extractionFields: [], + updateExtractionFields: () => {} +}); + +export function ReviewSettings({ onChange }: ReviewSettingsProps) { + const [rules, setRules] = useState([ + { id: '1', type: '', config: {} } + ]); + const [combinationLogic, setCombinationLogic] = useState('and'); + const [customLogic, setCustomLogic] = useState(''); + const [showCustomLogic, setShowCustomLogic] = useState(false); + // 添加评查后动作相关状态 + const [actionType, setActionType] = useState('none'); + const [actionDescription, setActionDescription] = useState(''); + + // 获取抽取字段的上下文 + const { extractionFields } = useContext(RuleContext); + + // 初始化评查通过/不通过/建议信息 + const [passMessage, setPassMessage] = useState('文档检查通过,符合规范要求。'); + const [failMessage, setFailMessage] = useState('文档存在以下问题,请修改后重新提交。'); + const [suggestMessage, setSuggestMessage] = useState(''); + + // 错误严重程度 + const [errorSeverity, setErrorSeverity] = useState('error'); + + // 保存最近一次可用的字段列表 + const [availableFields, setAvailableFields] = useState(extractionFields || []); + + // 监听抽取设置中的字段变化 + useEffect(() => { + // 当Context中的字段发生变化时,立即更新可用字段 + if (extractionFields.length > 0) { + setAvailableFields(extractionFields); + + // 同时更新已有规则中的可选字段状态 + updateRulesWithNewFields(extractionFields); + return; + } + + // 获取抽取字段函数 - 当Context不可用时的备选方案 + const getExtractedFields = (): string[] => { + const extractedFields: string[] = []; + + // 查找当前活跃的抽取设置容器 + const activeExtractConfig = document.querySelector('.extraction-config:not(.hidden)'); + if (!activeExtractConfig) { + // 如果没有找到活跃的配置,返回空数组 + return []; + } + + // 查找字段容器 + const fieldsContainer = activeExtractConfig.querySelector('.chips-container'); + if (fieldsContainer) { + // 获取所有字段芯片 + const chips = fieldsContainer.querySelectorAll('.chip'); + chips.forEach(chip => { + const fieldName = chip.textContent?.replace('×', '').trim(); + if (fieldName) { + extractedFields.push(fieldName); + } + }); + } + + // 查找正则字段 + const regexFields = document.querySelectorAll('.regex-field-row'); + regexFields.forEach(row => { + const fieldNameInput = row.querySelector('input[placeholder="字段名称"]'); + if (fieldNameInput instanceof HTMLInputElement && fieldNameInput.value.trim()) { + extractedFields.push(fieldNameInput.value.trim()); + } + }); + + return extractedFields; + }; + + // 初始化获取字段 - 仅在Context不可用时使用 + setAvailableFields(getExtractedFields()); + + // 监听抽取设置的变化 - 仅在Context不可用时使用 + const handleExtractionChange = (event: Event) => { + if (event instanceof CustomEvent && event.detail && Array.isArray(event.detail.fields)) { + setAvailableFields(event.detail.fields); + } else { + setAvailableFields(getExtractedFields()); + } + }; + + // 添加事件监听器,监听抽取设置中的字段变化 + document.addEventListener('extraction-fields-updated', handleExtractionChange); + + // 组件卸载时移除事件监听 + return () => { + document.removeEventListener('extraction-fields-updated', handleExtractionChange); + }; + }, [extractionFields]); + + // 当可用字段发生变化时,更新已有规则的配置 + const updateRulesWithNewFields = (newFields: string[]) => { + // 更新已有规则中的字段选择,保留已选择的字段 + const updatedRules = rules.map(rule => { + if (rule.type === 'exists' || rule.type === 'consistency' || rule.type === 'logic' || rule.type === 'regex') { + // 检查和更新字段相关的配置 + const currentSelectedFields = Array.isArray(rule.config.selectedFields) + ? rule.config.selectedFields as string[] + : []; + + // 使用已选中的字段和新字段的交集作为更新后的选中字段 + const validSelectedFields = currentSelectedFields.filter(field => + newFields.includes(field) || field.trim() !== '' + ); + + return { + ...rule, + config: { + ...rule.config, + selectedFields: validSelectedFields + } + }; + } + return rule; + }); + + setRules(updatedRules); + + if (onChange) { + onChange({ rules: updatedRules }); + } + }; + + const handleLogicChange = (logic: string) => { + setCombinationLogic(logic); + if (logic === 'custom') { + setShowCustomLogic(true); + } else { + setShowCustomLogic(false); + } + + if (onChange) { + onChange({ combinationLogic: logic, showCustomLogic: logic === 'custom', customLogic }); + } + }; + + const handleCustomLogicChange = (e: React.ChangeEvent) => { + setCustomLogic(e.target.value); + + if (onChange) { + onChange({ customLogic: e.target.value }); + } + }; + + const handleActionTypeChange = (type: string) => { + setActionType(type); + + if (onChange) { + onChange({ actionType: type }); + } + }; + + const handleAddRule = () => { + const newId = `${rules.length + 1}`; + const newRule = { id: newId, type: '', config: {} }; + setRules([...rules, newRule]); + + if (onChange) { + onChange({ rules: [...rules, newRule] }); + } + }; + + const handleRemoveRule = (id: string) => { + // 如果只有一个规则,不允许删除 + if (rules.length <= 1) { + return; + } + + const newRules = rules.filter(rule => rule.id !== id); + setRules(newRules); + + // 重新编号规则 + const reindexedRules = newRules.map((rule, index) => ({ + ...rule, + id: `${index + 1}` + })); + + setRules(reindexedRules); + + if (onChange) { + onChange({ rules: reindexedRules }); + } + }; + + const handleRuleTypeChange = (id: string, type: string) => { + const updatedRules = rules.map(rule => + rule.id === id ? { ...rule, type, config: {} } : rule + ); + + setRules(updatedRules); + + if (onChange) { + onChange({ rules: updatedRules }); + } + }; + + const handleRuleConfigChange = (id: string, config: Record) => { + const updatedRules = rules.map(rule => + rule.id === id ? { ...rule, config: { ...rule.config, ...config } } : rule + ); + + setRules(updatedRules); + + if (onChange) { + onChange({ rules: updatedRules }); + } + }; + + // 处理字段选择 + const handleFieldSelection = (id: string, fieldName: string, selected: boolean) => { + const updatedRules = rules.map(rule => { + if (rule.id === id) { + // 获取当前已选字段 + const selectedFields = Array.isArray(rule.config.selectedFields) + ? rule.config.selectedFields as string[] + : []; + + // 添加或删除字段 + const newSelectedFields = selected + ? [...selectedFields, fieldName] + : selectedFields.filter(f => f !== fieldName); + + return { + ...rule, + config: { ...rule.config, selectedFields: newSelectedFields } + }; + } + return rule; + }); + + setRules(updatedRules); + + if (onChange) { + onChange({ rules: updatedRules }); + } + }; + + // 渲染字段标签,确保已选择的字段即使在新的字段列表中不存在也会显示 + const renderFieldTags = (ruleId: string, ruleConfig: Record) => { + const selectedFields = Array.isArray(ruleConfig.selectedFields) + ? ruleConfig.selectedFields as string[] + : []; + + // 合并已选择的字段和可用字段,以确保已选择但不再可用的字段仍然显示 + const allFieldsToRender = [...new Set([...availableFields, ...selectedFields])]; + + return allFieldsToRender.map((field, index) => { + const isSelected = selectedFields.includes(field); + const isAvailable = availableFields.includes(field); + const buttonId = `field-tag-${ruleId}-${index}`; + + // 如果字段不再可用但已被选中,用特殊样式显示 + const fieldClass = !isAvailable && isSelected ? 'field-tag selected unavailable' : + isSelected ? 'field-tag selected' : 'field-tag'; + + // 只显示可用的字段或已选择的字段 + if (!isAvailable && !isSelected) { + return null; + } + + return ( + + ); + }).filter(Boolean); // 过滤掉null值 + }; + + // 获取规则类型的Badge样式 + const getRuleTypeBadgeClass = (type: string) => { + switch(type) { + case 'exists': return 'bg-green-500'; + case 'consistency': return 'bg-blue-500'; + case 'format': return 'bg-purple-500'; + case 'logic': return 'bg-yellow-500'; + case 'regex': return 'bg-red-500'; + case 'ai': return 'bg-indigo-500'; + case 'code': return 'bg-gray-700'; + default: return 'bg-primary'; + } + }; + + // 渲染规则配置区域 + const renderRuleConfig = (rule: RuleType) => { + const { id, type, config } = rule; + + if (!type) { + return ( +
+ +

请先选择评查类型

+
+ ); + } + + switch(type) { + case 'exists': + return ( +
+
+ +
+ {renderFieldTags(id, config)} +
+
点击选择需要判断是否存在的字段,已选中的字段会高亮显示
+
+
+ +
+ + +
+
+
+ ); + + case 'consistency': + return ( +
+
+ +
+ {Array.isArray(config.pairs) && config.pairs.length > 0 ? ( + config.pairs.map((pair, pairIndex) => ( +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+ )) + ) : ( +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ )} +
+
+ +
+
+
+
+ 逻辑关系 * +
+ + +
+
+
+
+ ); + + case 'logic': + return ( +
+
+ +
+
+
+
+ + +
+
+ + +
+
+ + { + const currentConditions = Array.isArray(config.conditions) ? [...config.conditions] : []; + currentConditions[0] = { ...currentConditions[0], value: e.target.value }; + handleRuleConfigChange(id, { conditions: currentConditions }); + }} + /> +
+
+
+ +
+
+
+
+ +
+
+
+
+ 逻辑关系 * +
+ + +
+
+
+
+ ); + + case 'regex': + return ( +
+
+ + +
+
+ + +
+
+
+ 匹配类型 * +
+ + +
+
+
+
+ ); + + case 'ai': + return ( +
+
+
+ + +
+
+ + handleRuleConfigChange(id, { temperature: e.target.value })} + /> +
+
+ + +
+
+ {availableFields.map((field, idx) => ( + + ))} +
+
+
+ ); + + case 'code': + return ( +
+
+
+ 代码语言 * +
+ + +
+
+
+
+ + handleRuleConfigChange(id, { code: value })} + /> +
+
+ ); + + case 'format': + return ( +
+
+ + +
+
+ + +
+
+ + handleRuleConfigChange(id, { formatParams: e.target.value })} + /> +
+
+ ); + + default: + return ( +
+

已选择 {type} 类型规则,请继续配置。

+
+ ); + } + }; + + // 处理评查结果消息变更 + const handleMessageChange = (type: string, value: string) => { + switch(type) { + case 'pass': + setPassMessage(value); + break; + case 'fail': + setFailMessage(value); + break; + case 'suggest': + setSuggestMessage(value); + break; + } + + if (onChange) { + onChange({ [`${type}Message`]: value }); + } + }; + + // 处理严重程度变更 + const handleSeverityChange = (value: string) => { + setErrorSeverity(value); + + if (onChange) { + onChange({ errorSeverity: value }); + } + }; + + return ( +
+
+

评查设置

+
+
+
+
+
+ +
+
+
+ + + +
+ + {showCustomLogic && ( +
+ + +
+ 使用规则编号和逻辑运算符(AND、OR、NOT)组合 +
+
+ )} +
+
+ +
+
+ +
+
+
+
已添加 {rules.length} 条规则
+ +
+ + {rules.length === 0 ? ( +
+
+ +

尚未添加任何规则

+

点击“添加规则”按钮开始创建评查规则

+
+
+ ) : ( +
+ {rules.map((rule) => ( +
+
+ + 规则 #{rule.id} + + +
+ +
+ + +
选择评查类型后将显示对应的配置项
+
+ +
+ {renderRuleConfig(rule)} +
+
+ ))} +
+ )} +
+
+ +
+
+ +
+ +
+
+ + + +
+ +
+
+ + +
建议在消息中说明问题出现的原因及建议的解决方案
+
+ + {actionType === 'notification' && ( +
+ + + +
+ +
+ + + +
+
+
+ )} +
+
+
+ +
+ + {/* 评查结果提示信息 */} +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + {/* 不通过提示类别 */} +
+ +
+ + + + + +
+
不同类别会影响问题的展示方式和处理流程
+
+ + {/* 评查后动作 */} +
+ + +
+ + {/* 动作描述区域 */} + {actionType && actionType !== 'none' && ( +
+ + +
+ )} +
+
+
+ ); +} \ No newline at end of file diff --git a/app/components/rules/new/SimpleCodeEditor.tsx b/app/components/rules/new/SimpleCodeEditor.tsx new file mode 100644 index 0000000..5a91656 --- /dev/null +++ b/app/components/rules/new/SimpleCodeEditor.tsx @@ -0,0 +1,145 @@ +import { useState, useEffect } from 'react'; + +interface SimpleCodeEditorProps { + id: string; + initialValue?: string; + language?: 'javascript' | 'python'; + onChange?: (value: string) => void; +} + +export function SimpleCodeEditor({ + id, + initialValue = '', + language = 'javascript', + onChange +}: SimpleCodeEditorProps) { + const [code, setCode] = useState(initialValue || getDefaultCode(language)); + const [copySuccess, setCopySuccess] = useState(false); + + // 复制代码到剪贴板 + const copyToClipboard = () => { + navigator.clipboard.writeText(code).then(() => { + setCopySuccess(true); + setTimeout(() => setCopySuccess(false), 2000); + }); + }; + + // 处理代码变化 + const handleChange = (e: React.ChangeEvent) => { + const value = e.target.value; + setCode(value); + if (onChange) { + onChange(value); + } + }; + + // 初始示例代码 + function getDefaultCode(lang: string) { + if (lang === 'javascript') { + return `// 示例代码 +function checkRule(data) { + // data 包含抽取的字段 + try { + // 在此编写检查逻辑 + if (data.fieldName && condition) { + return { + pass: true, + message: "检查通过" + }; + } else { + return { + pass: false, + message: "检查不通过,原因:..." + }; + } + } catch (error) { + return { + pass: false, + message: "执行出错:" + error.message + }; + } +}`; + } else { + return `# 示例代码 +def check_rule(data): + # data 包含抽取的字段 + try: + # 在此编写检查逻辑 + if 'field_name' in data and condition: + return { + 'pass': True, + 'message': "检查通过" + } + else: + return { + 'pass': False, + 'message': "检查不通过,原因:..." + } + except Exception as error: + return { + 'pass': False, + 'message': f"执行出错:{str(error)}" + }`; + } + } + + // 当语言变化时更新代码 + useEffect(() => { + if (!initialValue) { + setCode(getDefaultCode(language)); + } + }, [language, initialValue]); + + return ( +
+
+
+
{language === 'javascript' ? 'script.js' : 'script.py'}
+
+ +
+
+
+ +
+
+ {copySuccess && ( +
+ 代码已复制到剪贴板 +
+ )} +
+ ); +} \ No newline at end of file diff --git a/app/root.tsx b/app/root.tsx index 0c813a8..f4eddbf 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -14,7 +14,7 @@ import { Layout } from "~/components/layout/Layout"; import { ErrorBoundary as AppErrorBoundary } from "~/components/error/ErrorBoundary"; import "remixicon/fonts/remixicon.css"; // 导入样式 -import styles from "~/styles/main.css?url"; +import mainStyles from "~/styles/main.css?url"; // 添加客户端hydration错误处理 // if (typeof window !== "undefined") { @@ -42,7 +42,7 @@ export const meta: MetaFunction = () => { // 使用links函数为应用加载CSS和其他资源 export function links() { return [ - { rel: "stylesheet", href: styles }, + { rel: "stylesheet", href: mainStyles }, { rel: "preconnect", href: "https://fonts.googleapis.com" }, { rel: "preconnect", href: "https://fonts.gstatic.com", crossOrigin: "anonymous" }, { rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;700&display=swap" } @@ -55,6 +55,19 @@ export default function App() { +