优化评查详情,新增信息提示框组件

This commit is contained in:
2025-04-23 20:48:32 +08:00
parent ee36ce2620
commit be99fdec79
15 changed files with 1399 additions and 757 deletions
+46 -46
View File
@@ -251,61 +251,61 @@ export async function getReviewPoints(fileId: string) {
}
// 提取页码数组
let contentPage: Record<string, object> = {};
let contentPage: Record<string, string> = {};
// console.log('result-------', result.evaluated_results?.result);
// console.log('datacontent-------', data);
if (data && typeof data === 'object') {
try {
const dataObj = data as Record<string, string>;
// 检查是否是预期的格式 {'立案报告表-完整性检查''缺失部分内容'}
for (const key in dataObj) {
if (Object.prototype.hasOwnProperty.call(dataObj, key)) {
// 使用'-'分割获取前缀(如'立案报告表')
const prefix = key.split('-')[0];
// console.log('prefix-------', prefix);
// 检查document.data中的ocrResult是否存在这个key
if (documentData.data?.ocrResult &&
typeof documentData.data.ocrResult === 'object') {
// try {
// const dataObj = data as Record<string, string>;
// // 检查是否是预期的格式 {'立案报告表-完整性检查''缺失部分内容'}
// for (const key in dataObj) {
// if (Object.prototype.hasOwnProperty.call(dataObj, key)) {
// // 使用'-'分割获取前缀(如'立案报告表')
// const prefix = key.split('-')[0];
// // console.log('prefix-------', prefix);
// // 检查document.data中的ocrResult是否存在这个key
// if (documentData.data?.ocrResult &&
// typeof documentData.data.ocrResult === 'object') {
// ocrResult可能有嵌套的ocr_result属性
let ocrData: OcrData = documentData.data.ocrResult as OcrData;
// // ocrResult可能有嵌套的ocr_result属性
// let ocrData: OcrData = documentData.data.ocrResult as OcrData;
// 检查是否有嵌套的ocr_result对象
if ('ocr_result' in ocrData &&
ocrData.ocr_result &&
typeof ocrData.ocr_result === 'object') {
ocrData = ocrData.ocr_result as OcrData;
}
// // 检查是否有嵌套的ocr_result对象
// if ('ocr_result' in ocrData &&
// ocrData.ocr_result &&
// typeof ocrData.ocr_result === 'object') {
// ocrData = ocrData.ocr_result as OcrData;
// }
for (const ocrKey in ocrData) {
// 如果找到匹配的key
if (ocrKey === prefix &&
ocrData[ocrKey] &&
typeof ocrData[ocrKey] === 'object' &&
'pages' in ocrData[ocrKey]) {
// for (const ocrKey in ocrData) {
// // 如果找到匹配的key
// if (ocrKey === prefix &&
// ocrData[ocrKey] &&
// typeof ocrData[ocrKey] === 'object' &&
// 'pages' in ocrData[ocrKey]) {
// 获取pages数组
const pages = ocrData[ocrKey].pages;
if (Array.isArray(pages)) {
// 存储每个key对应的页码数组
contentPage[key] = pages;
}
break;
}
}
}
}
}
}
// // 4-22 更改数据结构:通过拿到的data数据(每一个key对应一个object),将object中的page提取出来
// try{
// const dataObj = data as Record<string, object>;
// for (const key in dataObj) {
// if (Object.prototype.hasOwnProperty.call(dataObj, key)) {
// contentPage[key] = dataObj[key];
// // 获取pages数组
// const pages = ocrData[ocrKey].pages;
// if (Array.isArray(pages)) {
// // 存储每个key对应的页码数组
// contentPage[key] = pages;
// }
// break;
// }
// }
// }
// }
// }
// }
// }
// 4-22 更改数据结构:通过拿到的data数据(每一个key对应一个object),将object中的page提取出来
try{
const dataObj = data as Record<string, {page: number | string,value: string}>;
for (const key in dataObj) {
if (Object.prototype.hasOwnProperty.call(dataObj, key)) {
contentPage[key] = dataObj[key].page.toString();
}
}
}
catch (e) {
console.error('解析评查点data失败:', e);
contentPage = {};
+35 -34
View File
@@ -18,6 +18,7 @@
* - 操作按钮: 提供一键替换和人工审核功能
*/
import { useState, useEffect } from 'react';
import { toastService } from '../ui/Toast';
// import { toastService } from '../ui/Toast';
/**
@@ -34,13 +35,13 @@ export interface ReviewPoint {
title: string;
groupName: string;
status: string;
content: Record<string, string | { page?: number, value?: string }>;
content: Record<string, { page?: number | string, value?: object }>;
suggestion: string;
needsHumanReview?: boolean;
humanReviewNote?: string;
humanReviewBy?: string;
humanReviewTime?: string;
contentPage?: Record<string, number[]>;
contentPage?: Record<string, number | string>;
position?: {
section: string;
index: number;
@@ -448,24 +449,27 @@ export function ReviewPointsList({
className="mb-2 pb-2 border-b border-gray-100 last:border-b-0 last:mb-0 last:pb-0 cursor-pointer hover:bg-gray-100 transition-colors duration-200 rounded p-1"
onClick={(e) => {
e.stopPropagation();
console.log(`通过:单独点击${key}----`, reviewPoint);
console.log(`单独点击${key}----`, reviewPoint);
// 检查value中的page属性是否存在
if (value && typeof value === 'object' && value.page) {
if (value && typeof value === 'object' && value.page && parseInt(value.page as string) > 0) {
// 获取当前 key 对应的第一个页码并跳转
console.log(`通过:单独点击${key}----页码---`, value.page);
console.log(`单独点击${key}----页码---`, value.page);
onReviewPointSelect(reviewPoint.id, parseInt(value.page as string));
onReviewPointSelect(reviewPoint.id, value.page);
} else {
console.log(`通过:单独点击${key}--------没有对应页码`);
toastService.error(`无法找到"${key}"对应的索引内容`);
console.log(`单独点击${key}--------没有对应页码`);
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (value && typeof value === 'object' && value.page) {
onReviewPointSelect(reviewPoint.id, value.page);
if (value && typeof value === 'object' && value.page && parseInt(value.page as string) > 0) {
onReviewPointSelect(reviewPoint.id, parseInt(value.page as string));
} else {
console.log(`通过:单独点击${key}--------没有对应页码`);
toastService.error(`无法找到"${key}"对应的索引内容`);
console.log(`单独点击${key}--------没有对应页码`);
}
}
}}
@@ -474,11 +478,16 @@ export function ReviewPointsList({
aria-label={`查看${key}内容详情`}
>
<div className="flex justify-between items-center mb-1">
<span className="text-xs">{key}</span>
<span className={`text-xs w-15 ${value ? 'text-error' : 'text-warning'}`}>
{value ? '' : '缺失'}
<span className="text-xs pr-5">{key}</span>
<span className={`flex-shrink-0 text-xs w-15 ${value.value?.toString().trim() ? 'text-error' : 'text-warning'}`}>
{value.value?.toString().trim() ? '' : '缺失'}
</span>
</div>
<p className="text-xs text-left select-text">
{(value.value?.toString().trim() === '')
? <span className="invisible"></span>
: value.value?.toString() || ''}
</p>
</div>
))}
</>
@@ -551,7 +560,7 @@ export function ReviewPointsList({
{reviewPoint.content && Object.entries(reviewPoint.content).length > 0 && (
<div className="p-2 bg-white rounded border border-gray-200 text-xs mb-3 select-text">
{/* 修改评查结果的结构之前,先显示旧的结构 */}
{Object.entries(reviewPoint.content).map(([key, value], index) => (
{/* {Object.entries(reviewPoint.content).map(([key, value], index) => (
<div
key={index}
className="mb-2 pb-2 border-b border-gray-100 last:border-b-0 last:mb-0 last:pb-0 cursor-pointer hover:bg-gray-100 transition-colors duration-200 rounded p-1"
@@ -584,7 +593,6 @@ export function ReviewPointsList({
tabIndex={0}
aria-label={`查看${key}内容详情`}
>
{/* 使用flex布局使key和状态标签左右对齐 */}
<div className="flex justify-between items-center mb-1">
<span className="text-xs">{key}</span>
<span className={`text-xs w-15 ${value ? 'text-error' : 'text-warning'}`}>
@@ -597,9 +605,9 @@ export function ReviewPointsList({
: (value || (value === '' ? <span className="invisible">占位符</span> : ''))}
</p>
</div>
))}
))} */}
{/* 修改评查结果的结构之后,显示新的结构 */}
{/* {renderContent(reviewPoint)} */}
{renderContent(reviewPoint)}
</div>
)}
@@ -655,7 +663,7 @@ export function ReviewPointsList({
<div className="p-2 bg-white rounded border border-gray-200 text-xs mb-3 select-text">
<div>
{/* 修改评查结果的结构之前,先显示旧的结构 */}
{Object.entries(reviewPoint.content).map(([key, value], index) => (
{/* {Object.entries(reviewPoint.content).map(([key, value], index) => (
<div
key={index}
className="mb-2 pb-2 border-b border-gray-100 last:border-b-0 last:mb-0 last:pb-0 cursor-pointer hover:bg-gray-100 transition-colors duration-200 rounded p-1"
@@ -688,7 +696,7 @@ export function ReviewPointsList({
tabIndex={0}
aria-label={`查看${key}内容详情`}
>
{/* 使用flex布局使key和状态标签左右对齐 */}
<div className="flex justify-between items-center mb-1">
<span className="text-xs">{key}</span>
<span className={`text-xs w-15 ${value ? 'text-error' : 'text-warning'}`}>
@@ -701,19 +709,15 @@ export function ReviewPointsList({
: (value || (value === '' ? <span className="invisible">占位符</span> : ''))}
</p>
</div>
))}
))} */}
{/* 修改评查结果的结构之后,显示新的结构 */}
{/* {renderContent(reviewPoint)} */}
{renderContent(reviewPoint)}
</div>
</div>
</>
);
}
// 非通过状态,显示内容和修改建议
const isErrorStatus = reviewPoint.result === false && reviewPoint.status === 'error';
return (
<div className="mt-2">
@@ -774,7 +778,7 @@ export function ReviewPointsList({
<div className="p-2 bg-white rounded border border-gray-200 text-xs mb-3 select-text">
<div>
{/* 修改评查结果的结构之前,先显示旧的结构 */}
{Object.entries(reviewPoint.content).map(([key, value], index) => (
{/* {Object.entries(reviewPoint.content).map(([key, value], index) => (
<div
key={index}
className="mb-2 pb-2 border-b border-gray-100 last:border-b-0 last:mb-0 cursor-pointer hover:bg-gray-100 transition-colors duration-200 rounded p-1"
@@ -810,7 +814,6 @@ export function ReviewPointsList({
tabIndex={0}
aria-label={`查看${key}内容详情`}
>
{/* 使用flex布局使key和状态标签左右对齐 */}
<div className="flex justify-between items-center mb-1">
<span className="text-xs">{key}</span>
<span className={`text-xs ${isErrorStatus ? 'text-error' : 'text-warning'}`}>
@@ -823,9 +826,9 @@ export function ReviewPointsList({
: (value || (value === '' ? <span className="invisible">占位符</span> : ''))}
</p>
</div>
))}
))} */}
{/* 修改评查结果的结构之后,显示新的结构 */}
{/* {renderContent(reviewPoint)} */}
{renderContent(reviewPoint)}
</div>
</div>
</>
@@ -969,12 +972,10 @@ export function ReviewPointsList({
// 遍历contentPage中的每个key
for (const key of Object.keys(reviewPoint.contentPage)) {
const pageArr = reviewPoint.contentPage[key];
// 如果数组存在且长度大于0
if (pageArr && pageArr.length > 0) {
// 返回第一个找到的有效页码,以及对应的key
if (reviewPoint.contentPage[key] && parseInt(reviewPoint.contentPage[key] as string) > 0) {
// 返回第一个找到的有效页码,以及对应的key
return {
pageIndex: pageArr[0],
pageIndex: parseInt(reviewPoint.contentPage[key] as string),
key,
id: reviewPoint.id
};
+7 -7
View File
@@ -1,5 +1,4 @@
import React from 'react';
import { Button } from '~/components/ui/Button';
interface ActionButtonsProps {
onSave?: () => void;
onSaveDraft?: () => void;
@@ -23,13 +22,14 @@ export function ActionButtons({ onSave, onSaveDraft, isEditMode }: ActionButtons
>
<i className="ri-draft-line mr-1"></i> {isEditMode ? '另存为草稿' : '保存草稿'}
</button>
<button
type="button"
className="ant-btn ant-btn-default min-w-[120px]"
<Button
type="default"
className="min-w-[120px] focus:!ring-gray-300"
onClick={() => window.history.back()}
icon="ri-arrow-left-line"
>
<i className="ri-arrow-left-line mr-1"></i>
</button>
</Button>
</div>
);
}
+112 -25
View File
@@ -1,5 +1,14 @@
// app/components/ui/Modal.tsx
import React, { useEffect } from 'react';
import React, { useEffect, useRef } from 'react';
import modalStyles from '~/styles/components/modal.css?url';
// 导出样式
export function links() {
return [{ rel: 'stylesheet', href: modalStyles }];
}
// 模态框尺寸
export type ModalSize = 'small' | 'medium' | 'large' | 'full';
interface ModalProps {
isOpen: boolean;
@@ -8,65 +17,143 @@ interface ModalProps {
children: React.ReactNode;
footer?: React.ReactNode;
width?: number | string;
size?: ModalSize;
className?: string;
closeOnEsc?: boolean;
closeOnBackdropClick?: boolean;
}
export function Modal({ isOpen, onClose, title, children, footer, width = 500 }: ModalProps) {
// 点击ESC键关闭模态框
useEffect(() => {
const handleEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) {
onClose();
}
};
window.addEventListener('keydown', handleEsc);
return () => window.removeEventListener('keydown', handleEsc);
}, [isOpen, onClose]);
export function Modal({
isOpen,
onClose,
title,
children,
footer,
width,
size = 'medium',
className = '',
closeOnEsc = true,
closeOnBackdropClick = true
}: ModalProps) {
// 引用模态框内容元素
const contentRef = useRef<HTMLDivElement>(null);
// 保存之前的活动元素,以便关闭时恢复焦点
const previousActiveElement = useRef<HTMLElement | null>(null);
// 禁用背景滚动
// 自动聚焦第一个可聚焦元素并保存先前的焦点元素
useEffect(() => {
if (isOpen) {
previousActiveElement.current = document.activeElement as HTMLElement;
if (contentRef.current) {
const focusableElements = contentRef.current.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (focusableElements.length > 0) {
(focusableElements[0] as HTMLElement).focus();
} else {
contentRef.current.focus();
}
}
// 当模态框打开时禁止背景滚动
document.body.style.overflow = 'hidden';
} else {
} else if (previousActiveElement.current) {
// 当模态框关闭时,恢复之前的焦点
previousActiveElement.current.focus();
// 恢复背景滚动
document.body.style.overflow = '';
}
// 清理函数
return () => {
document.body.style.overflow = '';
};
}, [isOpen]);
// 处理背景点击事件
const handleBackdropClick = () => {
if (closeOnBackdropClick) {
onClose();
}
};
// 获取尺寸样式类
const sizeClass = width ? '' : `modal-${size}`;
// 计算宽度样式
const widthStyle = width ?
{ width: typeof width === 'number' ? `${width}px` : width, maxWidth: typeof width === 'number' ? `${width}px` : width } : {};
// 使用useEffect添加键盘事件监听器
useEffect(() => {
const handleEscKey = (e: KeyboardEvent) => {
if (e.key === 'Escape' && closeOnEsc) {
onClose();
}
};
document.addEventListener('keydown', handleEscKey);
return () => {
document.removeEventListener('keydown', handleEscKey);
};
}, [closeOnEsc, onClose]);
if (!isOpen) return null;
return (
<div
<div
className="modal-backdrop"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
aria-hidden="true"
>
<div
className="modal-content"
style={{ maxWidth: typeof width === 'number' ? `${width}px` : width }}
onClick={e => e.stopPropagation()}
ref={contentRef}
className={`modal-content ${sizeClass} ${className}`}
style={widthStyle}
tabIndex={-1}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
>
<div className="modal-header">
<h3 className="text-lg font-medium">{title}</h3>
<h3 id="modal-title" className="modal-title">{title}</h3>
<button
className="text-gray-400 hover:text-gray-500"
className="modal-close"
onClick={onClose}
aria-label="关闭"
style={{ cursor: 'pointer' }}
>
<i className="ri-close-line"></i>
</button>
</div>
<div className="modal-body py-4">
<div className="modal-body">
{children}
</div>
{footer && (
<div className="modal-footer flex justify-end space-x-2 pt-4 border-t border-gray-100">
<div className="modal-footer">
{footer}
</div>
)}
</div>
{/* 背景点击处理 */}
<button
className="modal-backdrop-button"
onClick={handleBackdropClick}
aria-label="关闭模态框"
tabIndex={-1}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
background: 'transparent',
border: 'none',
cursor: 'default',
zIndex: 0
}}
/>
</div>
);
}
+17 -6
View File
@@ -54,6 +54,7 @@ export function Toast({
const [isClosing, setIsClosing] = useState(false);
const [portalElement, setPortalElement] = useState<HTMLElement | null>(null);
const [messageLines, setMessageLines] = useState<number>(1);
const [isHovered, setIsHovered] = useState(false);
// 在客户端渲染时获取 portal 容器
useEffect(() => {
@@ -89,7 +90,7 @@ export function Toast({
// 自动关闭
useEffect(() => {
if (isOpen && autoClose) {
if (isOpen && autoClose && !isHovered) {
// 根据消息长度调整显示时间,长消息显示更长时间
const adjustedDelay = Math.min(
autoCloseDelay + (message.length > 100 ? 2000 : 0),
@@ -101,7 +102,16 @@ export function Toast({
}, adjustedDelay);
return () => clearTimeout(timer);
}
}, [isOpen, autoClose, autoCloseDelay, handleClose, message]);
}, [isOpen, autoClose, autoCloseDelay, handleClose, message, isHovered]);
// 鼠标悬停处理
const handleMouseEnter = () => {
setIsHovered(true);
};
const handleMouseLeave = () => {
setIsHovered(false);
};
// 渲染图标
const renderIcon = () => {
@@ -138,10 +148,10 @@ export function Toast({
return createPortal(
<div
className={`toast toast-${type} ${isClosing ? 'closing' : ''} ${className} ${messageLines > 3 ? 'toast-multiline' : ''}`}
role="alert"
aria-live="assertive"
onKeyDown={handleKeyDown}
tabIndex={0}
role="status"
aria-live="polite"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<div className="toast-content">
<div className="toast-icon-wrapper">
@@ -155,6 +165,7 @@ export function Toast({
className="toast-close"
onClick={handleClose}
aria-label="关闭"
onKeyDown={handleKeyDown}
>
<i className="ri-close-line"></i>
</button>
+132 -109
View File
@@ -1,14 +1,17 @@
import { json, type MetaFunction, type LoaderFunctionArgs, type ActionFunctionArgs } from "@remix-run/node";
import { useLoaderData, useSearchParams, useSubmit, Link } from "@remix-run/react";
import { useState } from "react";
import { useLoaderData, useSearchParams, useFetcher, Link } from "@remix-run/react";
import { useState, useEffect } from "react";
import { Button } from "~/components/ui/Button";
import { Card } from "~/components/ui/Card";
import { FilterPanel, FilterSelect, SearchFilter } from "~/components/ui/FilterPanel";
import { Modal } from "~/components/ui/Modal";
import { Pagination } from "~/components/ui/Pagination";
import { Table } from "~/components/ui/Table";
import { Tag } from "~/components/ui/Tag";
import { getConfigLists, getConfigOptions, updateConfigStatus, type ConfigItem } from "~/api/system_setting/config-lists";
import configListsStyles from "~/styles/pages/config-lists_index.css?url";
import { toastService } from "~/components/ui/Toast";
import { messageService } from "~/components/ui/MessageModal";
export const links = () => [
{ rel: "stylesheet", href: configListsStyles }
@@ -54,16 +57,13 @@ export const MODULE_LABELS: Record<ConfigModule, string> = {
[ConfigModule.NOTIFICATION]: '通知'
};
interface LoaderData {
configs: ConfigItem[];
totalCount: number;
currentPage: number;
pageSize: number;
totalPages: number;
types: string[];
environments: string[];
// 操作响应
interface ActionResponse {
result: boolean;
message: string;
}
export async function loader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const name = url.searchParams.get("name") || "";
@@ -95,7 +95,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
throw new Error(optionsResponse.error || "获取配置选项失败");
}
return json<LoaderData>({
return Response.json({
configs: configsResponse.data,
totalCount: configsResponse.total,
currentPage,
@@ -110,8 +110,11 @@ export async function loader({ request }: LoaderFunctionArgs) {
});
} catch (error) {
console.error('加载配置列表失败:', error);
throw new Response('加载配置列表失败', { status: 500 });
}
return Response.json({
error: error || '加载配置列表失败',
status: 500
}, { status: 500 });
}
}
export async function action({ request }: ActionFunctionArgs) {
@@ -120,7 +123,7 @@ export async function action({ request }: ActionFunctionArgs) {
const configId = formData.get('configId');
if (!configId) {
return json({ success: false, error: "缺少配置ID" }, { status: 400 });
return Response.json({ result: false, message: "缺少配置ID" }, { status: 400 });
}
// 进行更新启用和禁用的状态
@@ -130,37 +133,48 @@ export async function action({ request }: ActionFunctionArgs) {
const response = await updateConfigStatus(parseInt(configId as string), is_active);
if (!response.success) {
return json({ success: false, error: response.error }, { status: 500 });
if (response.error) {
return Response.json({ result: false, message: response.error }, { status: 500 });
}
return json({ success: true });
return Response.json({ result: true, message: is_active ? '启用成功' : '禁用成功' });
}
return json({ success: false, error: "未知操作" }, { status: 400 });
return Response.json({ result: false, message: "未知操作" }, { status: 400 });
} catch (error) {
console.error('操作配置失败:', error);
return json({ success: false, error: "操作失败" }, { status: 500 });
return Response.json({ result: false, message: error || "操作失败" }, { status: 500 });
}
}
export function ErrorBoundary() {
return (
<div className="error-container p-6">
<h1 className="text-xl font-bold text-red-500 mb-4"></h1>
<p className="mb-4"></p>
<Button type="primary" to="/"></Button>
</div>
);
}
export default function ConfigListsIndex() {
const { configs, totalCount, currentPage, pageSize, types, environments } = useLoaderData<typeof loader>();
const { configs, totalCount, currentPage, pageSize, types, environments, error } = useLoaderData<typeof loader>();
const [searchParams, setSearchParams] = useSearchParams();
const submit = useSubmit();
const fetcher = useFetcher<ActionResponse>();
const [showDetailModal, setShowDetailModal] = useState(false);
const [selectedConfig, setSelectedConfig] = useState<ConfigItem | null>(null);
// 处理loader错误
useEffect(() => {
if(error) {
toastService.error(error);
}
}, [error]);
// 使用useEffect监听fetcher状态变化并显示Toast
useEffect(() => {
if(fetcher.state === 'idle' && fetcher.data) {
if(fetcher.data.result) {
toastService.success(fetcher.data.message);
} else if (fetcher.data.message) {
toastService.error(fetcher.data.message);
}
}
}, [fetcher.state,fetcher.data]);
// 处理筛选条件变化
const handleFilterChange = (e: React.ChangeEvent<HTMLSelectElement | HTMLInputElement>) => {
const { name, value } = e.target;
const newParams = new URLSearchParams(searchParams);
@@ -177,13 +191,11 @@ export default function ConfigListsIndex() {
setSearchParams(newParams);
};
// 搜索配置名称
const handleConfigNameSearch = (value: string) => {
const newParams = new URLSearchParams(searchParams);
if (value) {
newParams.set('name', value);
} else {
newParams.delete('name');
}
value ? newParams.set('name', value) : newParams.delete('name');
// 搜索时,重置到第一页
newParams.set('page', '1');
@@ -191,28 +203,40 @@ export default function ConfigListsIndex() {
setSearchParams(newParams);
};
// 处理启用和禁用状态
const handleToggleStatus = (config: ConfigItem) => {
if (window.confirm(`确定要${config.is_active ? '禁用' : '启用'}该配置吗?`)) {
const formData = new FormData();
formData.append('_action', 'toggleStatus');
formData.append('configId', config.id.toString());
formData.append('is_active', String(!config.is_active));
submit(formData, { method: 'post' });
}
messageService.show({
title: '提示',
message: `确定要${config.is_active ? '禁用' : '启用'}该配置吗?`,
type: config.is_active ? 'warning' : 'success',
confirmText: '确定',
cancelText: '取消',
onConfirm: () => {
const formData = new FormData();
formData.append('_action', 'toggleStatus');
formData.append('configId', config.id.toString());
formData.append('is_active', String(!config.is_active));
fetcher.submit(formData, { method: 'post' });
}
});
};
// 处理查看详情
const handleViewDetail = (config: ConfigItem) => {
setSelectedConfig(config);
setShowDetailModal(true);
};
// 处理分页
const handlePageChange = (page: number) => {
const newParams = new URLSearchParams(searchParams);
newParams.set('page', page.toString());
setSearchParams(newParams);
};
// 处理每页条数变更
const handlePageSizeChange = (size: number) => {
const newParams = new URLSearchParams(searchParams);
newParams.set('pageSize', size.toString());
@@ -223,17 +247,10 @@ export default function ConfigListsIndex() {
// 处理重置筛选
const handleReset = () => {
const nameInput = document.querySelector('input[placeholder="请输入配置名称"]') as HTMLInputElement;
const typeSelect = document.querySelector('select[name="type"]') as HTMLInputElement;
const environmentSelect = document.querySelector('select[name="environment"]') as HTMLInputElement;
const statusSelect = document.querySelector('select[name="is_active"]') as HTMLInputElement;
if(nameInput) nameInput.value = ''
setSearchParams(new URLSearchParams());
if(nameInput) nameInput.value = ''
if(typeSelect) typeSelect.value = ''
if(environmentSelect) environmentSelect.value = ''
if(statusSelect) statusSelect.value = ''
};
// 关闭详情模态框
@@ -355,7 +372,7 @@ export default function ConfigListsIndex() {
label="所属模块"
name="type"
value={searchParams.get('type') || ''}
options={[ ...types.map(type => ({ value: type, label: type }))]}
options={[ ...types.map((type: string) => ({ value: type, label: type }))]}
onChange={handleFilterChange}
className="flex-1 min-w-[200px]"
/>
@@ -364,7 +381,7 @@ export default function ConfigListsIndex() {
label="环境"
name="environment"
value={searchParams.get('environment') || ''}
options={[ ...environments.map(env => ({ value: env, label: env }))]}
options={[ ...environments.map((env: string) => ({ value: env, label: env }))]}
onChange={handleFilterChange}
className="flex-1 min-w-[200px]"
/>
@@ -409,70 +426,76 @@ export default function ConfigListsIndex() {
{/* 配置详情模态框 */}
{showDetailModal && selectedConfig && (
<div className="fixed inset-0 bg-black bg-opacity-30 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-3xl w-full">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium"></h3>
<button className="text-gray-500 hover:text-gray-700" onClick={closeDetailModal}>
<i className="ri-close-line text-xl"></i>
</button>
<Modal
isOpen={showDetailModal}
onClose={closeDetailModal}
title="查看配置详情"
width="800px"
footer={
<Button type="default" onClick={closeDetailModal}></Button>
}
>
<div className="config-detail-content">
<div className="config-detail-item">
<div className="config-detail-label"></div>
<div className="config-detail-value">{selectedConfig.name}</div>
</div>
<div className="config-detail-content">
<div className="config-detail-item">
<div className="config-detail-label"></div>
<div className="config-detail-value">{selectedConfig.name}</div>
</div>
<div className="config-detail-item">
<div className="config-detail-label"></div>
<div className="config-detail-value">{selectedConfig.type}</div>
</div>
<div className="config-detail-item">
<div className="config-detail-label"></div>
<div className="config-detail-value">
<span className="env-tag">
{selectedConfig.environment}
</span>
</div>
</div>
<div className="config-detail-item">
<div className="config-detail-label"></div>
<div className="config-detail-value">
<Tag color={selectedConfig.is_active ? 'green' : 'red'}>
{selectedConfig.is_active ? '已启用' : '已禁用'}
</Tag>
</div>
</div>
<div className="config-detail-item">
<div className="config-detail-label"></div>
<pre className="config-detail-code">
{JSON.stringify(selectedConfig.config, null, 2)}
</pre>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="config-detail-item">
<div className="config-detail-label"></div>
<div className="config-detail-value">{selectedConfig.created_at}</div>
</div>
<div className="config-detail-item">
<div className="config-detail-label"></div>
<div className="config-detail-value">{selectedConfig.updated_at}</div>
</div>
<div className="config-detail-item">
<div className="config-detail-label"></div>
<div className="config-detail-value">{selectedConfig.type}</div>
</div>
<div className="config-detail-item">
<div className="config-detail-label"></div>
<div className="config-detail-value">
<span className="env-tag">
{selectedConfig.environment}
</span>
</div>
</div>
<div className="flex justify-end mt-6">
<Button type="default" onClick={closeDetailModal}></Button>
<div className="config-detail-item">
<div className="config-detail-label"></div>
<div className="config-detail-value">
<Tag color={selectedConfig.is_active ? 'green' : 'red'}>
{selectedConfig.is_active ? '已启用' : '已禁用'}
</Tag>
</div>
</div>
<div className="config-detail-item">
<div className="config-detail-label"></div>
<pre className="config-detail-code">
{JSON.stringify(selectedConfig.config, null, 2)}
</pre>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="config-detail-item">
<div className="config-detail-label"></div>
<div className="config-detail-value">{selectedConfig.created_at}</div>
</div>
<div className="config-detail-item">
<div className="config-detail-label"></div>
<div className="config-detail-value">{selectedConfig.updated_at}</div>
</div>
</div>
</div>
</div>
</Modal>
)}
</div>
);
}
// 错误边界
export function ErrorBoundary() {
return (
<div className="error-container p-6">
<h1 className="text-xl font-bold text-red-500 mb-4"></h1>
<p className="mb-4"></p>
<Button type="primary" to="/"></Button>
</div>
);
}
+441 -180
View File
@@ -1,11 +1,12 @@
import { json, redirect, type ActionFunctionArgs, type LoaderFunctionArgs, type MetaFunction } from "@remix-run/node";
import { redirect, type ActionFunctionArgs, type LoaderFunctionArgs, type MetaFunction } from "@remix-run/node";
import { Form, useActionData, useLoaderData, useNavigation } from "@remix-run/react";
import { useEffect, useState } from "react";
import { useEffect, useState, useRef } from "react";
import { Button } from "~/components/ui/Button";
import { Card } from "~/components/ui/Card";
import { ConfigModule, MODULE_LABELS, ENVIRONMENT_LABELS } from "./config-lists._index";
import { ENVIRONMENT_LABELS } from "./config-lists._index";
import { getConfigOptions, getConfigDetail, createConfig, updateConfig } from "~/api/system_setting/config-lists";
import configNewStyles from "~/styles/pages/config-lists_new.css?url";
import { toastService } from "~/components/ui/Toast";
export const links = () => [
{ rel: "stylesheet", href: configNewStyles }
@@ -39,6 +40,7 @@ export const EXTENDED_ENVIRONMENT_LABELS: Record<string, string> = {
[ExtendedConfigEnvironment.COMMON]: '通用'
};
// 新增配置表单数据
interface ConfigData {
id: number;
name: string;
@@ -49,41 +51,16 @@ interface ConfigData {
remark?: string;
}
// 加载器数据类型
interface LoaderData {
config?: ConfigData;
isEdit: boolean;
types: string[];
environments: string[];
error?: string;
}
export async function loader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const id = url.searchParams.get("id");
let config: ConfigData | undefined = undefined;
// 获取配置选项
const optionsResponse = await getConfigOptions();
if (optionsResponse.error) {
throw new Error(optionsResponse.error);
}
if (id) {
// 获取配置详情
const detailResponse = await getConfigDetail(id);
if (detailResponse.error) {
throw new Error(detailResponse.error);
}
config = detailResponse.data;
}
return Response.json({
config,
isEdit: !!config,
types: optionsResponse.data?.types || [],
environments: optionsResponse.data?.environments || []
});
}
// 新增配置表单数据
interface ActionData {
success?: boolean;
errors?: {
@@ -93,81 +70,7 @@ interface ActionData {
config?: string;
general?: string;
};
}
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const id = formData.get("id") as string;
const name = formData.get("name") as string;
const type = formData.get("type") as string;
const environment = formData.get("environment") as string;
const config = formData.get("config") as string;
const is_active = formData.get("is_active") === "true";
const remark = formData.get("remark") as string;
const errors: ActionData["errors"] = {};
// 表单验证
if (!name || name.trim() === "") {
errors.name = "配置名称不能为空";
}
if (!type) {
errors.type = "请选择所属模块";
}
if (!environment) {
errors.environment = "请选择环境";
}
if (!config || config.trim() === "") {
errors.config = "配置数据不能为空";
} else {
try {
JSON.parse(config);
} catch (e) {
errors.config = "配置数据必须是有效的JSON格式";
}
}
if (Object.keys(errors).length > 0) {
return Response.json({ errors });
}
try {
const configData = {
name,
type,
environment,
config: JSON.parse(config),
is_active,
remark
};
if (id) {
// 更新配置
const response = await updateConfig(id, configData);
if (response.error) {
throw new Error(response.error);
}
} else {
// 创建配置
const response = await createConfig(configData);
if (response.error) {
throw new Error(response.error);
}
}
return redirect("/config-lists");
} catch (error) {
console.error("保存配置失败:", error);
return Response.json({
success: false,
errors: {
general: "保存配置失败,请稍后重试"
}
});
}
values?: Record<string, string>;
}
// 配置模板常量
@@ -205,29 +108,212 @@ const CONFIG_TEMPLATES = {
}
};
export async function loader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const id = url.searchParams.get("id");
let config: ConfigData | undefined = undefined;
try {
// 获取配置选项
const optionsResponse = await getConfigOptions();
if (optionsResponse.error) {
throw new Error(optionsResponse.error);
}
if (id) {
// 获取配置详情
const detailResponse = await getConfigDetail(id);
if (detailResponse.error) {
throw new Error(detailResponse.error);
}
config = detailResponse.data;
}
return Response.json({
config,
isEdit: !!config,
types: optionsResponse.data?.types || [],
environments: optionsResponse.data?.environments || [],
error: undefined
});
} catch (error) {
console.error("加载配置数据失败:", error);
return Response.json({
config: undefined,
isEdit: false,
types: [],
environments: [],
error: error instanceof Error ? error.message : "加载配置数据失败"
});
}
}
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const id = formData.get("id") as string;
const name = formData.get("name") as string;
const type = formData.get("type") as string;
const environment = formData.get("environment") as string;
const config = formData.get("config") as string;
const is_active = formData.get("is_active") === "true";
const remark = formData.get("remark") as string;
const errors: ActionData["errors"] = {};
// 表单验证
if (!name || name.trim() === "") {
errors.name = "配置名称不能为空";
}else if(/^[a-zA-Z_]+$/.test(name)){
errors.name = "配置名称只能包含英文字母和下划线";
}
if (!type) {
errors.type = "请选择或输入所属模块";
}
if (!environment) {
errors.environment = "请选择环境";
}
if (!config || config.trim() === "") {
errors.config = "配置数据不能为空";
} else {
try {
JSON.parse(config);
} catch (e) {
errors.config = "配置数据必须是有效的JSON格式";
}
}
if (Object.keys(errors).length > 0) {
return Response.json({
errors,
values: Object.fromEntries(formData) as Record<string, string>
});
}
try {
const configData = {
name,
type,
environment,
config: JSON.parse(config),
is_active,
remark
};
if (id) {
// 更新配置
const response = await updateConfig(id, configData);
if (response.error) {
throw new Error(response.error);
}
} else {
// 创建配置
const response = await createConfig(configData);
if (response.error) {
throw new Error(response.error);
}
}
// 保存成功,显示成功提示并重定向
toastService.success("保存成功");
return redirect("/config-lists");
} 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 ConfigNew() {
const { config, isEdit, types, environments } = useLoaderData<typeof loader>();
const data = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
const formRef = useRef<HTMLFormElement>(null);
const [jsonError, setJsonError] = useState<string | null>(null);
const [configDataValue, setConfigDataValue] = useState("");
const { config, isEdit, types, environments, error } = data;
// 表单状态管理
const [formValues, setFormValues] = useState<{
name: string;
type: string;
environment: string;
config: string;
is_active: boolean;
remark: string;
}>({
name: config?.name || "",
type: config?.type || "",
environment: config?.environment || "",
config: config?.config ? JSON.stringify(config.config, null, 2) : "",
is_active: config?.is_active ?? true,
remark: config?.remark || "",
});
// 表单验证错误状态
const [formErrors, setFormErrors] = useState<{
name?: string;
type?: string;
environment?: string;
config?: string;
general?: string;
}>({});
// 字段是否被触摸过(用于确定何时显示错误)
const [touchedFields, setTouchedFields] = useState<{
name: boolean;
type: boolean;
environment: boolean;
config: boolean;
}>({
name: false,
type: false,
environment: false,
config: false
});
// 示例JSON状态
const [exampleJsonValue, setExampleJsonValue] = useState("");
// 标签选择状态
const [selectedModule, setSelectedModule] = useState<string>("");
const [selectedEnvironment, setSelectedEnvironment] = useState<string>("");
// 在 ConfigNew 组件中添加状态来跟踪当前选中的模板
const [selectedTemplate, setSelectedTemplate] = useState<keyof typeof CONFIG_TEMPLATES | null>(null);
// 从 actionData 初始化表单错误
useEffect(() => {
if (actionData?.errors) {
setFormErrors(actionData.errors);
}
// 如果提交后有错误,则将所有字段标记为已触摸
if (actionData?.errors && Object.keys(actionData.errors).length > 0) {
setTouchedFields({
name: true,
type: true,
environment: true,
config: true
});
}
}, [actionData]);
// 根据加载的配置数据初始化表单
useEffect(() => {
// 初始化配置数据
if (config) {
setConfigDataValue(JSON.stringify(config.config, null, 2));
setSelectedModule(config.type);
setSelectedEnvironment(config.environment);
setFormValues({
name: config.name,
type: config.type,
environment: config.environment,
config: JSON.stringify(config.config, null, 2),
is_active: config.is_active,
remark: config.remark || ""
});
}
// 初始化示例JSON
@@ -246,46 +332,213 @@ export default function ConfigNew() {
}, null, 2));
}, [config]);
// 处理JSON数据变更
// 验证表单字段
const validateField = (field: string, value: string): string => {
switch (field) {
case 'name':
if(value.trim() === ""){
return "配置名称不能为空";
}else if(/^[a-zA-Z_]+$/.test(value)){
return "配置名称只能包含英文字母和下划线";
}else if(value.length > 100){
return "配置名称不能超过100个字符";
}
return "";
case 'type':
return value.trim() === "" ? "请选择所属模块" : "";
case 'environment':
return value.trim() === "" ? "请选择环境" : "";
case 'config':
if (value.trim() === "") {
return "配置数据不能为空";
} else {
try {
JSON.parse(value);
return "";
} catch (e) {
return "配置数据必须是有效的JSON格式";
}
}
default:
return "";
}
};
// 处理字段改变
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setFormValues(prev => ({
...prev,
[name]: value
}));
// 标记字段为已触摸
if (['name', 'type', 'environment', 'config'].includes(name)) {
setTouchedFields(prev => ({
...prev,
[name]: true
}));
}
// 实时验证
const error = validateField(name, value);
setFormErrors(prev => ({
...prev,
[name]: error
}));
};
// 处理配置数据变更(JSON编辑器)
const handleConfigDataChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value;
setConfigDataValue(value);
const { value } = e.target;
if (value.trim() === "") {
setJsonError(null);
return;
}
setFormValues(prev => ({
...prev,
config: value
}));
try {
JSON.parse(value);
setJsonError(null);
} catch (error) {
if (error instanceof Error) {
setJsonError(`配置数据必须是有效的JSON格式: ${error.message}`);
} else {
setJsonError("配置数据必须是有效的JSON格式");
}
}
// 标记字段为已触摸
setTouchedFields(prev => ({
...prev,
config: true
}));
// 实时验证
const error = validateField('config', value);
setFormErrors(prev => ({
...prev,
config: error
}));
};
// 格式化JSON
const handleFormatJson = () => {
if (configDataValue.trim() === "") return;
if (formValues.config.trim() === "") return;
try {
const parsed = JSON.parse(configDataValue);
setConfigDataValue(JSON.stringify(parsed, null, 2));
setJsonError(null);
const parsed = JSON.parse(formValues.config);
const formatted = JSON.stringify(parsed, null, 2);
setFormValues(prev => ({
...prev,
config: formatted
}));
setFormErrors(prev => ({
...prev,
config: ""
}));
} catch (error) {
if (error instanceof Error) {
setJsonError(`当前不是有效的JSON,无法格式化: ${error.message}`);
} else {
setJsonError("当前不是有效的JSON,无法格式化");
setFormErrors(prev => ({
...prev,
config: `当前不是有效的JSON,无法格式化: ${error instanceof Error ? error.message : '未知错误'}`
}));
}
};
// 处理模块类型选择
const handleModuleSelect = (moduleType: string) => {
setFormValues(prev => ({
...prev,
type: moduleType
}));
// 标记字段为已触摸
setTouchedFields(prev => ({
...prev,
type: true
}));
// 清除错误
setFormErrors(prev => ({
...prev,
type: ""
}));
};
// 处理环境选择
const handleEnvironmentSelect = (env: string) => {
setFormValues(prev => ({
...prev,
environment: env
}));
// 标记字段为已触摸
setTouchedFields(prev => ({
...prev,
environment: true
}));
// 清除错误
setFormErrors(prev => ({
...prev,
environment: ""
}));
};
// 处理模板选择
const handleTemplateSelect = (templateKey: keyof typeof CONFIG_TEMPLATES) => {
setExampleJsonValue(JSON.stringify(CONFIG_TEMPLATES[templateKey], null, 2));
setSelectedTemplate(templateKey);
};
// 处理启用状态变更
const handleActiveChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormValues(prev => ({
...prev,
is_active: e.target.checked
}));
};
// 提交前验证
const handleBeforeSubmit = (e: React.FormEvent) => {
// 标记所有字段为已触摸
setTouchedFields({
name: true,
type: true,
environment: true,
config: true
});
// 验证所有字段
const errors = {
name: validateField('name', formValues.name),
type: validateField('type', formValues.type),
environment: validateField('environment', formValues.environment),
config: validateField('config', formValues.config)
};
setFormErrors(errors);
// 如果有错误,阻止提交
if (errors.name || errors.type || errors.environment || errors.config) {
e.preventDefault();
// 滚动到第一个错误字段
if (formRef.current) {
const firstErrorField = Object.keys(errors).find(key => !!errors[key as keyof typeof errors]);
if (firstErrorField) {
const element = formRef.current.querySelector(`[name="${firstErrorField}"]`);
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
}
}
};
// 如果加载数据时出错,显示错误信息
if (error) {
return (
<div className="error-container p-6">
<h1 className="text-xl font-bold text-red-500 mb-4"></h1>
<p className="mb-4">{error}</p>
<Button type="primary" to="/config-lists"></Button>
</div>
);
}
return (
<div className="config-new-page">
<div className="flex justify-between items-center mb-4">
@@ -302,14 +555,20 @@ export default function ConfigNew() {
</div>
</div>
{actionData?.errors?.general && (
{formErrors.general && (
<div className="mb-4 w-full">
<div className="error-message general-error">{actionData.errors.general}</div>
<div className="error-message general-error">{formErrors.general}</div>
</div>
)}
<Card className="config-form-card">
<Form method="post" id="configForm" className="config-form">
<Form
method="post"
id="configForm"
className="config-form"
ref={formRef}
onSubmit={handleBeforeSubmit}
>
{config?.id && <input type="hidden" name="id" value={config.id} />}
{/* 配置名称和状态 */}
@@ -320,13 +579,14 @@ export default function ConfigNew() {
type="text"
id="name"
name="name"
className={`form-input ${actionData?.errors?.name ? 'input-error' : ''}`}
defaultValue={config?.name || ''}
className={`form-input ${touchedFields.name && formErrors.name ? 'input-error' : ''}`}
value={formValues.name}
onChange={handleInputChange}
placeholder="请输入配置名称,如database_connection"
required
/>
{actionData?.errors?.name && (
<div className="error-message">{actionData.errors.name}</div>
{touchedFields.name && formErrors.name && (
<div className="error-message">{formErrors.name}</div>
)}
<div className="form-help">
使使线
@@ -343,7 +603,8 @@ export default function ConfigNew() {
name="is_active"
value="true"
className="form-checkbox"
defaultChecked={config?.is_active !== false}
checked={formValues.is_active}
onChange={handleActiveChange}
/>
<label htmlFor="is_active" className="form-checkbox-label">
@@ -362,27 +623,28 @@ export default function ConfigNew() {
<input
type="hidden"
name="type"
value={selectedModule}
value={formValues.type}
/>
<input
type="text"
id="typeDisplay"
className={`form-input ${actionData?.errors?.type ? 'input-error' : ''}`}
value={selectedModule}
onChange={(e) => setSelectedModule(e.target.value)}
className={`form-input ${touchedFields.type && formErrors.type ? 'input-error' : ''}`}
value={formValues.type}
onChange={handleInputChange}
name="type"
placeholder="请输入或选择所属模块"
required
/>
{actionData?.errors?.type && (
<div className="error-message">{actionData.errors.type}</div>
{touchedFields.type && formErrors.type && (
<div className="error-message">{formErrors.type}</div>
)}
<div className="tag-buttons mt-2">
{types.map((type: string) => (
<button
key={type}
type="button"
className={`tag-button ${selectedModule === type ? 'active' : ''}`}
onClick={() => setSelectedModule(type)}
className={`tag-button ${formValues.type === type ? 'active' : ''}`}
onClick={() => handleModuleSelect(type)}
>
{type}
</button>
@@ -399,27 +661,28 @@ export default function ConfigNew() {
<input
type="hidden"
name="environment"
value={selectedEnvironment}
value={formValues.environment}
/>
<input
type="text"
id="environmentDisplay"
className={`form-input ${actionData?.errors?.environment ? 'input-error' : ''}`}
value={selectedEnvironment}
onChange={(e) => setSelectedEnvironment(e.target.value)}
className={`form-input ${touchedFields.environment && formErrors.environment ? 'input-error' : ''}`}
value={formValues.environment}
onChange={handleInputChange}
name="environment"
placeholder="请输入或选择环境"
required
/>
{actionData?.errors?.environment && (
<div className="error-message">{actionData.errors.environment}</div>
{touchedFields.environment && formErrors.environment && (
<div className="error-message">{formErrors.environment}</div>
)}
<div className="tag-buttons mt-2">
{environments.map((env: string) => (
<button
key={env}
type="button"
className={`tag-button ${selectedEnvironment === env ? 'active' : ''}`}
onClick={() => setSelectedEnvironment(env)}
className={`tag-button ${formValues.environment === env ? 'active' : ''}`}
onClick={() => handleEnvironmentSelect(env)}
>
{env}
</button>
@@ -439,8 +702,8 @@ export default function ConfigNew() {
<textarea
id="config"
name="config"
className={`json-editor ${(actionData?.errors?.config || jsonError) ? 'input-error' : ''}`}
value={configDataValue}
className={`json-editor ${touchedFields.config && formErrors.config ? 'input-error' : ''}`}
value={formValues.config}
onChange={handleConfigDataChange}
required
placeholder='请输入JSON格式的配置数据'
@@ -457,8 +720,8 @@ export default function ConfigNew() {
<i className="ri-braces-line mr-1"></i> JSON
</Button>
</div>
{(actionData?.errors?.config || jsonError) && (
<div className="error-message">{actionData?.errors?.config || jsonError}</div>
{touchedFields.config && formErrors.config && (
<div className="error-message">{formErrors.config}</div>
)}
</div>
@@ -498,8 +761,7 @@ export default function ConfigNew() {
className={`${selectedTemplate === 'database' ? 'border-[#00684a] text-[#00684a] focus:ring-0' : ''}`}
onClick={(e) => {
e.preventDefault();
setExampleJsonValue(JSON.stringify(CONFIG_TEMPLATES.database, null, 2));
setSelectedTemplate('database');
handleTemplateSelect('database');
}}
>
@@ -510,8 +772,7 @@ export default function ConfigNew() {
className={`${selectedTemplate === 'file' ? 'border-[#00684a] text-[#00684a] focus:ring-0' : ''}`}
onClick={(e) => {
e.preventDefault();
setExampleJsonValue(JSON.stringify(CONFIG_TEMPLATES.file, null, 2));
setSelectedTemplate('file');
handleTemplateSelect('file');
}}
>
@@ -522,8 +783,7 @@ export default function ConfigNew() {
className={`${selectedTemplate === 'ai' ? 'border-[#00684a] text-[#00684a] focus:ring-0' : ''}`}
onClick={(e) => {
e.preventDefault();
setExampleJsonValue(JSON.stringify(CONFIG_TEMPLATES.ai, null, 2));
setSelectedTemplate('ai');
handleTemplateSelect('ai');
}}
>
AI服务配置
@@ -545,7 +805,8 @@ export default function ConfigNew() {
id="remark"
name="remark"
className="form-textarea"
defaultValue={config?.remark || ''}
value={formValues.remark}
onChange={handleInputChange}
rows={2}
placeholder="请输入配置备注信息"
/>
+131 -108
View File
@@ -13,6 +13,7 @@ import { getDocuments, deleteDocument, type DocumentUI } from "~/api/files/docum
import { getDocumentTypes } from "~/api/document-types/document-types";
import { updateDocumentAuditStatus } from "~/api/evaluation_points/rules-files";
import { toastService } from "~/components/ui/Toast";
import { messageService } from "~/components/ui/MessageModal";
// 导入样式
export function links() {
@@ -56,77 +57,49 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
pageSize
};
// 获取文档列表
const documentsResponse = await getDocuments(searchParams);
// console.log('documentsResponse---1--',JSON.stringify(documentsResponse,null,2));
if (documentsResponse.error) {
throw new Error(documentsResponse.error);
try {
// 获取文档列表
const documentsResponse = await getDocuments(searchParams);
// console.log('documentsResponse---1--',JSON.stringify(documentsResponse,null,2));
if (documentsResponse.error) {
throw new Error(documentsResponse.error);
}
// 获取文档类型列表,用于筛选条件,设置较大的pageSize确保获取所有数据
const typesResponse = await getDocumentTypes({ pageSize: 500 });
// console.log('typesResponse-----',typesResponse);
const documentTypes = typesResponse.data?.types || [];
const documentTypeOptions = documentTypes.map(type => ({
value: type.id,
label: type.name
}));
return Response.json({
documents: documentsResponse.data?.documents || [],
total: documentsResponse.data?.total || 0,
page,
pageSize,
documentTypeOptions
});
} catch (error) {
console.error('获取文档列表失败:', error);
return Response.json({
error: '获取文档列表失败',
status: 500
}, { status: 500 });
}
// 获取文档类型列表,用于筛选条件,设置较大的pageSize确保获取所有数据
const typesResponse = await getDocumentTypes({ pageSize: 500 });
// console.log('typesResponse-----',typesResponse);
const documentTypes = typesResponse.data?.types || [];
const documentTypeOptions = documentTypes.map(type => ({
value: type.id,
label: type.name
}));
return Response.json({
documents: documentsResponse.data?.documents || [],
total: documentsResponse.data?.total || 0,
page,
pageSize,
documentTypeOptions
});
};
// 定义action返回的数据类型
interface ActionResponse {
success: boolean;
result: boolean;
message: string;
}
// 处理表单提交和删除等操作
export const action = async ({ request }: ActionFunctionArgs) => {
const formData = await request.formData();
const action = formData.get("_action");
if (action === "delete") {
const id = formData.get("id") as string;
const response = await deleteDocument(id);
if (response.error) {
return Response.json({ success: false, message: response.error }, { status: response.status || 500 });
}
return Response.json({ success: true, message: "文档已成功删除" });
}
if (action === "batchDelete") {
const ids = formData.getAll("ids") as string[];
// 批量删除处理
const results = await Promise.all(ids.map(id => deleteDocument(id)));
const failures = results.filter(r => r.error);
if (failures.length > 0) {
return Response.json({
success: false,
message: `删除失败: ${failures.map(f => f.error).join(', ')}`
}, { status: 400 });
}
return Response.json({ success: true, message: `已成功删除${ids.length}个文档` });
}
// 未知操作
return Response.json({ success: false, message: "未知操作" }, { status: 400 });
};
// 审核状态筛选选项
const auditStatusOptions = [
// { value: "", label: "全部" },
{ value: "-2", label: "警告" },
// { value: "-2", label: "警告" },
{ value: "-1", label: "不通过" },
{ value: "0", label: "待审核" },
{ value: "1", label: "通过" },
@@ -172,19 +145,49 @@ const formatFileSize = (bytes: number) => {
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
};
// 获取文档类型标签背景颜色
// 此函数已不再需要,改用 FileTypeTag 组件
// const getDocumentTypeTagColor = (type: string): string => {
// const colorMap: Record<string, string> = {
// "sales-contract": "blue",
// "purchase-contract": "green",
// "license": "purple",
// "punishment": "yellow",
// "agreement": "orange",
// "default": "gray"
// };
// return colorMap[type] || colorMap.default;
// };
// 处理表单提交和删除等操作
export const action = async ({ request }: ActionFunctionArgs) => {
try {
const formData = await request.formData();
const action = formData.get("_action");
if (action === "delete") {
const id = formData.get("id") as string;
const response = await deleteDocument(id);
if (response.error) {
return Response.json({ result: false, message: response.error }, { status: response.status || 500 });
}
return Response.json({ result: true, message: "文档已成功删除" });
}
if (action === "batchDelete") {
const ids = formData.getAll("ids") as string[];
// 批量删除处理
const results = await Promise.all(ids.map(id => deleteDocument(id)));
const failures = results.filter(r => r.error);
if (failures.length > 0) {
return Response.json({
result: false,
message: `删除失败: ${failures.map(f => f.error).join(', ')}`
}, { status: 400 });
}
return Response.json({ result: true, message: `已成功删除${ids.length}个文档` });
}
// 未知操作
return Response.json({ result: false, message: "未知操作" }, { status: 400 });
} catch (error) {
console.error('处理表单提交和删除等操作失败:', error);
return Response.json({
result: false,
error: error instanceof Error ? error.message : "处理表单提交和删除等操作失败"
}, { status: 500 });
}
};
export default function DocumentsIndex() {
const [searchParams, setSearchParams] = useSearchParams();
@@ -207,6 +210,24 @@ export default function DocumentsIndex() {
// 获取API返回的数据
const { documents, total, documentTypeOptions } = loaderData;
// 处理loader错误
useEffect(() => {
if (loaderData.error) {
toastService.error(loaderData.error);
}
}, [loaderData.error]);
// 使用useEffect监听fetcher状态变化并显示Toast
useEffect(() => {
if (fetcher.data && fetcher.state === 'idle') {
if (fetcher.data.result) {
toastService.success(fetcher.data.message);
} else if (fetcher.data.message) {
toastService.error(fetcher.data.message);
}
}
}, [fetcher.data, fetcher.state]);
// 分页处理函数
const handlePageChange = (page: number) => {
searchParams.set("page", page.toString());
@@ -365,7 +386,7 @@ export default function DocumentsIndex() {
}, 100);
} catch (error) {
console.error('下载文件失败:', error);
alert(`下载文件失败: ${error instanceof Error ? error.message : '未知错误'}`);
toastService.error(`下载文件失败: ${error instanceof Error ? error.message : '未知错误'}`);
}
};
@@ -377,19 +398,25 @@ export default function DocumentsIndex() {
return;
}
if (confirm(`确定要删除文档"${name}"吗?`)) {
const form = new FormData();
form.append("_action", "delete");
form.append("id", id);
fetcher.submit(form, { method: "post" });
}
messageService.show({
title: "确认删除",
message: `确定要删除文档"${name}"吗?`,
type: "warning",
confirmText: "删除",
cancelText: "取消",
onConfirm: () => {
const form = new FormData();
form.append("_action", "delete");
form.append("id", id);
fetcher.submit(form, { method: "post" });
}
});
};
// 批量删除
const handleBatchDelete = () => {
if (selectedRowKeys.length === 0) {
// alert('请至少选择一个文档');
toastService.error('请至少选择一个文档');
return;
}
@@ -400,26 +427,32 @@ export default function DocumentsIndex() {
);
if (hasProcessingFiles) {
// alert('存在服务器未处理完成的文件,请重新选择需要删除的文件');
toastService.error('存在服务器未处理完成的文件,请重新选择需要删除的文件');
return;
}
if (window.confirm(`确认删除选中的 ${selectedRowKeys.length} 个文档?`)) {
// 使用fetcher提交表单
const formData = new FormData();
formData.append('_action', 'batchDelete');
// 添加所有选中的ID
selectedRowKeys.forEach(id => {
formData.append('ids', id);
});
fetcher.submit(formData, { method: 'post' });
// 清空选中行
setSelectedRowKeys([]);
}
messageService.show({
title: "确认批量删除",
message: `确认删除选中的 ${selectedRowKeys.length} 个文档?`,
type: "warning",
confirmText: "删除",
cancelText: "取消",
onConfirm: () => {
// 使用fetcher提交表单
const formData = new FormData();
formData.append('_action', 'batchDelete');
// 添加所有选中的ID
selectedRowKeys.forEach(id => {
formData.append('ids', id);
});
fetcher.submit(formData, { method: 'post' });
// 清空选中行
setSelectedRowKeys([]);
}
});
};
// 处理文件状态变更
@@ -548,16 +581,6 @@ export default function DocumentsIndex() {
navigate(`/reviews?id=${fileId}&previousRoute=documents`);
};
// 使用useEffect监听fetcher状态变化并显示Toast
useEffect(() => {
if (fetcher.data && fetcher.state === 'idle') {
if (fetcher.data.success) {
toastService.success(fetcher.data.message);
} else if (fetcher.data.message) {
toastService.error(fetcher.data.message);
}
}
}, [fetcher.data, fetcher.state]);
// 表格列定义
const columns = [
+169 -66
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } from "react";
import { useLoaderData, useActionData, useNavigate, Form } from "@remix-run/react";
import { redirect, type ActionFunctionArgs, type LoaderFunctionArgs, type MetaFunction } from "@remix-run/node";
import { Card } from "~/components/ui/Card";
@@ -27,7 +27,18 @@ export const handle = {
breadcrumb: "文档编辑"
};
// 定义action返回数据类型
export interface ActionData {
success?: boolean;
error?: string;
fieldErrors?: {
document_number?: string;
audit_status?: string;
remark?: string;
general?: string;
};
values?: Record<string, string>;
}
// 文档审核状态定义
enum DocumentAuditStatus {
@@ -120,21 +131,19 @@ export async function action({ request }: ActionFunctionArgs) {
const isTest = formData.get("is_test_document") === "on";
const remark = formData.get("remark") as string;
// 验证必填字段
// if (!type || auditStatus === undefined || isNaN(auditStatus)) {
// return Response.json(
// {
// error: "缺少必填字段",
// fieldErrors: {
// type_id: !type ? "文档类型不能为空" : null,
// audit_status: (auditStatus === undefined || isNaN(auditStatus)) ? "审核状态不能为空" : null
// }
// },
// { status: 400 }
// );
// }
// 表单验证
const errors: ActionData["fieldErrors"] = {};
// console.log('提交更新:', { type, documentNumber, auditStatus, isTest, remark });
if (isNaN(auditStatus)) {
errors.audit_status = "审核状态必须选择";
}
if (Object.keys(errors).length > 0) {
return Response.json({
fieldErrors: errors,
values: Object.fromEntries(formData) as Record<string, string>
}, { status: 400 });
}
// 更新文档
const updateResponse = await updateDocument(id, {
@@ -146,26 +155,25 @@ export async function action({ request }: ActionFunctionArgs) {
});
if (updateResponse.error) {
console.error('更新文档失败1:', updateResponse.error);
console.error('更新文档失败:', updateResponse.error);
return Response.json({
error: updateResponse.error,
message: "更新文档失败,请检查提交的数据是否正确"
fieldErrors: {
general: "更新文档失败,请检查提交的数据是否正确"
},
values: Object.fromEntries(formData) as Record<string, string>
}, { status: updateResponse.status || 500 });
}
// toastService.success('更新文档成功');
// 重定向回文档列表
// return redirect("/documents");
return Response.json({
success: true,
message: "更新文档成功"
});
toastService.success('更新文档成功');
return redirect("/documents");
} catch (error) {
console.error("更新文档失败2:", error);
return Response.json({
error: "更新文档失败",
message: error instanceof Error ? error.message : "发生未知错误"
fieldErrors: {
general: error instanceof Error ? error.message : "发生未知错误"
}
}, { status: 500 });
}
}
@@ -173,20 +181,117 @@ export async function action({ request }: ActionFunctionArgs) {
// 文档编辑页面组件
export default function DocumentEdit() {
const { document, documentTypes } = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
const actionData = useActionData<ActionData>();
const navigate = useNavigate();
const [numPages, setNumPages] = useState(0);
const [loadError, setLoadError] = useState<string | null>(null);
const navigate = useNavigate();
const formRef = useRef<HTMLFormElement>(null);
// 表单状态管理 - 使用受控组件
const [formValues, setFormValues] = useState({
documentNumber: document.documentNumber || "",
auditStatus: document.auditStatus,
isTest: document.isTest || false,
remark: document.remark || ""
});
// 表单验证错误状态
const [formErrors, setFormErrors] = useState<{
documentNumber?: string;
auditStatus?: string;
remark?: string;
general?: string;
}>({});
// 字段是否被触摸过(用于确定何时显示错误)
const [touchedFields, setTouchedFields] = useState({
documentNumber: false,
auditStatus: false,
remark: false
});
// 从 actionData 初始化表单错误
useEffect(() => {
// console.log('actionData', actionData);
if (actionData?.error) {
toastService.error('更新文档失败:' + actionData.error);
}
if (actionData?.success) {
toastService.success('更新文档成功');
if (actionData?.fieldErrors) {
// general 是loader的时候返回的错误信息
setFormErrors(actionData.fieldErrors);
}
}, [actionData]);
// 验证表单字段
const validateField = (field: string, value: string | number | undefined): string => {
switch (field) {
case 'auditStatus': {
const statusValue = typeof value === 'string' ? parseInt(value) : value;
return statusValue === undefined || isNaN(statusValue as number) ? "审核状态必须选择" : "";
}
default:
return "";
}
};
// 处理字段改变
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
const { name, value, type } = e.target;
if (type === 'checkbox') {
const checked = (e.target as HTMLInputElement).checked;
setFormValues(prev => ({
...prev,
[name === 'is_test_document' ? 'isTest' : name]: checked
}));
} else {
setFormValues(prev => ({
...prev,
[name === 'document_number' ? 'documentNumber' :
name === 'audit_status' ? 'auditStatus' : name]:
name === 'audit_status' ? (parseInt(value)) : value
}));
}
// 标记字段为已触摸
if (name === 'document_number' || name === 'audit_status' || name === 'remark') {
const fieldName = name === 'document_number' ? 'documentNumber' :
name === 'audit_status' ? 'auditStatus' : name;
setTouchedFields(prev => ({
...prev,
[fieldName]: true
}));
}
// 实时验证
if (name === 'audit_status') {
const statusValue = parseInt(value);
const error = validateField('auditStatus', statusValue);
setFormErrors(prev => ({
...prev,
auditStatus: error
}));
}
};
// 处理表单提交前验证
const handleBeforeSubmit = (e: React.FormEvent) => {
// 标记所有字段为已触摸
setTouchedFields({
documentNumber: true,
auditStatus: true,
remark: true
});
// 验证所有字段
const errors = {
auditStatus: validateField('auditStatus', formValues.auditStatus)
};
setFormErrors(errors);
// 如果有错误,阻止提交
if (errors.auditStatus) {
toastService.error('审核状态不能为空');
e.preventDefault();
}
};
const onDocumentLoadSuccess = ({numPages}: {numPages: number}) => {
setNumPages(numPages);
@@ -242,10 +347,8 @@ export default function DocumentEdit() {
<div
className="page-wrapper flex justify-center"
style={{
// transform: `scale(${zoomFactor})`, // 根据zoomLevel应用缩放
transformOrigin: 'top center', // 缩放原点设置为顶部中心
position: 'relative', // 相对定位,作为评查点高亮的定位参考
maxWidth: '100%', // 限制最大宽度
position: 'relative',
maxWidth: '100%',
}}
>
{/* 渲染PDF页面组件 */}
@@ -270,14 +373,6 @@ export default function DocumentEdit() {
name: string;
}
// 状态
const [localStatus, setLocalStatus] = useState<number>(document.auditStatus);
// 处理状态变更
const handleStatusChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
setLocalStatus(parseInt(e.target.value));
};
// 获取文档类型名称
const getDocumentTypeName = (typeId: string): string => {
const docType = documentTypes.find((type: DocType) => type.id.toString() === typeId);
@@ -299,8 +394,6 @@ export default function DocumentEdit() {
// 在新窗口打开文档预览
const openPreview = () => {
// 假设有一个预览URL的格式,比如 /preview?path=xxx
// console.log('documentstest', document);
const urlBefore = 'http://172.18.0.100:9000/docauditai/'
const previewUrl = `${urlBefore}${document.path}`;
window.open(previewUrl, '_blank');
@@ -368,7 +461,15 @@ export default function DocumentEdit() {
<i className="ri-information-line mr-2"></i>
</div>
<Form id="edit-form" method="post">
{/* 错误提示 */}
{formErrors.general && (
<div className="general-error mb-4">
<i className="ri-error-warning-line mr-2"></i>
{formErrors.general}
</div>
)}
<Form id="edit-form" method="post" ref={formRef} onSubmit={handleBeforeSubmit}>
<div className="grid grid-cols-2 gap-6">
<div className="form-group">
<label htmlFor="type-id" className="form-label"> <span className="text-red-500">*</span></label>
@@ -376,8 +477,7 @@ export default function DocumentEdit() {
id="type-id"
name="type_id"
className="form-select"
defaultValue={document.type}
// disabled={document.fileStatus !== 'Processed'}
value={document.type}
disabled={true}
required
>
@@ -386,9 +486,6 @@ export default function DocumentEdit() {
))}
</select>
<div className="text-sm text-secondary mt-1"></div>
{actionData?.fieldErrors?.type_id && (
<div className="text-red-500 text-sm mt-1">{actionData.fieldErrors.type_id}</div>
)}
</div>
<div className="form-group">
@@ -399,7 +496,8 @@ export default function DocumentEdit() {
name="document_number"
className="form-input"
placeholder="请输入合同编号、许可证号等"
defaultValue={document.documentNumber || ""}
value={formValues.documentNumber}
onChange={handleChange}
/>
<div className="text-sm text-secondary mt-1"></div>
</div>
@@ -409,19 +507,20 @@ export default function DocumentEdit() {
<select
id="audit-status"
name="audit_status"
className="form-select"
value={localStatus}
onChange={handleStatusChange}
className={`form-select ${touchedFields.auditStatus && formErrors.auditStatus ? 'error' : ''}`}
value={formValues.auditStatus}
onChange={handleChange}
required
>
{/* <option value='全部'>请选择审核状态</option> */}
<option value={DocumentAuditStatus.WAITING}>{STATUS_LABELS[DocumentAuditStatus.WAITING]}</option>
<option value={DocumentAuditStatus.PROCESSING}>{STATUS_LABELS[DocumentAuditStatus.PROCESSING]}</option>
<option value={DocumentAuditStatus.PASS}>{STATUS_LABELS[DocumentAuditStatus.PASS]}</option>
<option value={DocumentAuditStatus.FAIL}>{STATUS_LABELS[DocumentAuditStatus.FAIL]}</option>
</select>
<div className="text-sm text-secondary mt-1"></div>
{actionData?.fieldErrors?.audit_status && (
<div className="text-red-500 text-sm mt-1">{actionData.fieldErrors.audit_status}</div>
{touchedFields.auditStatus && formErrors.auditStatus && (
<div className="text-red-500 text-sm mt-1">{formErrors.auditStatus}</div>
)}
</div>
@@ -433,7 +532,8 @@ export default function DocumentEdit() {
type="checkbox"
id="is-test-document"
name="is_test_document"
defaultChecked={document.isTest}
checked={formValues.isTest}
onChange={handleChange}
/>
<span className="slider"></span>
<span className="sr-only"></span>
@@ -450,7 +550,8 @@ export default function DocumentEdit() {
className="form-textarea"
placeholder="可输入文档的相关描述或备注信息"
rows={3}
defaultValue={document.remark || ""}
value={formValues.remark}
onChange={handleChange}
></textarea>
</div>
</div>
@@ -488,9 +589,11 @@ export default function DocumentEdit() {
</div>
</div>
{/* 预览窗口 */}
{loadError ?(<div className="text-red-500">
{loadError}
</div>):(
{loadError ? (
<div className="text-red-500">
{loadError}
</div>
) : (
renderDocumentContent()
)}
</div>
+2 -1
View File
@@ -145,7 +145,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
// 表单处理
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;
@@ -156,6 +156,7 @@ export async function action({ request }: ActionFunctionArgs) {
const parentId = groupType === "secondary" ? formData.get("parentId") as string : null;
// 表单验证
// action是处于服务端的表单提交方法,这里再次验证表单数据也是出于安全考虑,防止客户端验证被绕过从而提交非法数据
const errors: ActionData["errors"] = {};
if (!name || name.trim() === "") {
+31 -47
View File
@@ -1,5 +1,6 @@
import { type MetaFunction, type LoaderFunctionArgs } from "@remix-run/node";
import { useLoaderData, useSearchParams, useNavigate } from "@remix-run/react";
import { useEffect } from "react";
import { Button } from "~/components/ui/Button";
import { Card } from "~/components/ui/Card";
import { FileIcon } from "~/components/ui/FileIcon";
@@ -50,7 +51,7 @@ export const REVIEW_STATUS_LABELS: Record<string, string> = {
'pending': '待人工确认'
};
// 加载评查文件列表
export async function loader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const fileType = url.searchParams.get("fileType") || "";
@@ -86,7 +87,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
const filesResponse = await getReviewFiles(searchParams);
if (filesResponse.error) {
console.error('获取评查文件列表失败:', filesResponse.error);
throw new Response('获取评查文件列表失败', { status: filesResponse.status || 500 });
return Response.json({ result: false, message: filesResponse.error }, { status: filesResponse.status || 500 });
}
const files = filesResponse.data?.files || [];
@@ -101,29 +102,24 @@ export async function loader({ request }: LoaderFunctionArgs) {
});
} catch (error) {
console.error('加载评查文件列表失败:', error);
throw new Response('加载评查文件列表失败', { status: 500 });
return Response.json({ result: false, message: error instanceof Error ? error.message : '加载评查文件列表失败' }, { status: 500 });
}
}
// 提取renderErrorBoundary函数作为命名导出
export function ErrorBoundary() {
return (
<div className="error-container p-6">
<h1 className="text-xl font-normal text-red-500 mb-4"></h1>
<p className="mb-4"></p>
<Button type="primary" to="/"></Button>
</div>
);
}
// 在文件中定义一个与路由文件名匹配的命名函数组件
export default function RulesFiles() {
const navigate = useNavigate();
const { files, documentTypes, totalCount, currentPage, pageSize } = useLoaderData<typeof loader>();
const { files, documentTypes, totalCount, currentPage, pageSize, result, message } = useLoaderData<typeof loader>();
const [searchParams, setSearchParams] = useSearchParams();
const dateFrom = searchParams.get('dateFrom') || '';
const dateTo = searchParams.get('dateTo') || '';
// 处理初始加载数据loader的错误
useEffect(() => {
if(!result) {
toastService.error(message);
}
}, [result, message]);
// 处理筛选条件变更
const handleFilterChange = (e: React.ChangeEvent<HTMLSelectElement | HTMLInputElement>) => {
const { name, value } = e.target;
@@ -178,12 +174,12 @@ export default function RulesFiles() {
try {
const response = await updateDocumentAuditStatus(fileId, 2);
if (response.error) {
console.error('更新文件审核状态失败:', response.error);
// 尽管更新失败,仍然导航到文件详情页
throw new Error(response.error);
}
} catch (error) {
console.error('更新文件审核状态时出错:', error);
// 尽管发生错误,仍然导航到文件详情页
toastService.error(`更新文件审核状态时出错:${error instanceof Error ? error.message : '未知错误'}`);
return;
}
}
@@ -193,9 +189,10 @@ export default function RulesFiles() {
// 渲染问题摘要
const renderIssues = (file: ReviewFileUI) => {
// 如果评查状态为通过(说明所有评查结果为true),显示"所有评查点均通过"
// 如果文件状态为完成
if (file.status === 'Processed') {
if (file.reviewStatus === 'pass') {
// 如果没有问题,显示"所有评查点均通过"
if (file.warningCount <= 0 && file.failCount <= 0) {
return (
<div className="text-sm text-success">
<i className="ri-check-double-line mr-1"></i>
@@ -213,7 +210,7 @@ export default function RulesFiles() {
// }
// 显示问题列表
if (file.reviewStatus !== 'pass' && file.reviewStatus !== 'fail' && file.issues && file.issues.length > 0) {
if (file.issues && file.issues.length > 0) {
// 最多显示2个问题
const displayIssues = file.issues.slice(0, 2);
@@ -292,19 +289,11 @@ export default function RulesFiles() {
};
const handleReset = () => {
const newParams = new URLSearchParams(searchParams);
const newParams = new URLSearchParams();
const searchInput = document.querySelector('input[name="keyword"]');
if(searchInput) {
(searchInput as HTMLInputElement).value = '';
}
// newParams.delete('keyword');
newParams.delete('dateFrom');
newParams.delete('dateTo');
newParams.delete('fileType');
// newParams.delete('reviewStatus');
newParams.delete('sortOrder');
newParams.set('page', '1');
setSearchParams(newParams);
};
@@ -314,22 +303,6 @@ export default function RulesFiles() {
label: type.name
}));
// 评查状态选项
// const reviewStatusOptions = [
// { value: 'pass', label: '通过' },
// { value: 'warning', label: '警告' },
// { value: 'fail', label: '不通过' },
// { value: 'pending', label: '待人工确认' }
// ];
// 时间范围选项
// const dateRangeOptions = [
// { value: DateRange.TODAY, label: '今天' },
// { value: DateRange.WEEK, label: '本周' },
// { value: DateRange.MONTH, label: '本月' },
// // { value: DateRange.CUSTOM, label: '自定义时间段' }
// ];
// 定义表格列配置
const columns = [
{
@@ -570,4 +543,15 @@ export default function RulesFiles() {
</Card>
</div>
);
}
// 错误边界
export function ErrorBoundary() {
return (
<div className="error-container p-6">
<h1 className="text-xl font-normal text-red-500 mb-4"></h1>
<p className="mb-4"></p>
<Button type="primary" to="/"></Button>
</div>
);
}
+107 -128
View File
@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react';
import { type MetaFunction, type LoaderFunctionArgs, redirect } from "@remix-run/node";
import { useLoaderData, useSearchParams, useSubmit, Link, useNavigate } from "@remix-run/react";
import { type MetaFunction, type LoaderFunctionArgs } from "@remix-run/node";
import { useLoaderData, useSearchParams, Link, useNavigate, useFetcher } from "@remix-run/react";
import { Button } from '~/components/ui/Button';
import { Card } from '~/components/ui/Card';
import { Tag } from '~/components/ui/Tag';
@@ -12,10 +12,11 @@ import type { TagColor } from '~/components/ui/Tag';
import { Table } from '~/components/ui/Table';
import { FilterPanel, FilterSelect, SearchFilter } from '~/components/ui/FilterPanel';
import { Pagination } from '~/components/ui/Pagination';
import { messageService } from '~/components/ui/MessageModal';
import { toastService } from '~/components/ui/Toast';
import {
getRulesList,
deleteRule,
duplicateRule,
getRuleTypes,
getRuleGroupsByType,
type RuleType as ApiRuleType,
@@ -60,6 +61,11 @@ interface ApiRule {
updatedAt: string;
}
interface ActionResponse {
result: boolean;
message: string;
}
function mapApiRuleToModel(apiRule: ApiRule): Rule {
return {
id: apiRule.id,
@@ -109,30 +115,15 @@ export async function loader({ request }: LoaderFunctionArgs) {
throw new Error(response.error);
}
if (!response.data) {
throw new Error('API返回数据为空');
}
const apiRules = response.data.rules;
const totalCount = response.data.totalCount;
const apiRules = response.data?.rules || [];
const totalCount = response.data?.totalCount || 0;
const rules = apiRules.map((apiRule: ApiRule) => mapApiRuleToModel(apiRule));
// 计算总页数
const totalPages = Math.ceil(totalCount / params.pageSize);
// 验证页码范围
if (params.page < 1 || (totalCount > 0 && params.page > totalPages)) {
const newUrl = new URL(request.url);
newUrl.searchParams.set('page', '1');
return redirect(newUrl.pathname + newUrl.search);
}
return Response.json({
rules,
totalCount,
currentPage: params.page,
pageSize: params.pageSize,
totalPages,
ruleTypes
}, {
headers: {
@@ -142,7 +133,10 @@ export async function loader({ request }: LoaderFunctionArgs) {
} catch (error) {
console.error('加载评查点列表失败:', error);
throw new Response('加载评查点列表失败', { status: 500 });
return Response.json({
error: error || '加载评查点列表失败',
status: 500
}, { status: 500 });
}
}
@@ -152,7 +146,7 @@ export async function action({ request }: LoaderFunctionArgs) {
const ruleId = formData.get('ruleId');
if (!ruleId) {
return Response.json({ success: false, error: "缺少评查点ID" }, { status: 400 });
return Response.json({ result: false, message: "缺少评查点ID" }, { status: 400 });
}
try {
@@ -160,66 +154,20 @@ export async function action({ request }: LoaderFunctionArgs) {
// 调用API删除评查点
console.log(`删除评查点 ${ruleId}`);
try {
const deleteResponse = await deleteRule(ruleId as string);
if (deleteResponse.error) {
throw new Error(deleteResponse.error);
}
// 删除成功,获取当前URL
const url = new URL(request.url);
// 返回重定向响应,以刷新页面数据
return redirect(`${url.pathname}${url.search}`);
} catch (error) {
console.error('删除评查点失败:', error);
return Response.json({
success: false,
error: error instanceof Error ? error.message : "删除失败"
}, { status: 500 });
}
}
if (_action === 'duplicate') {
// 复制评查点
console.log(`复制评查点 ${ruleId}`);
const deleteResponse = await deleteRule(ruleId as string);
try {
const duplicateResponse = await duplicateRule(ruleId as string);
if (duplicateResponse.error) {
throw new Error(duplicateResponse.error);
}
// 复制成功,获取当前URL
const url = new URL(request.url);
// 返回重定向响应,以刷新页面数据
return redirect(`${url.pathname}${url.search}`);
} catch (error) {
console.error('复制评查点失败:', error);
return Response.json({
success: false,
error: error instanceof Error ? error.message : "复制失败"
}, { status: 500 });
if (deleteResponse.error) {
return Response.json({ result: false, message: deleteResponse.error }, { status: deleteResponse.status || 500 });
}
return Response.json({ result: true, message: "评查点删除成功" }, { status: 200 });
}
return Response.json({ success: false, error: "未知操作" }, { status: 400 });
} catch (error) {
console.error('操作评查点失败:', error);
return Response.json({ success: false, error: "操作失败" }, { status: 500 });
return Response.json({ result: false, message: error instanceof Error ? error.message : "操作失败" }, { status: 500 });
}
}
export function ErrorBoundary() {
return (
<div className="error-container p-6">
<h1 className="text-xl font-bold text-red-500 mb-4"></h1>
<p className="mb-4"></p>
<Button type="primary" to="/"></Button>
</div>
);
}
// 规则优先级的描述标签映射
const priorityLabels = {
@@ -233,24 +181,30 @@ export default function RulesIndex() {
const { rules, totalCount, currentPage, pageSize } = loaderData;
const ruleTypes = loaderData.ruleTypes || []; // 添加默认空数组避免undefined
const [searchParams, setSearchParams] = useSearchParams();
const submit = useSubmit();
const navigate = useNavigate();
const fetcher = useFetcher<ActionResponse>();
// 状态管理
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [ruleToDelete, setRuleToDelete] = useState<Rule | null>(null);
const [ruleGroups, setRuleGroups] = useState<RuleGroup[]>([]);
const [loadingGroups, setLoadingGroups] = useState(false);
// 判断是否禁用规则组选择
const isRuleGroupSelectDisabled = loadingGroups || !searchParams.get('ruleType') || ruleGroups.length === 0;
// 获取当前的ruleType值
const ruleTypeParam = searchParams.get('ruleType');
// 判断是否禁用规则组选择
const isRuleGroupSelectDisabled = loadingGroups || !ruleTypeParam || ruleGroups.length === 0;
// 使用useEffect监听loaderData.error变化并显示Toast
useEffect(() => {
if(loaderData.error) {
toastService.error(loaderData.error);
}
}, [loaderData.error]);
// 当评查点类型变化时,加载对应的规则组
useEffect(() => {
const selectedType = searchParams.get('ruleType');
// 如果选择了"全部"或未选择,则清空规则组
if (!selectedType || selectedType === 'all') {
if (!ruleTypeParam || ruleTypeParam === 'all') {
setRuleGroups([]);
return;
}
@@ -259,7 +213,7 @@ export default function RulesIndex() {
const loadRuleGroups = async () => {
setLoadingGroups(true);
try {
const response = await getRuleGroupsByType(selectedType);
const response = await getRuleGroupsByType(ruleTypeParam);
if (response.data) {
setRuleGroups(response.data);
} else if (response.error) {
@@ -275,8 +229,24 @@ export default function RulesIndex() {
};
loadRuleGroups();
}, [searchParams.get('ruleType')]);
}, [ruleTypeParam]);
// 使用useEffect监听fetcher状态变化并显示Toast
useEffect(() => {
if (fetcher.data && fetcher.state === 'idle') {
if (fetcher.data.result) {
toastService.success(fetcher.data.message);
} else if (!fetcher.data.result) {
if(fetcher.data.message.includes("evaluation_results_evaluation_point_id_fkey")) {
toastService.error("对表“evaluation_points”进行更新或删除违反了表“evaluation results”上的外键约束“evaluations results_evaluation _point_id_fkey”");
} else {
toastService.error(fetcher.data.message);
}
}
}
}, [fetcher.data,fetcher.state]);
// 筛选评查点
const handleFilterChange = (e: React.ChangeEvent<HTMLSelectElement | HTMLInputElement>) => {
const { name, value } = e.target;
const newParams = new URLSearchParams(searchParams);
@@ -313,6 +283,7 @@ export default function RulesIndex() {
setSearchParams(newParams);
};
// 搜索评查点
const handleSearch = (keyword: string) => {
const newParams = new URLSearchParams(searchParams);
if (keyword) {
@@ -327,30 +298,26 @@ export default function RulesIndex() {
setSearchParams(newParams);
};
// 删除评查点
const handleDeleteClick = (rule: Rule) => {
console.log("handleDELETEclick",rule)
setRuleToDelete(rule);
setShowDeleteConfirm(true);
};
const confirmDelete = () => {
if (!ruleToDelete) return;
const formData = new FormData();
formData.append('_action', 'delete');
formData.append('ruleId', ruleToDelete.id);
submit(formData, { method: 'post' });
setShowDeleteConfirm(false);
setRuleToDelete(null);
messageService.show({
title: "确认删除",
message: `确认删除评查点【${rule.name}】吗?`,
type: "warning",
confirmText: "删除",
cancelText: "取消",
onConfirm: () => {
const form = new FormData();
form.append("_action", "delete");
form.append("ruleId", rule.id);
fetcher.submit(form, { method: "post" });
}
});
};
// 复制评查点
const handleCopy = (rule: Rule) => {
// const formData = new FormData();
// formData.append('_action', 'duplicate');
// formData.append('ruleId', rule.id);
// submit(formData, { method: 'post' });
navigate(`/rules-new?id=${rule.id}&mode=copy`);
};
@@ -360,6 +327,7 @@ export default function RulesIndex() {
setSearchParams(newParams);
};
// 处理每页条数变化
const handlePageSizeChange = (size: number) => {
const newParams = new URLSearchParams(searchParams);
newParams.set('pageSize', size.toString());
@@ -368,9 +336,13 @@ export default function RulesIndex() {
};
// 处理重置筛选
// const handleReset = () => {
// setSearchParams(new URLSearchParams());
// };
const handleReset = () => {
const input = document.querySelector('input[placeholder="输入评查点名称或编码"]');
if (input) {
(input as HTMLInputElement).value = '';
}
setSearchParams(new URLSearchParams());
};
// 定义表格列配置
const columns = [
@@ -475,7 +447,15 @@ export default function RulesIndex() {
</div>
{/* 筛选区域 */}
<FilterPanel>
<FilterPanel className="px-3 py-3" noActionDivider={true}
actions={
<>
<Button type="default" icon="ri-refresh-line" onClick={handleReset} className="mr-2 hover:!border-gray-300">
</Button>
</>
}
>
<FilterSelect
label="评查点类型"
name="ruleType"
@@ -487,7 +467,7 @@ export default function RulesIndex() {
}))
]}
onChange={handleFilterChange}
className="mr-3 w-[20%]"
className="mr-3 w-[15%]"
/>
<FilterSelect
@@ -514,15 +494,16 @@ export default function RulesIndex() {
{ value: "false", label: "禁用" }
]}
onChange={handleFilterChange}
className="mr-3 w-[20%]"
className="mr-3 w-[15%]"
/>
<SearchFilter
label="搜索"
placeholder="输入评查点名称或编码"
value={searchParams.get('keyword') || ''}
buttonText="搜索"
onSearch={handleSearch}
className="w-[30%]"
className="min-w-[200px] flex-1"
/>
</FilterPanel>
@@ -552,19 +533,17 @@ export default function RulesIndex() {
)}
</Card>
{/* 删除确认对话框 */}
{showDeleteConfirm && ruleToDelete && (
<div className="fixed inset-0 bg-black bg-opacity-30 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-md w-full">
<h3 className="text-lg font-medium mb-4"></h3>
<p className="mb-6">&ldquo;{ruleToDelete.name}&rdquo;</p>
<div className="flex justify-end space-x-2">
<Button type="default" onClick={() => setShowDeleteConfirm(false)}></Button>
<Button type="danger" onClick={confirmDelete}></Button>
</div>
</div>
</div>
)}
</div>
);
}
}
// 错误边界
export function ErrorBoundary() {
return (
<div className="error-container p-6">
<h1 className="text-xl font-bold text-red-500 mb-4"></h1>
<p className="mb-4"></p>
<Button type="primary" to="/"></Button>
</div>
);
}
+164
View File
@@ -0,0 +1,164 @@
/*
* 模态框样式
* 包含动画效果、尺寸变体和响应式布局
*/
/* 模态框背景遮罩 */
.modal-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
animation: fadeIn 0.3s ease forwards;
padding: 1rem;
overflow-y: auto;
width: 100vw;
height: 100vh;
}
/* 模态框容器 */
.modal-content {
background-color: #fff;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
position: relative;
animation: slideUpIn 0.3s ease forwards;
width: 100%;
margin: auto;
display: flex;
flex-direction: column;
max-width: 90%;
max-height: 90vh;
overflow: hidden;
z-index: 1;
}
/* 模态框尺寸变体 */
.modal-small {
max-width: 400px;
}
.modal-medium {
max-width: 600px;
}
.modal-large {
max-width: 800px;
}
.modal-full {
max-width: 90%;
height: 90%;
}
/* 模态框头部 */
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
flex-shrink: 0;
}
.modal-title {
margin: 0;
color: #333;
font-size: 18px;
font-weight: 600;
line-height: 1.4;
}
.modal-close {
background: none;
border: none;
font-size: 20px;
cursor: pointer !important;
color: #999;
padding: 0;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
width: 24px;
height: 24px;
min-width: 24px;
min-height: 24px;
}
.modal-close:hover {
background-color: rgba(0, 0, 0, 0.04);
color: #666;
}
/* .modal-close:focus {
outline: 2px solid #4f46e5;
outline-offset: 2px;
} */
/* 模态框内容区域 */
.modal-body {
padding: 24px;
overflow-y: auto;
flex: 1;
max-height: calc(90vh - 120px); /* 减去header和footer的高度 */
}
/* 模态框底部 */
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 16px 24px;
border-top: 1px solid rgba(0, 0, 0, 0.06);
background-color: #fafafa;
border-radius: 0 0 8px 8px;
flex-shrink: 0;
}
/* 动画效果 */
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUpIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 响应式调整 */
@media (max-width: 640px) {
.modal-backdrop {
padding: 0.5rem;
}
.modal-small,
.modal-medium,
.modal-large {
max-width: 100%;
}
.modal-header,
.modal-body,
.modal-footer {
padding: 12px 16px;
}
}
+1
View File
@@ -5,6 +5,7 @@
/* 导入组件样式 */
@import './components/badge.css';
@import './components/modal.css';
@import './components/button.css';
@import './components/card.css';
@import './components/form.css';
+4
View File
@@ -199,3 +199,7 @@ input:checked + .slider:before {
.error-container {
@apply p-6;
}
.document-edit-page .form-select.error {
@apply border-red-500 focus:ring-0 focus:shadow-none;
}