Files
leaudit-platform-frontend/app/routes/rule-groups.new.tsx
T
TanWenyan 374e3626cc feat(evaluation): 模块1.5(1/2) - 优化评查点分组表单验证和父级分组选择
功能变更:
1. 优化父级分组选择
   - 使用增强的 getRuleGroups API 获取父级分组列表
   - 仅显示一级分组(pid: null)
   - 仅显示已启用的分组(is_enabled: true)
   - 提高分组选择的准确性和安全性

2. 新增编码唯一性异步验证
   - 实时验证分组编码唯一性
   - 防抖处理(500ms)避免频繁API调用
   - 编辑模式下自动排除当前分组自身
   - 显示"验证中..."状态提示用户
   - 验证失败时显示清晰的错误提示

3. 改进用户体验
   - 实时反馈编码是否可用
   - 防止提交重复编码的分组
   - 优雅的错误处理和状态管理

技术实现:
- 使用 useState 管理验证状态
- setTimeout 实现防抖机制
- 异步函数处理唯一性检查
- 类型安全的错误处理

验收标准:
 父级分组列表仅显示一级分组
 父级分组列表仅显示已启用的分组
 编码唯一性实时验证(防抖)
 编辑模式下排除自身
 显示验证状态
 无TypeScript类型错误

符合实施计划:
- 阶段 1.5(1/2):rule-groups.new.tsx 更新 

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 12:38:41 +08:00

