Files
leaudit-platform-frontend/app/routes/rule-groups.new.tsx
T

647 lines
21 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 === '0' ? null : apiGroup.pid,
sortOrder: 0 // API中不存在sortOrder字段,使用默认值
};
}
// 数据加载器
export async function loader({ request }: LoaderFunctionArgs) {
// console.log("rule-groups.new loader被调用,URL:", request.url);
try {
const url = new URL(request.url);
const id = url.searchParams.get("id");
// console.log("获取到的ID参数:", id);
// 获取一级分组列表 (用于选择父级分组)
const parentGroupsResponse = await getRuleGroups();
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);
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 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 ? "0" : parentId
};
try {
// 根据是否有ID决定是创建还是更新
let response;
if (id) {
response = await updateRuleGroup(id, saveData);
} else {
response = await createRuleGroup(saveData);
}
// 处理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';
// 判断表单是否为只读模式
const isReadOnly = userRole === 'common';
// 解构数据
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 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 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
}));
};
// 处理分组类型更改
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>
</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>
);
}