feat(evaluation): 完成模块2.6 - 评查点前端组件增强

- rules.list.tsx 新增批量操作功能:
  * 添加批量选择复选框
  * 实现批量启用/禁用评查点
  * 实现批量删除评查点
  * 添加操作结果提示和部分失败处理

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

- 通过TypeScript类型检查,无新增类型错误
- 批量操作支持部分成功场景,详细报告结果
- 改善用户体验,提供实时反馈
This commit is contained in:
2025-11-25 13:23:36 +08:00
parent eb5a0c8b47
commit 37134ff650
4 changed files with 307 additions and 15 deletions
+83 -5
View File
@@ -1,15 +1,26 @@
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 = [] }: BasicInfoProps) {
export function BasicInfo({
onChange,
initialData,
evaluationPointGroups = [],
riskOptions = [],
frontendJWT,
evaluationPointId
}: BasicInfoProps) {
const [formData, setFormData] = useState<EvaluationPoint>({
risk: 'medium', // 风险等级 默认中风险
is_enabled: true, // 是否启用 默认启用
@@ -21,6 +32,47 @@ export function BasicInfo({ onChange, initialData, evaluationPointGroups = [], r
...(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 "";
@@ -80,11 +132,23 @@ export function BasicInfo({ onChange, initialData, evaluationPointGroups = [], r
const newData = { ...formData };
// 映射id到表单字段名
switch(id) {
case 'rule-name':
case 'rule-name':
newData.name = value;
break;
case 'rule-code':
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;
@@ -197,6 +261,15 @@ export function BasicInfo({ onChange, initialData, evaluationPointGroups = [], r
// }
// }, [filteredRuleGroups, onChange]);
// 清理验证定时器
useEffect(() => {
return () => {
if (codeValidationTimer) {
clearTimeout(codeValidationTimer);
}
};
}, [codeValidationTimer]);
return (
<div className="ant-card">
<div className="ant-card-header">
@@ -221,16 +294,21 @@ export function BasicInfo({ onChange, initialData, evaluationPointGroups = [], r
<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"
className={`form-input ${codeError ? 'border-red-500' : ''}`}
placeholder="请输入评查点编码"
value={formData.code}
onChange={handleInputChange}
/>
<div className="form-tip"></div>
{codeError ? (
<div className="form-tip text-red-500">{codeError}</div>
) : (
<div className="form-tip"></div>
)}
</div>
<div>
<label className="form-label" htmlFor="risk-level">
+155 -10
View File
@@ -15,13 +15,15 @@ import { FilterPanel, FilterSelect, SearchFilter } from '~/components/ui/FilterP
import { Pagination } from '~/components/ui/Pagination';
import { messageService } from '~/components/ui/MessageModal';
import { toastService } from '~/components/ui/Toast';
import {
getRulesList,
deleteRule,
getRuleTypes,
import {
getRulesList,
deleteRule,
getRuleTypes,
getRuleGroupsByType,
batchUpdateRuleStatus,
batchDeleteRules,
type RuleType as ApiRuleType,
type RuleGroup
type RuleGroup
} from '~/api/evaluation_points/rules';
import type { UserRole } from '~/root';
@@ -209,6 +211,9 @@ export default function RulesIndex() {
// 添加一个状态来跟踪是否执行了删除操作
const [isDeleting, setIsDeleting] = useState(false);
// 批量选择状态
const [selectedIds, setSelectedIds] = useState<string[]>([]);
// 使用 ref 跟踪是否正在加载数据,避免重复加载
const isLoadingRef = useRef(false);
@@ -541,6 +546,92 @@ export default function RulesIndex() {
navigate(`/rules/new?id=${rule.id}&mode=copy`);
};
// 批量选择处理
const handleSelectAll = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.checked) {
setSelectedIds(filteredRules.map(rule => rule.id));
} else {
setSelectedIds([]);
}
};
const handleSelectRow = (id: string) => {
setSelectedIds(prev =>
prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]
);
};
// 批量启用/禁用
const handleBatchEnable = async (isEnabled: boolean) => {
if (selectedIds.length === 0) {
toastService.warning('请先选择要操作的评查点');
return;
}
try {
setLoading(true);
const result = await batchUpdateRuleStatus(selectedIds, isEnabled, loaderData.frontendJWT);
if (result.success) {
toastService.success(`成功${isEnabled ? '启用' : '禁用'} ${result.updated_count} 个评查点`);
if (result.failed_ids.length > 0) {
toastService.warning(`${result.failed_ids.length} 个评查点操作失败`);
}
// 清空选择
setSelectedIds([]);
// 重新加载数据
fetchData();
} else {
toastService.error('批量操作失败');
}
} catch (error) {
console.error('批量操作失败:', error);
toastService.error('批量操作失败');
} finally {
setLoading(false);
}
};
// 批量删除
const handleBatchDelete = async () => {
if (selectedIds.length === 0) {
toastService.warning('请先选择要删除的评查点');
return;
}
messageService.show({
title: "确认批量删除",
message: `确定要删除选中的 ${selectedIds.length} 个评查点吗?此操作不可恢复。`,
type: "warning",
confirmText: "删除",
cancelText: "取消",
onConfirm: async () => {
try {
setLoading(true);
const result = await batchDeleteRules(selectedIds, loaderData.frontendJWT);
if (result.success) {
toastService.success(`成功删除 ${result.deleted_count} 个评查点`);
if (result.failed_ids.length > 0) {
toastService.warning(`${result.failed_ids.length} 个评查点删除失败`);
}
// 清空选择
setSelectedIds([]);
// 重新加载数据
fetchData();
} else {
toastService.error('批量删除失败');
}
} catch (error) {
console.error('批量删除失败:', error);
toastService.error('批量删除失败');
} finally {
setLoading(false);
}
}
});
};
const handlePageChange = (page: number) => {
const newParams = new URLSearchParams(searchParams);
newParams.set('page', page.toString());
@@ -573,6 +664,28 @@ export default function RulesIndex() {
// 定义表格列配置
const columns = [
// 添加复选框列(仅开发者可见)
...(isDeveloper ? [{
title: (
<input
type="checkbox"
checked={selectedIds.length > 0 && selectedIds.length === filteredRules.length && filteredRules.length > 0}
onChange={handleSelectAll}
disabled={filteredRules.length === 0}
/>
),
key: "selection",
align: "center" as const,
width: "50px",
render: (_: unknown, record: Rule) => (
<input
type="checkbox"
checked={selectedIds.includes(record.id)}
onChange={() => handleSelectRow(record.id)}
onClick={(e) => e.stopPropagation()}
/>
)
}] : []),
{
title: "评查点编码",
dataIndex: "code" as keyof Rule,
@@ -691,11 +804,43 @@ export default function RulesIndex() {
</div>
)}
</div>
{isDeveloper && (
<Button type="primary" icon="ri-add-line" to="/rules/new" className="btn-add-rule">
</Button>
)}
<div className="flex items-center gap-2">
{/* 批量操作按钮(仅在有选择时显示) */}
{isDeveloper && selectedIds.length > 0 && (
<>
<Button
type="default"
icon="ri-check-line"
onClick={() => handleBatchEnable(true)}
className="btn-batch-enable"
>
({selectedIds.length})
</Button>
<Button
type="default"
icon="ri-close-line"
onClick={() => handleBatchEnable(false)}
className="btn-batch-disable"
>
({selectedIds.length})
</Button>
<Button
type="danger"
icon="ri-delete-bin-line"
onClick={handleBatchDelete}
className="btn-batch-delete"
>
({selectedIds.length})
</Button>
</>
)}
{/* 新增按钮 */}
{isDeveloper && (
<Button type="primary" icon="ri-add-line" to="/rules/new" className="btn-add-rule">
</Button>
)}
</div>
</div>
{/* 筛选区域 */}
+3
View File
@@ -52,6 +52,7 @@ import { postgrestGet, postgrestPost, postgrestPut } from "~/api/postgrest-clien
import { toastService } from '~/components/ui/Toast';
import type { UserRole } from '~/root';
import { getPromptTemplateOptions } from '~/api/prompts/prompts';
import { getRulesList } from '~/api/evaluation_points/rules';
export const meta: MetaFunction = () => {
return [
@@ -1051,6 +1052,8 @@ export default function RuleNew() {
initialData={formData}
evaluationPointGroups={evaluationPointGroups}
riskOptions={EVALUATION_OPTIONS.riskLevelOptions}
frontendJWT={frontendJWT}
evaluationPointId={formData.id}
/>
</div>
+66
View File
@@ -0,0 +1,66 @@
> typecheck
> tsc
app/api/db-client.server.ts(3,10): error TS2305: Module '"./postgrest-client"' has no exported member 'runWithContext'.
app/api/entry-modules/entry-modules.ts(133,7): error TS2322: Type 'string | null | undefined' is not assignable to type 'string | undefined'.
Type 'null' is not assignable to type 'string | undefined'.
app/api/entry-modules/entry-modules.ts(166,7): error TS2345: Argument of type 'string | null | undefined' is not assignable to parameter of type 'string | undefined'.
Type 'null' is not assignable to type 'string | undefined'.
app/api/entry-modules/entry-modules.ts(198,7): error TS2345: Argument of type 'string | null | undefined' is not assignable to parameter of type 'string | undefined'.
Type 'null' is not assignable to type 'string | undefined'.
app/api/entry-modules/entry-modules.ts(227,7): error TS2554: Expected 1-2 arguments, but got 3.
app/api/files/documents.ts(190,3): error TS2739: Type '{ id: number; name: string; documentNumber: string; type: string; typeName: string; size: number; auditStatus: number; fileStatus: "warning" | "waiting" | "processing" | "pass" | "fail"; issues: number; ... 7 more ...; ocrResult: { ...; } | undefined; }' is missing the following properties from type 'DocumentUI': pass_count, warning_count, error_count, manual_count
app/api/files/documents.ts(702,11): error TS2322: Type '{ id: any; name: any; documentNumber: any; type: any; typeName: any; size: any; auditStatus: any; fileStatus: any; issues: any; issuesDiff: number | undefined; issuesDiffType: "increase" | "decrease" | "same" | undefined; ... 7 more ...; versionNumber: number; }[]' is not assignable to type 'DocumentVersionUI[]'.
Type '{ id: any; name: any; documentNumber: any; type: any; typeName: any; size: any; auditStatus: any; fileStatus: any; issues: any; issuesDiff: number | undefined; issuesDiffType: "increase" | "decrease" | "same" | undefined; ... 7 more ...; versionNumber: number; }' is missing the following properties from type 'DocumentVersionUI': pass_count, warning_count, error_count, manual_count
app/api/role-permissions/role-permissions.ts(44,41): error TS2552: Cannot find name 'ApiResponse'. Did you mean 'Response'?
app/api/role-permissions/role-permissions.ts(506,28): error TS2304: Cannot find name 'get'.
app/api/role-permissions/role-permissions.ts(1032,28): error TS2304: Cannot find name 'get'.
app/api/role-permissions/role-permissions.ts(1051,28): error TS2304: Cannot find name 'get'.
app/api/role-permissions/role-permissions.ts(1072,28): error TS2304: Cannot find name 'post'.
app/api/role-permissions/role-permissions.ts(1111,28): error TS2304: Cannot find name 'put'.
app/api/role-permissions/role-permissions.ts(1132,28): error TS2304: Cannot find name 'del'.
app/api/role-permissions/role-permissions.ts(1153,28): error TS2304: Cannot find name 'get'.
app/api/role-permissions/role-permissions.ts(1182,28): error TS2304: Cannot find name 'post'.
app/api/role-permissions/role-permissions.ts(1215,28): error TS2304: Cannot find name 'put'.
app/api/role-permissions/role-permissions.ts(1241,28): error TS2304: Cannot find name 'del'.
app/config/api-config-b.ts(386,47): error TS2367: This comparison appears to be unintentional because the types '"test" | "production"' and '"testing"' have no overlap.
app/config/api-config.ts(398,47): error TS2367: This comparison appears to be unintentional because the types '"test" | "production"' and '"testing"' have no overlap.
app/routes/_index.tsx(43,7): error TS7034: Variable 'entryModules' implicitly has type 'any[]' in some locations where its type cannot be determined.
app/routes/_index.tsx(66,46): error TS7005: Variable 'entryModules' implicitly has an 'any[]' type.
app/routes/_index.tsx(145,48): error TS7006: Parameter 'dt' implicitly has an 'any' type.
app/routes/_index.tsx(293,47): error TS7006: Parameter 'module' implicitly has an 'any' type.
app/routes/api.file-upload.tsx(7,17): error TS2339: Property 'user' does not exist on type '{ sessionId: any; session: Session<SessionData, SessionData>; }'.
app/routes/config-lists._index.tsx(11,87): error TS2307: Cannot find module '~/api/system_setting/config-lists' or its corresponding type declarations.
app/routes/config-lists.new.tsx(7,79): error TS2307: Cannot find module '~/api/system_setting/config-lists' or its corresponding type declarations.
app/routes/documents.list.tsx(1504,65): error TS2554: Expected 2 arguments, but got 3.
app/routes/entry-modules._index.tsx(355,13): error TS2322: Type '"link"' is not assignable to type 'ButtonType | undefined'.
app/routes/entry-modules._index.tsx(364,13): error TS2322: Type '"link"' is not assignable to type 'ButtonType | undefined'.
app/routes/entry-modules._index.tsx(399,22): error TS2322: Type '{ children: Element[]; onReset: () => void; }' is not assignable to type 'IntrinsicAttributes & FilterPanelProps'.
Property 'onReset' does not exist on type 'IntrinsicAttributes & FilterPanelProps'.
app/routes/entry-modules._index.tsx(402,13): error TS2322: Type '{ placeholder: string; defaultValue: string; onSearch: (value: string) => void; }' is not assignable to type 'IntrinsicAttributes & SearchFilterProps'.
Property 'defaultValue' does not exist on type 'IntrinsicAttributes & SearchFilterProps'.
app/routes/entry-modules._index.tsx(417,11): error TS2322: Type '{ columns: ({ key: string; title: string; width: string; render: (row: EntryModule) => number | undefined; } | { key: string; title: string; width: string; render: (row: EntryModule) => Element; } | { ...; })[]; data: EntryModule[]; loading: false; emptyText: string; }' is not assignable to type 'IntrinsicAttributes & TableProps<Record<string, any>>'.
Property 'data' does not exist on type 'IntrinsicAttributes & TableProps<Record<string, any>>'.
app/routes/entry-modules._index.tsx(425,13): error TS2322: Type '{ current: number; pageSize: number; total: number; onPageChange: (page: number) => void; onPageSizeChange: (size: number) => void; }' is not assignable to type 'IntrinsicAttributes & PaginationProps'.
Property 'current' does not exist on type 'IntrinsicAttributes & PaginationProps'.
app/routes/entry-modules.new.tsx(222,57): error TS2345: Argument of type '{ name: string; description: string | undefined; path: string | null; areas: string[]; }' is not assignable to parameter of type 'Partial<Omit<EntryModule, "id" | "created_at" | "updated_at">>'.
Types of property 'path' are incompatible.
Type 'string | null' is not assignable to type 'string | undefined'.
Type 'null' is not assignable to type 'string | undefined'.
app/routes/entry-modules.new.tsx(224,42): error TS2345: Argument of type '{ name: string; description: string | undefined; path: string | null; areas: string[]; }' is not assignable to parameter of type 'Omit<EntryModule, "id" | "created_at" | "updated_at">'.
Types of property 'path' are incompatible.
Type 'string | null' is not assignable to type 'string | undefined'.
Type 'null' is not assignable to type 'string | undefined'.
app/routes/entry-modules.new.tsx(373,13): error TS2322: Type '{ children: string; type: "primary"; onClick: () => Promise<void>; loading: boolean; disabled: boolean; }' is not assignable to type 'IntrinsicAttributes & ButtonProps & Omit<ButtonHTMLAttributes<HTMLButtonElement>, "type">'.
Property 'loading' does not exist on type 'IntrinsicAttributes & ButtonProps & Omit<ButtonHTMLAttributes<HTMLButtonElement>, "type">'.
app/routes/entry-modules.new.tsx(397,15): error TS2322: Type '{ children: string; type: "primary"; danger: true; onClick: () => void; }' is not assignable to type 'IntrinsicAttributes & ButtonProps & Omit<ButtonHTMLAttributes<HTMLButtonElement>, "type">'.
Property 'danger' does not exist on type 'IntrinsicAttributes & ButtonProps & Omit<ButtonHTMLAttributes<HTMLButtonElement>, "type">'.
app/routes/pdf-demo.tsx(856,13): error TS2322: Type '"canvas" | "svg"' is not assignable to type 'RenderMode | undefined'.
Type '"svg"' is not assignable to type 'RenderMode | undefined'.
app/routes/role-permissions._index.tsx(1035,25): error TS2322: Type 'boolean | undefined' is not assignable to type 'boolean'.
Type 'undefined' is not assignable to type 'boolean'.
app/routes/role-permissions._index.tsx(1399,15): error TS2322: Type '{ children: string; variant: string; onClick: () => void; }' is not assignable to type 'IntrinsicAttributes & ButtonProps & Omit<ButtonHTMLAttributes<HTMLButtonElement>, "type">'.
Property 'variant' does not exist on type 'IntrinsicAttributes & ButtonProps & Omit<ButtonHTMLAttributes<HTMLButtonElement>, "type">'.
app/routes/role-permissions._index.tsx(1408,15): error TS2322: Type '{ children: string; variant: string; onClick: () => void; disabled: boolean; }' is not assignable to type 'IntrinsicAttributes & ButtonProps & Omit<ButtonHTMLAttributes<HTMLButtonElement>, "type">'.
Property 'variant' does not exist on type 'IntrinsicAttributes & ButtonProps & Omit<ButtonHTMLAttributes<HTMLButtonElement>, "type">'.