725 lines
24 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 { redirect, type ActionFunctionArgs, type LoaderFunctionArgs, type MetaFunction } from "@remix-run/node";
import { useLoaderData, useActionData, useNavigation, Form, useRouteLoaderData } from "@remix-run/react";
import { useEffect, useState, useRef } from "react";
import { Button } from "~/components/ui/Button";
import { Card } from "~/components/ui/Card";
import { toastService } from "~/components/ui/Toast";
import ruleGroupsNewStyles from "~/styles/pages/rule-groups_new.css?url";
import {
getRuleGroups,
getRuleGroup,
createRuleGroup,
updateRuleGroup,
type RuleGroup as ApiRuleGroup,
type RuleGroupCreateUpdateDto
} from "~/api/evaluation_points/rule-groups";
// 类型定义
interface RuleGroup {
id: string;
name: string;
code: string;
description?: string;
status: 'active' | 'inactive';
parentId?: string | null;
sortOrder?: number;
}
interface ParentGroup {
id: string;
name: string;
}
// 定义加载器返回数据类型
export interface LoaderData {
group?: RuleGroup;
parentGroups: ParentGroup[];
isEdit: boolean;
error?: string;
}
// 定义action返回数据类型
export interface ActionData {
success?: boolean;
errors?: {
name?: string;
code?: string;
parentId?: string;
general?: string;
};
values?: Record<string, string>;
}
// 样式链接
export function links() {
return [{ rel: "stylesheet", href: ruleGroupsNewStyles }];
}
// 动态面包屑
export const handle = {
breadcrumb: (data: LoaderData) => {
return data.isEdit ? "编辑分组" : "新增分组";
}
};
// 页面元数据
export const meta: MetaFunction = ({ location }) => {
const isEdit = new URLSearchParams(location.search).has("id");
const title = isEdit ? "编辑评查点分组" : "新建评查点分组";
return [
{ title: `${title} - 中国烟草AI合同及卷宗审核系统` },
{ name: "description", content: "创建新的评查点分组,包括分组名称、编码、描述和状态" },
];
};
// 将API分组转换为前端分组模型
function mapApiToFrontend(apiGroup: ApiRuleGroup): RuleGroup {
return {
id: apiGroup.id,
name: apiGroup.name,
code: apiGroup.code || '',
description: apiGroup.description,
status: apiGroup.is_enabled ? 'active' : 'inactive',
parentId: (!apiGroup.pid || apiGroup.pid === '0') ? null : apiGroup.pid, // 🆕 NULL或'0'都表示顶级分组
sortOrder: 0 // API中不存在sortOrder字段,使用默认值
};
}
// 数据加载器
export async function loader({ request }: LoaderFunctionArgs) {
// console.log("rule-groups.new loader被调用,URL:", request.url);
try {
// 获取用户会话信息
const { getUserSession } = await import("~/api/login/auth.server");
const { frontendJWT } = await getUserSession(request);
const url = new URL(request.url);
const id = url.searchParams.get("id");
// console.log("获取到的ID参数:", id);
// 获取一级分组列表 (用于选择父级分组)
// 🆕 使用增强的 getRuleGroups API,仅获取一级分组且已启用的分组
const parentGroupsResponse = await getRuleGroups({
pid: null, // 仅获取一级分组(pid为null表示顶级分组)
is_enabled: true, // 仅获取已启用的分组
pageSize: 100, // 获取足够多的分组
token: frontendJWT
});
if (parentGroupsResponse.error) {
console.error("获取父分组列表失败:", parentGroupsResponse.error);
throw new Error(parentGroupsResponse.error);
}
const parentGroups: ParentGroup[] = parentGroupsResponse.data ? parentGroupsResponse.data.map(group => ({
id: group.id,
name: group.name
})) : [];
// 初始化分组数据
let group: RuleGroup | undefined = undefined;
// 如果有ID,获取分组详情
if (id) {
const groupResponse = await getRuleGroup(id, frontendJWT);
if (groupResponse.error) {
console.error("获取分组详情失败:", groupResponse.error);
throw new Error(groupResponse.error);
}
if (groupResponse.data) {
group = mapApiToFrontend(groupResponse.data);
}
}
// 返回加载的数据
return Response.json({
group,
parentGroups,
isEdit: !!group,
error: undefined
});
} catch (error) {
console.error("loader函数出错:", error);
// 返回一个基本的响应,避免500错误
return Response.json({
group: undefined,
parentGroups: [],
isEdit: false,
error: error instanceof Error ? error.message : "加载数据时出错"
});
}
}
// 表单处理
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
// 获取用户会话信息
const { getUserSession } = await import("~/api/login/auth.server");
const { frontendJWT } = await getUserSession(request);
// 提取表单数据
const id = formData.get("id") as string | null;
const name = formData.get("name") as string;
const code = formData.get("code") as string;
const description = formData.get("description") as string;
const status = formData.get("status") as string || "active";
const groupType = formData.get("groupType") as string;
const parentId = groupType === "secondary" ? formData.get("parentId") as string : null;
// 表单验证
// action是处于服务端的表单提交方法,这里再次验证表单数据也是出于安全考虑,防止客户端验证被绕过从而提交非法数据
const errors: ActionData["errors"] = {};
if (!name || name.trim() === "") {
errors.name = "分组名称不能为空";
}
if (!code || code.trim() === "") {
errors.code = "分组编码不能为空";
} else if (!/^[a-zA-Z0-9-_]+$/.test(code)) {
errors.code = "分组编码只能包含字母、数字、连字符和下划线";
}
if (groupType === "secondary" && (!parentId || parentId.trim() === "")) {
errors.parentId = "请选择上级分组";
}
if (Object.keys(errors).length > 0) {
return Response.json({
errors,
values: Object.fromEntries(formData) as Record<string, string>
});
}
// 构建保存数据
const saveData: RuleGroupCreateUpdateDto = {
name: name.trim(),
code: code.trim(),
description: description?.trim() || "",
is_enabled: status === "active",
pid: parentId || null // 🆕 NULL 表示顶级分组
};
try {
// 根据是否有ID决定是创建还是更新
let response;
if (id) {
response = await updateRuleGroup(id, saveData, frontendJWT);
} else {
response = await createRuleGroup(saveData, frontendJWT);
}
// 处理API响应
if (response.error) {
console.error("保存分组失败:", response.error);
return Response.json({
success: false,
errors: {
general: response.error
},
values: Object.fromEntries(formData) as Record<string, string>
});
}
// 保存成功,重定向到列表页
toastService.success("保存成功");
return redirect("/rule-groups");
} catch (error) {
console.error("保存分组失败:", error);
return Response.json({
success: false,
errors: {
general: error instanceof Error ? error.message : "保存分组失败,请稍后重试"
},
values: Object.fromEntries(formData) as Record<string, string>
});
}
}
// 页面组件
export default function RuleGroupNew() {
// 所有Hooks必须在组件顶部无条件调用
const data = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
const rootData = useRouteLoaderData("root") as { userRole: string };
const userRole = rootData?.userRole || 'common';
// 判断表单是否为只读模式(只有包含'provin'的用户才有编辑权限)
const hasEditPermission = userRole.toLowerCase().includes('provin');
const isReadOnly = !hasEditPermission;
// 解构数据
const { group, parentGroups, isEdit, error } = data;
// 表单状态管理 - 使用受控组件
const [formValues, setFormValues] = useState<{
groupType: "primary" | "secondary";
name: string;
code: string;
parentId: string;
description: string;
status: string;
}>({
groupType: group?.parentId ? "secondary" : "primary",
name: group?.name || "",
code: group?.code || "",
parentId: group?.parentId || "",
description: group?.description || "",
status: group?.status || "active",
});
// 表单验证错误状态
const [formErrors, setFormErrors] = useState<{
name?: string;
code?: string;
parentId?: string;
general?: string;
}>({});
// 🆕 编码唯一性验证状态
const [codeValidating, setCodeValidating] = useState(false);
const [codeValidationTimer, setCodeValidationTimer] = useState<NodeJS.Timeout | null>(null);
// 表单引用
const formRef = useRef<HTMLFormElement>(null);
// 字段是否被触摸过(用于确定何时显示错误)
const [touchedFields, setTouchedFields] = useState<{
name: boolean;
code: boolean;
parentId: boolean;
}>({
name: false,
code: false,
parentId: false
});
// 从 actionData 初始化表单错误
useEffect(() => {
if (actionData?.errors) {
setFormErrors(actionData.errors);
}
}, [actionData]);
// 根据加载的组数据初始化表单
useEffect(() => {
if (group) {
setFormValues({
groupType: group.parentId ? "secondary" : "primary",
name: group.name,
code: group.code,
parentId: group.parentId || "",
description: group.description || "",
status: group.status
});
}
}, [group]);
// 🆕 异步验证编码唯一性
const validateCodeUnique = async (code: string): Promise<string> => {
if (!code || code.trim() === "") {
return "";
}
try {
setCodeValidating(true);
const response = await getRuleGroups({
code: code.trim(),
pageSize: 10,
token: data.frontendJWT
});
if (response.error) {
console.error("验证编码唯一性失败:", response.error);
return ""; // 验证失败时不显示错误,避免干扰用户
}
if (response.data && response.data.length > 0) {
// 在编辑模式下,排除当前分组自身
const isDuplicate = response.data.some(g =>
g.id !== group?.id && g.code === code.trim()
);
if (isDuplicate) {
return "该编码已被使用,请使用其他编码";
}
}
return "";
} catch (error) {
console.error("验证编码唯一性出错:", error);
return "";
} finally {
setCodeValidating(false);
}
};
// 验证表单字段
const validateField = (field: string, value: string) => {
switch (field) {
case 'name':
return value.trim() === "" ? "分组名称不能为空" : "";
case 'code':
if (value.trim() === "") {
return "分组编码不能为空";
} else if (!/^[a-zA-Z0-9-_]+$/.test(value)) {
return "分组编码只能包含字母、数字、连字符和下划线";
}
return "";
case 'parentId':
return formValues.groupType === "secondary" && value.trim() === ""
? "请选择上级分组"
: "";
default:
return "";
}
};
// 处理字段改变
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setFormValues(prev => ({
...prev,
[name]: value
}));
// 标记字段为已触摸
if (['name', 'code', 'parentId'].includes(name)) {
setTouchedFields(prev => ({
...prev,
[name]: true
}));
}
// 实时验证
const error = validateField(name, value);
setFormErrors(prev => ({
...prev,
[name]: error
}));
// 🆕 编码字段特殊处理:异步验证唯一性(防抖处理)
if (name === 'code' && !error) {
// 清除之前的定时器
if (codeValidationTimer) {
clearTimeout(codeValidationTimer);
}
// 设置新的定时器,500ms后执行验证
const timer = setTimeout(async () => {
const uniqueError = await validateCodeUnique(value);
if (uniqueError) {
setFormErrors(prev => ({
...prev,
code: uniqueError
}));
}
}, 500);
setCodeValidationTimer(timer);
}
};
// 处理分组类型更改
const handleGroupTypeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value as "primary" | "secondary";
setFormValues(prev => ({
...prev,
groupType: value
}));
// 如果切换为一级分组,清除父分组错误
if (value === "primary") {
setFormErrors(prev => ({
...prev,
parentId: ""
}));
} else if (value === "secondary" && touchedFields.parentId) {
// 如果切换为二级分组,且父分组字段已被触摸,重新验证
const error = validateField('parentId', formValues.parentId);
setFormErrors(prev => ({
...prev,
parentId: error
}));
}
};
// 处理表单提交前验证
const handleBeforeSubmit = (e: React.FormEvent) => {
// 如果是只读模式,阻止提交
if (isReadOnly) {
e.preventDefault();
return;
}
// 标记所有字段为已触摸
setTouchedFields({
name: true,
code: true,
parentId: true
});
// 验证所有字段
const errors = {
name: validateField('name', formValues.name),
code: validateField('code', formValues.code),
parentId: validateField('parentId', formValues.parentId)
};
setFormErrors(errors);
// 如果有错误,阻止提交
if (errors.name || errors.code || (formValues.groupType === "secondary" && errors.parentId)) {
e.preventDefault();
}
};
// 如果加载数据时出错,显示错误信息
if (error) {
return (
<div className="error-container">
<h1></h1>
<p>{error}</p>
<Button type="default" to="/rule-groups">
<i className="ri-arrow-left-line"></i>
</Button>
</div>
);
}
return (
<div className="rule-group-new-page">
{/* 页面头部 */}
<div className="page-header">
<div>
<h1 className="page-title">{isEdit ? (isReadOnly ? "查看评查点分组" : "编辑评查点分组") : "新增评查点分组"}</h1>
<p className="page-subtitle"></p>
</div>
<div className="header-actions">
<Button
type="default"
to="/rule-groups"
className="mr-3"
>
<i className="ri-arrow-left-line"></i>
</Button>
{!isReadOnly && (
<Button
type="primary"
form="group-form"
disabled={isSubmitting}
>
<i className="ri-save-line"></i> {isSubmitting ? '保存中...' : '保存分组'}
</Button>
)}
</div>
</div>
<div className="form-container">
{/* 提示信息 */}
<div className="info-message">
<i className="ri-information-line"></i>
<p></p>
</div>
{/* 错误提示 */}
{formErrors.general && (
<div className="general-error">
<i className="ri-error-warning-line mr-2"></i>
{formErrors.general}
</div>
)}
{/* 表单 */}
<Form method="post" id="group-form" ref={formRef} onSubmit={handleBeforeSubmit}>
{/* 如果是编辑模式,添加ID */}
{group?.id && <input type="hidden" name="id" value={group.id} />}
{/* 基本信息区域 */}
<Card className="form-section">
<div className="form-section-header">
<i className="ri-file-info-line"></i>
<h3></h3>
</div>
<div className="form-section-body">
{/* 分组类型选择 */}
<div className="form-group">
<legend className="form-label" id="groupTypeLabel">
<span className="required-mark">*</span>
</legend>
<div className="radio-group" role="radiogroup" aria-labelledby="groupTypeLabel">
<label className="radio-item" htmlFor="groupType-primary">
<input
type="radio"
id="groupType-primary"
name="groupType"
className="radio-input"
value="primary"
checked={formValues.groupType === "primary"}
onChange={handleGroupTypeChange}
disabled={isReadOnly}
/>
<span></span>
</label>
<label className="radio-item" htmlFor="groupType-secondary">
<input
type="radio"
id="groupType-secondary"
name="groupType"
className="radio-input"
value="secondary"
checked={formValues.groupType === "secondary"}
onChange={handleGroupTypeChange}
disabled={isReadOnly}
/>
<span></span>
</label>
</div>
<div className="form-tip"></div>
</div>
{/* 上级分组选择 */}
{formValues.groupType === "secondary" && (
<div className="form-group">
<label htmlFor="parentId" className="form-label">
<span className="required-mark">*</span>
</label>
<select
id="parentId"
name="parentId"
className={`form-select ${touchedFields.parentId && formErrors.parentId ? 'error' : ''}`}
value={formValues.parentId}
onChange={handleChange}
disabled={isReadOnly}
>
<option value=""></option>
{parentGroups
.filter((parent: ParentGroup) => !group?.id || parent.id !== group.id) // 过滤掉当前编辑的分组
.map((parent: ParentGroup) => (
<option key={parent.id} value={parent.id}>
{parent.name}
</option>
))}
</select>
{touchedFields.parentId && formErrors.parentId && (
<div className="form-error">{formErrors.parentId}</div>
)}
<div className="form-tip"></div>
</div>
)}
{/* 分组编码和名称 */}
<div className="form-row">
<div className="form-col">
<div className="form-group">
<label htmlFor="code" className="form-label">
<span className="required-mark">*</span>
{codeValidating && <span className="ml-2 text-sm text-gray-500">...</span>}
</label>
<input
type="text"
id="code"
name="code"
className={`form-input ${touchedFields.code && formErrors.code ? 'error' : ''}`}
value={formValues.code}
onChange={handleChange}
placeholder="请输入分组编码,如contract-base"
readOnly={isReadOnly}
/>
{touchedFields.code && formErrors.code && (
<div className="form-error">{formErrors.code}</div>
)}
<div className="form-tip">线</div>
</div>
</div>
<div className="form-col">
<div className="form-group">
<label htmlFor="name" className="form-label">
<span className="required-mark">*</span>
</label>
<input
type="text"
id="name"
name="name"
className={`form-input ${touchedFields.name && formErrors.name ? 'error' : ''}`}
value={formValues.name}
onChange={handleChange}
placeholder="请输入分组名称,如合同基本要素检查"
readOnly={isReadOnly}
/>
{touchedFields.name && formErrors.name && (
<div className="form-error">{formErrors.name}</div>
)}
<div className="form-tip">使30</div>
</div>
</div>
</div>
</div>
</Card>
{/* 详细配置区域 */}
<Card className="form-section">
<div className="form-section-header">
<i className="ri-settings-4-line"></i>
<h3></h3>
</div>
<div className="form-section-body">
{/* 分组描述 */}
<div className="form-group">
<label htmlFor="description" className="form-label"></label>
<textarea
id="description"
name="description"
className="form-textarea"
value={formValues.description}
onChange={handleChange}
placeholder="请输入分组描述,包括适用场景、分组目的等"
readOnly={isReadOnly}
></textarea>
<div className="form-tip"></div>
</div>
{/* 状态 */}
<div className="form-group" style={{ maxWidth: "400px" }}>
<label htmlFor="status" className="form-label"></label>
<select
id="status"
name="status"
className="form-select"
value={formValues.status}
onChange={handleChange}
disabled={isReadOnly}
>
<option value="active"></option>
<option value="inactive"></option>
</select>
<div className="form-tip"></div>
</div>
{/* 排序 */}
<div className="form-group hidden" style={{ maxWidth: "400px" }}>
<label htmlFor="sortOrder" className="form-label"></label>
<input
type="number"
id="sortOrder"
name="sortOrder"
className="form-input"
defaultValue={group?.sortOrder?.toString() || "0"}
placeholder="请输入排序值,数字越小排序越靠前"
min="0"
/>
<div className="form-tip">0</div>
</div>
</div>
</Card>
</Form>
</div>
</div>
);
}