Files
leaudit-platform-frontend/app/routes/rule-groups.new.tsx
T
LiangShiyong 30e100ef3e feat: 1. 本地化思源黑体的字体包并优先使用。
2. 添加权限映射表和全局查看权限的hook,便于路由控制不同权限按钮显示/隐藏。
3. 删除评查点分组的部分旧api方法。
4. 对接评查点分组接口,文档类型接口, 提示词管理接口, 入口模块管理的接口。
5. 优化角色权限管理的接口,完善不用地区的访问权限认证。
6. 优化主页交叉评查和设置的入口样式和布局。
7. 优化评查点分组,评查规则的功能权限校验。
2025-11-29 10:37:35 +08:00

768 lines
26 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 } 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 { usePermission } from "~/hooks/usePermission";
import ruleGroupsNewStyles from "~/styles/pages/rule-groups_new.css?url";
import {
getEvaluationPointGroups,
getEvaluationPointGroup,
createEvaluationPointGroup,
updateEvaluationPointGroup,
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);
// 获取一级分组列表 (用于选择父级分组)
// 🆕 使用 FastAPI v3 的 getEvaluationPointGroups API,仅获取一级分组且已启用的分组
const parentGroupsResponse = await getEvaluationPointGroups({
pid: null, // 仅获取一级分组(pid为null表示顶级分组)
is_enabled: true, // 仅获取已启用的分组
pageSize: 100, // 获取足够多的分组
token: frontendJWT
}, 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 getEvaluationPointGroup(id, true, 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 updateEvaluationPointGroup(id, saveData, frontendJWT);
} else {
response = await createEvaluationPointGroup(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";
// ✅ Use permission Hook
const { canCreate, canUpdate } = usePermission();
const canCreateGroup = canCreate('evaluation_group');
const canUpdateGroup = canUpdate('evaluation_group');
// 解构数据
const { group, parentGroups, isEdit, error } = data;
// ✅ 根据当前操作类型判断权限
const hasEditPermission = isEdit ? canUpdateGroup : canCreateGroup;
const isReadOnly = !hasEditPermission;
// 表单状态管理 - 使用受控组件
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
});
// ✅ 页面加载时检查权限并提示(仅在只读模式下提示)
// useEffect(() => {
// console.log("权限",canCreateGroup,canUpdateGroup)
// if (isReadOnly) {
// if (isEdit) {
// toastService.info('当前为查看模式,您没有编辑权限');
// } else {
// toastService.warning('您没有创建分组的权限');
// }
// }
// }, [isReadOnly, isEdit]);
// 从 actionData 初始化表单错误
useEffect(() => {
if (actionData?.errors) {
setFormErrors(actionData.errors);
// ✅ 如果有通用错误,使用 toast 显示
if (actionData.errors.general) {
toastService.error(actionData.errors.general);
}
}
}, [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 getEvaluationPointGroups({
code: code.trim(),
pageSize: 10,
token: data.frontendJWT
}, 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) => {
// ✅ Runtime permission check
if (isEdit && !canUpdateGroup) {
e.preventDefault();
toastService.warning('您没有修改权限,无法保存更改');
return;
}
if (!isEdit && !canCreateGroup) {
e.preventDefault();
toastService.warning('您没有创建权限,无法新增分组');
return;
}
// 如果是只读模式,阻止提交
if (isReadOnly) {
e.preventDefault();
toastService.info('当前为只读模式,无法提交');
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);
// 如果有错误,阻止提交并提示
const hasErrors = errors.name || errors.code || (formValues.groupType === "secondary" && errors.parentId);
if (hasErrors) {
e.preventDefault();
// ✅ 收集所有错误信息并提示
const errorMessages = [];
if (errors.name) errorMessages.push(errors.name);
if (errors.code) errorMessages.push(errors.code);
if (errors.parentId) errorMessages.push(errors.parentId);
toastService.error(`表单验证失败:${errorMessages[0]}`);
}
};
// 如果加载数据时出错,显示错误信息
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>
{/* ✅ 仅在有对应权限时显示保存按钮 */}
{hasEditPermission && (
<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>
);
}