完善列表和编辑页面的数据验证和交互,实现服务端和客户端两重数据验证

This commit is contained in:
2025-04-24 18:33:09 +08:00
parent be99fdec79
commit 65b7d0739a
13 changed files with 444 additions and 229 deletions
+16 -10
View File
@@ -60,7 +60,8 @@ export interface DocumentTypeGroup {
// 搜索参数
export interface DocumentTypeSearchParams {
name?: string;
group_id?: string;
ruleType?: string;
groupId?: string;
page?: number;
pageSize?: number;
}
@@ -239,13 +240,18 @@ export async function getDocumentTypes(searchParams: DocumentTypeSearchParams =
}
// 如果有分组ID筛选条件
if (searchParams.group_id) {
filter['evaluation_point_groups_ids'] = `cs.{${searchParams.group_id}}`;
if (searchParams.ruleType) {
filter['evaluation_point_groups_ids'] = `cs.[${searchParams.ruleType}]`;
}
if (searchParams.groupId) {
// 如果groupId存在,则将groupId作为子级评查点分组ID
filter['evaluation_point_groups_ids'] = `cs.[${searchParams.groupId}]`;
}
params.filter = filter;
// console.log('获取文档类型列表,参数:', params);
console.log('获取文档类型列表,参数:', params);
const response = await postgrestGet<DocumentType[]>('document_types', params);
if (response.error) {
@@ -529,8 +535,8 @@ export async function createDocumentType(documentType: DocumentTypeCreateDTO): P
if (!groupId || isNaN(parseInt(groupId, 10))) {
return { error: '无效的评查点分组ID', status: 400 };
}
const groupIds = parseInt(groupId, 10); // 修改为数组形式
// const groupIds = [parseInt(groupId, 10)]; // 修改为数组形式
// const groupIds = parseInt(groupId, 10); // 修改为数组形式
const groupIds = [parseInt(groupId, 10)]; // 修改为数组形式
// 构建提示词配置 - 确保所有字段都有明确的设置
const promptConfig: Record<string, number | null> = {
@@ -580,13 +586,13 @@ export async function createDocumentType(documentType: DocumentTypeCreateDTO): P
description: documentType.description || '',
evaluation_point_groups_ids: groupIds,
prompt_config: promptConfig,
code: documentType.code || null
// code: documentType.code || null
};
// console.log('创建文档类型请求数据:', JSON.stringify(apiDocumentType, null, 2));
// console.log('创建文档类型请求数据:', apiDocumentType);
// if(apiDocumentType){
// throw new Error('测试错误');
// throw new Error('测试错误');
// }
// 发送创建请求
@@ -660,7 +666,7 @@ export async function updateDocumentType(id: string, documentType: DocumentTypeU
const promptConfig: Record<string, number | null> = {
llm_extract_template: null,
vlm_extract_template: null,
evaluation_template: null,
// evaluation_template: null,
execution_template: null,
summary_template: null
};
@@ -707,7 +713,7 @@ export async function updateDocumentType(id: string, documentType: DocumentTypeU
};
console.log('更新文档类型请求数据:', JSON.stringify(apiDocumentType, null, 2));
// throw new Error('测试错误');
// 发送更新请求
const response = await postgrestPut<DocumentType, typeof apiDocumentType>(
'document_types',
+4 -4
View File
@@ -121,17 +121,17 @@ export async function getReviewPoints(fileId: string) {
return { error: evaluationResultsResponse.error, status: evaluationResultsResponse.status };
}
const evaluationResultsData = extractApiData<EvaluationResult[]>(evaluationResultsResponse.data);
const evaluationResultsData = extractApiData<EvaluationResult[]>(evaluationResultsResponse.data) || [];
if (!evaluationResultsData || !Array.isArray(evaluationResultsData)) {
return { data: [], stats: { total: 0, success: 0, warning: 0, error: 0, score: 0 } };
if (Array.isArray(evaluationResultsData) && evaluationResultsData.length <= 0) {
return { data: [], stats: { total: 0, success: 0, warning: 0, error: 0, score: 0 },error: '获取评查结果数据失败' };
}
// 收集所有评查点ID,用于查询评查点详情
const evaluationPointIds = evaluationResultsData.map(item => item.evaluation_point_id).filter(Boolean);
if (evaluationPointIds.length === 0) {
return { data: [], stats: { total: 0, success: 0, warning: 0, error: 0, score: 0 } };
return { data: [], stats: { total: 0, success: 0, warning: 0, error: 0, score: 0 },error: '获取评查点ID失败' };
}
// 步骤2:根据evaluation_point_id查询evaluation_points表
+6 -1
View File
@@ -1,4 +1,5 @@
import { useNavigate } from "@remix-run/react";
import { toastService } from "~/components/ui/Toast";
interface FileInfoProps {
fileInfo: {
fileName: string;
@@ -54,6 +55,10 @@ export function FileInfo({ fileInfo, onConfirmResults }: FileInfoProps) {
}
};
const handleBack = () => {
navigate(-1);
};
const handleExportReport = () => {
alert('导出评查报告功能');
};
@@ -83,7 +88,7 @@ export function FileInfo({ fileInfo, onConfirmResults }: FileInfoProps) {
{/* 返回上一级 */}
<button
className="ant-btn ant-btn-default flex items-center"
onClick={() => navigate(-1)}
onClick={() => handleBack()}
>
<i className="ri-arrow-left-line mr-1"></i>
</button>
+13 -9
View File
@@ -146,16 +146,18 @@ export function MessageModal({
onClick={handleClose}
onKeyDown={handleKeyDown}
tabIndex={0}
role="button"
role="presentation"
aria-label="关闭对话框"
>
<div
<div
className={`message-modal message-modal-${type} ${isClosing ? 'closing' : ''}`}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="message-modal-title"
aria-describedby="message-modal-content"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
tabIndex={-1}
>
{showCloseButton && (
<button
@@ -198,12 +200,14 @@ export function MessageModal({
>
{confirmText}
</button>
<button
className="message-modal-button"
onClick={handleClose}
>
{cancelText}
</button>
{cancelText && (
<button
className="message-modal-button"
onClick={handleClose}
>
{cancelText}
</button>
)}
</>
)}
+56 -12
View File
@@ -1,4 +1,4 @@
import React from 'react';
import React, { useState, useRef } from 'react';
interface SearchBoxProps {
placeholder?: string;
@@ -17,6 +17,11 @@ export function SearchBox({
className = '',
name = 'keyword'
}: SearchBoxProps) {
const [inputValue, setInputValue] = useState(defaultValue);
const [isHovering, setIsHovering] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
@@ -25,28 +30,67 @@ export function SearchBox({
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setInputValue(value);
// 对于没有按钮的输入框,我们希望在输入时就触发搜索
if (className.includes('form-input-only')) {
onSearch(e.target.value);
onSearch(value);
}
};
const handleClear = () => {
setInputValue('');
if (inputRef.current) {
inputRef.current.value = '';
inputRef.current.focus();
}
onSearch('');
};
const handleMouseEnter = () => {
setIsHovering(true);
};
const handleMouseLeave = () => {
setIsHovering(false);
};
const isIconOnly = buttonText === '';
const isFilterControl = className.includes('filter-control');
const hasButton = !className.includes('form-input-only');
const searchBoxClass = `search-box ${className} ${isFilterControl ? 'search-box-row' : ''}`;
const showClearButton = inputValue && isHovering;
return (
<form onSubmit={handleSubmit} className={searchBoxClass}>
<input
type="text"
id={name}
name={name}
className={`form-input ${isFilterControl ? 'flex-1' : ''}`}
placeholder={placeholder}
defaultValue={defaultValue}
onChange={handleChange}
/>
{!className.includes('form-input-only') && (
<div
className={`relative ${hasButton ? 'flex-1' : 'w-full'}`}
ref={containerRef}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<input
type="text"
id={name}
name={name}
className={`form-input w-full ${hasButton ? 'rounded-r-none' : ''}`}
placeholder={placeholder}
defaultValue={defaultValue}
onChange={handleChange}
ref={inputRef}
/>
{showClearButton && (
<button
type="button"
className="search-box-clear"
onClick={handleClear}
>
<i className="ri-close-circle-line"></i>
</button>
)}
</div>
{hasButton && (
<button
type="submit"
className={`search-button ${isIconOnly ? "icon-only-btn" : ""}`}
+7 -9
View File
@@ -338,7 +338,7 @@ export default function ConfigNew() {
case 'name':
if(value.trim() === ""){
return "配置名称不能为空";
}else if(/^[a-zA-Z_]+$/.test(value)){
}else if(!/^[a-zA-Z_]+$/.test(value)){
return "配置名称只能包含英文字母和下划线";
}else if(value.length > 100){
return "配置名称不能超过100个字符";
@@ -574,7 +574,7 @@ export default function ConfigNew() {
{/* 配置名称和状态 */}
<div className="form-row">
<div className="form-group">
<label htmlFor="name" className="form-label required"></label>
<label htmlFor="name" className="form-label "> <span className="text-red-500">*</span></label>
<input
type="text"
id="name"
@@ -583,7 +583,6 @@ export default function ConfigNew() {
value={formValues.name}
onChange={handleInputChange}
placeholder="请输入配置名称,如database_connection"
required
/>
{touchedFields.name && formErrors.name && (
<div className="error-message">{formErrors.name}</div>
@@ -619,7 +618,7 @@ export default function ConfigNew() {
{/* 所属模块 */}
<div className="form-group">
<label htmlFor="type" className="form-label required"></label>
<label htmlFor="type" className="form-label"> <span className="text-red-500">*</span></label>
<input
type="hidden"
name="type"
@@ -633,7 +632,7 @@ export default function ConfigNew() {
onChange={handleInputChange}
name="type"
placeholder="请输入或选择所属模块"
required
/>
{touchedFields.type && formErrors.type && (
<div className="error-message">{formErrors.type}</div>
@@ -657,7 +656,7 @@ export default function ConfigNew() {
{/* 环境 */}
<div className="form-group">
<label htmlFor="environment" className="form-label required"></label>
<label htmlFor="environment" className="form-label"> <span className="text-red-500">*</span></label>
<input
type="hidden"
name="environment"
@@ -671,7 +670,6 @@ export default function ConfigNew() {
onChange={handleInputChange}
name="environment"
placeholder="请输入或选择环境"
required
/>
{touchedFields.environment && formErrors.environment && (
<div className="error-message">{formErrors.environment}</div>
@@ -695,7 +693,7 @@ export default function ConfigNew() {
{/* 配置数据 */}
<div className="form-group">
<label htmlFor="config" className="form-label required"> (JSON)</label>
<label htmlFor="config" className="form-label "> (JSON) <span className="text-red-500">*</span></label>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4" style={{ minHeight: '390px' }}>
{/* 左侧JSON编辑区 */}
<div className="h-full">
@@ -705,7 +703,7 @@ export default function ConfigNew() {
className={`json-editor ${touchedFields.config && formErrors.config ? 'input-error' : ''}`}
value={formValues.config}
onChange={handleConfigDataChange}
required
placeholder='请输入JSON格式的配置数据'
/>
<div className="editor-actions">
+130 -81
View File
@@ -1,21 +1,23 @@
import { useState } from "react";
import { useState, useEffect } from "react";
import { useSearchParams, useNavigate, useLoaderData } from "@remix-run/react";
import { ActionFunctionArgs, LoaderFunctionArgs, MetaFunction, json } from "@remix-run/node";
import { ActionFunctionArgs, LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
import { Table } from "~/components/ui/Table";
import { Card } from "~/components/ui/Card";
import { Button } from "~/components/ui/Button";
import { Pagination } from "~/components/ui/Pagination";
import { FilterPanel, FilterSelect, SearchFilter } from "~/components/ui/FilterPanel";
import { getRuleTypes, getRuleGroupsByType, type RuleType, type RuleGroup } from "~/api/evaluation_points/rules";
import { toastService } from "~/components/ui/Toast";
import {
getDocumentTypes,
deleteDocumentType,
getAllEvaluationPointGroups,
type DocumentTypeUI,
type DocumentTypeSearchParams,
type DocumentTypeGroup
} from "~/api/document-types/document-types";
import documentTypesStyles from "~/styles/pages/document-types_index.css?url";
// 引入CSS样式
export function links() {
return [
@@ -39,6 +41,7 @@ interface LoaderData {
currentPage: number;
error?: string;
groups: DocumentTypeGroup[];
ruleTypes: RuleType[];
}
// 加载函数 - 获取文档类型列表
@@ -46,60 +49,51 @@ export async function loader({ request }: LoaderFunctionArgs) {
try {
const url = new URL(request.url);
const name = url.searchParams.get('name') || undefined;
const group_id = url.searchParams.get('group_id') || undefined;
const ruleType = url.searchParams.get('ruleType') || undefined;
const groupId = url.searchParams.get('groupId') || undefined;
const page = parseInt(url.searchParams.get('page') || '1', 10);
const pageSize = parseInt(url.searchParams.get('pageSize') || '10', 10);
// 构建搜索参数
const searchParams: DocumentTypeSearchParams = {
name,
group_id,
ruleType,
groupId,
page,
pageSize
};
// 并行获取文档类型数据和所有评查点分组
const [typesResult, groupsResult] = await Promise.all([
getDocumentTypes(searchParams),
getAllEvaluationPointGroups()
]);
// 并行获取文档类型数据和父级评查点分组
const ruleTypesResponse = await getRuleTypes();
if(ruleTypesResponse.error){
console.error("获取父级评查点分组失败:", ruleTypesResponse.error);
}
const ruleTypes = ruleTypesResponse.error ? [] : ruleTypesResponse.data;
const typesResponse = await getDocumentTypes(searchParams);
if(typesResponse.error){
console.error("获取文档类型失败:", typesResponse.error);
throw new Error(typesResponse.error);
}
const typesResult = typesResponse.data?.types || [];
// console.log('文档类型数据:', typesResult.data?.types);
// console.log('评查点分组数据:', groupsResult.data);
// console.log('父级评查点分组:', groupsResult.data);
if (typesResult.error) {
return json<LoaderData>(
{
types: [],
total: 0,
pageSize,
currentPage: page,
error: typesResult.error,
groups: groupsResult.data || []
},
{ status: typesResult.status || 500 }
);
}
return json<LoaderData>({
types: typesResult.data?.types || [],
total: typesResult.data?.total || 0,
return Response.json({
types: typesResult,
total: typesResponse.data?.total || typesResult.length,
pageSize,
currentPage: page,
groups: groupsResult.data || []
ruleTypes
});
} catch (error) {
console.error("加载文档类型列表失败:", error);
return json<LoaderData>(
return Response.json(
{
types: [],
total: 0,
pageSize: 10,
currentPage: 1,
error: "加载文档类型列表失败",
groups: []
},
{ status: 500 }
error: error || "加载文档类型列表失败",
status: 500
}
);
}
}
@@ -138,13 +132,60 @@ export default function DocumentTypesList() {
const [isDeleting, setIsDeleting] = useState(false);
// 获取加载器数据
const { types, total, pageSize: initialPageSize, currentPage: initialPage, error, groups } = useLoaderData<LoaderData>();
const { types, total, error, ruleTypes } = useLoaderData<LoaderData>();
// 状态管理
const [ruleGroups, setRuleGroups] = useState<RuleGroup[]>([]);
const [loadingGroups, setLoadingGroups] = useState(false);
// 获取当前的ruleType值
const ruleTypeParam = searchParams.get('ruleType');
// 获取搜索参数
const name = searchParams.get('name') || '';
const group_id = searchParams.get('group_id') || '';
const currentPage = parseInt(searchParams.get('page') || String(initialPage), 10);
const pageSize = parseInt(searchParams.get('pageSize') || String(initialPageSize), 10);
const currentPage = parseInt(searchParams.get('page') || String(1), 10);
const pageSize = parseInt(searchParams.get('pageSize') || String(10), 10);
// 判断是否禁用子级评查分组选择,true表示禁用,false表示不禁用
const isRuleGroupSelectDisabled = loadingGroups || !ruleTypeParam || ruleGroups.length === 0;
// 当评查点类型变化时,加载对应的子级评查分组
useEffect(() => {
// 如果选择了"全部"或未选择,则清空子级评查分组
if (!ruleTypeParam || ruleTypeParam === 'all') {
setRuleGroups([]);
return;
}
// 加载当前类型的子级评查分组
const loadRuleGroups = async () => {
setLoadingGroups(true);
try {
const response = await getRuleGroupsByType(ruleTypeParam);
if (response.data) {
setRuleGroups(response.data);
} else if (response.error) {
console.error('加载子级规则组失败:', response.error);
setRuleGroups([]);
}
} catch (error) {
console.error('加载子级规则组出错:', error);
toastService.error('加载子级规则组出错:' + error);
setRuleGroups([]);
} finally {
setLoadingGroups(false);
}
};
loadRuleGroups();
}, [ruleTypeParam]);
// 处理loader加载数据的时候的错误
useEffect(() => {
if(error){
toastService.error(error);
}
}, [error]);
// 处理名称搜索
const handleNameSearch = (value: string) => {
@@ -158,29 +199,51 @@ export default function DocumentTypesList() {
setSearchParams(newParams);
};
// 处理分组筛选
const handleGroupChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const { value } = e.target;
// 处理筛选变更
const handleFilterChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const { name, value } = e.target;
const newParams = new URLSearchParams(searchParams);
if (value) {
newParams.set('group_id', value);
} else {
newParams.delete('group_id');
// 如果是子级评查分组选择,但是当前应该被禁用,则不处理
if (name === 'groupId' && isRuleGroupSelectDisabled) {
return;
}
if (value) {
newParams.set(name, value);
// 如果是评查点类型变更,清空子级评查分组选择
if (name === 'ruleType') {
newParams.delete('groupId');
// 如果选择了"全部"或空值,也清空子级评查分组选择
if (value === '' || value === 'all') {
setRuleGroups([]);
}
}
} else {
newParams.delete(name);
// 如果清除评查点类型,也清除规则组
if (name === 'ruleType') {
newParams.delete('groupId');
setRuleGroups([]);
}
}
// 切换筛选条件时,重置到第一页
newParams.set('page', '1');
setSearchParams(newParams);
};
// 处理重置筛选
const handleReset = () => {
const nameInput = document.querySelector('input[name="name"]');
const nameInput = document.querySelector('input[placeholder="请输入文档类型名称"]');
if (nameInput) {
(nameInput as HTMLInputElement).value = '';
}
const groupIdInput = document.querySelector('select[name="group_id"]');
if (groupIdInput) {
(groupIdInput as HTMLSelectElement).value = '';
}
// 重置所有筛选条件
setSearchParams(new URLSearchParams());
};
@@ -351,35 +414,12 @@ export default function DocumentTypesList() {
}
noActionDivider={true}
>
<SearchFilter
label="类型名称"
placeholder="请输入文档类型名称"
value={name}
onSearch={handleNameSearch}
className="flex-1 min-w-[200px]"
instantSearch={true}
/>
<FilterSelect
label="关联分组"
name="group_id"
value={group_id}
options={[
...(groups || []).map(group => ({
value: group.id,
label: group.name
}))
]}
onChange={handleGroupChange}
className="flex-1 min-w-[200px]"
/>
{/* <FilterSelect
label="评查点类型"
label="父级评查分组"
name="ruleType"
value={searchParams.get('ruleType') || ''}
options={[
...ruleTypes.map((type: ApiRuleType) => ({
...(ruleTypes || []).map(type => ({
value: type.id,
label: type.name
}))
@@ -389,7 +429,7 @@ export default function DocumentTypesList() {
/>
<FilterSelect
label="所属规则组"
label="所属子级评查分组"
name="groupId"
value={searchParams.get('groupId') || ''}
options={[
@@ -401,7 +441,16 @@ export default function DocumentTypesList() {
]}
onChange={handleFilterChange}
className={`mr-3 w-[20%] ${isRuleGroupSelectDisabled ? 'opacity-50' : ''}`}
/> */}
/>
<SearchFilter
label="类型名称"
placeholder="请输入文档类型名称"
value={name}
onSearch={handleNameSearch}
className="flex-1 min-w-[200px]"
instantSearch={true}
/>
</FilterPanel>
+160 -83
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect } from "react";
import React, { useState, useEffect, useRef } from "react";
import { Form, useActionData, useLoaderData, useNavigate, useSearchParams } from "@remix-run/react";
import { redirect, type MetaFunction, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/node";
import { Card } from "~/components/ui/Card";
@@ -7,6 +7,7 @@ import documentTypesNewStyles from "~/styles/pages/document-types_new.css?url";
import { getAllRuleGroups, type RuleGroup } from "~/api/evaluation_points/rule-groups";
import { getDocumentType, createDocumentType, updateDocumentType } from "~/api/document-types/document-types";
import { getPromptTemplates, type PromptTemplateUI } from "~/api/prompts/prompts";
import { toastService } from "~/components/ui/Toast";
export function links() {
return [{ rel: "stylesheet", href: documentTypesNewStyles }];
@@ -42,8 +43,23 @@ const TEMPLATE_TYPES = {
SUMMARY: "Summary"
};
// 定义动作返回的数据类型
interface ActionData {
result?: boolean;
errors?: {
name?: string;
groups?: string;
general?: string;
llmExtractionTemplate?: string;
vlmExtractionTemplate?: string;
evaluationTemplate?: string;
summaryTemplate?: string;
};
values?: Record<string, string | string[]>;
}
// 加载函数 - 获取数据
// 获取数据
export async function loader({ request }: LoaderFunctionArgs) {
try {
const url = new URL(request.url);
@@ -54,6 +70,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
const ruleGroupsResponse = await getAllRuleGroups();
if (ruleGroupsResponse.error) {
console.error("获取评查点分组失败:", ruleGroupsResponse.error);
// throw new Error(ruleGroupsResponse.error);
}
// ruleGroupsResponse.data已经是树形结构数据,getAllRuleGroups内部已处理好parent-children关系
@@ -96,26 +113,13 @@ export async function loader({ request }: LoaderFunctionArgs) {
llmExtractionTemplates: [],
vlmExtractionTemplates: [],
evaluationTemplates: [],
summaryTemplates: []
summaryTemplates: [],
error: error || "加载数据失败"
});
}
}
// 定义动作返回的数据类型
interface ActionData {
success?: boolean;
errors?: {
name?: string;
groups?: string;
general?: string;
llmExtractionTemplate?: string;
vlmExtractionTemplate?: string;
evaluationTemplate?: string;
summaryTemplate?: string;
};
}
// 动作函数 - 处理表单提交
// 处理表单提交
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const id = formData.get("id") as string | null;
@@ -159,7 +163,7 @@ export async function action({ request }: ActionFunctionArgs) {
// 如果有错误,返回错误信息
if (Object.keys(errors).length > 0) {
return Response.json({ errors });
return Response.json({ errors, result: false });
}
try {
@@ -198,7 +202,7 @@ export async function action({ request }: ActionFunctionArgs) {
} catch (error) {
console.error("保存文档类型失败:", error);
return Response.json({
success: false,
result: false,
errors: {
general: error instanceof Error ? error.message : "保存文档类型失败"
}
@@ -235,12 +239,28 @@ export default function DocumentTypeNew() {
});
// 添加本地验证错误状态
const [localErrors, setLocalErrors] = useState<ActionData["errors"]>({} as ActionData["errors"]);
const [formErrors, setFormErrors] = useState<ActionData["errors"]>({} as ActionData["errors"]);
// 表单引用
const formRef = useRef<HTMLFormElement>(null);
// 字段是否被触摸过(用于确定何时显示错误)
const [touchedFields, setTouchedFields] = useState({
name: false,
llmExtractionTemplate: false,
vlmExtractionTemplate: false,
evaluationTemplate: false,
summaryTemplate: false,
groups: false
});
// 从actionData初始化本地错误
useEffect(() => {
if (actionData?.errors) {
setLocalErrors(actionData.errors);
if (!actionData?.result) {
setFormErrors(actionData?.errors);
if (actionData?.errors?.general) {
toastService.error(actionData?.errors?.general || "保存文档类型失败");
}
}
}, [actionData]);
@@ -281,6 +301,26 @@ export default function DocumentTypeNew() {
}
}, [documentType, ruleGroups]);
// 验证表单字段
const validateField = (field: string, value: string | string[]): string => {
switch (field) {
case 'name':
return !value || (typeof value === 'string' && value.trim() === "") ? "文档类型名称不能为空" : "";
case 'llmExtractionTemplate':
return !value || (typeof value === 'string' && value.trim() === "") ? "请选择llm抽取提示词模板" : "";
case 'vlmExtractionTemplate':
return !value || (typeof value === 'string' && value.trim() === "") ? "请选择vlm抽取提示词模板" : "";
case 'evaluationTemplate':
return !value || (typeof value === 'string' && value.trim() === "") ? "请选择评查提示词模板" : "";
case 'summaryTemplate':
return !value || (typeof value === 'string' && value.trim() === "") ? "请选择总结提示词模板" : "";
case 'groups':
return Array.isArray(value) && value.length === 0 ? "请至少选择一个关联的评查点分组" : "";
default:
return "";
}
};
// 处理分组勾选
const handleGroupCheckChange = (
groupId: string,
@@ -292,18 +332,16 @@ export default function DocumentTypeNew() {
if (isChecked) {
// 只添加当前选中的分组
newSelectedGroups = [groupId];
// 如果选择的是父分组,不自动选择子分组
// 如果选择的是子分组,不影响父分组状态
}
// 如果取消选中,则清空选择(在单选模式下可能不需要,但保留逻辑以防万一)
setFormData(prev => ({ ...prev, selectedGroups: newSelectedGroups }));
// 清除groups相关的错误
if (localErrors?.groups) {
setLocalErrors(prev => ({...prev, groups: undefined}));
}
// 标记字段为已触摸
setTouchedFields(prev => ({...prev, groups: true}));
// 实时验证
const error = validateField('groups', newSelectedGroups);
setFormErrors(prev => ({...prev, groups: error}));
};
// 修复展开/折叠功能
@@ -322,39 +360,78 @@ export default function DocumentTypeNew() {
const { name, value } = e.target;
// 根据name属性映射到对应的formData字段
let fieldName = name;
if (name === 'llm_extraction_template') {
setFormData(prev => ({ ...prev, llmExtractionTemplateId: value }));
// 清除相关错误
if (localErrors?.llmExtractionTemplate) {
setLocalErrors(prev => ({...prev, llmExtractionTemplate: undefined}));
}
fieldName = 'llmExtractionTemplateId';
// 标记字段为已触摸
setTouchedFields(prev => ({...prev, llmExtractionTemplate: true}));
} else if (name === 'vlm_extraction_template') {
setFormData(prev => ({ ...prev, vlmExtractionTemplateId: value }));
// 清除相关错误
if (localErrors?.vlmExtractionTemplate) {
setLocalErrors(prev => ({...prev, vlmExtractionTemplate: undefined}));
}
fieldName = 'vlmExtractionTemplateId';
// 标记字段为已触摸
setTouchedFields(prev => ({...prev, vlmExtractionTemplate: true}));
} else if (name === 'evaluation_template') {
setFormData(prev => ({ ...prev, evaluationTemplateId: value }));
// 清除相关错误
if (localErrors?.evaluationTemplate) {
setLocalErrors(prev => ({...prev, evaluationTemplate: undefined}));
}
fieldName = 'evaluationTemplateId';
// 标记字段为已触摸
setTouchedFields(prev => ({...prev, evaluationTemplate: true}));
} else if (name === 'summary_template') {
setFormData(prev => ({ ...prev, summaryTemplateId: value }));
// 清除相关错误
if (localErrors?.summaryTemplate) {
setLocalErrors(prev => ({...prev, summaryTemplate: undefined}));
}
fieldName = 'summaryTemplateId';
// 标记字段为已触摸
setTouchedFields(prev => ({...prev, summaryTemplate: true}));
} else if (name === 'name') {
setFormData(prev => ({ ...prev, [name]: value }));
// 清除相关错误
if (localErrors?.name) {
setLocalErrors(prev => ({...prev, name: undefined}));
}
} else {
// 其他表单字段(description等)
setFormData(prev => ({ ...prev, [name]: value }));
// 标记字段为已触摸
setTouchedFields(prev => ({...prev, name: true}));
}
setFormData(prev => ({ ...prev, [fieldName]: value }));
// 实时验证
if (name === 'name') {
const error = validateField(name, value);
setFormErrors(prev => ({...prev, name: error}));
} else if (name === 'llm_extraction_template') {
const error = validateField('llmExtractionTemplate', value);
setFormErrors(prev => ({...prev, llmExtractionTemplate: error}));
} else if (name === 'vlm_extraction_template') {
const error = validateField('vlmExtractionTemplate', value);
setFormErrors(prev => ({...prev, vlmExtractionTemplate: error}));
} else if (name === 'evaluation_template') {
const error = validateField('evaluationTemplate', value);
setFormErrors(prev => ({...prev, evaluationTemplate: error}));
} else if (name === 'summary_template') {
const error = validateField('summaryTemplate', value);
setFormErrors(prev => ({...prev, summaryTemplate: error}));
}
};
// 处理表单提交前验证
const handleBeforeSubmit = (e: React.FormEvent) => {
// 标记所有字段为已触摸
setTouchedFields({
name: true,
llmExtractionTemplate: true,
vlmExtractionTemplate: true,
evaluationTemplate: true,
summaryTemplate: true,
groups: true
});
// 验证所有字段
const errors = {
name: validateField('name', formData.name),
llmExtractionTemplate: validateField('llmExtractionTemplate', formData.llmExtractionTemplateId),
vlmExtractionTemplate: validateField('vlmExtractionTemplate', formData.vlmExtractionTemplateId),
evaluationTemplate: validateField('evaluationTemplate', formData.evaluationTemplateId),
summaryTemplate: validateField('summaryTemplate', formData.summaryTemplateId),
groups: validateField('groups', formData.selectedGroups)
};
setFormErrors(errors);
// 如果有错误,阻止提交
if (errors.name || errors.llmExtractionTemplate || errors.vlmExtractionTemplate ||
errors.evaluationTemplate || errors.summaryTemplate || errors.groups) {
e.preventDefault();
}
};
@@ -384,16 +461,16 @@ export default function DocumentTypeNew() {
{/* 表单内容 */}
<Card>
<Form id="type-form" method="post" noValidate>
<Form id="type-form" method="post" noValidate ref={formRef} onSubmit={handleBeforeSubmit}>
{/* 如果是编辑模式,添加隐藏的ID字段 */}
{formData.id && <input type="hidden" name="id" value={formData.id} />}
<div className="grid grid-cols-1 gap-6">
{/* 错误提示 */}
{localErrors?.general && (
<div className="error-message general-error error-show">
{formErrors?.general && (
<div className="error-message general-error">
<i className="ri-error-warning-line"></i>
{localErrors.general}
{formErrors.general}
</div>
)}
@@ -406,16 +483,16 @@ export default function DocumentTypeNew() {
type="text"
id="type-name"
name="name"
className={`form-input ${localErrors?.name ? 'input-error' : ''}`}
className={`form-input ${touchedFields.name && formErrors?.name ? 'input-error' : ''}`}
placeholder="请输入文档类型名称"
value={formData.name}
onChange={handleInputChange}
required
/>
<div className="form-tip"></div>
{localErrors?.name && (
<div className="error-message error-show">{localErrors.name}</div>
{touchedFields.name && formErrors?.name && (
<div className="error-message">{formErrors.name}</div>
)}
<div className="form-tip"></div>
</div>
{/* 类型描述 */}
@@ -440,7 +517,7 @@ export default function DocumentTypeNew() {
<select
id="llm-extraction-template"
name="llm_extraction_template"
className={`form-select ${localErrors?.llmExtractionTemplate ? 'input-error' : ''}`}
className={`form-select ${touchedFields.llmExtractionTemplate && formErrors?.llmExtractionTemplate ? 'input-error' : ''}`}
value={formData.llmExtractionTemplateId}
onChange={handleInputChange}
>
@@ -451,8 +528,8 @@ export default function DocumentTypeNew() {
</option>
))}
</select>
{localErrors?.llmExtractionTemplate && (
<div className="error-message error-show">{localErrors.llmExtractionTemplate}</div>
{touchedFields.llmExtractionTemplate && formErrors?.llmExtractionTemplate && (
<div className="error-message">{formErrors.llmExtractionTemplate}</div>
)}
<div className="form-tip">llm提示词模板</div>
</div>
@@ -463,7 +540,7 @@ export default function DocumentTypeNew() {
<select
id="vlm-extraction-template"
name="vlm_extraction_template"
className={`form-select ${localErrors?.vlmExtractionTemplate ? 'input-error' : ''}`}
className={`form-select ${touchedFields.vlmExtractionTemplate && formErrors?.vlmExtractionTemplate ? 'input-error' : ''}`}
value={formData.vlmExtractionTemplateId}
onChange={handleInputChange}
>
@@ -474,8 +551,8 @@ export default function DocumentTypeNew() {
</option>
))}
</select>
{localErrors?.vlmExtractionTemplate && (
<div className="error-message error-show">{localErrors.vlmExtractionTemplate}</div>
{touchedFields.vlmExtractionTemplate && formErrors?.vlmExtractionTemplate && (
<div className="error-message">{formErrors.vlmExtractionTemplate}</div>
)}
<div className="form-tip">vlm提示词模板</div>
</div>
@@ -486,7 +563,7 @@ export default function DocumentTypeNew() {
<select
id="evaluation-template"
name="evaluation_template"
className={`form-select ${localErrors?.evaluationTemplate ? 'input-error' : ''}`}
className={`form-select ${touchedFields.evaluationTemplate && formErrors?.evaluationTemplate ? 'input-error' : ''}`}
value={formData.evaluationTemplateId}
onChange={handleInputChange}
>
@@ -497,8 +574,8 @@ export default function DocumentTypeNew() {
</option>
))}
</select>
{localErrors?.evaluationTemplate && (
<div className="error-message error-show">{localErrors.evaluationTemplate}</div>
{touchedFields.evaluationTemplate && formErrors?.evaluationTemplate && (
<div className="error-message">{formErrors.evaluationTemplate}</div>
)}
<div className="form-tip"></div>
</div>
@@ -509,7 +586,7 @@ export default function DocumentTypeNew() {
<select
id="summary-template"
name="summary_template"
className={`form-select ${localErrors?.summaryTemplate ? 'input-error' : ''}`}
className={`form-select ${touchedFields.summaryTemplate && formErrors?.summaryTemplate ? 'input-error' : ''}`}
value={formData.summaryTemplateId}
onChange={handleInputChange}
>
@@ -520,8 +597,8 @@ export default function DocumentTypeNew() {
</option>
))}
</select>
{localErrors?.summaryTemplate && (
<div className="error-message error-show">{localErrors.summaryTemplate}</div>
{touchedFields.summaryTemplate && formErrors?.summaryTemplate && (
<div className="error-message">{formErrors.summaryTemplate}</div>
)}
<div className="form-tip"></div>
</div>
@@ -534,7 +611,7 @@ export default function DocumentTypeNew() {
<span className="text-red-500">*</span>
</legend>
<div
className={`checkbox-group ${localErrors?.groups ? 'group-error' : ''}`}
className={`checkbox-group ${touchedFields.groups && formErrors?.groups ? 'group-error' : ''}`}
aria-labelledby="checkpoint-groups-label"
role="group"
>
@@ -599,10 +676,10 @@ export default function DocumentTypeNew() {
</React.Fragment>
))}
</div>
<div className="form-tip"></div>
{localErrors?.groups && (
<div className="error-message error-show">{localErrors.groups}</div>
{touchedFields.groups && formErrors?.groups && (
<div className="error-message">{formErrors.groups}</div>
)}
<div className="form-tip"></div>
</fieldset>
</div>
</div>
+26 -7
View File
@@ -44,6 +44,7 @@ import {
// 从ReviewPointsList组件中导入ReviewPoint类型
import { type ReviewPoint } from '~/components/reviews';
import { messageService } from "~/components/ui/MessageModal";
/**
@@ -202,17 +203,17 @@ export async function loader({ request }: LoaderFunctionArgs) {
const previousRoute = url.searchParams.get('previousRoute') || '';
// console.log("id-------",id);
if (!id) {
return Response.json({ error: '评查ID不能为空' }, { status: 400 });
return Response.json({ result: false, message: '文件ID不能为空' });
}
// 获取评查点数据
const reviewData = await getReviewPoints(id);
// console.log("documentData-------",JSON.stringify(documentData.data,null,2));
// console.log("reviewData-------",JSON.stringify('data' in reviewData ? reviewData.data : '',null,2));
if ('error' in reviewData && reviewData.error) {
console.error("获取评查点数据错误:", reviewData.error);
return Response.json({ error: reviewData.error }, { status: reviewData.status || 500 });
return Response.json({ result: false, message: reviewData.error });
}
// 确保reviewData有效且具有预期的属性
@@ -225,24 +226,42 @@ export async function loader({ request }: LoaderFunctionArgs) {
statistics: reviewData.stats
});
} else {
console.error("返回的评查数据格式不正确");
return Response.json({ error: '返回的评查数据格式不正确' }, { status: 500 });
console.error("返回的评查数据格式不正确",JSON.stringify(reviewData,null,2));
return Response.json({ result: false, message: '返回的评查数据格式不正确' });
}
} catch (error) {
console.error('获取评查数据失败:', error);
return Response.json({ error: '获取评查数据失败' }, { status: 500 });
return Response.json({ result: false, message: '获取评查数据失败' });
}
}
export default function ReviewDetails() {
const navigate = useNavigate();
const { document, reviewPoints, statistics, reviewInfo } = useLoaderData<typeof loader>();
const loaderData = useLoaderData<typeof loader>();
const { document, reviewPoints, statistics, reviewInfo } = loaderData;
const [isLoading, setIsLoading] = useState(false); // 已经通过loader加载了数据,不需要再显示加载状态
const [activeTab, setActiveTab] = useState<string>('preview'); // 'preview', 'analysis', 'fileinfo'
const [reviewData, setReviewData] = useState<ReviewData | null>(null);
const [activeReviewPointResultId, setActiveReviewPointResultId] = useState<string | null>(null);
const [targetPage, setTargetPage] = useState<number | undefined>(undefined);
// loader 数据加载出错
useEffect(()=>{
if(Object.keys(loaderData).find(key => key === 'result') && !loaderData.result){
messageService.show({
title: '错误',
message: loaderData.message,
type: 'error',
confirmText: '确定',
cancelText: '',
onConfirm: () => {
navigate(-1);
}
})
}
},[loaderData, navigate]);
// 模拟获取评查数据
useEffect(() => {
if (!document) return;
+1 -1
View File
@@ -115,7 +115,7 @@ export default function RulesFiles() {
// 处理初始加载数据loader的错误
useEffect(() => {
if(!result) {
if(result === false && message) {
toastService.error(message);
}
}, [result, message]);
+3 -1
View File
@@ -198,8 +198,10 @@ export default function RulesIndex() {
useEffect(() => {
if(loaderData.error) {
toastService.error(loaderData.error);
}else if(loaderData.ruleTypes.length === 0){
toastService.error("评查点类型数据为空");
}
}, [loaderData.error]);
}, [loaderData.error,loaderData.ruleTypes]);
// 当评查点类型变化时,加载对应的规则组
useEffect(() => {
+19 -2
View File
@@ -65,8 +65,25 @@
/* 清除按钮 */
.search-box-clear {
@apply absolute right-3 top-1/2 transform -translate-y-1/2
text-gray-400 hover:text-gray-600 cursor-pointer transition-colors duration-200;
@apply absolute right-3 top-1/2 transform
text-gray-400 hover:text-gray-600 cursor-pointer transition-colors duration-200
border-none bg-transparent p-0 outline-none;
}
/* 当搜索框内有按钮时,调整清除按钮位置和输入框右侧内边距 */
.search-box:not(.form-input-only) .form-input {
@apply pr-8;
}
/* 修正当搜索框右侧有搜索按钮时,清除图标的位置,确保它显示在输入框内部 */
.search-box:not(.form-input-only) .search-box-clear {
@apply right-20;
z-index: 10;
}
/* 清除按钮的图标样式 */
.search-box-clear i {
@apply text-lg;
}
/* 带边框的搜索框 */
+3 -9
View File
@@ -38,22 +38,16 @@
}
.document-type-new-page .error-message {
@apply text-sm text-red-600 mt-1 font-medium;
@apply text-sm text-red-600 mt-1;
display: flex;
align-items: center;
}
.document-type-new-page .error-message::before {
/* .document-type-new-page .error-message::before {
content: "⚠️";
margin-right: 0.25rem;
}
} */
.document-type-new-page .error-show {
display: flex !important;
color: #ff4d4f;
font-weight: 500;
visibility: visible;
}
.document-type-new-page .general-error {
@apply flex items-center p-3 mb-4 bg-red-50 rounded-md text-red-600 text-sm;