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

713 lines
27 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 React, { useState, useEffect } from 'react';
import type { EvaluationPoint } from '~/models/evaluation_points';
import type { EvaluationPointGroup } from '~/models/evaluation_point_groups';
import { getRulesList, getRuleTypes, getAttributeTypes, type RuleType, type AttributeTypeOption } from '~/api/evaluation_points/rules';
interface BasicInfoProps {
onChange?: (data: Record<string, unknown>) => void;
initialData?: EvaluationPoint;
evaluationPointGroups?: EvaluationPointGroup[];
riskOptions?: Array<{value: string, label: string}>;
frontendJWT?: string;
evaluationPointId?: number | string;
}
// 评查点基本信息组件
export function BasicInfo({
onChange,
initialData,
evaluationPointGroups = [],
riskOptions = [],
frontendJWT,
evaluationPointId
}: BasicInfoProps) {
const [formData, setFormData] = useState<EvaluationPoint>({
risk: 'medium', // 风险等级 默认中风险
is_enabled: true, // 是否启用 默认启用
references_laws: {
name: '',
articles: [],
content: ''
},
...(initialData || {}) // 合并初始数据
});
// 编码验证状态
const [codeValidating, setCodeValidating] = useState(false);
const [codeError, setCodeError] = useState('');
const [codeValidationTimer, setCodeValidationTimer] = useState<NodeJS.Timeout | null>(null);
// 异步验证编码唯一性
const validateCodeUnique = async (code: string): Promise<string> => {
if (!code.trim()) {
return ''; // 空值不验证
}
setCodeValidating(true);
setCodeError('');
try {
const response = await getRulesList({
keyword: code.trim(),
pageSize: 10,
token: frontendJWT
});
if (response.data && response.data.rules && response.data.rules.length > 0) {
// 检查是否有完全匹配的编码(排除当前编辑的评查点)
const isDuplicate = response.data.rules.some(rule =>
rule.code === code.trim() && String(rule.id) !== String(evaluationPointId)
);
if (isDuplicate) {
return '该编码已被使用,请使用其他编码';
}
}
return '';
} catch (error) {
console.error('验证编码唯一性失败:', error);
return ''; // 验证失败不阻止用户输入
} finally {
setCodeValidating(false);
}
};
// 找到当前评查点类型对应的code
const getCheckpointTypeCode = () => {
if (!formData.evaluation_point_groups_pid) return "";
// 优先从 API 返回的 filteredRuleTypes 中查找
const fromApi = filteredRuleTypes.find(
ruleType => Number(ruleType.id) === formData.evaluation_point_groups_pid
);
if (fromApi?.code) return fromApi.code;
// 兜底:从 evaluationPointGroups 中查找
const typeGroup = evaluationPointGroups.find(
group => group.id === formData.evaluation_point_groups_pid && (!group.pid || group.pid === 0)
);
return typeGroup?.code || "";
};
// 评查点描述与法律依据 展开状态
const [isDescExpanded, setIsDescExpanded] = useState(false);
// 条款号临时输入字符串(不会触发自动分割)
const [lawArticlesText, setLawArticlesText] = useState('');
// 从 API 获取的评查点类型列表(根据 documentTypeIds 过滤)
const [filteredRuleTypes, setFilteredRuleTypes] = useState<RuleType[]>([]);
const [ruleTypesLoading, setRuleTypesLoading] = useState(false);
// 适用文档属性类型相关状态
const [attributeTypeOptions, setAttributeTypeOptions] = useState<AttributeTypeOption[]>([]);
const [attributeTypesLoading, setAttributeTypesLoading] = useState(false);
const [isCustomAttributeType, setIsCustomAttributeType] = useState(false); // 是否为自定义输入模式
const hasScopedGroupSource = filteredRuleTypes.length > 0 || evaluationPointGroups.length > 0;
// 从 Session Storage 获取 documentTypeIds 并调用 API 获取评查点类型
useEffect(() => {
const fetchRuleTypes = async () => {
try {
const storedIds = sessionStorage.getItem('documentTypeIds');
if (!storedIds) {
setFilteredRuleTypes([]);
return;
}
const parsedIds = JSON.parse(storedIds);
if (!Array.isArray(parsedIds) || parsedIds.length === 0) {
setFilteredRuleTypes([]);
return;
}
const documentTypeIds = parsedIds.map(id => Number(id));
setRuleTypesLoading(true);
// 调用 getRuleTypes API 获取过滤后的评查点类型
const response = await getRuleTypes(documentTypeIds, frontendJWT);
if (response.data) {
setFilteredRuleTypes(response.data);
} else {
console.error('获取评查点类型失败:', response.error);
setFilteredRuleTypes([]);
}
} catch (error) {
console.error('获取评查点类型失败:', error);
setFilteredRuleTypes([]);
} finally {
setRuleTypesLoading(false);
}
};
fetchRuleTypes();
}, [frontendJWT]);
// 获取适用文档属性类型选项
useEffect(() => {
const fetchAttributeTypes = async () => {
try {
setAttributeTypesLoading(true);
const response = await getAttributeTypes(frontendJWT);
if (response.data) {
setAttributeTypeOptions(response.data);
} else {
// 使用默认选项
setAttributeTypeOptions([{ code: 'ALL', label: '通用' }]);
}
} catch (error) {
console.error('获取适用属性类型失败:', error);
setAttributeTypeOptions([{ code: 'ALL', label: '通用' }]);
} finally {
setAttributeTypesLoading(false);
}
};
fetchAttributeTypes();
}, [frontendJWT]);
// 检查初始数据中的 document_attribute_type 是否为自定义值
useEffect(() => {
if (formData.document_attribute_type) {
const isInOptions = attributeTypeOptions.some(
opt => opt.code === formData.document_attribute_type
);
// 如果当前值不在选项列表中,则切换到自定义模式
if (!isInOptions && attributeTypeOptions.length > 0) {
setIsCustomAttributeType(true);
}
}
}, [formData.document_attribute_type, attributeTypeOptions]);
// 根据选择的评查点类型筛选可用的规则组
const filteredRuleGroups = evaluationPointGroups.filter(group =>
formData.evaluation_point_groups_pid &&
group.pid === formData.evaluation_point_groups_pid &&
group.is_enabled
);
// 🆕 获取评查点类型选项(使用 API 返回的过滤后数据)
const getCheckpointTypeOptions = () => {
if (ruleTypesLoading) {
return (
<>
<option value="">...</option>
</>
);
}
// 优先使用接口按当前文档类型范围返回的一级分组
if (filteredRuleTypes.length > 0) {
return (
<>
<option value=""></option>
{filteredRuleTypes.map(ruleType => (
<option key={ruleType.id} value={ruleType.code}>
{ruleType.name}
</option>
))}
</>
);
}
// 兜底仅限当前页面已加载的 scoped 分组数据,禁止回退到“全库分组”
if (!evaluationPointGroups || evaluationPointGroups.length === 0) {
return (
<>
<option value="">{ruleTypesLoading ? '加载中...' : '当前入口未绑定评查点类型'}</option>
</>
);
}
const typeGroups = evaluationPointGroups.filter(group =>
(!group.pid || group.pid === 0) && group.is_enabled
);
return (
<>
<option value=""></option>
{typeGroups.map(group => (
<option key={group.id} value={group.code}>
{group.name}
</option>
))}
</>
);
};
// 评查点描述与法律依据 展开状态
const handleToggleDescription = () => {
setIsDescExpanded(!isDescExpanded);
};
// 处理表单输入变化
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
const { id, value } = e.target;
const newData = { ...formData };
// 映射id到表单字段名
switch(id) {
case 'rule-name':
newData.name = value;
break;
case 'rule-code':
newData.code = value;
// 清除之前的验证定时器
if (codeValidationTimer) {
clearTimeout(codeValidationTimer);
}
// 清除错误信息
setCodeError('');
// 设置新的验证定时器(500ms后触发验证)
{
const timer = setTimeout(async () => {
const error = await validateCodeUnique(value);
setCodeError(error);
}, 500);
setCodeValidationTimer(timer);
}
break;
case 'risk-level':
newData.risk = value;
break;
case 'is-enabled':
newData.is_enabled = value === 'true';
break;
case 'rule-description':
newData.description = value;
break;
case 'law-name':
newData.references_laws = {
...formData.references_laws,
name: value
};
break;
case 'law-content':
newData.references_laws = {
...formData.references_laws,
content: value
};
break;
case 'evaluation-point-group':
newData.evaluation_point_groups_id = value ? parseInt(value) : null;
break;
case 'checkpoint-type':
// 处理评查点类型选择
if (value) {
newData.evaluation_point_groups_id = null;
// 优先从 API 返回的 filteredRuleTypes 中查找
const selectedFromApi = filteredRuleTypes.find(ruleType => ruleType.code === value);
if (selectedFromApi) {
newData.evaluation_point_groups_pid = Number(selectedFromApi.id);
} else {
// 兜底:从 evaluationPointGroups 中查找(pid为NULL或0表示顶级分组)
const selectedType = evaluationPointGroups.find(group => group.code === value && (!group.pid || group.pid === 0));
if (selectedType) {
newData.evaluation_point_groups_pid = selectedType.id;
}
}
} else {
newData.evaluation_point_groups_pid = null;
newData.evaluation_point_groups_id = null; // 清空规则组选择
}
break;
case 'document-attribute-type':
// 处理适用文档属性类型选择(下拉框模式)
newData.document_attribute_type = value || 'ALL';
break;
case 'document-attribute-type-custom':
// 处理适用文档属性类型输入(自定义模式)
newData.document_attribute_type = value;
break;
}
setFormData(newData);
if (onChange) {
onChange(newData);
}
};
// 处理条款号输入框变化
const handleLawArticlesChange = (e: React.ChangeEvent<HTMLInputElement>) => {
// 设置临时字符串状态,这样不会触发任何处理
setLawArticlesText(e.target.value);
};
// 处理条款号输入框失去焦点
const handleLawArticlesBlur = () => {
// 将输入的文本转换为数组
const articles = lawArticlesText
.split(',')
.map(article => article.trim())
.filter(article => article !== '');
// 创建一个新的引用法律对象,保留现有字段
const referencesLaws = {
...(formData.references_laws || {}),
articles: articles // ✅ 清空时会是空数组
};
// 更新表单数据
const newData = {
...formData,
references_laws: referencesLaws
};
setFormData(newData);
if (onChange) {
onChange(newData);
}
};
// 初始化条款号文本字段
useEffect(() => {
if (formData.references_laws?.articles && formData.references_laws.articles.length > 0) {
setLawArticlesText(formData.references_laws.articles.join(','));
} else {
// ✅ 当 articles 为空时,也清空输入框
setLawArticlesText('');
}
}, [formData.references_laws?.articles]);
// 检查是否需要自动展开描述区域(仅在初始数据加载时执行一次)
useEffect(() => {
// 如果初始数据中描述或法律依据相关字段有值,则自动展开
if (
initialData?.description ||
initialData?.references_laws?.name ||
(initialData?.references_laws?.articles && initialData.references_laws.articles.length > 0) ||
initialData?.references_laws?.content
) {
setIsDescExpanded(true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// 注释掉自动选择规则组的逻辑,避免无限循环
// 原因:此 useEffect 依赖 onChange 和 filteredRuleGroups,每次渲染都可能触发
// 导致 onChange -> 父组件更新 -> BasicInfo 重新渲染 -> useEffect 再次触发 -> 无限循环
// useEffect(() => {
// if (onChange && filteredRuleGroups.length === 1) {
// onChange({ evaluation_point_groups_id: filteredRuleGroups[0].id });
// }
// }, [filteredRuleGroups, onChange]);
// 清理验证定时器
useEffect(() => {
return () => {
if (codeValidationTimer) {
clearTimeout(codeValidationTimer);
}
};
}, [codeValidationTimer]);
return (
<div className="ant-card">
<div className="ant-card-header">
<h3></h3>
</div>
<div className="ant-card-body">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<label className="form-label" htmlFor="rule-name">
<span className="required-mark">*</span>
</label>
<input
type="text"
id="rule-name"
className="form-input"
placeholder="请输入评查点名称,简洁明了"
value={formData.name}
onChange={handleInputChange}
/>
<div className="form-tip">使30</div>
</div>
<div>
<label className="form-label" htmlFor="rule-code">
<span className="required-mark">*</span>
{codeValidating && <span className="ml-2 text-sm text-gray-500">...</span>}
</label>
<input
type="text"
id="rule-code"
className={`form-input ${codeError ? 'border-red-500' : ''}`}
placeholder="请输入评查点编码"
value={formData.code}
onChange={handleInputChange}
/>
{codeError ? (
<div className="form-tip text-red-500">{codeError}</div>
) : (
<div className="form-tip"></div>
)}
</div>
<div>
<label className="form-label" htmlFor="risk-level">
<span className="required-mark">*</span>
</label>
<select
id="risk-level"
className="form-select"
value={formData.risk}
onChange={handleInputChange}
>
{riskOptions.length > 0 ? (
riskOptions.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))
) : (
<>
<option value="high"></option>
<option value="medium"></option>
<option value="low"></option>
</>
)}
</select>
<div className="form-tip"></div>
</div>
<div>
<label className="form-label" htmlFor="checkpoint-type">
<span className="required-mark">*</span>
</label>
<select
id="checkpoint-type"
className="form-select"
value={getCheckpointTypeCode()}
onChange={handleInputChange}
>
{getCheckpointTypeOptions()}
</select>
<div className="form-tip">便</div>
</div>
<div>
<label className="form-label" htmlFor="evaluation-point-group">
<span className="required-mark">*</span>
</label>
<select
id="evaluation-point-group"
className={`form-select ${!formData.evaluation_point_groups_pid || filteredRuleGroups.length === 0 ? 'bg-gray-100 cursor-not-allowed' : ''}`}
value={formData.evaluation_point_groups_id?.toString() || ""}
onChange={handleInputChange}
disabled={!formData.evaluation_point_groups_pid || filteredRuleGroups.length === 0}
>
<option value="">
{!hasScopedGroupSource ? "当前入口未绑定规则组" :
!formData.evaluation_point_groups_pid ? "请先选择评查点类型" :
filteredRuleGroups.length === 0 ? "该类型下暂无可用规则组" :
"请选择规则组"}
</option>
{filteredRuleGroups.map(group => (
<option key={group.id} value={group.id.toString()}>
{group.name}
</option>
))}
</select>
<div className="form-tip">
{!hasScopedGroupSource ? "请先到系统设置为当前入口模块绑定文档类型与评查点分组" :
!formData.evaluation_point_groups_pid ? "请先选择评查点类型" :
filteredRuleGroups.length === 0 ? "该类型下暂无可用规则组" :
"选择评查点所属的规则组"}
</div>
</div>
<div>
<label className="form-label" htmlFor="is-enabled"></label>
<select
id="is-enabled"
className="form-select"
value={formData.is_enabled ? 'true' : 'false'}
onChange={handleInputChange}
>
<option value="true"></option>
<option value="false"></option>
</select>
<div className="form-tip"></div>
</div>
<div>
<label className="form-label" htmlFor="document-attribute-type">
</label>
<div className="flex items-center gap-2">
{isCustomAttributeType ? (
// 自定义输入模式
<input
type="text"
id="document-attribute-type-custom"
className="form-input flex-1"
placeholder="请输入自定义属性类型"
value={formData.document_attribute_type || ''}
onChange={handleInputChange}
/>
) : (
// 下拉选择模式
<select
id="document-attribute-type"
className="form-select flex-1"
value={formData.document_attribute_type || 'ALL'}
onChange={handleInputChange}
disabled={attributeTypesLoading}
>
{attributeTypesLoading ? (
<option value="">...</option>
) : (
attributeTypeOptions.map(option => (
<option key={option.code} value={option.code}>
{option.label}
</option>
))
)}
</select>
)}
<button
type="button"
className={`px-3 py-2 text-sm rounded border transition-colors ${
isCustomAttributeType
? 'bg-[#00684a] text-white border-[#00684a] hover:bg-[#005a3f]'
: 'bg-white text-gray-600 border-gray-300 hover:border-[#00684a] hover:text-[#00684a]'
}`}
onClick={() => {
setIsCustomAttributeType(!isCustomAttributeType);
// 切换到自定义模式时,如果当前值是预设值,清空它让用户输入
// 切换回下拉模式时,如果当前值不在选项中,设置为默认值 ALL
if (!isCustomAttributeType) {
// 切换到自定义模式
} else {
// 切换回下拉模式
const currentValue = formData.document_attribute_type;
const isInOptions = attributeTypeOptions.some(opt => opt.code === currentValue);
if (!isInOptions) {
const newData = { ...formData, document_attribute_type: 'ALL' };
setFormData(newData);
if (onChange) {
onChange(newData);
}
}
}
}}
title={isCustomAttributeType ? '切换到下拉选择' : '切换到自定义输入'}
>
<i className={`ri-${isCustomAttributeType ? 'list-check' : 'edit-line'}`}></i>
</button>
</div>
<div className="form-tip">
{isCustomAttributeType
? '输入自定义的文档属性类型,点击右侧按钮可切换回下拉选择'
: '选择评查点适用的文档属性类型,点击右侧按钮可自定义输入'}
</div>
</div>
</div>
<div className="mt-8">
<div
className={`flex items-center cursor-pointer ${isDescExpanded ? 'expanded' : ''}`}
onClick={handleToggleDescription}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
handleToggleDescription();
}
}}
tabIndex={0}
role="button"
>
<label className="form-label mb-0" htmlFor="description-section"></label>
<i className={`${isDescExpanded ? 'ri-arrow-drop-up-line' : 'ri-arrow-drop-down-line'} text-lg expand-icon ml-2`}></i>
</div>
<div className={`mt-2 ${isDescExpanded ? '' : 'hidden'}`} id="description-section">
<div className="mb-4">
<textarea
id="rule-description"
className="form-textarea"
placeholder="请输入评查点的详细描述"
style={{ minHeight: '80px' }}
value={formData.description}
onChange={handleInputChange}
></textarea>
<div className="form-tip"></div>
</div>
{/* 引用法典输入区域 */}
<div className="mb-4">
<label className="form-label" htmlFor="law-section"></label>
<div className="mb-3" id="law-section">
<label className="text-sm text-gray-600 mb-1 block" htmlFor="law-name"></label>
<input
type="text"
className="form-input"
placeholder="例如:《中华人民共和国民法典》"
id="law-name"
value={formData.references_laws?.name || ''}
onChange={handleInputChange}
/>
</div>
<div className="mb-3">
<label className="text-sm text-gray-600 mb-1 block" htmlFor="law-articles">
<span className="text-xs text-gray-400">()</span>
</label>
<input
type="text"
className="form-input"
placeholder="例如:第五百八十五条,第五百八十六条"
id="law-articles"
value={lawArticlesText}
onChange={handleLawArticlesChange}
onBlur={handleLawArticlesBlur}
/>
<div className="form-tip"></div>
</div>
<div className="mb-4">
<label className="text-sm text-gray-600 mb-1 block" htmlFor="law-content"></label>
<textarea
className="form-textarea"
style={{ minHeight: '60px' }}
placeholder="例如:当事人应当按照约定全面履行自己的义务。"
id="law-content"
value={formData.references_laws?.content || ''}
onChange={handleInputChange}
></textarea>
</div>
<div className="p-3 bg-blue-50 border border-blue-200 rounded-md text-sm text-blue-700 mb-2">
<i className="ri-information-line mr-1"></i>
</div>
{/* 预览区域 */}
<div className="mt-3">
<div className="text-sm font-medium mb-2"></div>
<div className="law-reference">
<div className="law-reference-title" id="preview-law-name">
{formData.references_laws?.name || '《中华人民共和国民法典》'}
</div>
<div className="law-reference-articles" id="preview-law-articles">
{formData.references_laws?.articles && formData.references_laws.articles.length > 0 ?
formData.references_laws.articles.map((article, index) => (
<span key={index} className="law-article">{article}</span>
)) : (
<>
<span className="law-article"></span>
<span className="law-article"></span>
</>
)}
</div>
<div className="law-reference-content" id="preview-law-content">
{formData.references_laws?.content || '当事人应当按照约定全面履行自己的义务。'}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
}