Files
leaudit-platform-frontend/app/components/rules/new/BasicInfo.tsx
T
TanWenyan 37134ff650 feat(evaluation): 完成模块2.6 - 评查点前端组件增强
- rules.list.tsx 新增批量操作功能:
  * 添加批量选择复选框
  * 实现批量启用/禁用评查点
  * 实现批量删除评查点
  * 添加操作结果提示和部分失败处理

- BasicInfo.tsx 新增异步编码验证:
  * 实现500ms防抖的实时编码唯一性验证
  * 集成 getRulesList API 进行编码查重
  * 编辑模式下排除当前评查点
  * 添加验证中状态和错误提示UI

- 通过TypeScript类型检查,无新增类型错误
- 批量操作支持部分成功场景,详细报告结果
- 改善用户体验,提供实时反馈
2025-11-25 13:23:36 +08:00

502 lines
18 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 } 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 "";
const typeGroup = evaluationPointGroups.find(
group => group.id === formData.evaluation_point_groups_pid && (!group.pid || group.pid === 0) // 🆕 NULL或0都表示顶级分组
);
return typeGroup?.code || "";
};
// 评查点描述与法律依据 展开状态
const [isDescExpanded, setIsDescExpanded] = useState(false);
// 条款号临时输入字符串(不会触发自动分割)
const [lawArticlesText, setLawArticlesText] = useState('');
// 根据选择的评查点类型筛选可用的规则组
const filteredRuleGroups = evaluationPointGroups.filter(group =>
formData.evaluation_point_groups_pid &&
group.pid === formData.evaluation_point_groups_pid &&
group.is_enabled
);
// 🆕 获取评查点类型选项(pid为NULL或0的数据)
const getCheckpointTypeOptions = () => {
if (!evaluationPointGroups || evaluationPointGroups.length === 0) {
return (
<>
<option value=""></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) {
// 🆕 找到选中的类型组(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;
}
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 (
formData.description ||
formData.references_laws?.name ||
(formData.references_laws?.articles && formData.references_laws.articles.length > 0) ||
formData.references_laws?.content
) {
setIsDescExpanded(true);
}
}, [formData]);
// 注释掉自动选择规则组的逻辑,避免无限循环
// 原因:此 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="">
{!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">
{!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>
<div className="mt-8">
<div
className={`flex justify-between 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={`ri-arrow-${isDescExpanded ? 'up' : 'down'}-s-line text-lg expand-icon`}></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>
);
}